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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "llimphi-module-diff-viewer"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "llimphi-module-diff-viewer — visualización side-by-side de cambios entre dos textos. Módulo Llimphi: el host provee before/after (typically HEAD vs working tree, o snapshot vs current buffer), el módulo computa el diff con `similar` y lo presenta en dos columnas con marcadores +/- y números de línea."
[dependencies]
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
similar = { workspace = true }
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Diff side-by-side de [llimphi](../../README.md).
Toma dos textos y muestra diff por línea: inserciones, eliminaciones, modificaciones. Algoritmo Myers; resaltado intra-línea opcional.
+5
View File
@@ -0,0 +1,5 @@
# llimphi-module-diff-viewer
> Side-by-side diff of [llimphi](../../README.md).
Takes two texts and shows line-by-line diff: insertions, deletions, modifications. Myers algorithm; optional intra-line highlight.
+398
View File
@@ -0,0 +1,398 @@
//! `llimphi-module-diff-viewer` — visualización side-by-side de cambios.
//!
//! Equivalente al "Compare with Saved" de VS Code o el panel "Compare"
//! de JetBrains, pero como módulo Llimphi enchufable. El host le pasa
//! dos textos (`before`/`after`) y dos etiquetas (`"HEAD"`, `"Working
//! Tree"`, `"Buffer"` — lo que tenga sentido en su contexto), y el
//! módulo computa el diff line-based con [`similar`] y lo renderiza
//! en dos columnas con marcadores `+`/`-` y números de línea.
//!
//! El módulo no abre archivos, no llama a `git`, no toca disco. Toda
//! la fuente del diff la decide el host: puede comparar el disco vs
//! el buffer dirty, dos branches, dos snapshots de history, etc.
//!
//! Sigue el contrato Llimphi de `docs/MODULES.md`:
//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
#![forbid(unsafe_code)]
use llimphi_ui::llimphi_layout::taffy::{
prelude::{length, percent, FlexDirection, Size, Style},
AlignItems, Rect,
};
use llimphi_ui::llimphi_raster::peniko::Color;
use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::{Key, KeyEvent, KeyState, NamedKey, View};
use similar::{ChangeTag, TextDiff};
/// Capabilities que aporta este módulo al host.
pub const CAPABILITIES: &[&str] = &["editor.diff-viewer"];
const HEADER_H: f32 = 18.0;
const ROW_H: f32 = 15.0;
/// Una línea del diff alineada para render side-by-side.
///
/// El render usa dos celdas por fila (izquierda = `before`, derecha =
/// `after`). En una línea `Equal`, ambas celdas tienen el mismo
/// contenido. En `Delete`, sólo la izquierda; en `Insert`, sólo la
/// derecha. La struct cumple las dos roles para simplificar el render.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffRow {
pub kind: DiffKind,
/// Contenido de la celda izquierda (Equal o Delete) o vacío.
pub left: Option<DiffCell>,
/// Contenido de la celda derecha (Equal o Insert) o vacío.
pub right: Option<DiffCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffCell {
/// Número de línea 1-based en el lado correspondiente.
pub line_no: usize,
pub text: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffKind {
Equal,
Delete,
Insert,
}
/// Estado del panel.
pub struct DiffState {
pub before_label: String,
pub after_label: String,
pub rows: Vec<DiffRow>,
pub scroll: usize,
/// Conteo agregado para mostrar en el header (`+12 / -3` etc.).
pub stats: DiffStats,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DiffStats {
pub inserts: usize,
pub deletes: usize,
pub equals: usize,
}
impl DiffState {
/// Construye el state computando el diff entre `before` y `after`.
/// Líneas se separan por '\n'; el último '\n' se conserva como
/// separador (no aparece como línea extra vacía).
pub fn new(
before_label: impl Into<String>,
after_label: impl Into<String>,
before: &str,
after: &str,
) -> Self {
let (rows, stats) = compute_rows(before, after);
Self {
before_label: before_label.into(),
after_label: after_label.into(),
rows,
scroll: 0,
stats,
}
}
}
/// Computa las filas alineadas a partir de los dos textos. La salida
/// preserva el orden lineal del archivo: bloques `Equal` mantienen las
/// líneas pareadas; un `Delete` que no tiene contraparte en el otro
/// lado aparece con `right = None`, y viceversa para `Insert`. No se
/// emparejan visualmente delete con insert — siguen la convención de
/// VS Code, que los muestra como líneas separadas.
pub fn compute_rows(before: &str, after: &str) -> (Vec<DiffRow>, DiffStats) {
let diff = TextDiff::from_lines(before, after);
let mut rows: Vec<DiffRow> = Vec::new();
let mut stats = DiffStats::default();
let mut left_no = 0usize;
let mut right_no = 0usize;
for change in diff.iter_all_changes() {
let text = change.value().trim_end_matches('\n').to_string();
match change.tag() {
ChangeTag::Equal => {
left_no += 1;
right_no += 1;
stats.equals += 1;
rows.push(DiffRow {
kind: DiffKind::Equal,
left: Some(DiffCell { line_no: left_no, text: text.clone() }),
right: Some(DiffCell { line_no: right_no, text }),
});
}
ChangeTag::Delete => {
left_no += 1;
stats.deletes += 1;
rows.push(DiffRow {
kind: DiffKind::Delete,
left: Some(DiffCell { line_no: left_no, text }),
right: None,
});
}
ChangeTag::Insert => {
right_no += 1;
stats.inserts += 1;
rows.push(DiffRow {
kind: DiffKind::Insert,
left: None,
right: Some(DiffCell { line_no: right_no, text }),
});
}
}
}
(rows, stats)
}
/// Vocabulario interno. El host lo wrapea en su Msg.
#[derive(Clone)]
pub enum DiffMsg {
Open,
Close,
/// Scroll vertical en líneas (positivo = baja).
Scroll(i32),
/// Salta al próximo hunk (∆+/-) en dirección.
NextHunk,
PrevHunk,
}
/// Efecto solicitado al host.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffAction {
None,
/// El host debería remover el state del modelo.
Close,
}
pub fn apply(state: &mut DiffState, msg: DiffMsg, visible_rows: usize) -> DiffAction {
match msg {
DiffMsg::Open => DiffAction::None,
DiffMsg::Close => DiffAction::Close,
DiffMsg::Scroll(delta) => {
scroll_by(state, delta, visible_rows);
DiffAction::None
}
DiffMsg::NextHunk => {
jump_to_hunk(state, true, visible_rows);
DiffAction::None
}
DiffMsg::PrevHunk => {
jump_to_hunk(state, false, visible_rows);
DiffAction::None
}
}
}
fn scroll_by(state: &mut DiffState, delta: i32, visible_rows: usize) {
let max_scroll = state.rows.len().saturating_sub(visible_rows);
let new_scroll = (state.scroll as i64 + delta as i64).max(0) as usize;
state.scroll = new_scroll.min(max_scroll);
}
/// Busca la próxima fila con `kind != Equal` en la dirección dada,
/// empezando justo después/antes del scroll actual. Si no hay más,
/// no-op.
fn jump_to_hunk(state: &mut DiffState, forward: bool, visible_rows: usize) {
let start = state.scroll;
let n = state.rows.len();
let found = if forward {
(start + 1..n).find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
} else {
(0..start.min(n)).rev().find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
};
if let Some(i) = found {
let max_scroll = n.saturating_sub(visible_rows);
state.scroll = i.min(max_scroll);
}
}
/// Routing de teclas cuando el panel está abierto.
pub fn on_key(_state: &DiffState, event: &KeyEvent) -> Option<DiffMsg> {
if event.state != KeyState::Pressed {
return None;
}
Some(match &event.key {
Key::Named(NamedKey::Escape) => DiffMsg::Close,
Key::Named(NamedKey::ArrowDown) => DiffMsg::Scroll(1),
Key::Named(NamedKey::ArrowUp) => DiffMsg::Scroll(-1),
Key::Named(NamedKey::PageDown) => DiffMsg::Scroll(20),
Key::Named(NamedKey::PageUp) => DiffMsg::Scroll(-20),
Key::Named(NamedKey::Home) => DiffMsg::Scroll(-(i32::MAX / 4)),
Key::Named(NamedKey::End) => DiffMsg::Scroll(i32::MAX / 4),
Key::Character(s) if s == "n" => DiffMsg::NextHunk,
Key::Character(s) if s == "N" => DiffMsg::PrevHunk,
_ => return None,
})
}
/// El atajo recomendado: **Ctrl+Shift+D**, similar al "Compare with
/// Saved" de VS Code (que usa Ctrl+Shift+P + comando).
pub fn open_shortcut(event: &KeyEvent) -> bool {
event.state == KeyState::Pressed
&& event.modifiers.ctrl
&& event.modifiers.shift
&& matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("d"))
}
/// Paleta visual con colores diff convencionales (verde para insert,
/// rojo apagado para delete).
#[derive(Debug, Clone)]
pub struct DiffPalette {
pub bg_panel: Color,
pub bg_header: Color,
pub bg_insert: Color,
pub bg_delete: Color,
pub bg_empty: Color,
pub fg_text: Color,
pub fg_muted: Color,
pub fg_insert: Color,
pub fg_delete: Color,
}
impl DiffPalette {
pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
// Verde/rojo apagados — visibles sobre fondo oscuro pero sin
// saturar. Si el theme expone colores semánticos de diff en
// el futuro, los usamos; por ahora hardcoded.
Self {
bg_panel: t.bg_panel,
bg_header: t.bg_panel_alt,
bg_insert: Color::from_rgba8(40, 80, 50, 255),
bg_delete: Color::from_rgba8(90, 40, 45, 255),
bg_empty: t.bg_panel_alt,
fg_text: t.fg_text,
fg_muted: t.fg_muted,
fg_insert: Color::from_rgba8(170, 230, 180, 255),
fg_delete: Color::from_rgba8(240, 180, 185, 255),
}
}
}
/// Render del panel side-by-side. `height_px` es la altura total
/// disponible; el módulo divide entre el header de 18 px y la grid.
pub fn view<HostMsg, F>(
state: &DiffState,
palette: &DiffPalette,
height_px: f32,
to_host: F,
) -> View<HostMsg>
where
HostMsg: Clone + 'static,
F: Fn(DiffMsg) -> HostMsg + Copy + 'static,
{
let _ = to_host; // v0 no monta eventos puntuales sobre filas
let header_text = format!(
"diff · {}{} · +{} -{} ={} · ↑↓ scroll · n/N hunk · Esc cierra",
state.before_label,
state.after_label,
state.stats.inserts,
state.stats.deletes,
state.stats.equals,
);
let header = View::new(Style {
size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
padding: Rect {
left: length(8.0_f32),
right: length(8.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_header)
.text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
let grid_h = (height_px - HEADER_H).max(0.0);
let max_rows = ((grid_h / ROW_H) as usize).max(1);
let end = (state.scroll + max_rows).min(state.rows.len());
let mut grid_rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
for row in &state.rows[state.scroll..end] {
grid_rows.push(render_row(row, palette));
}
while grid_rows.len() < max_rows {
// Padding visual para mantener altura constante.
grid_rows.push(empty_row(palette));
}
let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + grid_rows.len());
children.push(header);
children.extend(grid_rows);
View::new(Style {
flex_direction: FlexDirection::Column,
size: Size { width: percent(1.0_f32), height: length(height_px) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
.children(children)
}
fn render_row<HostMsg>(row: &DiffRow, palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
let (left_bg, left_fg, left_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Delete => (palette.bg_delete, palette.fg_delete, "-"),
DiffKind::Insert => (palette.bg_empty, palette.fg_muted, " "),
};
let (right_bg, right_fg, right_mark) = match row.kind {
DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
DiffKind::Insert => (palette.bg_insert, palette.fg_insert, "+"),
DiffKind::Delete => (palette.bg_empty, palette.fg_muted, " "),
};
let left_text = match &row.left {
Some(c) => format!("{:>4} {}{}", c.line_no, left_mark, c.text),
None => String::new(),
};
let right_text = match &row.right {
Some(c) => format!("{:>4} {}{}", c.line_no, right_mark, c.text),
None => String::new(),
};
let cell = |bg: Color, fg: Color, text: String| {
View::new(Style {
flex_grow: 1.0,
size: Size { width: percent(0.5_f32), height: length(ROW_H) },
padding: Rect {
left: length(6.0_f32),
right: length(6.0_f32),
top: length(0.0_f32),
bottom: length(0.0_f32),
},
align_items: Some(AlignItems::Center),
flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
.text_aligned(text, 10.5, fg, Alignment::Start)
};
View::new(Style {
flex_direction: FlexDirection::Row,
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.children(vec![cell(left_bg, left_fg, left_text), cell(right_bg, right_fg, right_text)])
}
fn empty_row<HostMsg>(palette: &DiffPalette) -> View<HostMsg>
where
HostMsg: Clone + 'static,
{
View::new(Style {
size: Size { width: percent(1.0_f32), height: length(ROW_H) },
flex_shrink: 0.0,
..Default::default()
})
.fill(palette.bg_panel)
}
+155
View File
@@ -0,0 +1,155 @@
//! Smoke tests del cómputo de filas y el routing de teclas. Sin
//! backend gráfico — pruebas puras sobre `compute_rows` y `apply`.
use llimphi_module_diff_viewer::{
self as diff, DiffAction, DiffKind, DiffMsg, DiffState,
};
#[test]
fn diff_basico_inserts_y_deletes() {
let before = "a\nb\nc\n";
let after = "a\nB\nc\nd\n";
let (rows, stats) = diff::compute_rows(before, after);
// El diff esperado:
// = a / a
// - b
// + B
// = c / c
// + d
assert_eq!(stats.equals, 2);
assert_eq!(stats.deletes, 1);
assert_eq!(stats.inserts, 2);
assert_eq!(rows[0].kind, DiffKind::Equal);
assert_eq!(rows[0].left.as_ref().unwrap().text, "a");
assert_eq!(rows[0].right.as_ref().unwrap().text, "a");
// El primer cambio debe ser un Delete o Insert (similar agrupa);
// verificamos que B aparezca y b no.
let texts_left: Vec<&str> = rows
.iter()
.filter_map(|r| r.left.as_ref().map(|c| c.text.as_str()))
.collect();
let texts_right: Vec<&str> = rows
.iter()
.filter_map(|r| r.right.as_ref().map(|c| c.text.as_str()))
.collect();
assert!(texts_left.contains(&"b"));
assert!(texts_right.contains(&"B"));
assert!(texts_right.contains(&"d"));
}
#[test]
fn numeros_de_linea_son_correctos() {
let before = "alpha\nbeta\ngamma\n";
let after = "alpha\nBETA\ngamma\ndelta\n";
let (rows, _) = diff::compute_rows(before, after);
// alpha en línea 1 de ambos.
let alpha_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "alpha").unwrap_or(false)
}).unwrap();
assert_eq!(alpha_row.left.as_ref().unwrap().line_no, 1);
assert_eq!(alpha_row.right.as_ref().unwrap().line_no, 1);
// beta (delete) en línea 2 izquierda.
let beta_row = rows.iter().find(|r| {
r.left.as_ref().map(|c| c.text == "beta").unwrap_or(false)
}).unwrap();
assert_eq!(beta_row.left.as_ref().unwrap().line_no, 2);
assert!(beta_row.right.is_none());
// delta (insert) en línea 4 derecha.
let delta_row = rows.iter().find(|r| {
r.right.as_ref().map(|c| c.text == "delta").unwrap_or(false)
}).unwrap();
assert_eq!(delta_row.right.as_ref().unwrap().line_no, 4);
assert!(delta_row.left.is_none());
}
#[test]
fn textos_identicos_solo_equal() {
let text = "uno\ndos\ntres\n";
let (rows, stats) = diff::compute_rows(text, text);
assert_eq!(rows.len(), 3);
assert!(rows.iter().all(|r| r.kind == DiffKind::Equal));
assert_eq!(stats.inserts, 0);
assert_eq!(stats.deletes, 0);
assert_eq!(stats.equals, 3);
}
#[test]
fn scroll_no_excede_los_limites() {
let before = (0..50).map(|i| i.to_string()).collect::<Vec<_>>().join("\n");
let after = before.clone(); // identical → 50 Equal rows
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
// Scroll grande hacia abajo: tope = 50 - visible_rows.
diff::apply(&mut state, DiffMsg::Scroll(1000), 10);
assert_eq!(state.scroll, 40);
// Scroll arriba: tope mínimo 0.
diff::apply(&mut state, DiffMsg::Scroll(-1000), 10);
assert_eq!(state.scroll, 0);
}
#[test]
fn next_hunk_salta_a_la_proxima_diferencia() {
// 20 líneas iguales + 2 cambios + 20 más. visible_rows=5 deja
// espacio real para scrollear.
let mut before = String::new();
let mut after = String::new();
for i in 0..20 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
before.push_str("DEL\n");
after.push_str("INS\n");
for i in 20..40 {
before.push_str(&format!("eq{i}\n"));
after.push_str(&format!("eq{i}\n"));
}
let mut state = DiffState::new("a", "b", &before, &after);
assert_eq!(state.scroll, 0);
diff::apply(&mut state, DiffMsg::NextHunk, 5);
assert!(state.scroll > 0, "scroll quedó en 0 — no saltó al hunk");
let row = &state.rows[state.scroll];
assert!(
!matches!(row.kind, DiffKind::Equal),
"esperaba aterrizar en un hunk, vi {:?}",
row.kind
);
// PrevHunk: vuelve al inicio (no hay hunk antes del primer cambio).
diff::apply(&mut state, DiffMsg::PrevHunk, 5);
// Puede quedarse en el mismo hunk si era el único accesible hacia
// atrás, o saltar más arriba. Lo único que verificamos es que no
// hubo panic ni scroll fuera de rango.
assert!(state.scroll < state.rows.len());
}
#[test]
fn escape_cierra() {
let mut state = DiffState::new("a", "b", "x\n", "y\n");
let action = diff::apply(&mut state, DiffMsg::Close, 10);
assert_eq!(action, DiffAction::Close);
}
#[test]
fn open_shortcut_es_ctrl_shift_d() {
use llimphi_ui::{Key, KeyEvent, KeyState, Modifiers};
let mk = |ctrl: bool, shift: bool, c: &str| KeyEvent {
key: Key::Character(c.into()),
state: KeyState::Pressed,
text: Some(c.into()),
modifiers: Modifiers { ctrl, shift, ..Modifiers::default() },
repeat: false,
};
assert!(diff::open_shortcut(&mk(true, true, "d")));
assert!(diff::open_shortcut(&mk(true, true, "D")));
assert!(!diff::open_shortcut(&mk(true, false, "d")));
assert!(!diff::open_shortcut(&mk(false, true, "d")));
}