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:
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, ¤t_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, ¤t_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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user