gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main modules/command-palette/src/lib.rs Rust · 367 siq'ikuna · 12.4 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-command-palette` — paleta de comandos reutilizable.
2//!
3//! Equivalente a Ctrl+Shift+P de VS Code: el host declara una lista
4//! plana de [`Command`]s (id opaco + título visible + grupo + hint del
5//! atajo) y el módulo presenta un overlay con input + resultados
6//! rankeados por fuzzy match. Cuando el user pica uno, el módulo emite
7//! [`PaletteAction::Invoke`] con el `id` — el host hace match y
8//! dispatcha lo que corresponda en su propio Msg.
9//!
10//! El módulo no sabe **qué** hacen los comandos. Eso es deliberado:
11//! mantiene al palette agnóstico de la app, y permite que aplicaciones
12//! muy distintas (un editor, un explorador de grafos, un viewer de
13//! imágenes) lo enchufen con sus respectivas listas sin acoplarse.
14//!
15//! Sigue el contrato Llimphi de `docs/MODULES.md`:
16//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
17
18#![forbid(unsafe_code)]
19
20use llimphi_compositor::llimphi_layout::taffy::{
21    prelude::{length, percent, FlexDirection, Size, Style},
22    AlignItems, Rect,
23};
24use llimphi_compositor::peniko::Color;
25use llimphi_compositor::llimphi_text::Alignment;
26use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
27use llimphi_widget_text_input::{
28    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
29};
30
31/// Capabilities que aporta este módulo al host.
32pub const CAPABILITIES: &[&str] = &["editor.command-palette"];
33
34/// Tope de resultados rankeados visibles.
35pub const MAX_RESULTS: usize = 200;
36
37const BAR_H: f32 = 280.0;
38const ROW_H: f32 = 22.0;
39const MAX_VISIBLE: usize = 10;
40
41/// Una entrada del catálogo de comandos que el host arma.
42///
43/// Los campos son convencionales:
44/// - `id`: identificador opaco, único dentro del catálogo del host.
45///   El host lo recibe en [`PaletteAction::Invoke`] y hace match a su
46///   propio Msg. Por convención, formato `"namespace.action"` (ej.
47///   `"editor.save"`, `"terminal.open"`).
48/// - `title`: lo que el user lee. Idealmente en lengua de la app.
49/// - `group`: categoría visible a la derecha de la fila (ej. `"Editor"`,
50///   `"Terminal"`, `"LSP"`). Sirve para escanear visualmente.
51/// - `shortcut`: hint textual del atajo nativo del comando, si existe
52///   (ej. `"Ctrl+S"`). Sólo decorativo — el módulo no captura nada
53///   distinto a Enter/Esc/↑↓.
54#[derive(Debug, Clone)]
55pub struct Command {
56    pub id: String,
57    pub title: String,
58    pub group: String,
59    pub shortcut: Option<String>,
60}
61
62impl Command {
63    pub fn new(
64        id: impl Into<String>,
65        title: impl Into<String>,
66        group: impl Into<String>,
67    ) -> Self {
68        Self { id: id.into(), title: title.into(), group: group.into(), shortcut: None }
69    }
70
71    pub fn with_shortcut(mut self, s: impl Into<String>) -> Self {
72        self.shortcut = Some(s.into());
73        self
74    }
75}
76
77/// Estado interno. `results` son índices al slice de commands que pasa
78/// el host: el módulo no copia, sólo guarda índices.
79pub struct PaletteState {
80    pub input: TextInputState,
81    pub results: Vec<usize>,
82    pub selected: usize,
83    /// Clipboard en memoria para copiar/cortar/pegar dentro del input.
84    clipboard: MemClipboard,
85}
86
87impl Default for PaletteState {
88    fn default() -> Self {
89        Self::new_empty()
90    }
91}
92
93impl PaletteState {
94    pub fn new_empty() -> Self {
95        Self {
96            input: TextInputState::new(),
97            results: Vec::new(),
98            selected: 0,
99            clipboard: MemClipboard::new(),
100        }
101    }
102
103    /// Crea un palette pre-poblado con todos los comandos sin filtro,
104    /// listo para mostrar después del shortcut de apertura.
105    pub fn new(commands: &[Command]) -> Self {
106        let mut s = Self::new_empty();
107        refilter(&mut s, commands);
108        s
109    }
110}
111
112/// Vocabulario interno. El host lo wrapea en su Msg.
113#[derive(Clone)]
114pub enum PaletteMsg {
115    /// Símbolo conveniente para que el host dispatche al detectar el
116    /// shortcut. El módulo no construye el state él mismo — eso lo hace
117    /// el host con la lista canónica de commands.
118    Open,
119    Close,
120    KeyInput(KeyEvent),
121    /// Evento de **mouse** del input (click→caret, doble/triple-click→palabra/todo,
122    /// arrastre→selección), emitido por el `text_input_view_full`. Las teclas van
123    /// aparte por [`PaletteMsg::KeyInput`].
124    MouseInput(TextInputEvent),
125    Nav(i32),
126    /// Enter: invoca el comando seleccionado.
127    Apply,
128}
129
130/// Efecto solicitado al host.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum PaletteAction {
133    None,
134    /// El host debería remover el state del modelo.
135    Close,
136    /// El host debería ejecutar el comando con este `id`. El módulo NO
137    /// se cierra automáticamente — el host decide (típicamente sí, igual
138    /// que un menú).
139    Invoke(String),
140}
141
142/// Aplica un mensaje al estado.
143pub fn apply(
144    state: &mut PaletteState,
145    msg: PaletteMsg,
146    commands: &[Command],
147) -> PaletteAction {
148    match msg {
149        PaletteMsg::Open => PaletteAction::None,
150        PaletteMsg::Close => PaletteAction::Close,
151        PaletteMsg::KeyInput(ev) => {
152            state.input.apply_key_with_clipboard(&ev, &mut state.clipboard);
153            refilter(state, commands);
154            PaletteAction::None
155        }
156        PaletteMsg::MouseInput(ev) => {
157            if state.input.handle(ev, &mut state.clipboard) {
158                refilter(state, commands);
159            }
160            PaletteAction::None
161        }
162        PaletteMsg::Nav(d) => {
163            let n = state.results.len() as i32;
164            if n > 0 {
165                state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
166            }
167            PaletteAction::None
168        }
169        PaletteMsg::Apply => {
170            let Some(&cmd_idx) = state.results.get(state.selected) else {
171                return PaletteAction::None;
172            };
173            let Some(cmd) = commands.get(cmd_idx) else {
174                return PaletteAction::None;
175            };
176            PaletteAction::Invoke(cmd.id.clone())
177        }
178    }
179}
180
181/// Routing de teclas cuando el palette está abierto.
182pub fn on_key(_state: &PaletteState, event: &KeyEvent) -> Option<PaletteMsg> {
183    if event.state != KeyState::Pressed {
184        return None;
185    }
186    Some(match &event.key {
187        Key::Named(NamedKey::Escape) => PaletteMsg::Close,
188        Key::Named(NamedKey::Enter) => PaletteMsg::Apply,
189        Key::Named(NamedKey::ArrowDown) => PaletteMsg::Nav(1),
190        Key::Named(NamedKey::ArrowUp) => PaletteMsg::Nav(-1),
191        _ => PaletteMsg::KeyInput(event.clone()),
192    })
193}
194
195/// El atajo recomendado: **Ctrl+Shift+P**, igual que VS Code.
196pub fn open_shortcut(event: &KeyEvent) -> bool {
197    event.state == KeyState::Pressed
198        && event.modifiers.ctrl
199        && event.modifiers.shift
200        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
201}
202
203/// Recalcula `state.results` según el query del input. Fuzzy match con
204/// `nucleo-matcher` sobre `"title · group"` (mismo string para que el
205/// usuario pueda buscar por grupo: "term" matchea "Open Terminal · Editor").
206/// Query vacío = lista completa ordenada como vino del host.
207/// Cap: [`MAX_RESULTS`].
208pub fn refilter(state: &mut PaletteState, commands: &[Command]) {
209    let q = state.input.text();
210    if q.trim().is_empty() {
211        state.results = (0..commands.len().min(MAX_RESULTS)).collect();
212        state.selected = 0;
213        return;
214    }
215    use nucleo_matcher::{
216        pattern::{CaseMatching, Normalization, Pattern},
217        Config, Matcher, Utf32Str,
218    };
219    let mut matcher = Matcher::new(Config::DEFAULT);
220    let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
221    let mut scored: Vec<(u32, usize)> = Vec::new();
222    let mut buf = Vec::new();
223    for (i, cmd) in commands.iter().enumerate() {
224        let hay_str = format!("{} {}", cmd.title, cmd.group);
225        buf.clear();
226        let hay = Utf32Str::new(&hay_str, &mut buf);
227        if let Some(score) = pat.score(hay, &mut matcher) {
228            scored.push((score, i));
229        }
230    }
231    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
232    scored.truncate(MAX_RESULTS);
233    state.results = scored.into_iter().map(|(_, i)| i).collect();
234    state.selected = 0;
235}
236
237/// Paleta visual.
238#[derive(Debug, Clone)]
239pub struct PalettePalette {
240    pub bg_panel: Color,
241    pub bg_header: Color,
242    pub bg_selected: Color,
243    pub fg_text: Color,
244    pub fg_muted: Color,
245    theme: llimphi_theme::Theme,
246}
247
248impl PalettePalette {
249    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
250        Self {
251            bg_panel: t.bg_panel,
252            bg_header: t.bg_panel_alt,
253            bg_selected: t.bg_selected,
254            fg_text: t.fg_text,
255            fg_muted: t.fg_muted,
256            theme: t.clone(),
257        }
258    }
259}
260
261/// Render del overlay. `to_host` mapea cada `PaletteMsg` interno al
262/// `Msg` de la app.
263pub fn view<HostMsg, F>(
264    state: &PaletteState,
265    commands: &[Command],
266    palette: &PalettePalette,
267    to_host: F,
268) -> View<HostMsg>
269where
270    HostMsg: Clone + 'static,
271    F: Fn(PaletteMsg) -> HostMsg + Copy + Send + Sync + 'static,
272{
273    let header = if state.results.is_empty() {
274        format!("command palette · sin matches · {} comandos · Esc cierra", commands.len())
275    } else {
276        format!(
277            "command palette · {} / {} · ↓↑ navega · Enter ejecuta · Esc cierra",
278            state.selected + 1,
279            state.results.len(),
280        )
281    };
282
283    let header_view = View::new(Style {
284        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
285        padding: Rect {
286            left: length(8.0_f32),
287            right: length(8.0_f32),
288            top: length(0.0_f32),
289            bottom: length(0.0_f32),
290        },
291        align_items: Some(AlignItems::CENTER),
292        flex_shrink: 0.0,
293        ..Default::default()
294    })
295    .fill(palette.bg_header)
296    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
297
298    let tp = TextInputPalette::from_theme(&palette.theme);
299    let input_view = View::new(Style {
300        size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
301        padding: Rect {
302            left: length(6.0_f32),
303            right: length(6.0_f32),
304            top: length(2.0_f32),
305            bottom: length(2.0_f32),
306        },
307        flex_shrink: 0.0,
308        ..Default::default()
309    })
310    .fill(palette.bg_panel)
311    .children(vec![text_input_view_full(
312        &state.input,
313        "filtro: nombre del comando…",
314        true,
315        &tp,
316        move |ev| to_host(PaletteMsg::MouseInput(ev)),
317    )]);
318
319    let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
320    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
321    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
322    for i in visible_start..visible_end {
323        let Some(&cmd_idx) = state.results.get(i) else { continue };
324        let Some(cmd) = commands.get(cmd_idx) else { continue };
325        let label = match (&cmd.shortcut, cmd.group.as_str()) {
326            (Some(sc), grp) if !grp.is_empty() => {
327                format!("{}    {}    [{sc}]", cmd.title, cmd.group)
328            }
329            (Some(sc), _) => format!("{}    [{sc}]", cmd.title),
330            (None, grp) if !grp.is_empty() => format!("{}    {}", cmd.title, cmd.group),
331            (None, _) => cmd.title.clone(),
332        };
333        let selected = i == state.selected;
334        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
335        let fg = if selected { palette.fg_text } else { palette.fg_muted };
336        rows.push(
337            View::new(Style {
338                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
339                padding: Rect {
340                    left: length(10.0_f32),
341                    right: length(8.0_f32),
342                    top: length(0.0_f32),
343                    bottom: length(0.0_f32),
344                },
345                align_items: Some(AlignItems::CENTER),
346                flex_shrink: 0.0,
347                ..Default::default()
348            })
349            .fill(bg)
350            .text_aligned(label, 12.0, fg, Alignment::Start),
351        );
352    }
353
354    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
355    children.push(header_view);
356    children.push(input_view);
357    children.extend(rows);
358
359    View::new(Style {
360        flex_direction: FlexDirection::Column,
361        size: Size { width: percent(1.0_f32), height: length(BAR_H) },
362        flex_shrink: 0.0,
363        ..Default::default()
364    })
365    .fill(palette.bg_panel)
366    .children(children)
367}