gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

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

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