feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "llimphi-module-bookmarks"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-bookmarks - marcadores per-file persistentes en la sesion del editor. Modulo Llimphi: el host emite ToggleAt(path, line) al disparar Ctrl+Alt+B, JumpNext/JumpPrev para navegar (devuelve JumpTo accion), y OpenList para abrir un overlay tipo symbol-outline con fuzzy filter sobre los marks. No persiste a disco - el host puede serializar marks si quiere."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
nucleo-matcher = { workspace = true }
[dev-dependencies]
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-bookmarks
> Bookmarks por archivo de [llimphi](../../README.md).
Marca posiciones en un archivo (línea + columna + nombre); navegación rápida (`F2`/`Shift+F2`). Persiste por workspace.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-bookmarks
> Per-file bookmarks of [llimphi](../../README.md).
Marks positions in a file (line + column + name); quick navigation (`F2`/`Shift+F2`). Persists per workspace.
+424
View File
@@ -0,0 +1,424 @@
//! llimphi-module-bookmarks - marcadores per-file persistentes en sesion.
//!
//! El usuario marca lineas con Ctrl+Alt+B y luego salta con
//! Ctrl+Alt+N / Ctrl+Alt+P. Ctrl+Shift+B abre un overlay con la
//! lista filtrable.
//!
//! Los marks son tuplas (PathBuf, line). Viven en memoria del
//! proceso; el host puede serializar marks si quiere persistir.
//!
//! Sigue el contrato Llimphi de docs/MODULES.md.
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
/// Capabilities que aporta este modulo al host.
pub const CAPABILITIES: &[&str] = &["editor.bookmarks"];
pub const MAX_RESULTS: usize = 500;
const PANEL_H: f32 = 320.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 12;
/// Sub-state del overlay tipo lista (input + results + selected).
/// None cuando no hay panel abierto.
pub struct BookmarksOverlay {
pub input: TextInputState,
/// Indices a state.marks rankeados por fuzzy match. Cap MAX_RESULTS.
pub results: Vec<usize>,
pub selected: usize,
}
impl BookmarksOverlay {
pub fn new() -> Self {
Self { input: TextInputState::new(), results: Vec::new(), selected: 0 }
}
}
/// Estado interno. Persiste durante toda la sesion (no es Option en
/// el host como otros modulos): los marks viven siempre, el overlay si
/// es opcional. Hace de mini-registro de waypoints del usuario.
pub struct BookmarksState {
/// Marks en orden de creacion. Cada uno es (path, line).
/// Toggle quita uno existente o agrega uno nuevo al final.
pub marks: Vec<(PathBuf, usize)>,
/// Overlay-list abierto cuando Some.
pub overlay: Option<BookmarksOverlay>,
}
impl Default for BookmarksState {
fn default() -> Self { Self::new() }
}
impl BookmarksState {
pub fn new() -> Self {
Self { marks: Vec::new(), overlay: None }
}
/// True si existe un mark con la misma (path, line).
pub fn contains(&self, path: &Path, line: usize) -> bool {
self.marks.iter().any(|(p, l)| p == path && *l == line)
}
/// Toggle: si ya existe lo remueve; si no, lo agrega al final.
/// Devuelve true si quedo agregado.
pub fn toggle(&mut self, path: PathBuf, line: usize) -> bool {
if let Some(idx) = self.marks.iter().position(|(p, l)| p == &path && *l == line) {
self.marks.remove(idx);
false
} else {
self.marks.push((path, line));
true
}
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Debug, Clone)]
pub enum BookmarksMsg {
/// Toggle del mark en (path, line). El host emite esto cuando
/// detecta el shortcut (Ctrl+Alt+B) y conoce la posicion del caret.
ToggleAt { path: PathBuf, line: usize },
/// Saltar al proximo mark cronologicamente despues de
/// (current_path, current_line). Si no hay marks, no-op.
JumpNext { current_path: PathBuf, current_line: usize },
/// Saltar al previo. Misma semantica reversa.
JumpPrev { current_path: PathBuf, current_line: usize },
/// Abrir el overlay-list.
OpenList,
/// Cerrar el overlay.
CloseList,
/// Teclas para el input del overlay.
ListKey(KeyEvent),
/// Navegacion en la lista del overlay.
ListNav(i32),
/// Enter: salta al mark seleccionado.
ListApply,
/// Limpia todos los marks.
ClearAll,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BookmarksAction {
None,
/// El host deberia cerrar el overlay (limpiar la sub-state).
Close,
/// El host deberia abrir ese path (si no esta abierto) y
/// posicionar el caret. Cierra el overlay automaticamente cuando
/// llega vinculado a ListApply.
JumpTo { path: PathBuf, line: usize },
/// Mensaje informativo para la status bar (eg toggle feedback).
SetStatus(String),
}
/// Aplica un mensaje al estado.
pub fn apply(state: &mut BookmarksState, msg: BookmarksMsg) -> BookmarksAction {
match msg {
BookmarksMsg::ToggleAt { path, line } => {
let added = state.toggle(path.clone(), line);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let msg = if added {
format!("bookmark agregado en {} linea {}", name, line + 1)
} else {
format!("bookmark removido de {} linea {}", name, line + 1)
};
BookmarksAction::SetStatus(msg)
}
BookmarksMsg::JumpNext { current_path, current_line } => {
match next_after(state, &current_path, current_line) {
Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
None => BookmarksAction::SetStatus("sin bookmarks".into()),
}
}
BookmarksMsg::JumpPrev { current_path, current_line } => {
match prev_before(state, &current_path, current_line) {
Some((p, l)) => BookmarksAction::JumpTo { path: p, line: l },
None => BookmarksAction::SetStatus("sin bookmarks".into()),
}
}
BookmarksMsg::OpenList => BookmarksAction::None,
BookmarksMsg::CloseList => BookmarksAction::Close,
BookmarksMsg::ListKey(ev) => {
if let Some(ov) = state.overlay.as_mut() {
ov.input.apply_key(&ev);
refilter_overlay(state);
}
BookmarksAction::None
}
BookmarksMsg::ListNav(d) => {
if let Some(ov) = state.overlay.as_mut() {
let n = ov.results.len() as i32;
if n > 0 {
ov.selected = (ov.selected as i32 + d).rem_euclid(n) as usize;
}
}
BookmarksAction::None
}
BookmarksMsg::ListApply => {
let Some(ov) = state.overlay.as_ref() else { return BookmarksAction::None };
let Some(&idx) = ov.results.get(ov.selected) else { return BookmarksAction::None };
let Some((p, l)) = state.marks.get(idx).cloned() else { return BookmarksAction::None };
BookmarksAction::JumpTo { path: p, line: l }
}
BookmarksMsg::ClearAll => {
let n = state.marks.len();
state.marks.clear();
if let Some(ov) = state.overlay.as_mut() {
ov.results.clear();
ov.selected = 0;
}
BookmarksAction::SetStatus(format!("bookmarks limpios ({} removidos)", n))
}
}
}
/// Devuelve el mark inmediatamente posterior a (path, line) en orden
/// de marks. Wraparound al final.
fn next_after(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
if state.marks.is_empty() { return None; }
let n = state.marks.len();
let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
let start = match cur_idx {
Some(i) => (i + 1) % n,
None => 0,
};
Some(state.marks[start].clone())
}
/// Devuelve el mark inmediatamente previo. Wraparound al inicio.
fn prev_before(state: &BookmarksState, path: &Path, line: usize) -> Option<(PathBuf, usize)> {
if state.marks.is_empty() { return None; }
let n = state.marks.len();
let cur_idx = state.marks.iter().position(|(p, l)| p == path && *l == line);
let start = match cur_idx {
Some(i) if i > 0 => i - 1,
Some(_) => n - 1,
None => n - 1,
};
Some(state.marks[start].clone())
}
/// Routing de teclas cuando el overlay esta abierto.
pub fn on_key(state: &BookmarksState, event: &KeyEvent) -> Option<BookmarksMsg> {
state.overlay.as_ref()?;
if event.state != KeyState::Pressed { return None; }
Some(match &event.key {
Key::Named(NamedKey::Escape) => BookmarksMsg::CloseList,
Key::Named(NamedKey::Enter) => BookmarksMsg::ListApply,
Key::Named(NamedKey::ArrowDown) => BookmarksMsg::ListNav(1),
Key::Named(NamedKey::ArrowUp) => BookmarksMsg::ListNav(-1),
_ => BookmarksMsg::ListKey(event.clone()),
})
}
/// Atajo de toggle: Ctrl+Alt+B.
pub fn toggle_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& !event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
}
/// Atajo de open-list: Ctrl+Shift+B. Tambien sirve como toggle del
/// panel (cierra si ya estaba abierto). El host decide en base a su
/// state.
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("b"))
}
/// Atajo de next: Ctrl+Alt+N.
pub fn next_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
}
/// Atajo de prev: Ctrl+Alt+P.
pub fn prev_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.alt
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("p"))
}
/// Recalcula overlay.results con fuzzy match contra path+line.
/// Query vacio = todos los marks en orden.
pub fn refilter_overlay(state: &mut BookmarksState) {
let Some(ov) = state.overlay.as_mut() else { return; };
let q = ov.input.text();
if q.trim().is_empty() {
ov.results = (0..state.marks.len().min(MAX_RESULTS)).collect();
ov.selected = 0;
return;
}
use nucleo_matcher::{pattern::{CaseMatching, Normalization, Pattern}, Config, Matcher, Utf32Str};
let mut matcher = Matcher::new(Config::DEFAULT);
let pat = Pattern::parse(&q, CaseMatching::Smart, Normalization::Smart);
let mut scored: Vec<(u32, usize)> = Vec::new();
let mut buf = Vec::new();
for (i, (p, l)) in state.marks.iter().enumerate() {
let hay_str = format!("{} {}", p.display(), l + 1);
buf.clear();
let hay = Utf32Str::new(&hay_str, &mut buf);
if let Some(score) = pat.score(hay, &mut matcher) {
scored.push((score, i));
}
}
scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
scored.truncate(MAX_RESULTS);
ov.results = scored.into_iter().map(|(_, i)| i).collect();
ov.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct BookmarksPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_accent: Color,
theme: llimphi_theme::Theme,
}
impl BookmarksPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_selected: t.bg_selected,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_accent: t.accent,
theme: t.clone(),
}
}
}
/// Render del overlay. Solo se llama cuando state.overlay es Some.
/// El host pasa root para mostrar paths relativos en la lista.
pub fn view<HostMsg, F>(
state: &BookmarksState,
root: &Path,
palette: &BookmarksPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(BookmarksMsg) -> HostMsg + Copy + 'static,
{
let ov = match state.overlay.as_ref() {
Some(o) => o,
None => return View::new(Style::default()),
};
let header = if state.marks.is_empty() {
"bookmarks - sin marks - Ctrl+Alt+B agrega - Esc cierra".to_string()
} else if ov.results.is_empty() {
format!("bookmarks - sin matches - {} marks - Esc cierra", state.marks.len())
} else {
format!(
"bookmarks - {} / {} - flechas navegan - Enter salta - Esc cierra",
ov.selected + 1,
ov.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(18.0_f32) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header, 10.0, palette.fg_muted, Alignment::Start);
let tp = TextInputPalette::from_theme(&palette.theme);
let input_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(26.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(vec![text_input_view(
&ov.input,
"filtro: path o numero de linea",
true,
&tp,
to_host(BookmarksMsg::OpenList),
)]);
let visible_start = ov.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(ov.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&idx) = ov.results.get(i) else { continue };
let Some((p, line)) = state.marks.get(idx) else { continue };
let rel: String = match p.strip_prefix(root) {
Ok(r) => r.display().to_string(),
Err(_) => p.display().to_string(),
};
let label = format!("{} : linea {}", rel, line + 1);
let selected = i == ov.selected;
let bg = if selected { palette.bg_selected } else { palette.bg_panel };
let fg = if selected { palette.fg_text } else { palette.fg_muted };
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
padding: Rect {
left: length(10.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(label, 11.0, fg, Alignment::Start),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(2 + rows.len());
children.push(header_view);
children.push(input_view);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(PANEL_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
+94
View File
@@ -0,0 +1,94 @@
//! Smoke tests del modulo bookmarks: toggle, jump-next/prev,
//! shortcuts, fuzzy refilter del overlay.
use std::path::PathBuf;
use llimphi_module_bookmarks::{
self as bm, BookmarksAction, BookmarksMsg, BookmarksOverlay, BookmarksState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn key_with(ctrl: bool, alt: bool, shift: bool, ch: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(ch.into()),
state: KeyState::Pressed,
text: Some(ch.into()),
modifiers: Modifiers { ctrl, alt, shift, ..Modifiers::default() },
repeat: false,
}
}
#[test]
fn toggle_agrega_y_remueve() {
let mut s = BookmarksState::new();
let p = PathBuf::from("/x/foo.rs");
let a1 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 });
assert!(matches!(a1, BookmarksAction::SetStatus(_)));
assert!(s.contains(&p, 5));
let a2 = bm::apply(&mut s, BookmarksMsg::ToggleAt { path: p.clone(), line: 5 });
assert!(matches!(a2, BookmarksAction::SetStatus(_)));
assert!(!s.contains(&p, 5));
}
#[test]
fn jump_next_wraparound() {
let mut s = BookmarksState::new();
let a = PathBuf::from("/x/a.rs");
let b = PathBuf::from("/x/b.rs");
s.toggle(a.clone(), 10);
s.toggle(b.clone(), 20);
s.toggle(a.clone(), 30);
// Estamos en (a, 10) - next debe ser (b, 20).
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 10 });
assert_eq!(action, BookmarksAction::JumpTo { path: b.clone(), line: 20 });
// Estamos en (a, 30) - next wrappea a (a, 10).
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: a.clone(), current_line: 30 });
assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 10 });
}
#[test]
fn jump_prev_wraparound() {
let mut s = BookmarksState::new();
let a = PathBuf::from("/x/a.rs");
s.toggle(a.clone(), 10);
s.toggle(a.clone(), 20);
s.toggle(a.clone(), 30);
// Estamos en (a, 10) - prev wrappea a (a, 30).
let action = bm::apply(&mut s, BookmarksMsg::JumpPrev { current_path: a.clone(), current_line: 10 });
assert_eq!(action, BookmarksAction::JumpTo { path: a.clone(), line: 30 });
}
#[test]
fn jump_sin_marks_es_setstatus() {
let mut s = BookmarksState::new();
let action = bm::apply(&mut s, BookmarksMsg::JumpNext { current_path: PathBuf::from("/x"), current_line: 0 });
assert!(matches!(action, BookmarksAction::SetStatus(_)));
}
#[test]
fn shortcuts_distinguibles() {
assert!(bm::toggle_shortcut(&key_with(true, true, false, "b")));
assert!(!bm::toggle_shortcut(&key_with(true, true, true, "b"))); // ctrl+alt+shift+b no
assert!(bm::open_shortcut(&key_with(true, false, true, "b")));
assert!(bm::next_shortcut(&key_with(true, true, false, "n")));
assert!(bm::prev_shortcut(&key_with(true, true, false, "p")));
}
#[test]
fn refilter_con_query_vacio_lista_todos() {
let mut s = BookmarksState::new();
s.toggle(PathBuf::from("/x/a.rs"), 1);
s.toggle(PathBuf::from("/x/b.rs"), 2);
s.overlay = Some(BookmarksOverlay::new());
bm::refilter_overlay(&mut s);
assert_eq!(s.overlay.as_ref().unwrap().results.len(), 2);
}
#[test]
fn clear_all_vacia_marks() {
let mut s = BookmarksState::new();
s.toggle(PathBuf::from("/x"), 1);
s.toggle(PathBuf::from("/y"), 2);
let _ = bm::apply(&mut s, BookmarksMsg::ClearAll);
assert!(s.marks.is_empty());
}