tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main modules/bookmarks/src/lib.rs Rust · 447 líneas · 15.8 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-bookmarks - marcadores per-file persistentes en sesion.
2//!
3//! El usuario marca lineas con Ctrl+Alt+B y luego salta con
4//! Ctrl+Alt+N / Ctrl+Alt+P. Ctrl+Shift+B abre un overlay con la
5//! lista filtrable.
6//!
7//! Los marks son tuplas (PathBuf, line). Viven en memoria del
8//! proceso; el host puede serializar marks si quiere persistir.
9//!
10//! Sigue el contrato Llimphi de docs/MODULES.md.
11
12#![forbid(unsafe_code)]
13
14use std::path::{Path, PathBuf};
15
16use llimphi_compositor::llimphi_layout::taffy::{
17    prelude::{length, percent, FlexDirection, Size, Style},
18    AlignItems, Rect,
19};
20use llimphi_compositor::peniko::Color;
21use llimphi_compositor::llimphi_text::Alignment;
22use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
23use llimphi_widget_text_input::{
24    text_input_view_full, MemClipboard, TextInputEvent, TextInputPalette, TextInputState,
25};
26
27/// Capabilities que aporta este modulo al host.
28pub const CAPABILITIES: &[&str] = &["editor.bookmarks"];
29
30pub const MAX_RESULTS: usize = 500;
31
32const PANEL_H: f32 = 320.0;
33const ROW_H: f32 = 20.0;
34const MAX_VISIBLE: usize = 12;
35
36/// Sub-state del overlay tipo lista (input + results + selected).
37/// None cuando no hay panel abierto.
38pub struct BookmarksOverlay {
39    pub input: TextInputState,
40    /// Indices a state.marks rankeados por fuzzy match. Cap MAX_RESULTS.
41    pub results: Vec<usize>,
42    pub selected: usize,
43    /// Clipboard en memoria para copiar/cortar/pegar dentro del input.
44    clipboard: MemClipboard,
45}
46
47impl BookmarksOverlay {
48    pub fn new() -> Self {
49        Self {
50            input: TextInputState::new(),
51            results: Vec::new(),
52            selected: 0,
53            clipboard: MemClipboard::new(),
54        }
55    }
56}
57
58/// Estado interno. Persiste durante toda la sesion (no es Option en
59/// el host como otros modulos): los marks viven siempre, el overlay si
60/// es opcional. Hace de mini-registro de waypoints del usuario.
61pub struct BookmarksState {
62    /// Marks en orden de creacion. Cada uno es (path, line).
63    /// Toggle quita uno existente o agrega uno nuevo al final.
64    pub marks: Vec<(PathBuf, usize)>,
65    /// Overlay-list abierto cuando Some.
66    pub overlay: Option<BookmarksOverlay>,
67}
68
69impl Default for BookmarksState {
70    fn default() -> Self { Self::new() }
71}
72
73impl BookmarksState {
74    pub fn new() -> Self {
75        Self { marks: Vec::new(), overlay: None }
76    }
77
78    /// True si existe un mark con la misma (path, line).
79    pub fn contains(&self, path: &Path, line: usize) -> bool {
80        self.marks.iter().any(|(p, l)| p == path && *l == line)
81    }
82
83    /// Toggle: si ya existe lo remueve; si no, lo agrega al final.
84    /// Devuelve true si quedo agregado.
85    pub fn toggle(&mut self, path: PathBuf, line: usize) -> bool {
86        if let Some(idx) = self.marks.iter().position(|(p, l)| p == &path && *l == line) {
87            self.marks.remove(idx);
88            false
89        } else {
90            self.marks.push((path, line));
91            true
92        }
93    }
94}
95
96/// Vocabulario interno. El host lo wrapea en su Msg.
97#[derive(Debug, Clone)]
98pub enum BookmarksMsg {
99    /// Toggle del mark en (path, line). El host emite esto cuando
100    /// detecta el shortcut (Ctrl+Alt+B) y conoce la posicion del caret.
101    ToggleAt { path: PathBuf, line: usize },
102    /// Saltar al proximo mark cronologicamente despues de
103    /// (current_path, current_line). Si no hay marks, no-op.
104    JumpNext { current_path: PathBuf, current_line: usize },
105    /// Saltar al previo. Misma semantica reversa.
106    JumpPrev { current_path: PathBuf, current_line: usize },
107    /// Abrir el overlay-list.
108    OpenList,
109    /// Cerrar el overlay.
110    CloseList,
111    /// Teclas para el input del overlay.
112    ListKey(KeyEvent),
113    /// Evento de **mouse** del input del overlay (click→caret,
114    /// doble/triple-click→palabra/todo, arrastre→selección), emitido por el
115    /// `text_input_view_full`. Las teclas van aparte por [`BookmarksMsg::ListKey`].
116    ListMouse(TextInputEvent),
117    /// Navegacion en la lista del overlay.
118    ListNav(i32),
119    /// Enter: salta al mark seleccionado.
120    ListApply,
121    /// Limpia todos los marks.
122    ClearAll,
123}
124
125/// Efecto solicitado al host.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum BookmarksAction {
128    None,
129    /// El host deberia cerrar el overlay (limpiar la sub-state).
130    Close,
131    /// El host deberia abrir ese path (si no esta abierto) y
132    /// posicionar el caret. Cierra el overlay automaticamente cuando
133    /// llega vinculado a ListApply.
134    JumpTo { path: PathBuf, line: usize },
135    /// Mensaje informativo para la status bar (eg toggle feedback).
136    SetStatus(String),
137}
138
139/// Aplica un mensaje al estado.
140pub fn apply(state: &mut BookmarksState, msg: BookmarksMsg) -> BookmarksAction {
141    match msg {
142        BookmarksMsg::ToggleAt { path, line } => {
143            let added = state.toggle(path.clone(), line);
144            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
145            let msg = if added {
146                format!("bookmark agregado en {} linea {}", name, line + 1)
147            } else {
148                format!("bookmark removido de {} linea {}", name, line + 1)
149            };
150            BookmarksAction::SetStatus(msg)
151        }
152        BookmarksMsg::JumpNext { current_path, current_line } => {
153            match next_after(state, &current_path, current_line) {
154                Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
155                None => BookmarksAction::SetStatus("sin bookmarks".into()),
156            }
157        }
158        BookmarksMsg::JumpPrev { current_path, current_line } => {
159            match prev_before(state, &current_path, current_line) {
160                Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
161                None => BookmarksAction::SetStatus("sin bookmarks".into()),
162            }
163        }
164        BookmarksMsg::OpenList => BookmarksAction::None,
165        BookmarksMsg::CloseList => BookmarksAction::Close,
166        BookmarksMsg::ListKey(ev) => {
167            if let Some(ov) = state.overlay.as_mut() {
168                ov.input.apply_key_with_clipboard(&ev, &mut ov.clipboard);
169                refilter_overlay(state);
170            }
171            BookmarksAction::None
172        }
173        BookmarksMsg::ListMouse(ev) => {
174            let changed = match state.overlay.as_mut() {
175                Some(ov) => ov.input.handle(ev, &mut ov.clipboard),
176                None => false,
177            };
178            if changed {
179                refilter_overlay(state);
180            }
181            BookmarksAction::None
182        }
183        BookmarksMsg::ListNav(d) => {
184            if let Some(ov) = state.overlay.as_mut() {
185                let n = ov.results.len() as i32;
186                if n > 0 {
187                    ov.selected = (ov.selected as i32 + d).rem_euclid(n) as usize;
188                }
189            }
190            BookmarksAction::None
191        }
192        BookmarksMsg::ListApply => {
193            let Some(ov) = state.overlay.as_ref() else { return BookmarksAction::None };
194            let Some(&idx) = ov.results.get(ov.selected) else { return BookmarksAction::None };
195            let Some((p, l)) = state.marks.get(idx).cloned() else { return BookmarksAction::None };
196            BookmarksAction::JumpTo { path: p, line: l }
197        }
198        BookmarksMsg::ClearAll => {
199            let n = state.marks.len();
200            state.marks.clear();
201            if let Some(ov) = state.overlay.as_mut() {
202                ov.results.clear();
203                ov.selected = 0;
204            }
205            BookmarksAction::SetStatus(format!("bookmarks limpios ({} removidos)", n))
206        }
207    }
208}
209
210/// Devuelve el mark inmediatamente posterior a (path, line) en orden
211/// de marks. Wraparound al final.
212fn next_after(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
213    if state.marks.is_empty() { return None; }
214    let n = state.marks.len();
215    let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
216    let start = match cur_idx {
217        Some(i) => (i + 1) % n,
218        None => 0,
219    };
220    Some(state.marks[start].clone())
221}
222
223/// Devuelve el mark inmediatamente previo. Wraparound al inicio.
224fn prev_before(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
225    if state.marks.is_empty() { return None; }
226    let n = state.marks.len();
227    let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
228    let start = match cur_idx {
229        Some(i) if i > 0 => i - 1,
230        Some(_) => n - 1,
231        None => n - 1,
232    };
233    Some(state.marks[start].clone())
234}
235
236/// Routing de teclas cuando el overlay esta abierto.
237pub fn on_key(state: &BookmarksState, event: &KeyEvent) -> Option<BookmarksMsg> {
238    state.overlay.as_ref()?;
239    if event.state != KeyState::Pressed { return None; }
240    Some(match &event.key {
241        Key::Named(NamedKey::Escape) => BookmarksMsg::CloseList,
242        Key::Named(NamedKey::Enter) => BookmarksMsg::ListApply,
243        Key::Named(NamedKey::ArrowDown) => BookmarksMsg::ListNav(1),
244        Key::Named(NamedKey::ArrowUp) => BookmarksMsg::ListNav(-1),
245        _ => BookmarksMsg::ListKey(event.clone()),
246    })
247}
248
249/// Atajo de toggle: Ctrl+Alt+B.
250pub fn toggle_shortcut(event: &KeyEvent) -> bool {
251    event.state == KeyState::Pressed
252        && event.modifiers.ctrl
253        && event.modifiers.alt
254        && !event.modifiers.shift
255        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
256}
257
258/// Atajo de open-list: Ctrl+Shift+B. Tambien sirve como toggle del
259/// panel (cierra si ya estaba abierto). El host decide en base a su
260/// state.
261pub fn open_shortcut(event: &KeyEvent) -> bool {
262    event.state == KeyState::Pressed
263        && event.modifiers.ctrl
264        && event.modifiers.shift
265        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
266}
267
268/// Atajo de next: Ctrl+Alt+N.
269pub fn next_shortcut(event: &KeyEvent) -> bool {
270    event.state == KeyState::Pressed
271        && event.modifiers.ctrl
272        && event.modifiers.alt
273        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
274}
275
276/// Atajo de prev: Ctrl+Alt+P.
277pub fn prev_shortcut(event: &KeyEvent) -> bool {
278    event.state == KeyState::Pressed
279        && event.modifiers.ctrl
280        && event.modifiers.alt
281        && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
282}
283
284/// Recalcula overlay.results con fuzzy match contra path+line.
285/// Query vacio = todos los marks en orden.
286pub fn refilter_overlay(state: &mut BookmarksState) {
287    let Some(ov) = state.overlay.as_mut() else { return; };
288    let q = ov.input.text();
289    if q.trim().is_empty() {
290        ov.results = (0..state.marks.len().min(MAX_RESULTS)).collect();
291        ov.selected = 0;
292        return;
293    }
294    use nucleo_matcher::{pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str};
295    let mut matcher = Matcher::new(Config::DEFAULT);
296    let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
297    let mut scored: Vec<(u32, usize)> = Vec::new();
298    let mut buf = Vec::new();
299    for (i, (p, l)) in state.marks.iter().enumerate() {
300        let hay_str = format!("{} {}", p.display(), l + 1);
301        buf.clear();
302        let hay = Utf32Str::new(&hay_str, &mut buf);
303        if let Some(score) = pat.score(hay, &mut matcher) {
304            scored.push((score, i));
305        }
306    }
307    scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
308    scored.truncate(MAX_RESULTS);
309    ov.results = scored.into_iter().map(|(_, i)| i).collect();
310    ov.selected = 0;
311}
312
313/// Paleta visual.
314#[derive(Debug, Clone)]
315pub struct BookmarksPalette {
316    pub bg_panel: Color,
317    pub bg_header: Color,
318    pub bg_selected: Color,
319    pub fg_text: Color,
320    pub fg_muted: Color,
321    pub fg_accent: Color,
322    theme: llimphi_theme::Theme,
323}
324
325impl BookmarksPalette {
326    pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
327        Self {
328            bg_panel: t.bg_panel,
329            bg_header: t.bg_panel_alt,
330            bg_selected: t.bg_selected,
331            fg_text: t.fg_text,
332            fg_muted: t.fg_muted,
333            fg_accent: t.accent,
334            theme: t.clone(),
335        }
336    }
337}
338
339/// Render del overlay. Solo se llama cuando state.overlay es Some.
340/// El host pasa root para mostrar paths relativos en la lista.
341pub fn view<HostMsg, F>(
342    state: &BookmarksState,
343    root: &Path,
344    palette: &BookmarksPalette,
345    to_host: F,
346) -> View<HostMsg>
347where
348    HostMsg: Clone + 'static,
349    F: Fn(BookmarksMsg) -> HostMsg + Copy + Send + Sync + 'static,
350{
351    let ov = match state.overlay.as_ref() {
352        Some(o) => o,
353        None => return View::new(Style::default()),
354    };
355    let header = if state.marks.is_empty() {
356        "bookmarks - sin marks - Ctrl+Alt+B agrega - Esc cierra".to_string()
357    } else if ov.results.is_empty() {
358        format!("bookmarks - sin matches - {} marks - Esc cierra", state.marks.len())
359    } else {
360        format!(
361            "bookmarks - {} / {} - flechas navegan - Enter salta - Esc cierra",
362            ov.selected + 1,
363            ov.results.len(),
364        )
365    };
366    let header_view = View::new(Style {
367        size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
368        padding: Rect {
369            left: length(8.0_f32),
370            right: length(8.0_f32),
371            top: length(0.0_f32),
372            bottom: length(0.0_f32),
373        },
374        align_items: Some(AlignItems::CENTER),
375        flex_shrink: 0.0,
376        ..Default::default()
377    })
378    .fill(palette.bg_header)
379    .text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
380
381    let tp = TextInputPalette::from_theme(&palette.theme);
382    let input_view = View::new(Style {
383        size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
384        padding: Rect {
385            left: length(6.0_f32),
386            right: length(6.0_f32),
387            top: length(2.0_f32),
388            bottom: length(2.0_f32),
389        },
390        flex_shrink: 0.0,
391        ..Default::default()
392    })
393    .fill(palette.bg_panel)
394    .children(vec![text_input_view_full(
395        &ov.input,
396        "filtro: path o numero de linea",
397        true,
398        &tp,
399        move |ev| to_host(BookmarksMsg::ListMouse(ev)),
400    )]);
401
402    let visible_start = ov.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
403    let visible_end = (visible_start + MAX_VISIBLE).min(ov.results.len());
404    let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
405    for i in visible_start..visible_end {
406        let Some(&idx) = ov.results.get(i) else { continue };
407        let Some((p, line)) = state.marks.get(idx) else { continue };
408        let rel: String = match p.strip_prefix(root) {
409            Ok(r) => r.display().to_string(),
410            Err(_) => p.display().to_string(),
411        };
412        let label = format!("{}  :  linea {}", rel, line + 1);
413        let selected = i == ov.selected;
414        let bg = if selected { palette.bg_selected } else { palette.bg_panel };
415        let fg = if selected { palette.fg_text } else { palette.fg_muted };
416        rows.push(
417            View::new(Style {
418                size: Size { width: percent(1.0_f32), height: length(ROW_H) },
419                padding: Rect {
420                    left: length(10.0_f32),
421                    right: length(8.0_f32),
422                    top: length(0.0_f32),
423                    bottom: length(0.0_f32),
424                },
425                align_items: Some(AlignItems::CENTER),
426                flex_shrink: 0.0,
427                ..Default::default()
428            })
429            .fill(bg)
430            .text_aligned(label, 11.0, fg, Alignment::Start),
431        );
432    }
433
434    let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
435    children.push(header_view);
436    children.push(input_view);
437    children.extend(rows);
438
439    View::new(Style {
440        flex_direction: FlexDirection::Column,
441        size: Size { width: percent(1.0_f32), height: length(PANEL_H) },
442        flex_shrink: 0.0,
443        ..Default::default()
444    })
445    .fill(palette.bg_panel)
446    .children(children)
447}