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