tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Definiciones

⎇ main modules/symbol-outline/src/lib.rs Rust · 367 líneas · 12.4 KB Historia Quién tocó qué crudo

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

1//! `llimphi-module-symbol-outline` — outline navegable de símbolos.
2//!
3//! Equivalente al "Outline" panel de VS Code o "Structure" de JetBrains.
4//! El host arma una lista plana de [`SymbolItem`] (funciones, structs,
5//! métodos, con su posición en el buffer) y el módulo presenta un
6//! overlay con input + lista rankeada por fuzzy. Cuando el user pica
7//! uno, el módulo emite [`OutlineAction::GoTo`] y el host mueve el caret.
8//!
9//! El módulo es **agnóstico de la fuente de símbolos**. El host puede
10//! poblarlo desde:
11//!
12//! - LSP (`textDocument/documentSymbol`) — fuente canónica.
13//! - tree-sitter — sirve para archivos sin LSP.
14//! - parser propio del lenguaje del host.
15//! - una lista hardcodeada (en una app no-código que tenga "secciones").
16//!
17//! Sigue el contrato Llimphi de `docs/MODULES.md`:
18//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
19
20#![forbid(unsafe_code)]
21
22use llimphi_compositor::llimphi_layout::taffy::{
23    prelude::{length, percent, FlexDirection, Size, Style},
24    AlignItems, Rect,
25};
26use llimphi_compositor::peniko::Color;
27use llimphi_compositor::llimphi_text::Alignment;
28use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
29use llimphi_widget_text_input::{
30    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
31};
32
33/// Capabilities que aporta este módulo al host.
34pub const CAPABILITIES: &[&str] = &["editor.symbol-outline"];
35
36pub const MAX_RESULTS: usize = 500;
37
38const BAR_H: f32 = 320.0;
39const ROW_H: f32 = 20.0;
40const MAX_VISIBLE: usize = 12;
41
42/// Un símbolo del documento. Los campos son convencionales:
43///
44/// - `name`: nombre visible (`foo`, `MyStruct`, `parse_line`).
45/// - `kind`: etiqueta corta del tipo de símbolo (`fn`, `struct`, `method`,
46///   `mod`, `const`, …). El módulo la pinta sin interpretar — el host
47///   elige el vocabulario (LSP usa `SymbolKind` numérico; el host
48///   convierte a string).
49/// - `line`, `col`: posición 0-based en el buffer. El módulo no toca
50///   coordenadas — sólo las devuelve en `GoTo`.
51/// - `container`: nombre del símbolo padre (`Some("MyStruct")` para
52///   un método). Visible en el render como anotación a la derecha;
53///   también participa del fuzzy match para que tipear el nombre de
54///   la clase filtre sus métodos.
55/// - `depth`: profundidad jerárquica para indentación visual. El
56///   módulo asume que la lista ya viene ordenada (parent antes que
57///   children, en orden de aparición).
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct SymbolItem {
60    pub name: String,
61    pub kind: String,
62    pub line: usize,
63    pub col: usize,
64    pub container: Option<String>,
65    pub depth: u32,
66}
67
68/// Estado interno. `results` son índices al slice de symbols que pasa
69/// el host: el módulo no copia, sólo guarda índices.
70pub struct OutlineState {
71    pub input: TextInputState,
72    pub results: Vec<usize>,
73    pub selected: usize,
74    /// Clipboard en memoria para copiar/cortar/pegar dentro del input.
75    clipboard: MemClipboard,
76}
77
78impl Default for OutlineState {
79    fn default() -> Self {
80        Self::new_empty()
81    }
82}
83
84impl OutlineState {
85    pub fn new_empty() -> Self {
86        Self {
87            input: TextInputState::new(),
88            results: Vec::new(),
89            selected: 0,
90            clipboard: MemClipboard::new(),
91        }
92    }
93
94    /// Crea un outline poblado con todos los símbolos sin filtro.
95    pub fn new(items: &[SymbolItem]) -> Self {
96        let mut s = Self::new_empty();
97        refilter(&mut s, items);
98        s
99    }
100}
101
102/// Vocabulario interno. El host lo wrapea en su Msg.
103#[derive(Clone)]
104pub enum OutlineMsg {
105    /// Símbolo conveniente que el host emite al detectar el shortcut.
106    /// El módulo no construye el state ni la lista él mismo.
107    Open,
108    Close,
109    KeyInput(KeyEvent),
110    /// Evento de **mouse** del input (click→caret, doble/triple-click→palabra/todo,
111    /// arrastre→selección), emitido por el `text_input_view_full`. Las teclas van
112    /// aparte por [`OutlineMsg::KeyInput`].
113    MouseInput(TextInputEvent),
114    Nav(i32),
115    /// Enter: salta al símbolo seleccionado.
116    Apply,
117}
118
119/// Efecto solicitado al host.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum OutlineAction {
122    None,
123    /// El host debería remover el state del modelo.
124    Close,
125    /// El host debería mover el caret a esta posición del buffer activo.
126    /// El módulo NO se cierra automáticamente — el host decide
127    /// (típicamente sí, para que la navegación sea "salta y mira").
128    GoTo { line: usize, col: usize },
129}
130
131/// Aplica un mensaje al estado.
132pub fn apply(
133    state: &mut OutlineState,
134    msg: OutlineMsg,
135    items: &[SymbolItem],
136) -> OutlineAction {
137    match msg {
138        OutlineMsg::Open => OutlineAction::None,
139        OutlineMsg::Close => OutlineAction::Close,
140        OutlineMsg::KeyInput(ev) => {
141            state.input.apply_key_with_clipboard(&ev, &mut state.clipboard);
142            refilter(state, items);
143            OutlineAction::None
144        }
145        OutlineMsg::MouseInput(ev) => {
146            if state.input.handle(ev, &mut state.clipboard) {
147                refilter(state, items);
148            }
149            OutlineAction::None
150        }
151        OutlineMsg::Nav(d) => {
152            let n = state.results.len() as i32;
153            if n > 0 {
154                state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
155            }
156            OutlineAction::None
157        }
158        OutlineMsg::Apply => {
159            let Some(&idx) = state.results.get(state.selected) else {
160                return OutlineAction::None;
161            };
162            let Some(it) = items.get(idx) else {
163                return OutlineAction::None;
164            };
165            OutlineAction::GoTo { line: it.line, col: it.col }
166        }
167    }
168}
169
170/// Routing de teclas cuando el outline está abierto.
171pub fn on_key(_state: &OutlineState, event: &KeyEvent) -> Option<OutlineMsg> {
172    if event.state != KeyState::Pressed {
173        return None;
174    }
175    Some(match &event.key {
176        Key::Named(NamedKey::Escape) => OutlineMsg::Close,
177        Key::Named(NamedKey::Enter) => OutlineMsg::Apply,
178        Key::Named(NamedKey::ArrowDown) => OutlineMsg::Nav(1),
179        Key::Named(NamedKey::ArrowUp) => OutlineMsg::Nav(-1),
180        _ => OutlineMsg::KeyInput(event.clone()),
181    })
182}
183
184/// El atajo recomendado: **Ctrl+Shift+O**, igual que VS Code.
185pub fn open_shortcut(event: &KeyEvent) -> bool {
186    event.state == KeyState::Pressed
187        && event.modifiers.ctrl
188        && event.modifiers.shift
189        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("o"))
190}
191
192/// Recalcula `state.results` con fuzzy match sobre `"name kind container"`.
193/// Query vacío = lista completa. Cap: [`MAX_RESULTS`].
194pub fn refilter(state: &mut OutlineState, items: &[SymbolItem]) {
195    let q = state.input.text();
196    if q.trim().is_empty() {
197        state.results = (0..items.len().min(MAX_RESULTS)).collect();
198        state.selected = 0;
199        return;
200    }
201    use nucleo_matcher::{
202        pattern::{CaseMatching, Normalization, Pattern},
203        Config, Matcher, Utf32Str,
204    };
205    let mut matcher = Matcher::new(Config::DEFAULT);
206    let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
207    let mut scored: Vec<(u32, usize)> = Vec::new();
208    let mut buf = Vec::new();
209    for (i, it) in items.iter().enumerate() {
210        let hay_str = match &it.container {
211            Some(c) => format!("{} {} {c}", it.name, it.kind),
212            None => format!("{} {}", it.name, it.kind),
213        };
214        buf.clear();
215        let hay = Utf32Str::new(&hay_str, &mut buf);
216        if let Some(score) = pat.score(hay, &mut matcher) {
217            scored.push((score, i));
218        }
219    }
220    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
221    scored.truncate(MAX_RESULTS);
222    state.results = scored.into_iter().map(|(_, i)| i).collect();
223    state.selected = 0;
224}
225
226/// Paleta visual.
227#[derive(Debug, Clone)]
228pub struct OutlinePalette {
229    pub bg_panel: Color,
230    pub bg_header: Color,
231    pub bg_selected: Color,
232    pub fg_text: Color,
233    pub fg_muted: Color,
234    theme: llimphi_theme::Theme,
235}
236
237impl OutlinePalette {
238    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
239        Self {
240            bg_panel: t.bg_panel,
241            bg_header: t.bg_panel_alt,
242            bg_selected: t.bg_selected,
243            fg_text: t.fg_text,
244            fg_muted: t.fg_muted,
245            theme: t.clone(),
246        }
247    }
248}
249
250/// Render del overlay. `to_host` mapea cada `OutlineMsg` al `Msg` de la
251/// app.
252pub fn view<HostMsg, F>(
253    state: &OutlineState,
254    items: &[SymbolItem],
255    palette: &OutlinePalette,
256    to_host: F,
257) -> View<HostMsg>
258where
259    HostMsg: Clone + 'static,
260    F: Fn(OutlineMsg) -> HostMsg + Copy + Send + Sync + 'static,
261{
262    let header = if items.is_empty() {
263        "outline · sin símbolos · Esc cierra".to_string()
264    } else if state.results.is_empty() {
265        format!("outline · sin matches · {} símbolos · Esc cierra", items.len())
266    } else {
267        format!(
268            "outline · {} / {} · ↓↑ navega · Enter salta · Esc cierra",
269            state.selected + 1,
270            state.results.len(),
271        )
272    };
273
274    let header_view = View::new(Style {
275        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
276        padding: Rect {
277            left: length(8.0_f32),
278            right: length(8.0_f32),
279            top: length(0.0_f32),
280            bottom: length(0.0_f32),
281        },
282        align_items: Some(AlignItems::CENTER),
283        flex_shrink: 0.0,
284        ..Default::default()
285    })
286    .fill(palette.bg_header)
287    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
288
289    let tp = TextInputPalette::from_theme(&palette.theme);
290    let input_view = View::new(Style {
291        size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
292        padding: Rect {
293            left: length(6.0_f32),
294            right: length(6.0_f32),
295            top: length(2.0_f32),
296            bottom: length(2.0_f32),
297        },
298        flex_shrink: 0.0,
299        ..Default::default()
300    })
301    .fill(palette.bg_panel)
302    .children(vec![text_input_view_full(
303        &state.input,
304        "filtro: nombre del símbolo o clase…",
305        true,
306        &tp,
307        move |ev| to_host(OutlineMsg::MouseInput(ev)),
308    )]);
309
310    let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
311    let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
312    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
313    for i in visible_start..visible_end {
314        let Some(&idx) = state.results.get(i) else { continue };
315        let Some(it) = items.get(idx) else { continue };
316        // Indentación visual por depth (sólo cuando no hay query — con
317        // query el orden ya vino del ranking y la jerarquía se pierde).
318        let indent = if state.input.text().trim().is_empty() {
319            "  ".repeat(it.depth as usize)
320        } else {
321            String::new()
322        };
323        let container_tag = match &it.container {
324            Some(c) if !c.is_empty() => format!("    in {c}"),
325            _ => String::new(),
326        };
327        let label = format!(
328            "{indent}{} {}    line {}{container_tag}",
329            it.kind,
330            it.name,
331            it.line + 1,
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, 11.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}