tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ a3c2ea9ffa5a97812e9ec043374b756586210bfb
modules/bookmarks/src/lib.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
feat: llimphi standalone — framework UI soberano extraído del monorepo
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, ¤t_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, ¤t_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}