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());
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-command-palette"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-command-palette — paleta de comandos estilo Ctrl+Shift+P de VS Code. Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de Commands que provee el host. Fuzzy match con nucleo-matcher."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
nucleo-matcher = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-command-palette
> Paleta de comandos de [llimphi](../../README.md).
`Ctrl+Shift+P` abre un fuzzy-finder de comandos registrados (`Command { id, label, shortcut, action }`). Cada app declara sus comandos al iniciar.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-command-palette
> Command palette of [llimphi](../../README.md).
`Ctrl+Shift+P` opens a fuzzy-finder of registered commands (`Command { id, label, shortcut, action }`). Each app declares its commands on init.
+352
View File
@@ -0,0 +1,352 @@
//! `llimphi-module-command-palette` — paleta de comandos reutilizable.
//!
//! Equivalente a Ctrl+Shift+P de VS Code: el host declara una lista
//! plana de [`Command`]s (id opaco + título visible + grupo + hint del
//! atajo) y el módulo presenta un overlay con input + resultados
//! rankeados por fuzzy match. Cuando el user pica uno, el módulo emite
//! [`PaletteAction::Invoke`] con el `id` — el host hace match y
//! dispatcha lo que corresponda en su propio Msg.
//!
//! El módulo no sabe **qué** hacen los comandos. Eso es deliberado:
//! mantiene al palette agnóstico de la app, y permite que aplicaciones
//! muy distintas (un editor, un explorador de grafos, un viewer de
//! imágenes) lo enchufen con sus respectivas listas sin acoplarse.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
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 módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.command-palette"];
/// Tope de resultados rankeados visibles.
pub const MAX_RESULTS: usize = 200;
const BAR_H: f32 = 280.0;
const ROW_H: f32 = 22.0;
const MAX_VISIBLE: usize = 10;
/// Una entrada del catálogo de comandos que el host arma.
///
/// Los campos son convencionales:
/// - `id`: identificador opaco, único dentro del catálogo del host.
/// El host lo recibe en [`PaletteAction::Invoke`] y hace match a su
/// propio Msg. Por convención, formato `"namespace.action"` (ej.
/// `"editor.save"`, `"terminal.open"`).
/// - `title`: lo que el user lee. Idealmente en lengua de la app.
/// - `group`: categoría visible a la derecha de la fila (ej. `"Editor"`,
/// `"Terminal"`, `"LSP"`). Sirve para escanear visualmente.
/// - `shortcut`: hint textual del atajo nativo del comando, si existe
/// (ej. `"Ctrl+S"`). Sólo decorativo — el módulo no captura nada
/// distinto a Enter/Esc/↑↓.
#[derive(Debug, Clone)]
pub struct Command {
pub id: String,
pub title: String,
pub group: String,
pub shortcut: Option<String>,
}
impl Command {
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
group: impl Into<String>,
) -> Self {
Self { id: id.into(), title: title.into(), group: group.into(), shortcut: None }
}
pub fn with_shortcut(mut self, s: impl Into<String>) -> Self {
self.shortcut = Some(s.into());
self
}
}
/// Estado interno. `results` son índices al slice de commands que pasa
/// el host: el módulo no copia, sólo guarda índices.
pub struct PaletteState {
pub input: TextInputState,
pub results: Vec<usize>,
pub selected: usize,
}
impl Default for PaletteState {
fn default() -> Self {
Self::new_empty()
}
}
impl PaletteState {
pub fn new_empty() -> Self {
Self {
input: TextInputState::new(),
results: Vec::new(),
selected: 0,
}
}
/// Crea un palette pre-poblado con todos los comandos sin filtro,
/// listo para mostrar después del shortcut de apertura.
pub fn new(commands: &[Command]) -> Self {
let mut s = Self::new_empty();
refilter(&mut s, commands);
s
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum PaletteMsg {
/// Símbolo conveniente para que el host dispatche al detectar el
/// shortcut. El módulo no construye el state él mismo — eso lo hace
/// el host con la lista canónica de commands.
Open,
Close,
KeyInput(KeyEvent),
Nav(i32),
/// Enter: invoca el comando seleccionado.
Apply,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería ejecutar el comando con este `id`. El módulo NO
/// se cierra automáticamente — el host decide (típicamente sí, igual
/// que un menú).
Invoke(String),
}
/// Aplica un mensaje al estado.
pub fn apply(
state: &mut PaletteState,
msg: PaletteMsg,
commands: &[Command],
) -> PaletteAction {
match msg {
PaletteMsg::Open => PaletteAction::None,
PaletteMsg::Close => PaletteAction::Close,
PaletteMsg::KeyInput(ev) => {
state.input.apply_key(&ev);
refilter(state, commands);
PaletteAction::None
}
PaletteMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
PaletteAction::None
}
PaletteMsg::Apply => {
let Some(&cmd_idx) = state.results.get(state.selected) else {
return PaletteAction::None;
};
let Some(cmd) = commands.get(cmd_idx) else {
return PaletteAction::None;
};
PaletteAction::Invoke(cmd.id.clone())
}
}
}
/// Routing de teclas cuando el palette está abierto.
pub fn on_key(_state: &PaletteState, event: &KeyEvent) -> Option<PaletteMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => PaletteMsg::Close,
Key::Named(NamedKey::Enter) => PaletteMsg::Apply,
Key::Named(NamedKey::ArrowDown) => PaletteMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => PaletteMsg::Nav(-1),
_ => PaletteMsg::KeyInput(event.clone()),
})
}
/// El atajo recomendado: **Ctrl+Shift+P**, igual que VS Code.
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("p"))
}
/// Recalcula `state.results` según el query del input. Fuzzy match con
/// `nucleo-matcher` sobre `"title · group"` (mismo string para que el
/// usuario pueda buscar por grupo: "term" matchea "Open Terminal · Editor").
/// Query vacío = lista completa ordenada como vino del host.
/// Cap: [`MAX_RESULTS`].
pub fn refilter(state: &mut PaletteState, commands: &[Command]) {
let q = state.input.text();
if q.trim().is_empty() {
state.results = (0..commands.len().min(MAX_RESULTS)).collect();
state.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, cmd) in commands.iter().enumerate() {
let hay_str = format!("{} {}", cmd.title, cmd.group);
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);
state.results = scored.into_iter().map(|(_, i)| i).collect();
state.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct PalettePalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
theme: llimphi_theme::Theme,
}
impl PalettePalette {
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,
theme: t.clone(),
}
}
}
/// Render del overlay. `to_host` mapea cada `PaletteMsg` interno al
/// `Msg` de la app.
pub fn view<HostMsg, F>(
state: &PaletteState,
commands: &[Command],
palette: &PalettePalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(PaletteMsg) -> HostMsg + Copy + 'static,
{
let header = if state.results.is_empty() {
format!("command palette · sin matches · {} comandos · Esc cierra", commands.len())
} else {
format!(
"command palette · {} / {} · ↓↑ navega · Enter ejecuta · Esc cierra",
state.selected + 1,
state.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(
&state.input,
"filtro: nombre del comando…",
true,
&tp,
to_host(PaletteMsg::Open),
)]);
let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&cmd_idx) = state.results.get(i) else { continue };
let Some(cmd) = commands.get(cmd_idx) else { continue };
let label = match (&cmd.shortcut, cmd.group.as_str()) {
(Some(sc), grp) if !grp.is_empty() => {
format!("{} {} [{sc}]", cmd.title, cmd.group)
}
(Some(sc), _) => format!("{} [{sc}]", cmd.title),
(None, grp) if !grp.is_empty() => format!("{} {}", cmd.title, cmd.group),
(None, _) => cmd.title.clone(),
};
let selected = i == state.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, 12.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(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
+125
View File
@@ -0,0 +1,125 @@
//! Smoke tests del fuzzy match y del flujo `Open → KeyInput → Apply`.
//! No requieren backend gráfico — sólo el reducer puro y `refilter`.
use llimphi_module_command_palette::{
self as palette, Command, PaletteAction, PaletteMsg, PaletteState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn seed() -> Vec<Command> {
vec![
Command::new("editor.save", "Save File", "Editor").with_shortcut("Ctrl+S"),
Command::new("editor.open", "Open File", "Editor").with_shortcut("Ctrl+P"),
Command::new("editor.findInFiles", "Find in Files", "Editor")
.with_shortcut("Ctrl+Shift+F"),
Command::new("terminal.open", "Open Terminal", "Terminal")
.with_shortcut("Ctrl+`"),
Command::new("lsp.format", "Format Document", "LSP")
.with_shortcut("Ctrl+Alt+L"),
Command::new("lsp.goto", "Go to Definition", "LSP").with_shortcut("F12"),
]
}
fn key_char(c: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers::default(),
repeat: false,
}
}
#[test]
fn estado_vacio_lista_todos_los_comandos() {
let cmds = seed();
let s = PaletteState::new(&cmds);
assert_eq!(s.results.len(), cmds.len());
assert_eq!(s.selected, 0);
}
#[test]
fn fuzzy_match_acerca_el_comando_correcto_al_top() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
// Tipear "term" debería rankear "Open Terminal" o "Terminal" arriba.
for ch in ["t", "e", "r", "m"] {
let action = palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
assert_eq!(action, PaletteAction::None);
}
let top = s.results.first().expect("debe haber al menos un match");
assert_eq!(
cmds[*top].id, "terminal.open",
"esperaba terminal.open al top, vi {:?}",
cmds[*top].title
);
}
#[test]
fn enter_emite_invoke_con_el_id_seleccionado() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
for ch in ["s", "a", "v"] {
palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
}
let action = palette::apply(&mut s, PaletteMsg::Apply, &cmds);
assert_eq!(action, PaletteAction::Invoke("editor.save".into()));
}
#[test]
fn nav_circula_por_los_resultados() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
assert_eq!(s.selected, 0);
palette::apply(&mut s, PaletteMsg::Nav(1), &cmds);
assert_eq!(s.selected, 1);
// Saltar al final desde la cima con -1 (wrap-around).
let mut s = PaletteState::new(&cmds);
palette::apply(&mut s, PaletteMsg::Nav(-1), &cmds);
assert_eq!(s.selected, cmds.len() - 1);
}
#[test]
fn escape_emite_close() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
let action = palette::apply(&mut s, PaletteMsg::Close, &cmds);
assert_eq!(action, PaletteAction::Close);
}
#[test]
fn open_shortcut_es_ctrl_shift_p() {
use llimphi_ui::Modifiers;
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(palette::open_shortcut(&mk(true, true, "p")));
assert!(palette::open_shortcut(&mk(true, true, "P")));
// Sin shift no — ese es Ctrl+P del file-picker.
assert!(!palette::open_shortcut(&mk(true, false, "p")));
// Sin ctrl no.
assert!(!palette::open_shortcut(&mk(false, true, "p")));
// Otra letra no.
assert!(!palette::open_shortcut(&mk(true, true, "q")));
}
#[test]
fn busqueda_por_grupo_funciona() {
let cmds = seed();
let mut s = PaletteState::new(&cmds);
// "lsp" debería traer Format y Goto Definition (ambos del grupo LSP).
for ch in ["l", "s", "p"] {
palette::apply(&mut s, PaletteMsg::KeyInput(key_char(ch)), &cmds);
}
let ids: Vec<&str> = s.results.iter().map(|&i| cmds[i].id.as_str()).collect();
assert!(ids.contains(&"lsp.format"), "esperaba lsp.format en {ids:?}");
assert!(ids.contains(&"lsp.goto"), "esperaba lsp.goto en {ids:?}");
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-diff-viewer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-diff-viewer — visualización side-by-side de cambios entre dos textos. Módulo Llimphi: el host provee before/after (typically HEAD vs working tree, o snapshot vs current buffer), el módulo computa el diff con `similar` y lo presenta en dos columnas con marcadores +/- y números de línea."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
similar = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Diff side-by-side de [llimphi](../../README.md).
Toma dos textos y muestra diff por línea: inserciones, eliminaciones, modificaciones. Algoritmo Myers; resaltado intra-línea opcional.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Side-by-side diff of [llimphi](../../README.md).
Takes two texts and shows line-by-line diff: insertions, deletions, modifications. Myers algorithm; optional intra-line highlight.
+398
View File
@@ -0,0 +1,398 @@
//! `llimphi-module-diff-viewer` — visualización side-by-side de cambios.
//!
//! Equivalente al "Compare with Saved" de VS Code o el panel "Compare"
//! de JetBrains, pero como módulo Llimphi enchufable. El host le pasa
//! dos textos (`before`/`after`) y dos etiquetas (`"HEAD"`, `"Working
//! Tree"`, `"Buffer"` — lo que tenga sentido en su contexto), y el
//! módulo computa el diff line-based con [`similar`] y lo renderiza
//! en dos columnas con marcadores `+`/`-` y números de línea.
//!
//! El módulo no abre archivos, no llama a `git`, no toca disco. Toda
//! la fuente del diff la decide el host: puede comparar el disco vs
//! el buffer dirty, dos branches, dos snapshots de history, etc.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
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 similar::{ChangeTag, TextDiff};
/// Capabilities que aporta este módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.diff-viewer"];
const HEADER_H: f32 = 18.0;
const ROW_H: f32 = 15.0;
/// Una línea del diff alineada para render side-by-side.
///
/// El render usa dos celdas por fila (izquierda = `before`, derecha =
/// `after`). En una línea `Equal`, ambas celdas tienen el mismo
/// contenido. En `Delete`, sólo la izquierda; en `Insert`, sólo la
/// derecha. La struct cumple las dos roles para simplificar el render.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffRow {
pub kind: DiffKind,
/// Contenido de la celda izquierda (Equal o Delete) o vacío.
pub left: Option<DiffCell>,
/// Contenido de la celda derecha (Equal o Insert) o vacío.
pub right: Option<DiffCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffCell {
/// Número de línea 1-based en el lado correspondiente.
pub line_no: usize,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffKind {
Equal,
Delete,
Insert,
}
/// Estado del panel.
pub struct DiffState {
pub before_label: String,
pub after_label: String,
pub rows: Vec<DiffRow>,
pub scroll: usize,
/// Conteo agregado para mostrar en el header (`+12 / -3` etc.).
pub stats: DiffStats,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub inserts: usize,
pub deletes: usize,
pub equals: usize,
}
impl DiffState {
/// Construye el state computando el diff entre `before` y `after`.
/// Líneas se separan por '\n'; el último '\n' se conserva como
/// separador (no aparece como línea extra vacía).
pub fn new(
before_label: impl Into<String>,
after_label: impl Into<String>,
before: &str,
after: &str,
) -> Self {
let (rows, stats) = compute_rows(before, after);
Self {
before_label: before_label.into(),
after_label: after_label.into(),
rows,
scroll: 0,
stats,
}
}
}
/// Computa las filas alineadas a partir de los dos textos. La salida
/// preserva el orden lineal del archivo: bloques `Equal` mantienen las
/// líneas pareadas; un `Delete` que no tiene contraparte en el otro
/// lado aparece con `right = None`, y viceversa para `Insert`. No se
/// emparejan visualmente delete con insert — siguen la convención de
/// VS Code, que los muestra como líneas separadas.
pub fn compute_rows(before: &str, after: &str) -> (Vec<DiffRow>, DiffStats) {
let diff = TextDiff::from_lines(before, after);
let mut rows: Vec<DiffRow> = Vec::new();
let mut stats = DiffStats::default();
let mut left_no = 0usize;
let mut right_no = 0usize;
for change in diff.iter_all_changes() {
let text = change.value().trim_end_matches('\n').to_string();
match change.tag() {
ChangeTag::Equal => {
left_no += 1;
right_no += 1;
stats.equals += 1;
rows.push(DiffRow {
kind: DiffKind::Equal,
left: Some(DiffCell { line_no: left_no, text: text.clone() }),
right: Some(DiffCell { line_no: right_no, text }),
});
}
ChangeTag::Delete => {
left_no += 1;
stats.deletes += 1;
rows.push(DiffRow {
kind: DiffKind::Delete,
left: Some(DiffCell { line_no: left_no, text }),
right: None,
});
}
ChangeTag::Insert => {
right_no += 1;
stats.inserts += 1;
rows.push(DiffRow {
kind: DiffKind::Insert,
left: None,
right: Some(DiffCell { line_no: right_no, text }),
});
}
}
}
(rows, stats)
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum DiffMsg {
Open,
Close,
/// Scroll vertical en líneas (positivo = baja).
Scroll(i32),
/// Salta al próximo hunk (∆+/-) en dirección.
NextHunk,
PrevHunk,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAction {
None,
/// El host debería remover el state del modelo.
Close,
}
pub fn apply(state: &mut DiffState, msg: DiffMsg, visible_rows: usize) -> DiffAction {
match msg {
DiffMsg::Open => DiffAction::None,
DiffMsg::Close => DiffAction::Close,
DiffMsg::Scroll(delta) => {
scroll_by(state, delta, visible_rows);
DiffAction::None
}
DiffMsg::NextHunk => {
jump_to_hunk(state, true, visible_rows);
DiffAction::None
}
DiffMsg::PrevHunk => {
jump_to_hunk(state, false, visible_rows);
DiffAction::None
}
}
}
fn scroll_by(state: &mut DiffState, delta: i32, visible_rows: usize) {
let max_scroll = state.rows.len().saturating_sub(visible_rows);
let new_scroll = (state.scroll as i64 + delta as i64).max(0) as usize;
state.scroll = new_scroll.min(max_scroll);
}
/// Busca la próxima fila con `kind != Equal` en la dirección dada,
/// empezando justo después/antes del scroll actual. Si no hay más,
/// no-op.
fn jump_to_hunk(state: &mut DiffState, forward: bool, visible_rows: usize) {
let start = state.scroll;
let n = state.rows.len();
let found = if forward {
(start + 1..n).find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
} else {
(0..start.min(n)).rev().find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
};
if let Some(i) = found {
let max_scroll = n.saturating_sub(visible_rows);
state.scroll = i.min(max_scroll);
}
}
/// Routing de teclas cuando el panel está abierto.
pub fn on_key(_state: &DiffState, event: &KeyEvent) -> Option<DiffMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => DiffMsg::Close,
Key::Named(NamedKey::ArrowDown) => DiffMsg::Scroll(1),
Key::Named(NamedKey::ArrowUp) => DiffMsg::Scroll(-1),
Key::Named(NamedKey::PageDown) => DiffMsg::Scroll(20),
Key::Named(NamedKey::PageUp) => DiffMsg::Scroll(-20),
Key::Named(NamedKey::Home) => DiffMsg::Scroll(-(i32::MAX / 4)),
Key::Named(NamedKey::End) => DiffMsg::Scroll(i32::MAX / 4),
Key::Character(s) if s == "n" => DiffMsg::NextHunk,
Key::Character(s) if s == "N" => DiffMsg::PrevHunk,
_ => return None,
})
}
/// El atajo recomendado: **Ctrl+Shift+D**, similar al "Compare with
/// Saved" de VS Code (que usa Ctrl+Shift+P + comando).
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("d"))
}
/// Paleta visual con colores diff convencionales (verde para insert,
/// rojo apagado para delete).
#[derive(Debug, Clone)]
pub struct DiffPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_insert: Color,
pub bg_delete: Color,
pub bg_empty: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_insert: Color,
pub fg_delete: Color,
}
impl DiffPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// Verde/rojo apagados — visibles sobre fondo oscuro pero sin
// saturar. Si el theme expone colores semánticos de diff en
// el futuro, los usamos; por ahora hardcoded.
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_insert: Color::from_rgba8(40, 80, 50, 255),
bg_delete: Color::from_rgba8(90, 40, 45, 255),
bg_empty: t.bg_panel_alt,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_insert: Color::from_rgba8(170, 230, 180, 255),
fg_delete: Color::from_rgba8(240, 180, 185, 255),
}
}
}
/// Render del panel side-by-side. `height_px` es la altura total
/// disponible; el módulo divide entre el header de 18 px y la grid.
pub fn view<HostMsg, F>(
state: &DiffState,
palette: &DiffPalette,
height_px: f32,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(DiffMsg) -> HostMsg + Copy + 'static,
{
let _ = to_host; // v0 no monta eventos puntuales sobre filas
let header_text = format!(
"diff · {}{} · +{} -{} ={} · ↑↓ scroll · n/N hunk · Esc cierra",
state.before_label,
state.after_label,
state.stats.inserts,
state.stats.deletes,
state.stats.equals,
);
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
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_text, 10.0, palette.fg_muted, Alignment::Start);
let grid_h = (height_px - HEADER_H).max(0.0);
let max_rows = ((grid_h / ROW_H) as usize).max(1);
let end = (state.scroll + max_rows).min(state.rows.len());
let mut grid_rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
for row in &state.rows[state.scroll..end] {
grid_rows.push(render_row(row, palette));
}
while grid_rows.len() < max_rows {
// Padding visual para mantener altura constante.
grid_rows.push(empty_row(palette));
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + grid_rows.len());
children.push(header);
children.extend(grid_rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(height_px) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
fn render_row<HostMsg>(row: &DiffRow, palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let (left_bg, left_fg, left_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Delete => (palette.bg_delete, palette.fg_delete, "-"),
DiffKind::Insert => (palette.bg_empty, palette.fg_muted, " "),
};
let (right_bg, right_fg, right_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Insert => (palette.bg_insert, palette.fg_insert, "+"),
DiffKind::Delete => (palette.bg_empty, palette.fg_muted, " "),
};
let left_text = match &row.left {
Some(c) => format!("{:>4} {}{}", c.line_no, left_mark, c.text),
None => String::new(),
};
let right_text = match &row.right {
Some(c) => format!("{:>4} {}{}", c.line_no, right_mark, c.text),
None => String::new(),
};
let cell = |bg: Color, fg: Color, text: String| {
View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.5_f32), height: length(ROW_H) },
padding: Rect {
left: length(6.0_f32),
right: length(6.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(text, 10.5, fg, Alignment::Start)
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![cell(left_bg, left_fg, left_text), cell(right_bg, right_fg, right_text)])
}
fn empty_row<HostMsg>(palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
}
+155
View File
@@ -0,0 +1,155 @@
//! Smoke tests del cómputo de filas y el routing de teclas. Sin
//! backend gráfico — pruebas puras sobre `compute_rows` y `apply`.
use llimphi_module_diff_viewer::{
self as diff, DiffAction, DiffKind, DiffMsg, DiffState,
};
#[test]
fn diff_basico_inserts_y_deletes() {
let before = "a\nb\nc\n";
let after = "a\nB\nc\nd\n";
let (rows, stats) = diff::compute_rows(before, after);
// El diff esperado:
// = a / a
// - b
// + B
// = c / c
// + d
assert_eq!(stats.equals, 2);
assert_eq!(stats.deletes, 1);
assert_eq!(stats.inserts, 2);
assert_eq!(rows[0].kind, DiffKind::Equal);
assert_eq!(rows[0].left.as_ref().unwrap().text, "a");
assert_eq!(rows[0].right.as_ref().unwrap().text, "a");
// El primer cambio debe ser un Delete o Insert (similar agrupa);
// verificamos que B aparezca y b no.
let texts_left: Vec<&str> = rows
.iter()
.filter_map(|r| r.left.as_ref().map(|c| c.text.as_str()))
.collect();
let texts_right: Vec<&str> = rows
.iter()
.filter_map(|r| r.right.as_ref().map(|c| c.text.as_str()))
.collect();
assert!(texts_left.contains(&"b"));
assert!(texts_right.contains(&"B"));
assert!(texts_right.contains(&"d"));
}
#[test]
fn numeros_de_linea_son_correctos() {
let before = "alpha\nbeta\ngamma\n";
let after = "alpha\nBETA\ngamma\ndelta\n";
let (rows, _) = diff::compute_rows(before, after);
// alpha en línea 1 de ambos.
let alpha_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "alpha").unwrap_or(false)
}).unwrap();
assert_eq!(alpha_row.left.as_ref().unwrap().line_no, 1);
assert_eq!(alpha_row.right.as_ref().unwrap().line_no, 1);
// beta (delete) en línea 2 izquierda.
let beta_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "beta").unwrap_or(false)
}).unwrap();
assert_eq!(beta_row.left.as_ref().unwrap().line_no, 2);
assert!(beta_row.right.is_none());
// delta (insert) en línea 4 derecha.
let delta_row = rows.iter().find(|r| {
r.right.as_ref().map(|c| c.text == "delta").unwrap_or(false)
}).unwrap();
assert_eq!(delta_row.right.as_ref().unwrap().line_no, 4);
assert!(delta_row.left.is_none());
}
#[test]
fn textos_identicos_solo_equal() {
let text = "uno\ndos\ntres\n";
let (rows, stats) = diff::compute_rows(text, text);
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|r| r.kind == DiffKind::Equal));
assert_eq!(stats.inserts, 0);
assert_eq!(stats.deletes, 0);
assert_eq!(stats.equals, 3);
}
#[test]
fn scroll_no_excede_los_limites() {
let before = (0..50).map(|i| i.to_string()).collect::<Vec<_>>().join("\n");
let after = before.clone(); // identical → 50 Equal rows
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
// Scroll grande hacia abajo: tope = 50 - visible_rows.
diff::apply(&mut state, DiffMsg::Scroll(1000), 10);
assert_eq!(state.scroll, 40);
// Scroll arriba: tope mínimo 0.
diff::apply(&mut state, DiffMsg::Scroll(-1000), 10);
assert_eq!(state.scroll, 0);
}
#[test]
fn next_hunk_salta_a_la_proxima_diferencia() {
// 20 líneas iguales + 2 cambios + 20 más. visible_rows=5 deja
// espacio real para scrollear.
let mut before = String::new();
let mut after = String::new();
for i in 0..20 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
before.push_str("DEL\n");
after.push_str("INS\n");
for i in 20..40 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
diff::apply(&mut state, DiffMsg::NextHunk, 5);
assert!(state.scroll > 0, "scroll quedó en 0 — no saltó al hunk");
let row = &state.rows[state.scroll];
assert!(
!matches!(row.kind, DiffKind::Equal),
"esperaba aterrizar en un hunk, vi {:?}",
row.kind
);
// PrevHunk: vuelve al inicio (no hay hunk antes del primer cambio).
diff::apply(&mut state, DiffMsg::PrevHunk, 5);
// Puede quedarse en el mismo hunk si era el único accesible hacia
// atrás, o saltar más arriba. Lo único que verificamos es que no
// hubo panic ni scroll fuera de rango.
assert!(state.scroll < state.rows.len());
}
#[test]
fn escape_cierra() {
let mut state = DiffState::new("a", "b", "x\n", "y\n");
let action = diff::apply(&mut state, DiffMsg::Close, 10);
assert_eq!(action, DiffAction::Close);
}
#[test]
fn open_shortcut_es_ctrl_shift_d() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(diff::open_shortcut(&mk(true, true, "d")));
assert!(diff::open_shortcut(&mk(true, true, "D")));
assert!(!diff::open_shortcut(&mk(true, false, "d")));
assert!(!diff::open_shortcut(&mk(false, true, "d")));
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-fif"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-fif — find-in-files reusable (estilo JetBrains). Módulo Llimphi: state + Msg + Action + apply/on_key/view. Cualquier app que mantenga una lista de paths puede enchufarlo."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files de [llimphi](../../README.md).
Buscar en todos los archivos del workspace con regex + glob de filenames. Streaming de resultados (no espera al fin del scan). Click en resultado abre el archivo en la línea.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-fif
> Find-in-files of [llimphi](../../README.md).
Search across all workspace files with regex + filename glob. Streamed results (doesn't wait for scan end). Click on result opens the file at the line.
+815
View File
@@ -0,0 +1,815 @@
//! `llimphi-module-fif` — find-in-files reutilizable (estilo JetBrains).
//!
//! Módulo Llimphi con dos vistas independientes:
//!
//! - [`view_dialog`] — popup compacto (header + input) que el host pinta
//! como overlay modal centrado. Sólo visible cuando
//! [`FifState::dialog_open`] es `true`.
//! - [`view_results_bar`] — barra inferior persistente con la lista de
//! matches. El host la pinta como tool window al pie (estilo JetBrains
//! "Find" tool window). Sobrevive al cierre del dialog: el user puede
//! Esc-cerrar el popup y seguir clickeando los resultados.
//!
//! El flujo típico es: `Ctrl+Shift+F` abre el dialog → tipear → Enter
//! ejecuta `search` → resultados aparecen en la barra inferior → Esc
//! cierra el popup pero la barra queda → click en una fila abre el
//! archivo. Re-disparar `Ctrl+Shift+F` reabre el popup conservando los
//! últimos resultados.
//!
//! ## Cómo lo enchufa una app
//!
//! ```ignore
//! struct AppModel {
//! all_files: Vec<PathBuf>,
//! fif: Option<FifState>,
//! // …
//! }
//!
//! enum AppMsg { Fif(llimphi_module_fif::FifMsg), … }
//!
//! // En update(model, msg):
//! AppMsg::Fif(fm) => {
//! // Lazy-init en Open:
//! if matches!(fm, FifMsg::Open) && model.fif.is_none() {
//! model.fif = Some(FifState::new());
//! } else if matches!(fm, FifMsg::Open) {
//! model.fif.as_mut().unwrap().dialog_open = true;
//! }
//! let action = match model.fif.as_mut() {
//! Some(s) => llimphi_module_fif::apply(s, fm, &model.all_files),
//! None => FifAction::None,
//! };
//! match action {
//! FifAction::None => {}
//! FifAction::CloseDialog => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! }
//! FifAction::CloseAll => model.fif = None,
//! FifAction::Searched { .. } => { /* actualizar status bar */ }
//! FifAction::OpenAt { path, line, col } => {
//! if let Some(s) = model.fif.as_mut() { s.dialog_open = false; }
//! open_path_in_app(path, line, col);
//! }
//! }
//! }
//!
//! // En on_key(model, event): solo rutea cuando el dialog está visible.
//! if let Some(state) = model.fif.as_ref() {
//! if let Some(fm) = llimphi_module_fif::on_key(state, event) {
//! return Some(AppMsg::Fif(fm));
//! }
//! }
//! if llimphi_module_fif::open_shortcut(event) {
//! return Some(AppMsg::Fif(FifMsg::Open));
//! }
//!
//! // En view(model):
//! // - dialog como overlay arriba del editor:
//! if let Some(s) = model.fif.as_ref().filter(|s| s.dialog_open) {
//! overlay_children.push(view_dialog(s, &palette, AppMsg::Fif));
//! }
//! // - barra de resultados como panel inferior persistente:
//! if let Some(s) = model.fif.as_ref().filter(|s| !s.results.is_empty()) {
//! bottom_panels.push(view_results_bar(
//! s, &model.all_files, &model.root, &palette, AppMsg::Fif,
//! ));
//! }
//! ```
//!
//! ## Por qué Action en lugar de un trait `FifHost`
//!
//! El módulo no toma `&mut Host` porque acoplar el módulo a un trait
//! arrastra problemas de ownership/lifetimes en el loop tipo Elm que usa
//! Llimphi (Model se mueve por value en update). Devolver una [`FifAction`]
//! deja al host libre de aplicar el efecto donde y como quiera, y mantiene
//! al módulo libre de cualquier conocimiento sobre el host.
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use std::time::Duration;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, JustifyContent, 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 este módulo aporta al host. Convención del protocolo
/// Brahman Card aplicada a módulos compile-time: el host (cuando construye
/// su [`card_core::Card`]) puede agregar esto a `provides` para anunciar
/// — vía broker — que su instancia ofrece find-in-files al ecosistema.
pub const CAPABILITIES: &[&str] = &["editor.find-in-files"];
/// Caps razonables para que un workspace grande no funda el UI.
pub const MAX_RESULTS: usize = 1000;
pub const MAX_FILE_SIZE: u64 = 2_000_000;
pub const SNIPPET_MAX_CHARS: usize = 160;
pub const MIN_QUERY_LEN: usize = 2;
const DIALOG_W: f32 = 560.0;
const DIALOG_H: f32 = 116.0;
const BAR_H: f32 = 220.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 9;
/// Qué input tiene el foco dentro del dialog. `Tab` alterna.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FifFocus {
Search,
Replace,
}
/// Un match individual.
#[derive(Debug, Clone)]
pub struct FifMatch {
/// Índice dentro del slice de paths que el host pasa a [`apply`] y
/// las vistas. Convención: el host no debe reordenar/mutar el slice
/// entre frames mientras el módulo esté abierto.
pub file_idx: usize,
/// 0-based.
pub line: usize,
/// 0-based, en chars (no bytes).
pub col: usize,
/// Línea matcheada trimmed-left y truncada a [`SNIPPET_MAX_CHARS`].
pub snippet: String,
}
/// Estado interno del módulo.
pub struct FifState {
pub input: TextInputState,
/// Texto de reemplazo. Si vacío, `ReplaceAll` borra los matches.
pub replace: TextInputState,
pub focus: FifFocus,
pub results: Vec<FifMatch>,
pub selected: usize,
/// Última query realmente ejecutada (puede diferir del input si el
/// user siguió tipeando sin re-Enter).
pub last_query: String,
/// `true` cuando el popup modal está visible. La barra de resultados
/// se pinta independientemente de esto: sobrevive al cierre del popup.
pub dialog_open: bool,
}
impl Default for FifState {
fn default() -> Self {
Self::new()
}
}
impl FifState {
pub fn new() -> Self {
Self {
input: TextInputState::new(),
replace: TextInputState::new(),
focus: FifFocus::Search,
results: Vec::new(),
selected: 0,
last_query: String::new(),
dialog_open: true,
}
}
}
/// Vocabulario interno. El host lo wrapea en su propio Msg.
#[derive(Clone)]
pub enum FifMsg {
/// El host detectó el atajo de apertura (o un comando). Lazy-init del
/// state lo hace el host; `apply` sólo marca `dialog_open = true`.
Open,
/// El user pidió cerrar el popup (Esc). Los resultados quedan en la
/// barra inferior.
CloseDialog,
/// Cerrar todo: el host debería tirar el `FifState` completo.
CloseAll,
/// Tecla rumbo al input.
KeyInput(KeyEvent),
/// Navegación dentro de la lista de resultados.
Nav(i32),
/// Enter: la primera vez ejecuta search; subsiguientes abren el
/// match seleccionado.
Submit,
/// Click en una fila de la barra inferior: selecciona y abre.
ActivateAt(usize),
/// Alterna el foco entre los inputs search ↔ replace (Tab).
ToggleFocus,
/// Reemplaza el texto matcheado por `replace.text()` en todos los
/// matches actuales. Idempotente: re-leer el archivo, sustituir
/// case-insensitive por la query, escribir. Vacía `results` para
/// forzar nueva búsqueda si el user quiere ver el estado posterior.
ReplaceAll,
}
/// Efecto solicitado al host. El módulo nunca toca el FS ni el resto del
/// modelo de la app — devuelve el deseo, el host elige cómo lo aplica.
#[derive(Debug, Clone)]
pub enum FifAction {
None,
/// El host debería marcar `state.dialog_open = false` y dejar el
/// resto del state intacto (resultados visibles en la barra).
CloseDialog,
/// El host debería remover el state del modelo entero.
CloseAll,
/// Tras un Submit que ejecutó search.
Searched { matches: usize, elapsed: Duration, query: String },
/// El host debería abrir `path` y posicionar el caret en `(line, col)`.
/// El módulo NO se cierra automáticamente: el host decide si ocultar
/// el dialog tras abrir el match.
OpenAt { path: PathBuf, line: usize, col: usize },
/// Tras `ReplaceAll`: cuántos archivos tocados, cuántos matches
/// sustituidos, cuántos fallaron. El host debería refrescar buffers
/// abiertos (recargar de disco si no-dirty) y mostrar status.
Replaced {
files_changed: usize,
replacements: usize,
failures: usize,
query: String,
replacement: String,
},
}
/// Aplica un mensaje al estado y retorna el efecto que el host debe ejecutar.
///
/// `paths` es la lista canónica de archivos sobre la que buscar. El host
/// la pasa por referencia; cuando Submit dispara una búsqueda, este
/// vector se itera y se leen los archivos (skip binarios y >MAX_FILE_SIZE).
pub fn apply(state: &mut FifState, msg: FifMsg, paths: &[PathBuf]) -> FifAction {
match msg {
FifMsg::Open => {
state.dialog_open = true;
FifAction::None
}
FifMsg::CloseDialog => FifAction::CloseDialog,
FifMsg::CloseAll => FifAction::CloseAll,
FifMsg::KeyInput(ev) => {
let _ = match state.focus {
FifFocus::Search => state.input.apply_key(&ev),
FifFocus::Replace => state.replace.apply_key(&ev),
};
FifAction::None
}
FifMsg::ToggleFocus => {
state.focus = match state.focus {
FifFocus::Search => FifFocus::Replace,
FifFocus::Replace => FifFocus::Search,
};
FifAction::None
}
FifMsg::ReplaceAll => {
let query = state.last_query.clone();
if query.is_empty() || state.results.is_empty() {
return FifAction::None;
}
let replacement = state.replace.text();
let (files_changed, replacements, failures) =
replace_all(paths, &state.results, &query, &replacement);
// Invalidamos resultados: las posiciones (line, col) ya no
// necesariamente apuntan al mismo texto. El user puede re-Enter.
state.results.clear();
state.selected = 0;
FifAction::Replaced {
files_changed,
replacements,
failures,
query,
replacement,
}
}
FifMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
FifAction::None
}
FifMsg::Submit => {
let query = state.input.text();
let needs_search = query != state.last_query || state.results.is_empty();
if needs_search {
if query.len() < MIN_QUERY_LEN {
return FifAction::None;
}
let started = std::time::Instant::now();
let results = search(paths, &query);
let elapsed = started.elapsed();
let n = results.len();
state.results = results;
state.selected = 0;
state.last_query = query.clone();
FifAction::Searched { matches: n, elapsed, query }
} else {
let Some(fm) = state.results.get(state.selected).cloned() else {
return FifAction::None;
};
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
FifMsg::ActivateAt(idx) => {
if idx >= state.results.len() {
return FifAction::None;
}
state.selected = idx;
let fm = state.results[idx].clone();
let Some(path) = paths.get(fm.file_idx).cloned() else {
return FifAction::None;
};
FifAction::OpenAt { path, line: fm.line, col: fm.col }
}
}
}
/// Routing de teclas cuando el dialog está abierto. Si el popup está
/// cerrado, devuelve `None` y el host puede seguir routeando al editor.
pub fn on_key(state: &FifState, event: &KeyEvent) -> Option<FifMsg> {
if !state.dialog_open {
return None;
}
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => FifMsg::CloseDialog,
Key::Named(NamedKey::Enter) => FifMsg::Submit,
Key::Named(NamedKey::Tab) => FifMsg::ToggleFocus,
Key::Named(NamedKey::ArrowDown) => FifMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => FifMsg::Nav(-1),
_ => FifMsg::KeyInput(event.clone()),
})
}
/// Chequea si el evento es el atajo recomendado: **Ctrl+Shift+F**. El
/// host puede ignorar esto y definir su propio binding.
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("f"))
}
/// Paleta visual. Construible desde un [`llimphi_theme::Theme`].
#[derive(Debug, Clone)]
pub struct FifPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub border: Color,
/// Theme cacheado para reusar en `TextInputPalette::from_theme`.
theme: llimphi_theme::Theme,
}
impl FifPalette {
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,
border: t.border,
theme: t.clone(),
}
}
}
/// Popup modal compacto: header + input. Sin lista de resultados — esa
/// vive en [`view_results_bar`]. El host lo pinta como overlay centrado.
///
/// El `View` devuelto tiene tamaño fijo ([`DIALOG_W`] × [`DIALOG_H`]). Si
/// el host quiere centrarlo, debe envolverlo en un container con
/// `JustifyContent::Center`/`AlignItems::Center` o usar el slot de overlay.
pub fn view_dialog<HostMsg, F>(
state: &FifState,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let dirty_query = state.input.text() != state.last_query;
let header = if state.last_query.is_empty() {
"find in files · Enter busca · Esc cierra".to_string()
} else if state.results.is_empty() {
format!("«{}» · sin matches · Esc cierra", state.last_query)
} else {
let staleness = if dirty_query { " · Enter re-busca" } else { "" };
format!(
"«{}» · {} matches · ↓↑ navega · Enter abre{staleness} · Esc cierra",
state.last_query,
state.results.len(),
)
};
let header_view = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(10.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 search_focus = state.focus == FifFocus::Search;
let search_view = labelled_input(
"buscar",
&state.input,
"buscar en archivos…",
search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_view = labelled_input(
"reemplazar",
&state.replace,
"(vacío para borrar)",
!search_focus,
palette,
&tp,
to_host(FifMsg::Open),
);
let replace_btn = View::new(Style {
size: Size { width: length(118.0_f32), height: length(20.0_f32) },
padding: Rect {
left: length(6.0_f32),
right: length(6.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)
.radius(3.0)
.text_aligned(
"reemplazar todo".to_string(),
10.0,
palette.fg_muted,
Alignment::Center,
)
.on_click(to_host(FifMsg::ReplaceAll));
let hint = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.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),
..Default::default()
})
.text_aligned("Tab alterna campos".to_string(), 9.0, palette.fg_muted, Alignment::Start);
let actions = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.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_panel)
.children(vec![hint, replace_btn]);
// Wrapper exterior: tamaño fijo del dialog + borde sutil.
let dialog = View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: length(DIALOG_W), height: length(DIALOG_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.radius(6.0)
.children(vec![header_view, search_view, replace_view, actions]);
// Container que centra el dialog horizontalmente — el host pone esto
// como overlay arriba del editor; un click en zona vacía no hace nada
// (no cerramos por click-outside, sería sorpresivo si el user está
// ojeando resultados en la barra).
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(DIALOG_H + 16.0) },
padding: Rect {
left: length(0.0_f32),
right: length(0.0_f32),
top: length(12.0_f32),
bottom: length(4.0_f32),
},
justify_content: Some(JustifyContent::Center),
align_items: Some(AlignItems::Start),
flex_shrink: 0.0,
..Default::default()
})
.children(vec![dialog])
}
/// Barra inferior persistente con los matches. Filas clickeables (click
/// → [`FifMsg::ActivateAt`]). El host la pinta como tool window al pie
/// del editor, hermana del terminal/output (estilo JetBrains).
///
/// Si no hay resultados, devuelve una barra mínima con un mensaje — el
/// host puede usar `state.results.is_empty()` para no renderizarla.
pub fn view_results_bar<HostMsg, F>(
state: &FifState,
paths: &[PathBuf],
root: &Path,
palette: &FifPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(FifMsg) -> HostMsg + Copy + 'static,
{
let header_text = if state.results.is_empty() {
format!("find · «{}» · sin matches", state.last_query)
} else {
format!(
"find · «{}» · {} / {} matches · click abre · Ctrl+Shift+F reabre",
state.last_query,
state.selected + 1,
state.results.len(),
)
};
let close_btn = View::new(Style {
size: Size { width: length(54.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("cerrar ✕".to_string(), 10.0, palette.fg_muted, Alignment::Center)
.on_click(to_host(FifMsg::CloseAll));
let header_label = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(20.0_f32) },
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),
..Default::default()
})
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let header_bar = View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(20.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.children(vec![header_label, close_btn]);
let visible_start = state
.selected
.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(fm) = state.results.get(i) else { continue };
let Some(path) = paths.get(fm.file_idx) else { continue };
let rel = relative_to(root, path);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let dir = rel.strip_suffix(name).unwrap_or("").trim_end_matches('/');
let dir_label = if dir.is_empty() { String::new() } else { format!(" {dir}") };
let label = format!("{name}:{}{dir_label} {}", fm.line + 1, fm.snippet);
let selected = i == state.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(12.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)
.on_click(to_host(FifMsg::ActivateAt(i))),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header_bar);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
/// Búsqueda substring case-insensitive. Pública para tests / hosts que
/// quieran disparar una búsqueda sin pasar por el state machine.
pub fn search(paths: &[PathBuf], query: &str) -> Vec<FifMatch> {
let mut out: Vec<FifMatch> = Vec::new();
let q_lc = query.to_lowercase();
for (file_idx, path) in paths.iter().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
if let Ok(meta) = std::fs::metadata(path) {
if meta.len() > MAX_FILE_SIZE {
continue;
}
}
let Ok(content) = std::fs::read_to_string(path) else { continue };
for (line_idx, line) in content.lines().enumerate() {
if out.len() >= MAX_RESULTS {
break;
}
let line_lc = line.to_ascii_lowercase();
let Some(byte_off) = line_lc.find(&q_lc) else { continue };
let col = line[..byte_off.min(line.len())].chars().count();
let trimmed = line.trim_start();
let snippet = if trimmed.chars().count() <= SNIPPET_MAX_CHARS {
trimmed.to_string()
} else {
let cut: String = trimmed.chars().take(SNIPPET_MAX_CHARS - 1).collect();
format!("{cut}")
};
out.push(FifMatch { file_idx, line: line_idx, col, snippet });
}
}
out
}
/// Reemplazo case-insensitive sobre los archivos involucrados en
/// `results`. Devuelve `(files_changed, replacements, failures)`.
/// Lee cada archivo una sola vez, sustituye todas las apariciones de
/// `query` por `replacement` (case-insensitive, preservando el resto), y
/// escribe sólo si hubo cambios. No toca buffers en memoria del host —
/// el host es responsable de recargar tabs si quiere ver los cambios.
pub fn replace_all(
paths: &[PathBuf],
results: &[FifMatch],
query: &str,
replacement: &str,
) -> (usize, usize, usize) {
if query.is_empty() {
return (0, 0, 0);
}
let mut touched: std::collections::BTreeSet<usize> =
std::collections::BTreeSet::new();
for fm in results {
touched.insert(fm.file_idx);
}
let mut files_changed = 0usize;
let mut total_replacements = 0usize;
let mut failures = 0usize;
let q_lc = query.to_lowercase();
for idx in touched {
let Some(path) = paths.get(idx) else { continue };
let Ok(content) = std::fs::read_to_string(path) else {
failures += 1;
continue;
};
let (new_content, n) = ci_replace_all(&content, query, &q_lc, replacement);
if n == 0 {
continue;
}
if std::fs::write(path, new_content).is_err() {
failures += 1;
continue;
}
files_changed += 1;
total_replacements += n;
}
(files_changed, total_replacements, failures)
}
/// Reemplazo case-insensitive preservando los bytes no-matchados.
fn ci_replace_all(haystack: &str, _needle: &str, needle_lc: &str, repl: &str) -> (String, usize) {
let hay_lc = haystack.to_lowercase();
let mut out = String::with_capacity(haystack.len());
let mut count = 0usize;
let mut i = 0usize;
while i <= hay_lc.len() {
if let Some(pos) = hay_lc[i..].find(needle_lc) {
let abs = i + pos;
out.push_str(&haystack[i..abs]);
out.push_str(repl);
i = abs + needle_lc.len();
count += 1;
} else {
out.push_str(&haystack[i..]);
break;
}
}
(out, count)
}
// ---------------------------------------------------------------------
// Helpers internos
// ---------------------------------------------------------------------
/// Pinta un input con etiqueta a la izquierda; cuando `focus` es true,
/// el fondo se realza para que el user vea dónde está tipeando.
fn labelled_input<HostMsg>(
label: &str,
state: &TextInputState,
placeholder: &str,
focus: bool,
palette: &FifPalette,
tp: &TextInputPalette,
fallback_msg: HostMsg,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let bg = if focus { palette.bg_selected } else { palette.bg_panel };
let label_view = View::new(Style {
size: Size { width: length(82.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(10.0_f32),
right: length(4.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.text_aligned(label.to_string(), 10.0, palette.fg_muted, Alignment::Start);
let input_view = View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.0_f32), height: length(28.0_f32) },
padding: Rect {
left: length(4.0_f32),
right: length(10.0_f32),
top: length(2.0_f32),
bottom: length(2.0_f32),
},
..Default::default()
})
.children(vec![text_input_view(
state,
placeholder,
focus,
tp,
fallback_msg,
)]);
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(28.0_f32) },
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.children(vec![label_view, input_view])
}
fn relative_to(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-file-picker"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-file-picker — fuzzy file picker (estilo Ctrl+P de VS Code). Módulo Llimphi reutilizable: state + Msg + Action + apply/on_key/view sobre un slice de paths que provee el host."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-file-picker
> Picker de archivos de [llimphi](../../README.md).
Fuzzy-finder de paths. Modal sobre la app. Devuelve `PathBuf` por `Msg::FilePicked`. Recientes priorizados.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-file-picker
> File picker of [llimphi](../../README.md).
Path fuzzy-finder. Modal over the app. Returns `PathBuf` via `Msg::FilePicked`. Recents prioritized.
+382
View File
@@ -0,0 +1,382 @@
//! `llimphi-module-file-picker` — fuzzy file picker reutilizable.
//!
//! Equivalente a Ctrl+P de VS Code / "Go to file" de JetBrains: el host
//! mantiene una lista de paths candidatos (típicamente walk del workspace
//! cacheado al arrancar) y el módulo presenta un overlay con input +
//! resultados rankeados. Cuando el user pica uno, el módulo emite
//! [`PickerAction::Open`] y el host decide cómo abrir (tab nuevo, split,
//! etc.).
//!
//! Sigue el contrato Llimphi de [`docs/MODULES.md`]:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
//!
//! ## Cómo lo enchufa una app
//!
//! ```ignore
//! use llimphi_module_file_picker::{self as picker, PickerAction, PickerMsg,
//! PickerPalette, PickerState};
//!
//! struct Model { all_files: Vec<PathBuf>, picker: Option<PickerState>, … }
//! enum Msg { Picker(PickerMsg), … }
//!
//! // update:
//! Msg::Picker(pm) => {
//! let mut m = model;
//! if matches!(pm, PickerMsg::Open) && m.picker.is_none() {
//! m.picker = Some(PickerState::new(&m.all_files, &m.root));
//! return m;
//! }
//! let action = match m.picker.as_mut() {
//! Some(s) => picker::apply(s, pm, &m.all_files, &m.root),
//! None => return m,
//! };
//! match action {
//! PickerAction::Close => m.picker = None,
//! PickerAction::Open(path) => {
//! m.picker = None;
//! m = open_path_in_app(m, path);
//! }
//! PickerAction::None => {}
//! }
//! m
//! }
//!
//! // on_key:
//! if let Some(state) = model.picker.as_ref() {
//! if let Some(pm) = picker::on_key(state, event) {
//! return Some(Msg::Picker(pm));
//! }
//! }
//! if picker::open_shortcut(event) {
//! return Some(Msg::Picker(PickerMsg::Open));
//! }
//!
//! // view:
//! if let Some(state) = model.picker.as_ref() {
//! let panel = picker::view(
//! state, &model.all_files, &model.root,
//! &PickerPalette::from_theme(&theme),
//! Msg::Picker,
//! );
//! children.push(panel);
//! }
//! ```
#![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 este módulo aporta al host. El host (cuando construye
/// su `card_core::Card`) puede agregar esto a `provides` para anunciar
/// vía broker que ofrece file-picker al ecosistema.
pub const CAPABILITIES: &[&str] = &["editor.file-picker"];
/// Máximo de resultados rankeados que entran al popup.
pub const MAX_RESULTS: usize = 200;
const BAR_H: f32 = 220.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 9;
/// Estado interno. Los `results` son índices al slice de paths que pasa
/// el host: el módulo no copia paths, sólo guarda índices.
pub struct PickerState {
pub input: TextInputState,
pub results: Vec<usize>,
pub selected: usize,
}
impl Default for PickerState {
fn default() -> Self {
Self::new_empty()
}
}
impl PickerState {
/// Crea un picker vacío. Si querés pre-filtrar con los paths que ya
/// tenés, llamá [`PickerState::new`] en su lugar.
pub fn new_empty() -> Self {
Self {
input: TextInputState::new(),
results: Vec::new(),
selected: 0,
}
}
/// Crea un picker con todos los `paths` como resultados iniciales
/// (sin filtrar). Conveniente para el ack visual del Ctrl+P recién
/// disparado.
pub fn new(paths: &[PathBuf], root: &Path) -> Self {
let mut s = Self::new_empty();
refilter(&mut s, paths, root);
s
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum PickerMsg {
/// Símbolo conveniente para que el host dispatche al detectar el
/// shortcut. El módulo no maneja Open él mismo — la creación del
/// state corre por cuenta del host (porque típicamente quiere pasar
/// la lista canónica de paths).
Open,
Close,
KeyInput(KeyEvent),
Nav(i32),
/// Enter: abre el match seleccionado.
Apply,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone)]
pub enum PickerAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería abrir este `path`. El módulo NO se cierra
/// automáticamente — el host decide si ocultar el picker tras abrir.
Open(PathBuf),
}
/// Aplica un mensaje al estado.
pub fn apply(
state: &mut PickerState,
msg: PickerMsg,
paths: &[PathBuf],
root: &Path,
) -> PickerAction {
match msg {
PickerMsg::Open => PickerAction::None,
PickerMsg::Close => PickerAction::Close,
PickerMsg::KeyInput(ev) => {
state.input.apply_key(&ev);
refilter(state, paths, root);
PickerAction::None
}
PickerMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
PickerAction::None
}
PickerMsg::Apply => {
let Some(&file_idx) = state.results.get(state.selected) else {
return PickerAction::None;
};
let Some(path) = paths.get(file_idx).cloned() else {
return PickerAction::None;
};
PickerAction::Open(path)
}
}
}
/// Routing de teclas cuando el panel está abierto.
pub fn on_key(_state: &PickerState, event: &KeyEvent) -> Option<PickerMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => PickerMsg::Close,
Key::Named(NamedKey::Enter) => PickerMsg::Apply,
Key::Named(NamedKey::ArrowDown) => PickerMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => PickerMsg::Nav(-1),
_ => PickerMsg::KeyInput(event.clone()),
})
}
/// Chequea si el evento es el atajo recomendado: **Ctrl+P**.
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("p"))
}
/// Recalcula `state.results` según el query del input. Match case-insensitive
/// sobre el path relativo. Score penaliza paths largos y premia hits en el
/// basename. Query vacío = todos los paths ordenados por longitud asc.
/// Cap: [`MAX_RESULTS`].
pub fn refilter(state: &mut PickerState, paths: &[PathBuf], root: &Path) {
let q = state.input.text();
let q_lc = q.to_lowercase();
let mut scored: Vec<(i64, usize)> = Vec::new();
for (i, path) in paths.iter().enumerate() {
let rel = relative_to(root, path);
if q_lc.is_empty() {
scored.push((rel.len() as i64, i));
continue;
}
let rel_lc = rel.to_lowercase();
let Some(rel_hit) = rel_lc.find(&q_lc) else { continue };
let name = path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase())
.unwrap_or_default();
let name_hit = name.find(&q_lc);
let score = match name_hit {
Some(pos) => pos as i64 * 4 + rel.len() as i64,
None => 10_000 + rel_hit as i64 + rel.len() as i64,
};
scored.push((score, i));
}
scored.sort_by_key(|(s, _)| *s);
scored.truncate(MAX_RESULTS);
state.results = scored.into_iter().map(|(_, i)| i).collect();
state.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct PickerPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
theme: llimphi_theme::Theme,
}
impl PickerPalette {
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,
theme: t.clone(),
}
}
}
/// Render del panel. `to_host` mapea cada `PickerMsg` interno al `Msg`
/// de la app.
pub fn view<HostMsg, F>(
state: &PickerState,
paths: &[PathBuf],
root: &Path,
palette: &PickerPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(PickerMsg) -> HostMsg + Copy + 'static,
{
let header = if state.results.is_empty() {
format!("file picker · sin matches · {} archivos · Esc cierra", paths.len())
} else {
format!(
"file picker · {} / {} · ↓↑ navega · Enter abre · Esc cierra",
state.selected + 1,
state.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(
&state.input,
"filtro: nombre o ruta…",
true,
&tp,
to_host(PickerMsg::Open),
)]);
let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&file_idx) = state.results.get(i) else { continue };
let Some(path) = paths.get(file_idx) else { continue };
let rel = relative_to(root, path);
let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("?");
let dir = rel.strip_suffix(name).unwrap_or("");
let label = if dir.is_empty() {
name.to_string()
} else {
format!("{name} {}", dir.trim_end_matches('/'))
};
let selected = i == state.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(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
fn relative_to(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string())
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-mini-map"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-mini-map — overlay minimap del buffer activo. Modulo Llimphi: el host le pasa un snapshot del buffer + viewport + caret, el modulo pinta un panel vertical con un slab por linea (ancho aprox chars), resalta el viewport visible y emite Jump(line) al click. Estilo VS Code/Sublime."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
[dev-dependencies]
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-mini-map
> Mini-mapa del editor de [llimphi](../../README.md).
Overview a la derecha del [`text-editor`](../../widgets/text-editor/README.md): renderizado escalado del archivo con highlight de la posición actual. Click salta a esa porción.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-mini-map
> Editor mini-map of [llimphi](../../README.md).
Right-side overview of [`text-editor`](../../widgets/text-editor/README.md): scaled rendering of the file with highlight of current position. Click jumps to that section.
+274
View File
@@ -0,0 +1,274 @@
//! `llimphi-module-mini-map` — minimap del buffer activo.
//!
//! Equivalente al "Minimap" de VS Code / "thumbnail" de Sublime: un
//! panel angosto pegado al editor que pinta una linea horizontal por
//! cada linea del buffer (ancho ~= len_chars, cap a `usable_w`),
//! resalta el viewport visible como rect translucido y marca el caret.
//! Click sobre el minimap salta esa linea al editor.
//!
//! El modulo es agnostico del editor: el host pasa un slice con la
//! cantidad de chars por linea, el rango visible y la linea del
//! caret. No depende de `llimphi-widget-text-editor` — cualquier
//! buffer (rope, vec<String>, archivo memmaped) sirve.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
use std::sync::Arc;
use llimphi_ui::llimphi_layout::taffy::prelude::{length, percent, FlexDirection, Size, Style};
use llimphi_ui::llimphi_raster::kurbo::{Affine, Rect as KRect};
use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
use llimphi_ui::{Key, KeyEvent, KeyState, View};
/// Capabilities que aporta este modulo al host.
pub const CAPABILITIES: &[&str] = &["editor.mini-map"];
/// Ancho del panel en pixeles (estilo VS Code).
pub const PANEL_W: f32 = 120.0;
/// Altura maxima por linea del buffer dentro del minimap (cap).
pub const LINE_PX: f32 = 2.0;
/// Escala chars->pixels para el ancho de cada slab. ~75 chars caben
/// completos en `PANEL_W - PAD * 2`; lo demas se trunca.
pub const CHAR_PX: f32 = 1.4;
/// Padding lateral del panel (los slabs no tocan los bordes).
pub const PAD: f32 = 6.0;
/// Estado interno. Hoy efectivamente vacio — la informacion del buffer
/// la pasa el host en cada frame via [`view`] — pero existe como
/// `Option<MiniMapState>` en el host para representar abierto/cerrado
/// y para futuras extensiones (scrubbing, fold-aware, syntax per slab).
#[derive(Debug, Default, Clone)]
pub struct MiniMapState {
/// Reservado para drag-scrub: la y inicial en pixeles dentro del
/// panel cuando el usuario empieza a arrastrar. `None` = sin drag
/// activo. Hoy no se consume (click es suficiente); declarado para
/// que el contrato del state no cambie cuando se agregue.
pub drag_anchor_y: Option<f32>,
}
impl MiniMapState {
pub fn new() -> Self {
Self::default()
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MiniMapMsg {
/// Convencional: el host abre el panel guardando un `MiniMapState`
/// en el modelo. El modulo no construye state global.
Open,
Close,
/// El usuario clickeo o arrastro: salta a la linea indicada.
Jump(usize),
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MiniMapAction {
None,
/// El host deberia remover el state del modelo.
Close,
/// El host deberia centrar el viewport en esta linea del buffer
/// activo. El modulo NO se cierra — el minimap es persistente.
JumpTo(usize),
}
/// Snapshot del buffer que el host pasa en cada frame. El modulo no
/// copia, solo lee. La cantidad de chars por linea es lo unico que
/// necesita para dibujar; viewport + caret se overlayean encima.
pub struct Snapshot<'a> {
/// `lines[i]` = numero de chars (no bytes) en la linea `i`.
pub lines: &'a [usize],
/// Rango visible en el editor: `[start, end)`.
pub viewport_start: usize,
pub viewport_end: usize,
/// Linea del caret (0-based). Se pinta como marker accent.
pub caret_line: usize,
}
/// Aplica un mensaje al estado.
pub fn apply(state: &mut MiniMapState, msg: MiniMapMsg) -> MiniMapAction {
match msg {
MiniMapMsg::Open => MiniMapAction::None,
MiniMapMsg::Close => MiniMapAction::Close,
MiniMapMsg::Jump(line) => {
state.drag_anchor_y = None;
MiniMapAction::JumpTo(line)
}
}
}
/// Routing de teclas. El minimap NO captura teclas (es un viewer
/// pasivo). Devolvemos `None`; el host sigue su routing normal.
pub fn on_key(_state: &MiniMapState, _event: &KeyEvent) -> Option<MiniMapMsg> {
None
}
/// Atajo recomendado: **Ctrl+Shift+M** (mnemonic M = Minimap).
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("m"))
}
/// Convierte una posicion-y dentro del panel a indice de linea. La
/// conversion es proporcional al total de lineas; clamping en ambos
/// extremos.
pub fn y_to_line(y: f32, panel_h: f32, total_lines: usize) -> usize {
if total_lines == 0 || panel_h <= 0.0 {
return 0;
}
let t = (y / panel_h).clamp(0.0, 1.0);
let line = (t * total_lines as f32) as usize;
line.min(total_lines.saturating_sub(1))
}
/// Paleta visual derivable del theme.
#[derive(Debug, Clone)]
pub struct MiniMapPalette {
/// Fondo del panel del minimap.
pub bg_panel: Color,
/// Color de los slabs (uno por linea de buffer).
pub fg_slab: Color,
/// Color del rect translucido que marca el viewport visible.
pub bg_viewport: Color,
/// Borde del rect del viewport.
pub border_viewport: Color,
/// Color del marker del caret.
pub fg_caret: Color,
}
impl MiniMapPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel_alt,
fg_slab: t.fg_muted,
bg_viewport: with_alpha(t.bg_selected, 0.35),
border_viewport: t.border_focus,
fg_caret: t.accent,
}
}
}
fn with_alpha(c: Color, alpha: f32) -> Color {
let rgba = c.to_rgba8();
let a = (alpha.clamp(0.0, 1.0) * 255.0) as u8;
Color::from_rgba8(rgba.r, rgba.g, rgba.b, a)
}
/// Render del panel. `to_host` mapea cada `MiniMapMsg` al `Msg` de la app.
/// `snapshot` es la vista del buffer en este frame (sin copia).
///
/// Layout: columna fija de `PANEL_W` px que ocupa todo el alto del
/// contenedor padre. El host la mete en el `Row` del editor
/// (tipicamente al final, al estilo VS Code).
pub fn view<HostMsg, F>(
_state: &MiniMapState,
snapshot: &Snapshot,
palette: &MiniMapPalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(MiniMapMsg) -> HostMsg + Copy + Send + Sync + 'static,
{
// Capturamos por valor porque el painter es Arc<dyn Fn>: 'static + Send + Sync.
let lines: Vec<usize> = snapshot.lines.to_vec();
let viewport_start = snapshot.viewport_start;
let viewport_end = snapshot.viewport_end;
let caret_line = snapshot.caret_line;
let pal = palette.clone();
let total_lines = lines.len();
let click_host = to_host;
let on_click: Arc<dyn Fn(f32, f32, f32, f32) -> Option<HostMsg> + Send + Sync> = Arc::new(move |_x: f32, y: f32, _w: f32, h: f32| {
let line = y_to_line(y, h, total_lines);
Some(click_host(MiniMapMsg::Jump(line)))
});
let mut view = View::new(Style {
size: Size { width: length(PANEL_W), height: percent(1.0_f32) },
flex_shrink: 0.0,
flex_direction: FlexDirection::Column,
..Default::default()
})
.fill(pal.bg_panel)
.clip(true)
.paint_with(move |scene, _ts, rect| {
if rect.w <= 0.0 || rect.h <= 0.0 || lines.is_empty() {
return;
}
let n = lines.len() as f32;
let line_h = (rect.h / n).min(LINE_PX);
let usable_w = (rect.w - PAD * 2.0).max(1.0);
// 1) Viewport overlay debajo de los slabs.
if viewport_end > viewport_start {
let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
let y1 = rect.y + (viewport_end as f32 / n) * rect.h;
let vp = KRect::new(
rect.x as f64,
y0 as f64,
(rect.x + rect.w) as f64,
y1.max(y0 + 2.0) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.bg_viewport, None, &vp);
}
// 2) Slabs: uno por linea de buffer.
for (i, &chars) in lines.iter().enumerate() {
if chars == 0 {
continue;
}
let w = (chars as f32 * CHAR_PX).min(usable_w);
let y = rect.y + (i as f32 / n) * rect.h;
let slab_h = line_h.max(1.0);
let r = KRect::new(
(rect.x + PAD) as f64,
y as f64,
(rect.x + PAD + w) as f64,
(y + slab_h) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_slab, None, &r);
}
// 3) Borde del viewport encima de los slabs.
if viewport_end > viewport_start {
let y0 = rect.y + (viewport_start as f32 / n) * rect.h;
let y1 = (rect.y + (viewport_end as f32 / n) * rect.h).max(y0 + 2.0);
let top = KRect::new(
rect.x as f64,
y0 as f64,
(rect.x + rect.w) as f64,
(y0 + 1.0) as f64,
);
let bot = KRect::new(
rect.x as f64,
(y1 - 1.0) as f64,
(rect.x + rect.w) as f64,
y1 as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &top);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.border_viewport, None, &bot);
}
// 4) Marker del caret: barra horizontal accent.
if caret_line < lines.len() {
let y = rect.y + (caret_line as f32 / n) * rect.h;
let r = KRect::new(
rect.x as f64,
y as f64,
(rect.x + rect.w) as f64,
(y + 2.0) as f64,
);
scene.fill(Fill::NonZero, Affine::IDENTITY, pal.fg_caret, None, &r);
}
});
view.on_click_at = Some(on_click);
view
}
+63
View File
@@ -0,0 +1,63 @@
//! Smoke tests del minimap. Sin backend grafico — solo `apply`,
//! `on_key`, `open_shortcut` y la conversion y->line.
use llimphi_module_mini_map::{
self as minimap, MiniMapAction, MiniMapMsg, MiniMapState,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn key_with(ctrl: bool, shift: bool, ch: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(ch.into()),
state: KeyState::Pressed,
text: Some(ch.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
}
}
#[test]
fn open_shortcut_es_ctrl_shift_m() {
assert!(minimap::open_shortcut(&key_with(true, true, "m")));
assert!(minimap::open_shortcut(&key_with(true, true, "M")));
assert!(!minimap::open_shortcut(&key_with(true, false, "m")));
assert!(!minimap::open_shortcut(&key_with(false, true, "m")));
}
#[test]
fn jump_emite_jumpto() {
let mut s = MiniMapState::new();
let action = minimap::apply(&mut s, MiniMapMsg::Jump(42));
assert_eq!(action, MiniMapAction::JumpTo(42));
}
#[test]
fn close_emite_close() {
let mut s = MiniMapState::new();
let action = minimap::apply(&mut s, MiniMapMsg::Close);
assert_eq!(action, MiniMapAction::Close);
}
#[test]
fn y_to_line_proporcional() {
// 100 lineas, panel de 200 px → cada linea ocupa 2 px.
assert_eq!(minimap::y_to_line(0.0, 200.0, 100), 0);
assert_eq!(minimap::y_to_line(100.0, 200.0, 100), 50);
assert_eq!(minimap::y_to_line(200.0, 200.0, 100), 99);
// Clamping fuera de rango.
assert_eq!(minimap::y_to_line(-50.0, 200.0, 100), 0);
assert_eq!(minimap::y_to_line(500.0, 200.0, 100), 99);
}
#[test]
fn y_to_line_buffer_vacio_no_paniquea() {
assert_eq!(minimap::y_to_line(0.0, 100.0, 0), 0);
assert_eq!(minimap::y_to_line(50.0, 100.0, 0), 0);
}
#[test]
fn on_key_es_pasivo() {
let s = MiniMapState::new();
let ev = key_with(false, false, "a");
assert!(minimap::on_key(&s, &ev).is_none());
}
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "llimphi-plugin-host"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-plugin-host — runtime de plugins WASM (Tier 2) para apps Llimphi. Carga .wasm + manifest.toml, aplica sandbox por card_core::Permissions, e invoca capabilities devolviendo PluginAction."
[dependencies]
card-core = { path = "../../../../shared/card/card-core" }
wasmi = { workspace = true }
serde = { workspace = true }
toml = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
# wat sólo aparece en tests: los fixtures se escriben en WAT y se
# compilan en runtime, evitando una dependencia de toolchain wasm32.
wat = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-plugin-host
> Host para plugins WASM de [llimphi](../../README.md).
Carga módulos WASM con la capability API del notebook (sandbox), los enchufa a la app como handlers de `Msg` extra. Permite extender la app sin recompilar.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-plugin-host
> WASM plugin host for [llimphi](../../README.md).
Loads WASM modules with the notebook's capability API (sandbox), plugs them into the app as extra `Msg` handlers. Lets you extend the app without recompiling.
+334
View File
@@ -0,0 +1,334 @@
//! llimphi-plugin-host — runtime de plugins WASM Tier 2 para apps Llimphi.
//!
//! Vea `docs/MODULES.md` (§Tier 2 — Plugins WASM) para el contrato
//! completo. En síntesis:
//!
//! - Un plugin es un `.wasm` + un `manifest.toml` hermano que declara
//! `name`, `version`, `capabilities`, y los `Permissions` que pide.
//! - El host expone imports bajo el namespace `"plugin"`. Cada uno se
//! gatea por un campo de `card_core::Permissions`: si el permiso falta,
//! el import **no se enlaza** y el plugin trap-ea al intentar usarlo.
//! - El `.wasm` exporta `_invoke(cap_ptr, cap_len, arg_ptr, arg_len) -> i32`
//! y una `memory` lineal.
//! - Invocar un plugin devuelve `PluginAction` — intención, no ejecución.
//! El host decide cómo materializar `OpenAt`/`SetStatus` en su contexto.
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use card_core::{FsPolicy, Permissions};
use serde::Deserialize;
use thiserror::Error;
use tracing::{info, warn};
use wasmi::{Caller, CompilationMode, Config, Engine, Linker, Memory, Module, Store};
// =====================================================================
// Manifest
// =====================================================================
/// Manifest sidecar (`manifest.toml`) que acompaña a cada `.wasm`.
///
/// El formato es estable: campos extra se ignoran con `#[serde(default)]`
/// donde aplica, para que plugins viejos sigan cargando si el host suma
/// metadatos opcionales.
#[derive(Debug, Clone, Deserialize)]
pub struct PluginManifest {
pub name: String,
pub version: String,
/// Capabilities que el plugin atiende. El host enruta invocaciones
/// por el nombre exacto pasado a `PluginHost::invoke(_, cap, _)`.
#[serde(default)]
pub capabilities: Vec<String>,
/// Permisos que el plugin necesita para no trap-ear. Si el manifest
/// pide más de lo que el host está dispuesto a conceder, la carga
/// puede aceptarse "downgraded" — pero el plugin entonces trap-eará
/// al intentar los imports que no se enlazaron. La política la fija
/// quien llama a `PluginHost::load_*`.
#[serde(default)]
pub permissions: Permissions,
}
impl PluginManifest {
pub fn from_toml(s: &str) -> Result<Self, PluginError> {
toml::from_str(s).map_err(|e| PluginError::Manifest(e.to_string()))
}
}
// =====================================================================
// Acciones y errores
// =====================================================================
/// Intención que el plugin emite. Igual que en los módulos Tier 1, el
/// plugin no sabe cómo el host materializa cada variante — sólo declara
/// qué quiere que pase.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginAction {
None,
SetStatus(String),
OpenAt { path: PathBuf, line: u32, col: u32 },
}
#[derive(Debug, Error)]
pub enum PluginError {
#[error("manifest inválido: {0}")]
Manifest(String),
#[error("no se pudo leer {0}: {1}")]
Io(PathBuf, String),
#[error("compilando wasm: {0}")]
Compile(String),
#[error("instanciando wasm: {0}")]
Instantiate(String),
#[error("plugin no exporta `_invoke` con la signatura esperada: {0}")]
MissingEntry(String),
#[error("trap durante la ejecución del plugin: {0}")]
Trap(String),
#[error("no existe plugin con id {0:?}")]
UnknownPlugin(PluginId),
}
// =====================================================================
// Host
// =====================================================================
/// Identificador opaco de un plugin cargado. Sólo se construye desde
/// `PluginHost::load_*`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PluginId(u32);
struct LoadedPlugin {
manifest: PluginManifest,
module: Module,
}
/// Estado por invocación. Vive sólo durante un `invoke` — se descarta al
/// volver. Lo usamos como `Store::data()` para que los host imports
/// puedan emitir su `PluginAction` sin globals. Los permisos no viajan
/// aquí porque su efecto es link-time: los imports prohibidos
/// simplemente no se enlazan.
struct InvokeCtx {
/// Acción a devolver al host. `RefCell` porque los closures de
/// `func_wrap` toman `Caller` por referencia compartida.
pending: RefCell<PluginAction>,
}
pub struct PluginHost {
engine: Engine,
plugins: Vec<LoadedPlugin>,
}
impl Default for PluginHost {
fn default() -> Self {
Self::new()
}
}
impl PluginHost {
pub fn new() -> Self {
// Eager: mismo modo que arje-wasm, comportamiento predecible y
// los traps de compilación salen en `load_*`, no en `invoke`.
let mut config = Config::default();
config.compilation_mode(CompilationMode::Eager);
Self { engine: Engine::new(&config), plugins: Vec::new() }
}
/// Carga `dir/plugin.wasm` + `dir/manifest.toml`. Por convención el
/// `.wasm` se llama igual que el directorio o `plugin.wasm`. Probamos
/// ambos para ser indulgentes con el packaging.
pub fn load_from_dir(&mut self, dir: impl AsRef<Path>) -> Result<PluginId, PluginError> {
let dir = dir.as_ref();
let manifest_path = dir.join("manifest.toml");
let manifest_str = std::fs::read_to_string(&manifest_path)
.map_err(|e| PluginError::Io(manifest_path.clone(), e.to_string()))?;
let manifest = PluginManifest::from_toml(&manifest_str)?;
let candidates = [dir.join("plugin.wasm"), dir.join(format!("{}.wasm", manifest.name))];
let (wasm_path, wasm_bytes) = candidates
.iter()
.find_map(|p| std::fs::read(p).ok().map(|b| (p.clone(), b)))
.ok_or_else(|| {
PluginError::Io(dir.join("plugin.wasm"), "no encontré ningún .wasm".into())
})?;
let _ = wasm_path;
self.load_bytes(manifest, &wasm_bytes)
}
/// Carga un plugin desde bytes ya en memoria (útil en tests y para
/// plugins embebidos en el binario del host).
pub fn load_bytes(
&mut self,
manifest: PluginManifest,
wasm_bytes: &[u8],
) -> Result<PluginId, PluginError> {
let module = Module::new(&self.engine, wasm_bytes)
.map_err(|e| PluginError::Compile(e.to_string()))?;
let id = PluginId(self.plugins.len() as u32);
info!(
plugin = %manifest.name,
version = %manifest.version,
capabilities = ?manifest.capabilities,
"plugin Tier 2 cargado"
);
self.plugins.push(LoadedPlugin { manifest, module });
Ok(id)
}
pub fn manifest(&self, id: PluginId) -> Result<&PluginManifest, PluginError> {
self.plugins
.get(id.0 as usize)
.map(|p| &p.manifest)
.ok_or(PluginError::UnknownPlugin(id))
}
/// Devuelve la unión de capabilities de todos los plugins cargados —
/// la lista que el host enrola en su Card antes de `spawn_sidecar()`.
pub fn all_capabilities(&self) -> Vec<String> {
let mut caps: Vec<String> =
self.plugins.iter().flat_map(|p| p.manifest.capabilities.iter().cloned()).collect();
caps.sort();
caps.dedup();
caps
}
/// Invoca una capability sobre el plugin indicado. `args` se entrega
/// tal cual al plugin (bytes opacos — la app y el plugin acuerdan el
/// schema). El retorno colapsa el `_invoke` exit code y la
/// `PluginAction` que el plugin haya emitido.
pub fn invoke(
&self,
id: PluginId,
capability: &str,
args: &[u8],
) -> Result<PluginAction, PluginError> {
let plugin = self.plugins.get(id.0 as usize).ok_or(PluginError::UnknownPlugin(id))?;
let ctx = InvokeCtx { pending: RefCell::new(PluginAction::None) };
let mut store = Store::new(&self.engine, ctx);
let linker = build_linker(&self.engine, &plugin.manifest.permissions)?;
// wasmi 1.0: `instantiate_and_start` corre la `(start)` section
// si la hay; nuestros plugins no la usan — su entrada es
// `_invoke`, llamada explícitamente más abajo.
let instance = linker
.instantiate_and_start(&mut store, &plugin.module)
.map_err(|e| PluginError::Instantiate(e.to_string()))?;
let memory = instance
.get_memory(&store, "memory")
.ok_or_else(|| PluginError::MissingEntry("plugin sin export `memory`".into()))?;
// Escribimos cap + args al inicio de la memoria del plugin. v0
// del ABI: layout fijo, no negociado. Si el plugin necesita más
// espacio se va a cualquier offset por encima — su asunto.
let cap_bytes = capability.as_bytes();
write_memory(&mut store, memory, 0, cap_bytes)?;
let args_off = cap_bytes.len();
write_memory(&mut store, memory, args_off, args)?;
let func = instance
.get_typed_func::<(i32, i32, i32, i32), i32>(&store, "_invoke")
.map_err(|e| PluginError::MissingEntry(e.to_string()))?;
let _exit = func
.call(
&mut store,
(0, cap_bytes.len() as i32, args_off as i32, args.len() as i32),
)
.map_err(|e| PluginError::Trap(e.to_string()))?;
let action = store.data().pending.borrow().clone();
Ok(action)
}
}
// =====================================================================
// Host imports — gateados por Permissions
// =====================================================================
fn build_linker(
engine: &Engine,
perms: &Permissions,
) -> Result<Linker<InvokeCtx>, PluginError> {
let mut linker = Linker::<InvokeCtx>::new(engine);
// log — siempre disponible. Aún plugins sin permisos pueden trazar.
linker
.func_wrap("plugin", "log", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| {
if let Some(s) = read_utf8(&caller, ptr, len) {
info!("[plugin] {s}");
}
})
.map_err(|e| PluginError::Instantiate(e.to_string()))?;
// set_status — siempre disponible. No toca recursos del sistema.
linker
.func_wrap("plugin", "set_status", |caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32| {
if let Some(s) = read_utf8(&caller, ptr, len) {
*caller.data().pending.borrow_mut() = PluginAction::SetStatus(s);
}
})
.map_err(|e| PluginError::Instantiate(e.to_string()))?;
// open_at — requiere filesystem >= read-only. Si el permiso falta NO
// enlazamos el import: el plugin trap-eará al invocarlo, que es la
// semántica correcta para un sandbox.
if matches!(perms.filesystem, FsPolicy::ReadOnly | FsPolicy::ReadWrite) {
linker
.func_wrap(
"plugin",
"open_at",
|caller: Caller<'_, InvokeCtx>, ptr: i32, len: i32, line: i32, col: i32| {
if let Some(s) = read_utf8(&caller, ptr, len) {
*caller.data().pending.borrow_mut() = PluginAction::OpenAt {
path: PathBuf::from(s),
line: line.max(0) as u32,
col: col.max(0) as u32,
};
}
},
)
.map_err(|e| PluginError::Instantiate(e.to_string()))?;
} else {
warn!(
"plugin sin permiso filesystem — `plugin.open_at` no enlazado; \
llamarlo trap-eará"
);
}
Ok(linker)
}
// =====================================================================
// Helpers de memoria
// =====================================================================
fn read_utf8(caller: &Caller<'_, InvokeCtx>, ptr: i32, len: i32) -> Option<String> {
let memory = caller.get_export("memory")?.into_memory()?;
let bytes = read_memory(caller, memory, ptr, len)?;
String::from_utf8(bytes).ok()
}
fn read_memory(
caller: &Caller<'_, InvokeCtx>,
memory: Memory,
ptr: i32,
len: i32,
) -> Option<Vec<u8>> {
let ptr = ptr.max(0) as usize;
let len = len.max(0) as usize;
let data = memory.data(caller);
if ptr.saturating_add(len) > data.len() {
return None;
}
Some(data[ptr..ptr + len].to_vec())
}
fn write_memory(
store: &mut Store<InvokeCtx>,
memory: Memory,
off: usize,
bytes: &[u8],
) -> Result<(), PluginError> {
memory
.write(store, off, bytes)
.map_err(|e| PluginError::Trap(format!("write_memory off={off} len={}: {e}", bytes.len())))
}
@@ -0,0 +1 @@
plugin.wasm
@@ -0,0 +1,11 @@
name = "hello-status"
version = "0.1.0"
capabilities = ["status.greet"]
[permissions]
networking = "none"
filesystem = "none"
processes = false
[permissions.ipc]
allow = []
@@ -0,0 +1,44 @@
;; Plugin fixture: "hello-status".
;;
;; Lee el payload de args que el host escribió en memoria justo
;; después del nombre de la capability, y lo concatena con un saludo
;; fijo "hola, " en otro offset. Después emite el resultado via
;; `plugin.set_status`.
;;
;; Layout de memoria al entrar `_invoke`:
;; [0 .. cap_len) nombre de capability (UTF-8)
;; [cap_len .. cap_len+arg_len) args del host (UTF-8)
;;
;; El plugin coloca su buffer de salida en el offset 256 para no
;; pisar lo anterior. v0 del ABI no negocia layouts — la convención
;; es que el plugin elige offsets altos.
(module
(import "plugin" "log" (func $log (param i32 i32)))
(import "plugin" "set_status" (func $set_status (param i32 i32)))
(memory (export "memory") 1)
;; "hola, " en offset 256 (6 bytes)
(data (i32.const 256) "hola, ")
(func (export "_invoke")
(param $cap_ptr i32) (param $cap_len i32)
(param $arg_ptr i32) (param $arg_len i32)
(result i32)
;; Traza para debug: el host capturará "[plugin] greet"
(call $log (i32.const 256) (i32.const 5))
;; Copia los args al final del prefijo "hola, " en 256+6=262
(memory.copy
(i32.const 262) ;; dst = 256 + len("hola, ")
(local.get $arg_ptr) ;; src = donde el host puso args
(local.get $arg_len))
;; Total len = 6 ("hola, ") + arg_len
(call $set_status
(i32.const 256)
(i32.add (i32.const 6) (local.get $arg_len)))
(i32.const 0)
)
)
+109
View File
@@ -0,0 +1,109 @@
//! Smoke tests del runtime Tier 2 — verifican:
//!
//! 1. Carga desde disco (`manifest.toml` + `.wasm`) e invocación que
//! devuelve `PluginAction::SetStatus` con el saludo concatenado.
//! 2. Sandbox por permisos: un plugin con `filesystem = "none"` que
//! intenta llamar `plugin.open_at` trap-ea — el import no se
//! enlazó, así que el módulo importa una función inexistente.
//! 3. Permiso concedido: el mismo plugin con `filesystem = "read-only"`
//! sí enlaza, ejecuta, y emite `PluginAction::OpenAt`.
use std::path::PathBuf;
use card_core::{FsPolicy, Permissions};
use llimphi_plugin_host::{PluginAction, PluginError, PluginHost, PluginManifest};
fn fixture_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello-status")
}
/// Compila el .wat del fixture a .wasm en el OUT_DIR efímero del test.
/// Lo hacemos por test (no en build.rs) para mantener el crate sin
/// build script — el costo es trivial y la lógica vive con el test.
fn compile_fixture_to(dir: &std::path::Path) {
let wat = std::fs::read_to_string(dir.join("plugin.wat")).expect("leo plugin.wat");
let wasm = wat::parse_str(&wat).expect("WAT del fixture compila a wasm");
std::fs::write(dir.join("plugin.wasm"), wasm).expect("escribo plugin.wasm");
}
#[test]
fn carga_desde_directorio_y_devuelve_set_status() {
let dir = fixture_dir();
compile_fixture_to(&dir);
let mut host = PluginHost::new();
let id = host.load_from_dir(&dir).expect("plugin carga desde dir");
let manifest = host.manifest(id).expect("manifest accesible");
assert_eq!(manifest.name, "hello-status");
assert_eq!(manifest.capabilities, vec!["status.greet".to_string()]);
let action = host.invoke(id, "status.greet", b"mundo").expect("invoke ok");
assert_eq!(action, PluginAction::SetStatus("hola, mundo".into()));
// El host puede enumerar capabilities agregadas para construir su Card.
assert_eq!(host.all_capabilities(), vec!["status.greet".to_string()]);
}
/// WAT que intenta importar `plugin.open_at`. Sirve como "plugin
/// malicioso" para verificar el sandbox: si el host no concede
/// `filesystem`, el linker no enlaza el import → wasmi rechaza la
/// instanciación con un error de import faltante.
fn wants_open_at_wat() -> &'static str {
// El path va en offset 256 para no colisionar con el buffer
// [cap | args] que el host escribe a partir del offset 0.
r#"
(module
(import "plugin" "open_at" (func $open_at (param i32 i32 i32 i32)))
(memory (export "memory") 1)
(data (i32.const 256) "/etc/passwd")
(func (export "_invoke")
(param i32) (param i32) (param i32) (param i32)
(result i32)
(call $open_at (i32.const 256) (i32.const 11) (i32.const 10) (i32.const 5))
(i32.const 0)
)
)
"#
}
#[test]
fn sin_permiso_filesystem_el_plugin_no_instancia() {
let bytes = wat::parse_str(wants_open_at_wat()).unwrap();
let manifest = PluginManifest {
name: "wants-fs".into(),
version: "0.1.0".into(),
capabilities: vec!["fs.open".into()],
permissions: Permissions::default(), // filesystem = none
};
let mut host = PluginHost::new();
let id = host.load_bytes(manifest, &bytes).expect("carga ok — el sandbox actúa al invocar");
let err = host.invoke(id, "fs.open", b"").expect_err("debe fallar sin permiso fs");
// wasmi reporta el import faltante en la instanciación.
assert!(
matches!(err, PluginError::Instantiate(_)),
"esperaba Instantiate, vi {err:?}"
);
}
#[test]
fn con_permiso_filesystem_el_plugin_emite_open_at() {
let bytes = wat::parse_str(wants_open_at_wat()).unwrap();
let manifest = PluginManifest {
name: "wants-fs".into(),
version: "0.1.0".into(),
capabilities: vec!["fs.open".into()],
permissions: Permissions { filesystem: FsPolicy::ReadOnly, ..Permissions::default() },
};
let mut host = PluginHost::new();
let id = host.load_bytes(manifest, &bytes).unwrap();
let action = host.invoke(id, "fs.open", b"").expect("con permiso, debe correr");
assert_eq!(
action,
PluginAction::OpenAt { path: PathBuf::from("/etc/passwd"), line: 10, col: 5 }
);
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "llimphi-module-selector"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-selector — trait Selector con dos backends: HostSelector (paths del FS via std::fs) y WawaSelector (khipus por hash, sello digital). Una sola API 'abrir/guardar' que funciona en cualquier entorno gioser."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
+245
View File
@@ -0,0 +1,245 @@
//! `llimphi-module-selector` — abstracción de "abrir/guardar" portable
//! entre host (paths del FS) y wawa (khipus por hash).
//!
//! ## Por qué
//!
//! Una app gioser que sólo conoce paths (`PathBuf`) se rompe en wawa,
//! donde el almacenamiento es direccionado por contenido (BLAKE3 + DAG)
//! y no existe el concepto de "carpeta /home/usuario". Pero la mayoría
//! de las apps no necesitan saber la diferencia: sólo quieren preguntar
//! "qué item quiere abrir el usuario" o "dónde guardo este blob".
//!
//! Este crate expone:
//! - El trait [`Selector`] con dos métodos: `list_candidates()` (para
//! armar la UI del picker) y `realize(handle)` (para resolver el
//! item elegido a bytes).
//! - Un `ItemHandle` opaco — la app no debe inspeccionarlo, sólo
//! pasarlo de vuelta al selector.
//! - [`HostSelector`] con root path + extension filter (impl real).
//! - [`WawaSelector`] como **placeholder** con la API definida — la
//! integración real con `akasha` / `wawa-kernel` ocurre cuando la
//! suite empiece a correr in-cage. Por ahora exporta tipos y panica
//! si se invoca, lo cual está bien: el código que lo construye
//! queda compilable y las apps pueden tipar contra el trait.
//!
//! ## API mínima
//!
//! ```ignore
//! use llimphi_module_selector::{HostSelector, Selector};
//!
//! let sel = HostSelector::new("/home/usr/docs", &[".pluma", ".khipu"]);
//! let items = sel.list_candidates()?;
//! // (la app muestra `items.iter().map(|i| &i.display_name)` en su picker)
//! // user elige el index N:
//! let bytes = sel.realize(&items[N].handle)?;
//! ```
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
/// Resultado de la operación — `String` como error para que no le
/// importe a la app si el backend es FS o wawa.
pub type SelectorResult<T> = Result<T, String>;
/// Item visible en el picker. `handle` es opaco — sólo el `Selector`
/// que lo emitió sabe interpretarlo.
#[derive(Debug, Clone)]
pub struct Item {
/// Nombre legible para mostrar en el picker. Para `HostSelector`
/// es el path relativo al root; para `WawaSelector` será el alias
/// del khipu o un hash truncado si no tiene alias.
pub display_name: String,
/// Tamaño en bytes si se conoce — para mostrar al lado del nombre.
/// `None` cuando es caro de calcular (e.g. khipu blob remoto).
pub size_bytes: Option<u64>,
pub handle: ItemHandle,
}
/// Handle opaco. Internamente puede ser un path (host) o un hash
/// (wawa). La app no debe construir uno a mano — lo recibe del
/// `Selector` y se lo devuelve al `realize()`.
#[derive(Debug, Clone)]
pub enum ItemHandle {
/// Path absoluto en el FS del host.
HostPath(PathBuf),
/// Hash de contenido BLAKE3 (32 bytes hex) en el almacén wawa.
/// La integración real lo resuelve via `almacen::cargar(hash)`.
WawaHash([u8; 32]),
}
/// Trait que abstrae el medio de almacenamiento. Una app gioser que
/// quiera funcionar tanto en host como en wawa toma un `&dyn Selector`
/// en su modelo en lugar de un `PathBuf` concreto.
pub trait Selector {
/// Lista los items "abribles" según los criterios del selector
/// (extensión, glob, scope). Para host suele ser un walk del root;
/// para wawa, los khipus marcados con cierto namespace.
fn list_candidates(&self) -> SelectorResult<Vec<Item>>;
/// Resuelve un `ItemHandle` a los bytes del item.
fn realize(&self, handle: &ItemHandle) -> SelectorResult<Vec<u8>>;
/// Guarda `bytes` bajo el nombre lógico `name`. Devuelve el
/// `ItemHandle` del item recién creado. Para host esto es
/// `root.join(name) + write`; para wawa, ingerir en el almacén.
fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult<ItemHandle>;
}
// =====================================================================
// HostSelector — backend de FS clásico
// =====================================================================
/// Selector que walkea un root del filesystem y filtra por extensión.
/// Implementación lineal — para roots gigantes la app debería cachear
/// los candidates al arrancar (igual que hace el `file-picker` actual).
pub struct HostSelector {
root: PathBuf,
/// Lista de extensiones aceptadas (con el punto, ej. `".pluma"`).
/// Vacío = todas.
extensions: Vec<String>,
}
impl HostSelector {
pub fn new(root: impl Into<PathBuf>, extensions: &[&str]) -> Self {
Self {
root: root.into(),
extensions: extensions.iter().map(|s| (*s).to_string()).collect(),
}
}
fn accept(&self, path: &Path) -> bool {
if self.extensions.is_empty() {
return true;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
return false;
};
self.extensions.iter().any(|ext| name.ends_with(ext))
}
fn walk(&self, dir: &Path, out: &mut Vec<Item>) -> SelectorResult<()> {
let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
// Saltamos directorios "ruidosos" (target, .git, node_modules).
if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
if matches!(name, "target" | ".git" | "node_modules" | ".idea") {
continue;
}
}
self.walk(&path, out)?;
} else if self.accept(&path) {
let display_name = path
.strip_prefix(&self.root)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
let size_bytes = entry.metadata().ok().map(|m| m.len());
out.push(Item {
display_name,
size_bytes,
handle: ItemHandle::HostPath(path),
});
}
}
Ok(())
}
}
impl Selector for HostSelector {
fn list_candidates(&self) -> SelectorResult<Vec<Item>> {
let mut out = Vec::new();
self.walk(&self.root, &mut out)?;
Ok(out)
}
fn realize(&self, handle: &ItemHandle) -> SelectorResult<Vec<u8>> {
match handle {
ItemHandle::HostPath(p) => std::fs::read(p).map_err(|e| e.to_string()),
ItemHandle::WawaHash(_) => Err("HostSelector no resuelve hashes wawa".into()),
}
}
fn save(&self, name: &str, bytes: &[u8]) -> SelectorResult<ItemHandle> {
let path = self.root.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
Ok(ItemHandle::HostPath(path))
}
}
// =====================================================================
// WawaSelector — placeholder para integración con akasha/almacen
// =====================================================================
/// Selector para entorno wawa. **No implementado** — la integración real
/// requiere bindings al `wawa-kernel::almacen` (BLAKE3 + log + GC), que
/// vive fuera del workspace global. Por ahora expone la API para que el
/// código que la usa compile, y panica en runtime para flaggear que
/// alguien intentó usarla antes de tiempo.
///
/// Cuando llegue la integración real:
/// 1. `wawa-kernel` exporta una crate `wawa-almacen-client` cross-bound
/// accesible desde apps WASM.
/// 2. `WawaSelector::new(namespace)` se conecta a ese cliente.
/// 3. `list_candidates()` consulta `almacen::listar(namespace)`.
/// 4. `realize(WawaHash(h))` invoca `almacen::cargar(h)`.
/// 5. `save(name, bytes)` invoca `almacen::ingerir(bytes)` y registra
/// el alias `name → hash`.
pub struct WawaSelector {
/// Namespace lógico (ej. `"pluma.documentos"`) — el almacén filtra
/// los khipus marcados con este tag.
pub namespace: String,
}
impl WawaSelector {
pub fn new(namespace: impl Into<String>) -> Self {
Self { namespace: namespace.into() }
}
}
impl Selector for WawaSelector {
fn list_candidates(&self) -> SelectorResult<Vec<Item>> {
Err(format!(
"WawaSelector('{}') sin backend wawa registrado — pendiente de integración con wawa-almacen-client",
self.namespace
))
}
fn realize(&self, _handle: &ItemHandle) -> SelectorResult<Vec<u8>> {
Err("WawaSelector::realize sin backend wawa registrado".into())
}
fn save(&self, _name: &str, _bytes: &[u8]) -> SelectorResult<ItemHandle> {
Err("WawaSelector::save sin backend wawa registrado".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn host_selector_accept_with_extensions() {
let s = HostSelector::new("/tmp", &[".pluma", ".khipu"]);
assert!(s.accept(Path::new("/tmp/foo.pluma")));
assert!(s.accept(Path::new("/tmp/bar.khipu")));
assert!(!s.accept(Path::new("/tmp/baz.txt")));
}
#[test]
fn host_selector_empty_extensions_accepts_all() {
let s = HostSelector::new("/tmp", &[]);
assert!(s.accept(Path::new("/tmp/anything.rs")));
assert!(s.accept(Path::new("/tmp/anything.unknown")));
}
#[test]
fn wawa_selector_returns_err_until_backend_lands() {
let s = WawaSelector::new("pluma.documentos");
assert!(s.list_candidates().is_err());
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-shuma-term"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-shuma-term — terminal integrado tipo Ctrl+\\` de VS Code. Módulo Llimphi sobre shuma-exec (PTY real) + vt100 (emulación). Cualquier app Llimphi puede enchufar un terminal sandboxeado por el shell del usuario."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
shuma-exec = { path = "../../../shuma/sandbox/shuma-exec" }
vt100 = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-shuma-term
> Terminal embebida (shell shuma) de [llimphi](../../README.md).
Wrapper de [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) montable adentro de cualquier app. Útil para `nada`, IDE-like setups.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-shuma-term
> Embedded terminal (shuma shell) of [llimphi](../../README.md).
Wrapper of [`shuma-shell-llimphi`](../../../shuma/shuma-shell-llimphi/README.md) mountable inside any app. Useful for `nada`, IDE-like setups.
+511
View File
@@ -0,0 +1,511 @@
//! `llimphi-module-shuma-term` — terminal integrado al estilo Ctrl+` de
//! VS Code o "Terminal" de JetBrains, pero enchufable en cualquier app
//! Llimphi.
//!
//! Lo monta sobre dos piezas que ya existen:
//!
//! - [`shuma_exec::Exec::Pty`] aloja un pseudo-terminal cross-platform
//! (`portable-pty`), lanza el shell con `TERM=xterm-256color`, y
//! entrega los bytes crudos por un canal MPSC. El módulo no toca
//! syscalls — sólo consume eventos.
//! - [`vt100::Parser`] convierte esos bytes en un buffer de pantalla
//! ANSI: cursor, erase, OSC, scrollback. El módulo le pasa los bytes
//! y al renderizar pide `screen().contents()`.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`: `State + Msg +
//! Action + apply/on_key/open_shortcut/view + Palette`.
//!
//! ## Cómo lo enchufa una app (resumen)
//!
//! ```ignore
//! struct Model { term: Option<ShumaTermState>, … }
//! enum Msg { Term(ShumaTermMsg), Tick, … }
//!
//! // open: shuma_term::spawn("/home/user", 100, 30)?
//! // on_key: si term.is_some() y on_key devuelve Some(msg) → Msg::Term(msg)
//! // si term.is_none() y open_shortcut(ev) → Msg::Term(Open)
//! // tick periódico: dispatch Msg::Term(Tick) para drenar PTY
//! // apply: match action { Close → model.term = None, SetStatus(s) → … }
//! // view: si term.is_some() → push view(...)
//! ```
#![forbid(unsafe_code)]
use std::time::Instant;
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use shuma_exec::{CommandSpec, Exec, Killer, RunEvent, RunHandle};
/// Capabilities que aporta este módulo al host. El host las puede
/// agregar a `provides` en su `card_core::Card` para que el broker
/// chasqui descubra que la instancia ofrece terminal integrado.
pub const CAPABILITIES: &[&str] = &["editor.terminal"];
/// Dimensiones por defecto del PTY. Cubren un panel inferior tipo
/// VS Code en una pantalla 1080p. Las apps pueden pasar otras a
/// [`spawn_with`].
pub const DEFAULT_COLS: u16 = 100;
pub const DEFAULT_ROWS: u16 = 24;
const SCROLLBACK: usize = 2000;
// =====================================================================
// State
// =====================================================================
/// Estado del panel terminal. Encapsula el `RunHandle` del shell y un
/// `vt100::Parser` que mantiene el buffer de pantalla. No es `Clone`
/// (los handles son únicos), y el host lo embebe como
/// `Option<ShumaTermState>`.
pub struct ShumaTermState {
handle: RunHandle,
killer: Killer,
parser: vt100::Parser,
cols: u16,
rows: u16,
/// Si el shell ya emitió `Exited(code)`. El panel se queda visible
/// para que el usuario pueda leer la última salida antes de cerrar.
exit_code: Option<i32>,
/// CWD inicial — útil para el header sin tener que tocar /proc.
cwd: String,
started_at: Instant,
}
impl ShumaTermState {
/// Bytes que el módulo ya consumió desde el PTY. Útil para tests y
/// debug — no es parte del contrato Tier 1.
pub fn screen_contents(&self) -> String {
self.parser.screen().contents()
}
pub fn cols(&self) -> u16 {
self.cols
}
pub fn rows(&self) -> u16 {
self.rows
}
pub fn exit_code(&self) -> Option<i32> {
self.exit_code
}
pub fn cwd(&self) -> &str {
&self.cwd
}
}
impl Drop for ShumaTermState {
fn drop(&mut self) {
// Si el host descarta el state (panel cerrado), no dejamos al
// shell huérfano consumiendo CPU. SIGTERM educado primero;
// shuma-exec se encarga del SIGKILL si hace falta.
self.killer.term();
}
}
/// Lanza el shell por defecto (`$SHELL`, fallback `/bin/sh`) en `cwd`
/// con tamaño de PTY por defecto.
pub fn spawn(cwd: impl Into<String>) -> ShumaTermState {
spawn_with(cwd, default_shell(), Vec::new(), DEFAULT_COLS, DEFAULT_ROWS)
}
/// Variante con control fino de programa, args y tamaño.
pub fn spawn_with(
cwd: impl Into<String>,
program: String,
args: Vec<String>,
cols: u16,
rows: u16,
) -> ShumaTermState {
let cwd = cwd.into();
let spec = CommandSpec {
exec: Exec::Pty { program, args, cols, rows },
cwd: cwd.clone(),
capture_limit: 0,
spill_path: None,
stdin_data: None,
capture_stages: false,
};
let handle = shuma_exec::run(&spec);
let killer = handle.killer();
ShumaTermState {
handle,
killer,
parser: vt100::Parser::new(rows, cols, SCROLLBACK),
cols,
rows,
exit_code: None,
cwd,
started_at: Instant::now(),
}
}
fn default_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
// =====================================================================
// Msg / Action
// =====================================================================
/// Vocabulario interno. El host lo wrapea en su `Msg`.
#[derive(Debug, Clone)]
pub enum ShumaTermMsg {
/// Símbolo conveniente para que el host dispatche al detectar el
/// shortcut. El módulo no crea el state él mismo — el host lo crea
/// con [`spawn`] porque conoce el cwd canónico de la app.
Open,
/// El usuario pidió cerrar el panel.
Close,
/// Tecla mientras el panel está enfocado. Se traduce a bytes y se
/// reenvía al PTY.
KeyInput(KeyEvent),
/// Tick del host: drena eventos pendientes del PTY (bytes y exit).
/// El host debe enviar este Msg de forma periódica (en cada frame,
/// o cuando hay actividad). Sin Tick el terminal no avanza.
Tick,
/// Mata el shell (SIGTERM); el panel queda visible mostrando el
/// estado final hasta que el host reciba `Close`.
Terminate,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShumaTermAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería actualizar su barra de estado.
SetStatus(String),
}
// =====================================================================
// apply / on_key / open_shortcut
// =====================================================================
/// Aplica un mensaje al estado.
pub fn apply(state: &mut ShumaTermState, msg: ShumaTermMsg) -> ShumaTermAction {
match msg {
ShumaTermMsg::Open => ShumaTermAction::None,
ShumaTermMsg::Close => ShumaTermAction::Close,
ShumaTermMsg::Terminate => {
state.killer.term();
ShumaTermAction::SetStatus("shuma · SIGTERM".into())
}
ShumaTermMsg::Tick => drain(state),
ShumaTermMsg::KeyInput(ev) => {
// Interceptaciones del módulo (no llegan al PTY):
// Ctrl+Shift+W → cierra el panel.
// Cualquier otra combinación se traduce a bytes y se envía.
if ev.state == KeyState::Pressed
&& ev.modifiers.ctrl
&& ev.modifiers.shift
&& matches!(&ev.key, Key::Character(s) if s.eq_ignore_ascii_case("w"))
{
return ShumaTermAction::Close;
}
let bytes = key_to_bytes(&ev);
if !bytes.is_empty() {
state.handle.write_input(bytes);
}
ShumaTermAction::None
}
}
}
/// Routing de teclas cuando el panel está enfocado. Devuelve `Some` para
/// todo evento `Pressed` — el terminal **traga** las teclas; el host no
/// debe reusarlas para sus propios atajos mientras este panel esté
/// activo (la excepción es el atajo de apertura, que el host filtra
/// antes de delegar).
pub fn on_key(_state: &ShumaTermState, event: &KeyEvent) -> Option<ShumaTermMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(ShumaTermMsg::KeyInput(event.clone()))
}
/// El atajo recomendado para abrir: **Ctrl+`** (backtick), igual que
/// VS Code. Los hosts pueden ignorarlo y usar otro.
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 == "`")
}
// =====================================================================
// Drenado del PTY
// =====================================================================
fn drain(state: &mut ShumaTermState) -> ShumaTermAction {
let mut bytes_in = 0usize;
let mut final_action = ShumaTermAction::None;
for ev in state.handle.try_events() {
match ev {
RunEvent::Bytes(b) => {
bytes_in += b.len();
state.parser.process(&b);
}
RunEvent::Exited(code) => {
state.exit_code = Some(code);
let elapsed = state.started_at.elapsed().as_secs_f64();
final_action = ShumaTermAction::SetStatus(format!(
"shuma · exit {code} · {elapsed:.1}s"
));
}
RunEvent::Failed(err) => {
state.exit_code = Some(-1);
final_action =
ShumaTermAction::SetStatus(format!("shuma · falló: {err}"));
}
// Stdout/Stderr/Truncated/Spilled no aplican al modo Pty.
_ => {}
}
}
if matches!(final_action, ShumaTermAction::None) && bytes_in > 0 {
// Nada que reportar — el repaint que el host hará por el frame
// basta para mostrar lo nuevo.
ShumaTermAction::None
} else {
final_action
}
}
// =====================================================================
// Mapeo KeyEvent → bytes
// =====================================================================
/// Convierte un `KeyEvent` ya recibido en los bytes que un terminal
/// xterm espera. Cubre el subset usable (chars + control + flechas +
/// home/end/page + fn keys), suficiente para shells modernos, TUIs
/// (vim, htop, less) y CLIs interactivas (claude code, fzf).
pub fn key_to_bytes(ev: &KeyEvent) -> Vec<u8> {
if ev.state != KeyState::Pressed {
return Vec::new();
}
// Teclas con nombre primero: flechas, etc. Se mapean a CSI/SS3
// estándar (xterm-256color).
if let Key::Named(named) = &ev.key {
return named_to_bytes(*named);
}
// Caracter: si hay Ctrl+letra → control byte (Ctrl+C = 0x03).
if let Key::Character(s) = &ev.key {
if ev.modifiers.ctrl && !ev.modifiers.alt {
if let Some(b) = ctrl_byte(s) {
return vec![b];
}
}
// Alt+x → ESC + x (convención xterm meta-sends-escape).
if ev.modifiers.alt {
let mut out = vec![0x1b];
out.extend_from_slice(s.as_bytes());
return out;
}
}
// Caso general: si el backend ya nos dio el texto resultante
// (con shift/IME aplicados), eso es lo correcto para mandar.
if let Some(text) = &ev.text {
return text.as_bytes().to_vec();
}
Vec::new()
}
fn named_to_bytes(k: NamedKey) -> Vec<u8> {
match k {
// PTYs en modo raw esperan CR para Enter; el driver convierte a LF.
NamedKey::Enter => b"\r".to_vec(),
// Backspace moderno = DEL (0x7f). Los shells lo entienden mejor
// que 0x08, que se reserva para ^H en TUIs viejos.
NamedKey::Backspace => vec![0x7f],
NamedKey::Tab => b"\t".to_vec(),
NamedKey::Escape => vec![0x1b],
NamedKey::ArrowUp => b"\x1b[A".to_vec(),
NamedKey::ArrowDown => b"\x1b[B".to_vec(),
NamedKey::ArrowRight => b"\x1b[C".to_vec(),
NamedKey::ArrowLeft => b"\x1b[D".to_vec(),
NamedKey::Home => b"\x1b[H".to_vec(),
NamedKey::End => b"\x1b[F".to_vec(),
NamedKey::PageUp => b"\x1b[5~".to_vec(),
NamedKey::PageDown => b"\x1b[6~".to_vec(),
NamedKey::Delete => b"\x1b[3~".to_vec(),
NamedKey::Insert => b"\x1b[2~".to_vec(),
NamedKey::F1 => b"\x1bOP".to_vec(),
NamedKey::F2 => b"\x1bOQ".to_vec(),
NamedKey::F3 => b"\x1bOR".to_vec(),
NamedKey::F4 => b"\x1bOS".to_vec(),
NamedKey::F5 => b"\x1b[15~".to_vec(),
NamedKey::F6 => b"\x1b[17~".to_vec(),
NamedKey::F7 => b"\x1b[18~".to_vec(),
NamedKey::F8 => b"\x1b[19~".to_vec(),
NamedKey::F9 => b"\x1b[20~".to_vec(),
NamedKey::F10 => b"\x1b[21~".to_vec(),
NamedKey::F11 => b"\x1b[23~".to_vec(),
NamedKey::F12 => b"\x1b[24~".to_vec(),
_ => Vec::new(),
}
}
/// Ctrl+letter → byte de control ASCII (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26).
/// Maneja también Ctrl+@ (NUL), Ctrl+[ (ESC), Ctrl+\\ (FS), Ctrl+] (GS),
/// Ctrl+^ (RS), Ctrl+_ (US), Ctrl+? (DEL).
fn ctrl_byte(s: &str) -> Option<u8> {
let c = s.chars().next()?;
match c {
'a'..='z' => Some((c as u8) - b'a' + 1),
'A'..='Z' => Some((c as u8) - b'A' + 1),
'@' => Some(0),
'[' => Some(0x1b),
'\\' => Some(0x1c),
']' => Some(0x1d),
'^' => Some(0x1e),
'_' => Some(0x1f),
'?' => Some(0x7f),
' ' => Some(0), // Ctrl+Space = NUL, convención xterm
_ => None,
}
}
// =====================================================================
// View
// =====================================================================
/// Paleta visual del terminal. Monospace; fondo más oscuro que el
/// panel general para que el terminal "viva" visualmente.
#[derive(Debug, Clone)]
pub struct ShumaTermPalette {
pub bg_panel: llimphi_ui::llimphi_raster::peniko::Color,
pub bg_header: llimphi_ui::llimphi_raster::peniko::Color,
pub fg_text: llimphi_ui::llimphi_raster::peniko::Color,
pub fg_muted: llimphi_ui::llimphi_raster::peniko::Color,
}
impl ShumaTermPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
Self {
bg_panel: t.bg_panel_alt,
bg_header: t.bg_panel,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
}
}
}
const HEADER_H: f32 = 18.0;
const ROW_H: f32 = 14.0;
const CHAR_W: f32 = 7.5;
/// Render del panel. `to_host` mapea cada `ShumaTermMsg` al `Msg` del
/// host. `height_px` es la altura total del panel — el módulo divide
/// entre header + grid.
pub fn view<HostMsg, F>(
state: &ShumaTermState,
palette: &ShumaTermPalette,
height_px: f32,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(ShumaTermMsg) -> HostMsg + Copy + 'static,
{
let _ = to_host; // v0 no monta eventos puntuales sobre el grid
let header_text = match state.exit_code {
Some(code) => format!(
"shuma · {} · exit {code} · Ctrl+Shift+W cierra",
state.cwd
),
None => format!(
"shuma · {} · {}×{} · Ctrl+Shift+W cierra · Esc envía al shell",
state.cwd, state.cols, state.rows
),
};
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
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_text, 10.0, palette.fg_muted, Alignment::Start);
let contents = state.parser.screen().contents();
let grid_h = (height_px - HEADER_H).max(0.0);
let max_rows = ((grid_h / ROW_H) as usize).max(1);
// Tomamos las últimas `max_rows` líneas — preferimos mostrar el
// tail (donde está el cursor / prompt) si el render no alcanza
// para toda la pantalla.
let all_lines: Vec<&str> = contents.split('\n').collect();
let start = all_lines.len().saturating_sub(max_rows);
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
for line in &all_lines[start..] {
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
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_panel)
.text_aligned((*line).to_string(), 11.0, palette.fg_text, Alignment::Start),
);
}
// Si el render quedó corto, rellenamos con líneas vacías para que el
// panel mantenga su altura visual.
while rows.len() < max_rows {
rows.push(
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel),
);
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + rows.len());
children.push(header);
children.extend(rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(height_px) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
/// Estimación heurística de cuántas columnas caben en `width_px` con la
/// fuente actual. Útil para que el host calcule el tamaño antes de
/// llamar a [`spawn_with`].
pub fn cols_for_width(width_px: f32) -> u16 {
((width_px / CHAR_W).floor() as u16).max(20)
}
/// Idem para filas a partir de la altura disponible del panel
/// (descontando el header).
pub fn rows_for_height(height_px: f32) -> u16 {
(((height_px - HEADER_H) / ROW_H).floor() as u16).max(5)
}
+157
View File
@@ -0,0 +1,157 @@
//! Smoke test del terminal: spawnea un shell, le tipea `echo hola`,
//! drena hasta ver el output, y verifica que el contenido del screen
//! contenga "hola". Cierre con SIGTERM se valida por el Drop.
//!
//! Requiere `/bin/sh` y un sistema Linux real (no corre en sandbox
//! puro). Es razonable porque shuma-exec ya lo asume.
use std::time::{Duration, Instant};
use llimphi_module_shuma_term::{self as term, ShumaTermAction, ShumaTermMsg};
#[test]
fn echo_a_traves_del_pty_aparece_en_el_screen() {
let mut state = term::spawn_with(
"/tmp".to_string(),
"/bin/sh".to_string(),
Vec::new(),
80,
24,
);
// El shell escribe su prompt al arrancar; lo drenamos sin asumir
// su contenido (cambia por distro).
spin_drain(&mut state, Duration::from_millis(200));
// Tipeamos el comando. Sin Llimphi alrededor llamamos a write_input
// directamente — el módulo permite hacerlo via KeyInput, pero
// construir KeyEvents acá es ruido para este test.
write_raw(&mut state, b"echo hola_del_test\n");
// Esperamos hasta 2s a que el output llegue al screen.
let deadline = Instant::now() + Duration::from_secs(2);
let mut visto = false;
while Instant::now() < deadline {
spin_drain(&mut state, Duration::from_millis(50));
if state.screen_contents().contains("hola_del_test") {
visto = true;
break;
}
}
assert!(
visto,
"esperaba ver 'hola_del_test' en el screen, contenido actual:\n{}",
state.screen_contents()
);
}
#[test]
fn ctrl_shift_w_emite_action_close_sin_pasar_al_pty() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
let mut state = term::spawn_with(
"/tmp".to_string(),
"/bin/sh".to_string(),
Vec::new(),
80,
24,
);
spin_drain(&mut state, Duration::from_millis(100));
let ev = KeyEvent {
key: Key::Character("w".into()),
state: KeyState::Pressed,
text: Some("w".into()),
modifiers: Modifiers { ctrl: true, shift: true, ..Modifiers::default() },
repeat: false,
};
let action = term::apply(&mut state, ShumaTermMsg::KeyInput(ev));
assert_eq!(action, ShumaTermAction::Close);
}
#[test]
fn key_to_bytes_mapea_los_casos_canonicos() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers, NamedKey};
let mk = |key: Key, mods: Modifiers, text: Option<&str>| KeyEvent {
key,
state: KeyState::Pressed,
text: text.map(|s| s.to_string()),
modifiers: mods,
repeat: false,
};
// Enter → CR (no LF — el driver del PTY lo expande).
assert_eq!(
term::key_to_bytes(&mk(Key::Named(NamedKey::Enter), Modifiers::default(), None)),
b"\r"
);
// Backspace → DEL.
assert_eq!(
term::key_to_bytes(&mk(
Key::Named(NamedKey::Backspace),
Modifiers::default(),
None
)),
vec![0x7f]
);
// ArrowUp → CSI A.
assert_eq!(
term::key_to_bytes(&mk(
Key::Named(NamedKey::ArrowUp),
Modifiers::default(),
None
)),
b"\x1b[A"
);
// Ctrl+C → 0x03.
let ctrl = Modifiers { ctrl: true, ..Modifiers::default() };
assert_eq!(
term::key_to_bytes(&mk(Key::Character("c".into()), ctrl, Some("c"))),
vec![0x03]
);
// Texto plano (con shift aplicado por el backend) → ese mismo texto.
assert_eq!(
term::key_to_bytes(&mk(Key::Character("A".into()), Modifiers::default(), Some("A"))),
b"A"
);
// Alt+x → ESC + x.
let alt = Modifiers { alt: true, ..Modifiers::default() };
assert_eq!(
term::key_to_bytes(&mk(Key::Character("x".into()), alt, Some("x"))),
vec![0x1b, b'x']
);
}
// ---------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------
/// Pequeño polling: dispara Tick varias veces durante `total` para que
/// el módulo drene los bytes que el reader thread haya emitido.
fn spin_drain(state: &mut llimphi_module_shuma_term::ShumaTermState, total: Duration) {
let deadline = Instant::now() + total;
while Instant::now() < deadline {
term::apply(state, ShumaTermMsg::Tick);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Atajo: enviar bytes crudos al PTY sin construir un KeyEvent. Usa la
/// API pública via un truco — convertimos a un KeyEvent "texto" para
/// evitar exponer write_input crudo en el contrato.
fn write_raw(state: &mut llimphi_module_shuma_term::ShumaTermState, bytes: &[u8]) {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
// Texto entero (incluyendo el LF) en un solo KeyInput. apply() lo
// copia tal cual al PTY via la rama `text`.
let s = std::str::from_utf8(bytes).expect("test usa ascii");
let ev = KeyEvent {
// Key::Character vacío para que no entremos por la rama ctrl/alt.
key: Key::Character("".into()),
state: KeyState::Pressed,
text: Some(s.to_string()),
modifiers: Modifiers::default(),
repeat: false,
};
term::apply(state, ShumaTermMsg::KeyInput(ev));
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "llimphi-module-symbol-outline"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-symbol-outline — outline del documento (funciones, structs, métodos) navegable con fuzzy filter. Módulo Llimphi reutilizable: el host le pasa un Vec<SymbolItem> y el módulo emite GoTo(line, col). No depende de LSP — el host puede poblarlo desde cualquier fuente (tree-sitter, parser propio, LSP)."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-text-input = { workspace = true }
nucleo-matcher = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-symbol-outline
> Outline de símbolos LSP de [llimphi](../../README.md).
Vista jerárquica de los símbolos del archivo activo (funciones, structs, módulos, ...) — alimentada por el LSP servidor del lenguaje. Click salta al símbolo.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-symbol-outline
> LSP symbol outline of [llimphi](../../README.md).
Hierarchical view of the active file's symbols (functions, structs, modules, ...) — fed by the language's LSP server. Click jumps to the symbol.
+352
View File
@@ -0,0 +1,352 @@
//! `llimphi-module-symbol-outline` — outline navegable de símbolos.
//!
//! Equivalente al "Outline" panel de VS Code o "Structure" de JetBrains.
//! El host arma una lista plana de [`SymbolItem`] (funciones, structs,
//! métodos, con su posición en el buffer) y el módulo presenta un
//! overlay con input + lista rankeada por fuzzy. Cuando el user pica
//! uno, el módulo emite [`OutlineAction::GoTo`] y el host mueve el caret.
//!
//! El módulo es **agnóstico de la fuente de símbolos**. El host puede
//! poblarlo desde:
//!
//! - LSP (`textDocument/documentSymbol`) — fuente canónica.
//! - tree-sitter — sirve para archivos sin LSP.
//! - parser propio del lenguaje del host.
//! - una lista hardcodeada (en una app no-código que tenga "secciones").
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
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 módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.symbol-outline"];
pub const MAX_RESULTS: usize = 500;
const BAR_H: f32 = 320.0;
const ROW_H: f32 = 20.0;
const MAX_VISIBLE: usize = 12;
/// Un símbolo del documento. Los campos son convencionales:
///
/// - `name`: nombre visible (`foo`, `MyStruct`, `parse_line`).
/// - `kind`: etiqueta corta del tipo de símbolo (`fn`, `struct`, `method`,
/// `mod`, `const`, …). El módulo la pinta sin interpretar — el host
/// elige el vocabulario (LSP usa `SymbolKind` numérico; el host
/// convierte a string).
/// - `line`, `col`: posición 0-based en el buffer. El módulo no toca
/// coordenadas — sólo las devuelve en `GoTo`.
/// - `container`: nombre del símbolo padre (`Some("MyStruct")` para
/// un método). Visible en el render como anotación a la derecha;
/// también participa del fuzzy match para que tipear el nombre de
/// la clase filtre sus métodos.
/// - `depth`: profundidad jerárquica para indentación visual. El
/// módulo asume que la lista ya viene ordenada (parent antes que
/// children, en orden de aparición).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolItem {
pub name: String,
pub kind: String,
pub line: usize,
pub col: usize,
pub container: Option<String>,
pub depth: u32,
}
/// Estado interno. `results` son índices al slice de symbols que pasa
/// el host: el módulo no copia, sólo guarda índices.
pub struct OutlineState {
pub input: TextInputState,
pub results: Vec<usize>,
pub selected: usize,
}
impl Default for OutlineState {
fn default() -> Self {
Self::new_empty()
}
}
impl OutlineState {
pub fn new_empty() -> Self {
Self {
input: TextInputState::new(),
results: Vec::new(),
selected: 0,
}
}
/// Crea un outline poblado con todos los símbolos sin filtro.
pub fn new(items: &[SymbolItem]) -> Self {
let mut s = Self::new_empty();
refilter(&mut s, items);
s
}
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum OutlineMsg {
/// Símbolo conveniente que el host emite al detectar el shortcut.
/// El módulo no construye el state ni la lista él mismo.
Open,
Close,
KeyInput(KeyEvent),
Nav(i32),
/// Enter: salta al símbolo seleccionado.
Apply,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OutlineAction {
None,
/// El host debería remover el state del modelo.
Close,
/// El host debería mover el caret a esta posición del buffer activo.
/// El módulo NO se cierra automáticamente — el host decide
/// (típicamente sí, para que la navegación sea "salta y mira").
GoTo { line: usize, col: usize },
}
/// Aplica un mensaje al estado.
pub fn apply(
state: &mut OutlineState,
msg: OutlineMsg,
items: &[SymbolItem],
) -> OutlineAction {
match msg {
OutlineMsg::Open => OutlineAction::None,
OutlineMsg::Close => OutlineAction::Close,
OutlineMsg::KeyInput(ev) => {
state.input.apply_key(&ev);
refilter(state, items);
OutlineAction::None
}
OutlineMsg::Nav(d) => {
let n = state.results.len() as i32;
if n > 0 {
state.selected = (state.selected as i32 + d).rem_euclid(n) as usize;
}
OutlineAction::None
}
OutlineMsg::Apply => {
let Some(&idx) = state.results.get(state.selected) else {
return OutlineAction::None;
};
let Some(it) = items.get(idx) else {
return OutlineAction::None;
};
OutlineAction::GoTo { line: it.line, col: it.col }
}
}
}
/// Routing de teclas cuando el outline está abierto.
pub fn on_key(_state: &OutlineState, event: &KeyEvent) -> Option<OutlineMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => OutlineMsg::Close,
Key::Named(NamedKey::Enter) => OutlineMsg::Apply,
Key::Named(NamedKey::ArrowDown) => OutlineMsg::Nav(1),
Key::Named(NamedKey::ArrowUp) => OutlineMsg::Nav(-1),
_ => OutlineMsg::KeyInput(event.clone()),
})
}
/// El atajo recomendado: **Ctrl+Shift+O**, igual que VS Code.
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("o"))
}
/// Recalcula `state.results` con fuzzy match sobre `"name kind container"`.
/// Query vacío = lista completa. Cap: [`MAX_RESULTS`].
pub fn refilter(state: &mut OutlineState, items: &[SymbolItem]) {
let q = state.input.text();
if q.trim().is_empty() {
state.results = (0..items.len().min(MAX_RESULTS)).collect();
state.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, it) in items.iter().enumerate() {
let hay_str = match &it.container {
Some(c) => format!("{} {} {c}", it.name, it.kind),
None => format!("{} {}", it.name, it.kind),
};
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);
state.results = scored.into_iter().map(|(_, i)| i).collect();
state.selected = 0;
}
/// Paleta visual.
#[derive(Debug, Clone)]
pub struct OutlinePalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_selected: Color,
pub fg_text: Color,
pub fg_muted: Color,
theme: llimphi_theme::Theme,
}
impl OutlinePalette {
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,
theme: t.clone(),
}
}
}
/// Render del overlay. `to_host` mapea cada `OutlineMsg` al `Msg` de la
/// app.
pub fn view<HostMsg, F>(
state: &OutlineState,
items: &[SymbolItem],
palette: &OutlinePalette,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(OutlineMsg) -> HostMsg + Copy + 'static,
{
let header = if items.is_empty() {
"outline · sin símbolos · Esc cierra".to_string()
} else if state.results.is_empty() {
format!("outline · sin matches · {} símbolos · Esc cierra", items.len())
} else {
format!(
"outline · {} / {} · ↓↑ navega · Enter salta · Esc cierra",
state.selected + 1,
state.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(
&state.input,
"filtro: nombre del símbolo o clase…",
true,
&tp,
to_host(OutlineMsg::Open),
)]);
let visible_start = state.selected.saturating_sub(MAX_VISIBLE.saturating_sub(1));
let visible_end = (visible_start + MAX_VISIBLE).min(state.results.len());
let mut rows: Vec<View<HostMsg>> = Vec::with_capacity(MAX_VISIBLE);
for i in visible_start..visible_end {
let Some(&idx) = state.results.get(i) else { continue };
let Some(it) = items.get(idx) else { continue };
// Indentación visual por depth (sólo cuando no hay query — con
// query el orden ya vino del ranking y la jerarquía se pierde).
let indent = if state.input.text().trim().is_empty() {
" ".repeat(it.depth as usize)
} else {
String::new()
};
let container_tag = match &it.container {
Some(c) if !c.is_empty() => format!(" in {c}"),
_ => String::new(),
};
let label = format!(
"{indent}{} {} line {}{container_tag}",
it.kind,
it.name,
it.line + 1,
);
let selected = i == state.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(BAR_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
+130
View File
@@ -0,0 +1,130 @@
//! Smoke tests del fuzzy match y el routing de teclas. Sin backend
//! gráfico — sólo `apply` + `refilter`.
use llimphi_module_symbol_outline::{
self as outline, OutlineAction, OutlineMsg, OutlineState, SymbolItem,
};
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
fn seed() -> Vec<SymbolItem> {
vec![
SymbolItem {
name: "Model".into(),
kind: "struct".into(),
line: 100,
col: 0,
container: None,
depth: 0,
},
SymbolItem {
name: "init".into(),
kind: "fn".into(),
line: 110,
col: 4,
container: Some("Model".into()),
depth: 1,
},
SymbolItem {
name: "update".into(),
kind: "fn".into(),
line: 200,
col: 4,
container: Some("Model".into()),
depth: 1,
},
SymbolItem {
name: "Renderer".into(),
kind: "struct".into(),
line: 300,
col: 0,
container: None,
depth: 0,
},
SymbolItem {
name: "draw".into(),
kind: "fn".into(),
line: 310,
col: 4,
container: Some("Renderer".into()),
depth: 1,
},
]
}
fn key_char(c: &str) -> KeyEvent {
KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers::default(),
repeat: false,
}
}
#[test]
fn estado_vacio_lista_todos_los_simbolos() {
let items = seed();
let s = OutlineState::new(&items);
assert_eq!(s.results.len(), items.len());
}
#[test]
fn fuzzy_match_filtra_por_nombre_de_clase_contenedora() {
// Tipear "render" debería traer `draw` (su container es "Renderer")
// gracias a que refilter incluye container en la haystack.
let items = seed();
let mut s = OutlineState::new(&items);
for ch in ["r", "e", "n", "d", "e", "r"] {
outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items);
}
let names: Vec<&str> = s.results.iter().map(|&i| items[i].name.as_str()).collect();
assert!(
names.contains(&"draw") || names.contains(&"Renderer"),
"esperaba draw o Renderer en {names:?}"
);
}
#[test]
fn apply_emite_goto_con_line_col_del_item_seleccionado() {
let items = seed();
let mut s = OutlineState::new(&items);
// Filtrar "update".
for ch in ["u", "p", "d", "a", "t", "e"] {
outline::apply(&mut s, OutlineMsg::KeyInput(key_char(ch)), &items);
}
let action = outline::apply(&mut s, OutlineMsg::Apply, &items);
assert_eq!(action, OutlineAction::GoTo { line: 200, col: 4 });
}
#[test]
fn nav_wrap_around() {
let items = seed();
let mut s = OutlineState::new(&items);
assert_eq!(s.selected, 0);
outline::apply(&mut s, OutlineMsg::Nav(-1), &items);
assert_eq!(s.selected, items.len() - 1);
}
#[test]
fn open_shortcut_es_ctrl_shift_o() {
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(outline::open_shortcut(&mk(true, true, "o")));
assert!(outline::open_shortcut(&mk(true, true, "O")));
assert!(!outline::open_shortcut(&mk(true, false, "o")));
assert!(!outline::open_shortcut(&mk(false, true, "o")));
}
#[test]
fn items_vacios_no_paniquean() {
let items: Vec<SymbolItem> = Vec::new();
let mut s = OutlineState::new(&items);
assert!(s.results.is_empty());
let action = outline::apply(&mut s, OutlineMsg::Apply, &items);
assert_eq!(action, OutlineAction::None);
}