tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
modules/file-picker/src/lib.rs
Historia
Quién tocó qué
crudo
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! `llimphi-module-file-picker` — fuzzy file picker reutilizable.
2//!
3//! Equivalente a Ctrl+P de VS Code / "Go to file" de JetBrains: el host
4//! mantiene una lista de paths candidatos (típicamente walk del workspace
5//! cacheado al arrancar) y el módulo presenta un overlay con input +
6//! resultados rankeados. Cuando el user pica uno, el módulo emite
7//! [`PickerAction::Open`] y el host decide cómo abrir (tab nuevo, split,
8//! etc.).
9//!
10//! Sigue el contrato Llimphi de [`docs/MODULES.md`]:
11//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
12//!
13//! ## Cómo lo enchufa una app
14//!
15//! ```ignore
16//! use llimphi_module_file_picker::{self as picker, PickerAction, PickerMsg,
17//! PickerPalette, PickerState};
18//!
19//! struct Model { all_files: Vec<PathBuf>, picker: Option<PickerState>, … }
20//! enum Msg { Picker(PickerMsg), … }
21//!
22//! // update:
23//! Msg::Picker(pm) => {
24//! let mut m = model;
25//! if matches!(pm, PickerMsg::Open) && m.picker.is_none() {
26//! m.picker = Some(PickerState::new(&m.all_files, &m.root));
27//! return m;
28//! }
29//! let action = match m.picker.as_mut() {
30//! Some(s) => picker::apply(s, pm, &m.all_files, &m.root),
31//! None => return m,
32//! };
33//! match action {
34//! PickerAction::Close => m.picker = None,
35//! PickerAction::Open(path) => {
36//! m.picker = None;
37//! m = open_path_in_app(m, path);
38//! }
39//! PickerAction::None => {}
40//! }
41//! m
42//! }
43//!
44//! // on_key:
45//! if let Some(state) = model.picker.as_ref() {
46//! if let Some(pm) = picker::on_key(state, event) {
47//! return Some(Msg::Picker(pm));
48//! }
49//! }
50//! if picker::open_shortcut(event) {
51//! return Some(Msg::Picker(PickerMsg::Open));
52//! }
53//!
54//! // view:
55//! if let Some(state) = model.picker.as_ref() {
56//! let panel = picker::view(
57//! state, &model.all_files, &model.root,
58//! &PickerPalette::from_theme(&theme),
59//! Msg::Picker,
60//! );
61//! children.push(panel);
62//! }
63//! ```
64
65#![forbid(unsafe_code)]
66
67use std::path::{Path, PathBuf};
68
69use llimphi_ui::llimphi_layout::taffy::{
70 prelude::{length, percent, FlexDirection, Size, Style},
71 AlignItems, Rect,
72};
73use llimphi_ui::llimphi_raster::peniko::Color;
74use llimphi_ui::llimphi_text::Alignment;
75use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
76use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
77
78/// Capabilities que este módulo aporta al host. El host (cuando construye
79/// su `card_core::Card`) puede agregar esto a `provides` para anunciar
80/// vía broker que ofrece file-picker al ecosistema.
81pub const CAPABILITIES: &[&str] = &["editor.file-picker"];
82
83/// Máximo de resultados rankeados que entran al popup.
84pub const MAX_RESULTS: usize = 200;
85
86const BAR_H: f32 = 220.0;
87const ROW_H: f32 = 20.0;
88const MAX_VISIBLE: usize = 9;
89
90/// Estado interno. Los `results` son índices al slice de paths que pasa
91/// el host: el módulo no copia paths, sólo guarda índices.
92pub struct PickerState {
93 pub input: TextInputState,
94 pub results: Vec<usize>,
95 pub selected: usize,
96}
97
98impl Default for PickerState {
99 fn default() -> Self {
100 Self::new_empty()
101 }
102}
103
104impl PickerState {
105 /// Crea un picker vacío. Si querés pre-filtrar con los paths que ya
106 /// tenés, llamá [`PickerState::new`] en su lugar.
107 pub fn new_empty() -> Self {
108 Self {
109 input: TextInputState::new(),
110 results: Vec::new(),
111 selected: 0,
112 }
113 }
114
115 /// Crea un picker con todos los `paths` como resultados iniciales
116 /// (sin filtrar). Conveniente para el ack visual del Ctrl+P recién
117 /// disparado.
118 pub fn new(paths: &[PathBuf], root: &Path) -> Self {
119 let mut s = Self::new_empty();
120 refilter(&mut s, paths, root);
121 s
122 }
123}
124
125/// Vocabulario interno. El host lo wrapea en su Msg.
126#[derive(Clone)]
127pub enum PickerMsg {
128 /// Símbolo conveniente para que el host dispatche al detectar el
129 /// shortcut. El módulo no maneja Open él mismo — la creación del
130 /// state corre por cuenta del host (porque típicamente quiere pasar
131 /// la lista canónica de paths).
132 Open,
133 Close,
134 KeyInput(KeyEvent),
135 Nav(i32),
136 /// Enter: abre el match seleccionado.
137 Apply,
138}
139
140/// Efecto solicitado al host.
141#[derive(Debug, Clone)]
142pub enum PickerAction {
143 None,
144 /// El host debería remover el state del modelo.
145 Close,
146 /// El host debería abrir este `path`. El módulo NO se cierra
147 /// automáticamente — el host decide si ocultar el picker tras abrir.
148 Open(PathBuf),
149}
150
151/// Aplica un mensaje al estado.
152pub fn apply(
153 state: &mut PickerState,
154 msg: PickerMsg,
155 paths: &[PathBuf],
156 root: &Path,
157) -> PickerAction {
158 match msg {
159 PickerMsg::Open => PickerAction::None,
160 PickerMsg::Close => PickerAction::Close,
161 PickerMsg::KeyInput(ev) => {
162 state.input.apply_key(&ev);
163 refilter(state, paths, root);
164 PickerAction::None
165 }
166 PickerMsg::Nav(d) => {
167 let n = state.results.len() as i32;
168 if n > 0 {
169 state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
170 }
171 PickerAction::None
172 }
173 PickerMsg::Apply => {
174 let Some(&file_idx) = state.results.get(state.selected) else {
175 return PickerAction::None;
176 };
177 let Some(path) = paths.get(file_idx).cloned() else {
178 return PickerAction::None;
179 };
180 PickerAction::Open(path)
181 }
182 }
183}
184
185/// Routing de teclas cuando el panel está abierto.
186pub fn on_key(_state: &PickerState, event: &KeyEvent) -> Option<PickerMsg> {
187 if event.state != KeyState::Pressed {
188 return None;
189 }
190 Some(match &event.key {
191 Key::Named(NamedKey::Escape) => PickerMsg::Close,
192 Key::Named(NamedKey::Enter) => PickerMsg::Apply,
193 Key::Named(NamedKey::ArrowDown) => PickerMsg::Nav(1),
194 Key::Named(NamedKey::ArrowUp) => PickerMsg::Nav(-1),
195 _ => PickerMsg::KeyInput(event.clone()),
196 })
197}
198
199/// Chequea si el evento es el atajo recomendado: **Ctrl+P**.
200pub fn open_shortcut(event: &KeyEvent) -> bool {
201 event.state == KeyState::Pressed
202 && event.modifiers.ctrl
203 && !event.modifiers.shift
204 && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
205}
206
207/// Recalcula `state.results` según el query del input. Match case-insensitive
208/// sobre el path relativo. Score penaliza paths largos y premia hits en el
209/// basename. Query vacío = todos los paths ordenados por longitud asc.
210/// Cap: [`MAX_RESULTS`].
211pub fn refilter(state: &mut PickerState, paths: &[PathBuf], root: &Path) {
212 let q = state.input.text();
213 let q_lc = q.to_lowercase();
214 let mut scored: Vec<(i64, usize)> = Vec::new();
215 for (i, path) in paths.iter().enumerate() {
216 let rel = relative_to(root, path);
217 if q_lc.is_empty() {
218 scored.push((rel.len() as i64, i));
219 continue;
220 }
221 let rel_lc = rel.to_lowercase();
222 let Some(rel_hit) = rel_lc.find(&q_lc) else { continue };
223 let name = path
224 .file_name()
225 .and_then(|s| s.to_str())
226 .map(|s| s.to_lowercase())
227 .unwrap_or_default();
228 let name_hit = name.find(&q_lc);
229 let score = match name_hit {
230 Some(pos) => pos as i64 * 4 + rel.len() as i64,
231 None => 10_000 + rel_hit as i64 + rel.len() as i64,
232 };
233 scored.push((score, i));
234 }
235 scored.sort_by_key(|(s, _)| *s);
236 scored.truncate(MAX_RESULTS);
237 state.results = scored.into_iter().map(|(_, i)| i).collect();
238 state.selected = 0;
239}
240
241/// Paleta visual.
242#[derive(Debug, Clone)]
243pub struct PickerPalette {
244 pub bg_panel: Color,
245 pub bg_header: Color,
246 pub bg_selected: Color,
247 pub fg_text: Color,
248 pub fg_muted: Color,
249 theme: llimphi_theme::Theme,
250}
251
252impl PickerPalette {
253 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
254 Self {
255 bg_panel: t.bg_panel,
256 bg_header: t.bg_panel_alt,
257 bg_selected: t.bg_selected,
258 fg_text: t.fg_text,
259 fg_muted: t.fg_muted,
260 theme: t.clone(),
261 }
262 }
263}
264
265/// Render del panel. `to_host` mapea cada `PickerMsg` interno al `Msg`
266/// de la app.
267pub fn view<HostMsg, F>(
268 state: &PickerState,
269 paths: &[PathBuf],
270 root: &Path,
271 palette: &PickerPalette,
272 to_host: F,
273) -> View<HostMsg>
274where
275 HostMsg: Clone + 'static,
276 F: Fn(PickerMsg) -> HostMsg + Copy + 'static,
277{
278 let header = if state.results.is_empty() {
279 format!("file picker · sin matches · {} archivos · Esc cierra", paths.len())
280 } else {
281 format!(
282 "file picker · {} / {} · ↓↑ navega · Enter abre · Esc cierra",
283 state.selected + 1,
284 state.results.len(),
285 )
286 };
287
288 let header_view = View::new(Style {
289 size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
290 padding: Rect {
291 left: length(8.0_f32),
292 right: length(8.0_f32),
293 top: length(0.0_f32),
294 bottom: length(0.0_f32),
295 },
296 align_items: Some(AlignItems::Center),
297 flex_shrink: 0.0,
298 ..Default::default()
299 })
300 .fill(palette.bg_header)
301 .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
302
303 let tp = TextInputPalette::from_theme(&palette.theme);
304 let input_view = View::new(Style {
305 size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
306 padding: Rect {
307 left: length(6.0_f32),
308 right: length(6.0_f32),
309 top: length(2.0_f32),
310 bottom: length(2.0_f32),
311 },
312 flex_shrink: 0.0,
313 ..Default::default()
314 })
315 .fill(palette.bg_panel)
316 .children(vec![text_input_view(
317 &state.input,
318 "filtro: nombre o ruta…",
319 true,
320 &tp,
321 to_host(PickerMsg::Open),
322 )]);
323
324 let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
325 let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
326 let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
327 for i in visible_start..visible_end {
328 let Some(&file_idx) = state.results.get(i) else { continue };
329 let Some(path) = paths.get(file_idx) else { continue };
330 let rel = relative_to(root, path);
331 let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
332 let dir = rel.strip_suffix(name).unwrap_or("");
333 let label = if dir.is_empty() {
334 name.to_string()
335 } else {
336 format!("{name} {}", dir.trim_end_matches('/'))
337 };
338 let selected = i == state.selected;
339 let bg = if selected { palette.bg_selected } else { palette.bg_panel };
340 let fg = if selected { palette.fg_text } else { palette.fg_muted };
341 rows.push(
342 View::new(Style {
343 size: Size { width: percent(1.0_f32), height: length(ROW_H) },
344 padding: Rect {
345 left: length(10.0_f32),
346 right: length(8.0_f32),
347 top: length(0.0_f32),
348 bottom: length(0.0_f32),
349 },
350 align_items: Some(AlignItems::Center),
351 flex_shrink: 0.0,
352 ..Default::default()
353 })
354 .fill(bg)
355 .text_aligned(label, 11.0, fg, Alignment::Start),
356 );
357 }
358
359 let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
360 children.push(header_view);
361 children.push(input_view);
362 children.extend(rows);
363
364 View::new(Style {
365 flex_direction: FlexDirection::Column,
366 size: Size { width: percent(1.0_f32), height: length(BAR_H) },
367 flex_shrink: 0.0,
368 ..Default::default()
369 })
370 .fill(palette.bg_panel)
371 .children(children)
372}
373
374// ---------------------------------------------------------------------
375// Helpers
376// ---------------------------------------------------------------------
377
378fn relative_to(root: &Path, path: &Path) -> String {
379 path.strip_prefix(root)
380 .map(|p| p.display().to_string())
381 .unwrap_or_else(|_| path.display().to_string())
382}