tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main modules/file-picker/src/lib.rs Rust · 397 siq'ikuna · 12.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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_compositor::llimphi_layout::taffy::{
70    prelude::{length, percent, FlexDirection, Size, Style},
71    AlignItems, Rect,
72};
73use llimphi_compositor::peniko::Color;
74use llimphi_compositor::llimphi_text::Alignment;
75use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
76use llimphi_widget_text_input::{
77    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
78};
79
80/// Capabilities que este módulo aporta al host. El host (cuando construye
81/// su `card_core::Card`) puede agregar esto a `provides` para anunciar
82/// vía broker que ofrece file-picker al ecosistema.
83pub const CAPABILITIES: &[&str] = &["editor.file-picker"];
84
85/// Máximo de resultados rankeados que entran al popup.
86pub const MAX_RESULTS: usize = 200;
87
88const BAR_H: f32 = 220.0;
89const ROW_H: f32 = 20.0;
90const MAX_VISIBLE: usize = 9;
91
92/// Estado interno. Los `results` son índices al slice de paths que pasa
93/// el host: el módulo no copia paths, sólo guarda índices.
94pub struct PickerState {
95    pub input: TextInputState,
96    pub results: Vec<usize>,
97    pub selected: usize,
98    /// Clipboard en memoria para copiar/cortar/pegar dentro del input.
99    clipboard: MemClipboard,
100}
101
102impl Default for PickerState {
103    fn default() -> Self {
104        Self::new_empty()
105    }
106}
107
108impl PickerState {
109    /// Crea un picker vacío. Si quieres pre-filtrar con los paths que ya
110    /// tienes, llama [`PickerState::new`] en su lugar.
111    pub fn new_empty() -> Self {
112        Self {
113            input: TextInputState::new(),
114            results: Vec::new(),
115            selected: 0,
116            clipboard: MemClipboard::new(),
117        }
118    }
119
120    /// Crea un picker con todos los `paths` como resultados iniciales
121    /// (sin filtrar). Conveniente para el ack visual del Ctrl+P recién
122    /// disparado.
123    pub fn new(paths: &[PathBuf], root: &Path) -> Self {
124        let mut s = Self::new_empty();
125        refilter(&mut s, paths, root);
126        s
127    }
128}
129
130/// Vocabulario interno. El host lo wrapea en su Msg.
131#[derive(Clone)]
132pub enum PickerMsg {
133    /// Símbolo conveniente para que el host dispatche al detectar el
134    /// shortcut. El módulo no maneja Open él mismo — la creación del
135    /// state corre por cuenta del host (porque típicamente quiere pasar
136    /// la lista canónica de paths).
137    Open,
138    Close,
139    KeyInput(KeyEvent),
140    /// Evento de **mouse** del input (click→caret, doble/triple-click→palabra/todo,
141    /// arrastre→selección), emitido por el `text_input_view_full`. Las teclas van
142    /// aparte por [`PickerMsg::KeyInput`].
143    MouseInput(TextInputEvent),
144    Nav(i32),
145    /// Enter: abre el match seleccionado.
146    Apply,
147}
148
149/// Efecto solicitado al host.
150#[derive(Debug, Clone)]
151pub enum PickerAction {
152    None,
153    /// El host debería remover el state del modelo.
154    Close,
155    /// El host debería abrir este `path`. El módulo NO se cierra
156    /// automáticamente — el host decide si ocultar el picker tras abrir.
157    Open(PathBuf),
158}
159
160/// Aplica un mensaje al estado.
161pub fn apply(
162    state: &mut PickerState,
163    msg: PickerMsg,
164    paths: &[PathBuf],
165    root: &Path,
166) -> PickerAction {
167    match msg {
168        PickerMsg::Open => PickerAction::None,
169        PickerMsg::Close => PickerAction::Close,
170        PickerMsg::KeyInput(ev) => {
171            state.input.apply_key_with_clipboard(&ev, &mut state.clipboard);
172            refilter(state, paths, root);
173            PickerAction::None
174        }
175        PickerMsg::MouseInput(ev) => {
176            if state.input.handle(ev, &mut state.clipboard) {
177                refilter(state, paths, root);
178            }
179            PickerAction::None
180        }
181        PickerMsg::Nav(d) => {
182            let n = state.results.len() as i32;
183            if n > 0 {
184                state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
185            }
186            PickerAction::None
187        }
188        PickerMsg::Apply => {
189            let Some(&file_idx) = state.results.get(state.selected) else {
190                return PickerAction::None;
191            };
192            let Some(path) = paths.get(file_idx).cloned() else {
193                return PickerAction::None;
194            };
195            PickerAction::Open(path)
196        }
197    }
198}
199
200/// Routing de teclas cuando el panel está abierto.
201pub fn on_key(_state: &PickerState, event: &KeyEvent) -> Option<PickerMsg> {
202    if event.state != KeyState::Pressed {
203        return None;
204    }
205    Some(match &event.key {
206        Key::Named(NamedKey::Escape) => PickerMsg::Close,
207        Key::Named(NamedKey::Enter) => PickerMsg::Apply,
208        Key::Named(NamedKey::ArrowDown) => PickerMsg::Nav(1),
209        Key::Named(NamedKey::ArrowUp) => PickerMsg::Nav(-1),
210        _ => PickerMsg::KeyInput(event.clone()),
211    })
212}
213
214/// Chequea si el evento es el atajo recomendado: **Ctrl+P**.
215pub fn open_shortcut(event: &KeyEvent) -> bool {
216    event.state == KeyState::Pressed
217        && event.modifiers.ctrl
218        && !event.modifiers.shift
219        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
220}
221
222/// Recalcula `state.results` según el query del input. Match case-insensitive
223/// sobre el path relativo. Score penaliza paths largos y premia hits en el
224/// basename. Query vacío = todos los paths ordenados por longitud asc.
225/// Cap: [`MAX_RESULTS`].
226pub fn refilter(state: &mut PickerState, paths: &[PathBuf], root: &Path) {
227    let q = state.input.text();
228    let q_lc = q.to_lowercase();
229    let mut scored: Vec<(i64, usize)> = Vec::new();
230    for (i, path) in paths.iter().enumerate() {
231        let rel = relative_to(root, path);
232        if q_lc.is_empty() {
233            scored.push((rel.len() as i64, i));
234            continue;
235        }
236        let rel_lc = rel.to_lowercase();
237        let Some(rel_hit) = rel_lc.find(&q_lc) else { continue };
238        let name = path
239            .file_name()
240            .and_then(|s| s.to_str())
241            .map(|s| s.to_lowercase())
242            .unwrap_or_default();
243        let name_hit = name.find(&q_lc);
244        let score = match name_hit {
245            Some(pos) => pos as i64 * 4 + rel.len() as i64,
246            None => 10_000 + rel_hit as i64 + rel.len() as i64,
247        };
248        scored.push((score, i));
249    }
250    scored.sort_by_key(|(s, _)| *s);
251    scored.truncate(MAX_RESULTS);
252    state.results = scored.into_iter().map(|(_, i)| i).collect();
253    state.selected = 0;
254}
255
256/// Paleta visual.
257#[derive(Debug, Clone)]
258pub struct PickerPalette {
259    pub bg_panel: Color,
260    pub bg_header: Color,
261    pub bg_selected: Color,
262    pub fg_text: Color,
263    pub fg_muted: Color,
264    theme: llimphi_theme::Theme,
265}
266
267impl PickerPalette {
268    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
269        Self {
270            bg_panel: t.bg_panel,
271            bg_header: t.bg_panel_alt,
272            bg_selected: t.bg_selected,
273            fg_text: t.fg_text,
274            fg_muted: t.fg_muted,
275            theme: t.clone(),
276        }
277    }
278}
279
280/// Render del panel. `to_host` mapea cada `PickerMsg` interno al `Msg`
281/// de la app.
282pub fn view<HostMsg, F>(
283    state: &PickerState,
284    paths: &[PathBuf],
285    root: &Path,
286    palette: &PickerPalette,
287    to_host: F,
288) -> View<HostMsg>
289where
290    HostMsg: Clone + 'static,
291    F: Fn(PickerMsg) -> HostMsg + Copy + Send + Sync + 'static,
292{
293    let header = if state.results.is_empty() {
294        format!("file picker · sin matches · {} archivos · Esc cierra", paths.len())
295    } else {
296        format!(
297            "file picker · {} / {} · ↓↑ navega · Enter abre · Esc cierra",
298            state.selected + 1,
299            state.results.len(),
300        )
301    };
302
303    let header_view = View::new(Style {
304        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
305        padding: Rect {
306            left: length(8.0_f32),
307            right: length(8.0_f32),
308            top: length(0.0_f32),
309            bottom: length(0.0_f32),
310        },
311        align_items: Some(AlignItems::CENTER),
312        flex_shrink: 0.0,
313        ..Default::default()
314    })
315    .fill(palette.bg_header)
316    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
317
318    let tp = TextInputPalette::from_theme(&palette.theme);
319    let input_view = View::new(Style {
320        size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
321        padding: Rect {
322            left: length(6.0_f32),
323            right: length(6.0_f32),
324            top: length(2.0_f32),
325            bottom: length(2.0_f32),
326        },
327        flex_shrink: 0.0,
328        ..Default::default()
329    })
330    .fill(palette.bg_panel)
331    .children(vec![text_input_view_full(
332        &state.input,
333        "filtro: nombre o ruta…",
334        true,
335        &tp,
336        move |ev| to_host(PickerMsg::MouseInput(ev)),
337    )]);
338
339    let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
340    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
341    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
342    for i in visible_start..visible_end {
343        let Some(&file_idx) = state.results.get(i) else { continue };
344        let Some(path) = paths.get(file_idx) else { continue };
345        let rel = relative_to(root, path);
346        let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
347        let dir = rel.strip_suffix(name).unwrap_or("");
348        let label = if dir.is_empty() {
349            name.to_string()
350        } else {
351            format!("{name}    {}", dir.trim_end_matches('/'))
352        };
353        let selected = i == state.selected;
354        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
355        let fg = if selected { palette.fg_text } else { palette.fg_muted };
356        rows.push(
357            View::new(Style {
358                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
359                padding: Rect {
360                    left: length(10.0_f32),
361                    right: length(8.0_f32),
362                    top: length(0.0_f32),
363                    bottom: length(0.0_f32),
364                },
365                align_items: Some(AlignItems::CENTER),
366                flex_shrink: 0.0,
367                ..Default::default()
368            })
369            .fill(bg)
370            .text_aligned(label, 11.0, fg, Alignment::Start),
371        );
372    }
373
374    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
375    children.push(header_view);
376    children.push(input_view);
377    children.extend(rows);
378
379    View::new(Style {
380        flex_direction: FlexDirection::Column,
381        size: Size { width: percent(1.0_f32), height: length(BAR_H) },
382        flex_shrink: 0.0,
383        ..Default::default()
384    })
385    .fill(palette.bg_panel)
386    .children(children)
387}
388
389// ---------------------------------------------------------------------
390// Helpers
391// ---------------------------------------------------------------------
392
393fn relative_to(root: &Path, path: &Path) -> String {
394    path.strip_prefix(root)
395        .map(|p| p.display().to_string())
396        .unwrap_or_else(|_| path.display().to_string())
397}