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

⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb modules/bookmarks/src/lib.rs Rust · 424 siq'ikuna · 14.9 KB Ñawpa willay Pi imata llamkarqan ch'uya

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