tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
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, ¤t_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, ¤t_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}