tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! `llimphi-module-diff-viewer` — visualización side-by-side de cambios.
2//!
3//! Equivalente al "Compare with Saved" de VS Code o el panel "Compare"
4//! de JetBrains, pero como módulo Llimphi enchufable. El host le pasa
5//! dos textos (`before`/`after`) y dos etiquetas (`"HEAD"`, `"Working
6//! Tree"`, `"Buffer"` — lo que tenga sentido en su contexto), y el
7//! módulo computa el diff line-based con [`similar`] y lo renderiza
8//! en dos columnas con marcadores `+`/`-` y números de línea.
9//!
10//! El módulo no abre archivos, no llama a `git`, no toca disco. Toda
11//! la fuente del diff la decide el host: puede comparar el disco vs
12//! el buffer dirty, dos branches, dos snapshots de history, etc.
13//!
14//! Sigue el contrato Llimphi de `docs/MODULES.md`:
15//! `State + Msg + Action + apply/on_key/open_shortcut/view + Palette`.
16
17#![forbid(unsafe_code)]
18
19use llimphi_compositor::llimphi_layout::taffy::{
20 prelude::{length, percent, FlexDirection, Size, Style},
21 AlignItems, Rect,
22};
23use llimphi_compositor::peniko::Color;
24use llimphi_compositor::llimphi_text::Alignment;
25use llimphi_compositor::{Key, KeyEvent, KeyState, NamedKey, View};
26use similar::{ChangeTag, TextDiff};
27
28/// Capabilities que aporta este módulo al host.
29pub const CAPABILITIES: &[&str] = &["editor.diff-viewer"];
30
31const HEADER_H: f32 = 18.0;
32const ROW_H: f32 = 15.0;
33
34/// Una línea del diff alineada para render side-by-side.
35///
36/// El render usa dos celdas por fila (izquierda = `before`, derecha =
37/// `after`). En una línea `Equal`, ambas celdas tienen el mismo
38/// contenido. En `Delete`, sólo la izquierda; en `Insert`, sólo la
39/// derecha. La struct cumple las dos roles para simplificar el render.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DiffRow {
42 pub kind: DiffKind,
43 /// Contenido de la celda izquierda (Equal o Delete) o vacío.
44 pub left: Option<DiffCell>,
45 /// Contenido de la celda derecha (Equal o Insert) o vacío.
46 pub right: Option<DiffCell>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct DiffCell {
51 /// Número de línea 1-based en el lado correspondiente.
52 pub line_no: usize,
53 pub text: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum DiffKind {
58 Equal,
59 Delete,
60 Insert,
61}
62
63/// Estado del panel.
64pub struct DiffState {
65 pub before_label: String,
66 pub after_label: String,
67 pub rows: Vec<DiffRow>,
68 pub scroll: usize,
69 /// Conteo agregado para mostrar en el header (`+12 / -3` etc.).
70 pub stats: DiffStats,
71}
72
73#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
74pub struct DiffStats {
75 pub inserts: usize,
76 pub deletes: usize,
77 pub equals: usize,
78}
79
80impl DiffState {
81 /// Construye el state computando el diff entre `before` y `after`.
82 /// Líneas se separan por '\n'; el último '\n' se conserva como
83 /// separador (no aparece como línea extra vacía).
84 pub fn new(
85 before_label: impl Into<String>,
86 after_label: impl Into<String>,
87 before: &str,
88 after: &str,
89 ) -> Self {
90 let (rows, stats) = compute_rows(before, after);
91 Self {
92 before_label: before_label.into(),
93 after_label: after_label.into(),
94 rows,
95 scroll: 0,
96 stats,
97 }
98 }
99}
100
101/// Computa las filas alineadas a partir de los dos textos. La salida
102/// preserva el orden lineal del archivo: bloques `Equal` mantienen las
103/// líneas pareadas; un `Delete` que no tiene contraparte en el otro
104/// lado aparece con `right = None`, y viceversa para `Insert`. No se
105/// emparejan visualmente delete con insert — siguen la convención de
106/// VS Code, que los muestra como líneas separadas.
107pub fn compute_rows(before: &str, after: &str) -> (Vec<DiffRow>, DiffStats) {
108 let diff = TextDiff::from_lines(before, after);
109 let mut rows: Vec<DiffRow> = Vec::new();
110 let mut stats = DiffStats::default();
111 let mut left_no = 0usize;
112 let mut right_no = 0usize;
113 for change in diff.iter_all_changes() {
114 let text = change.value().trim_end_matches('\n').to_string();
115 match change.tag() {
116 ChangeTag::Equal => {
117 left_no += 1;
118 right_no += 1;
119 stats.equals += 1;
120 rows.push(DiffRow {
121 kind: DiffKind::Equal,
122 left: Some(DiffCell { line_no: left_no, text: text.clone() }),
123 right: Some(DiffCell { line_no: right_no, text }),
124 });
125 }
126 ChangeTag::Delete => {
127 left_no += 1;
128 stats.deletes += 1;
129 rows.push(DiffRow {
130 kind: DiffKind::Delete,
131 left: Some(DiffCell { line_no: left_no, text }),
132 right: None,
133 });
134 }
135 ChangeTag::Insert => {
136 right_no += 1;
137 stats.inserts += 1;
138 rows.push(DiffRow {
139 kind: DiffKind::Insert,
140 left: None,
141 right: Some(DiffCell { line_no: right_no, text }),
142 });
143 }
144 }
145 }
146 (rows, stats)
147}
148
149/// Vocabulario interno. El host lo wrapea en su Msg.
150#[derive(Clone)]
151pub enum DiffMsg {
152 Open,
153 Close,
154 /// Scroll vertical en líneas (positivo = baja).
155 Scroll(i32),
156 /// Salta al próximo hunk (∆+/-) en dirección.
157 NextHunk,
158 PrevHunk,
159}
160
161/// Efecto solicitado al host.
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum DiffAction {
164 None,
165 /// El host debería remover el state del modelo.
166 Close,
167}
168
169pub fn apply(state: &mut DiffState, msg: DiffMsg, visible_rows: usize) -> DiffAction {
170 match msg {
171 DiffMsg::Open => DiffAction::None,
172 DiffMsg::Close => DiffAction::Close,
173 DiffMsg::Scroll(delta) => {
174 scroll_by(state, delta, visible_rows);
175 DiffAction::None
176 }
177 DiffMsg::NextHunk => {
178 jump_to_hunk(state, true, visible_rows);
179 DiffAction::None
180 }
181 DiffMsg::PrevHunk => {
182 jump_to_hunk(state, false, visible_rows);
183 DiffAction::None
184 }
185 }
186}
187
188fn scroll_by(state: &mut DiffState, delta: i32, visible_rows: usize) {
189 let max_scroll = state.rows.len().saturating_sub(visible_rows);
190 let new_scroll = (state.scroll as i64 + delta as i64).max(0) as usize;
191 state.scroll = new_scroll.min(max_scroll);
192}
193
194/// Busca la próxima fila con `kind != Equal` en la dirección dada,
195/// empezando justo después/antes del scroll actual. Si no hay más,
196/// no-op.
197fn jump_to_hunk(state: &mut DiffState, forward: bool, visible_rows: usize) {
198 let start = state.scroll;
199 let n = state.rows.len();
200 let found = if forward {
201 (start + 1..n).find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
202 } else {
203 (0..start.min(n)).rev().find(|&i| !matches!(state.rows[i].kind, DiffKind::Equal))
204 };
205 if let Some(i) = found {
206 let max_scroll = n.saturating_sub(visible_rows);
207 state.scroll = i.min(max_scroll);
208 }
209}
210
211/// Routing de teclas cuando el panel está abierto.
212pub fn on_key(_state: &DiffState, event: &KeyEvent) -> Option<DiffMsg> {
213 if event.state != KeyState::Pressed {
214 return None;
215 }
216 Some(match &event.key {
217 Key::Named(NamedKey::Escape) => DiffMsg::Close,
218 Key::Named(NamedKey::ArrowDown) => DiffMsg::Scroll(1),
219 Key::Named(NamedKey::ArrowUp) => DiffMsg::Scroll(-1),
220 Key::Named(NamedKey::PageDown) => DiffMsg::Scroll(20),
221 Key::Named(NamedKey::PageUp) => DiffMsg::Scroll(-20),
222 Key::Named(NamedKey::Home) => DiffMsg::Scroll(-(i32::MAX / 4)),
223 Key::Named(NamedKey::End) => DiffMsg::Scroll(i32::MAX / 4),
224 Key::Character(s) if s == "n" => DiffMsg::NextHunk,
225 Key::Character(s) if s == "N" => DiffMsg::PrevHunk,
226 _ => return None,
227 })
228}
229
230/// El atajo recomendado: **Ctrl+Shift+D**, similar al "Compare with
231/// Saved" de VS Code (que usa Ctrl+Shift+P + comando).
232pub fn open_shortcut(event: &KeyEvent) -> bool {
233 event.state == KeyState::Pressed
234 && event.modifiers.ctrl
235 && event.modifiers.shift
236 && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("d"))
237}
238
239/// Paleta visual con colores diff convencionales (verde para insert,
240/// rojo apagado para delete).
241#[derive(Debug, Clone)]
242pub struct DiffPalette {
243 pub bg_panel: Color,
244 pub bg_header: Color,
245 pub bg_insert: Color,
246 pub bg_delete: Color,
247 pub bg_empty: Color,
248 pub fg_text: Color,
249 pub fg_muted: Color,
250 pub fg_insert: Color,
251 pub fg_delete: Color,
252}
253
254impl DiffPalette {
255 pub fn from_theme(t: &llimphi_theme::Theme) -> Self {
256 // Verde/rojo apagados — visibles sobre fondo oscuro pero sin
257 // saturar. Si el theme expone colores semánticos de diff en
258 // el futuro, los usamos; por ahora hardcoded.
259 Self {
260 bg_panel: t.bg_panel,
261 bg_header: t.bg_panel_alt,
262 bg_insert: Color::from_rgba8(40, 80, 50, 255),
263 bg_delete: Color::from_rgba8(90, 40, 45, 255),
264 bg_empty: t.bg_panel_alt,
265 fg_text: t.fg_text,
266 fg_muted: t.fg_muted,
267 fg_insert: Color::from_rgba8(170, 230, 180, 255),
268 fg_delete: Color::from_rgba8(240, 180, 185, 255),
269 }
270 }
271}
272
273/// Render del panel side-by-side. `height_px` es la altura total
274/// disponible; el módulo divide entre el header de 18 px y la grid.
275pub fn view<HostMsg, F>(
276 state: &DiffState,
277 palette: &DiffPalette,
278 height_px: f32,
279 to_host: F,
280) -> View<HostMsg>
281where
282 HostMsg: Clone + 'static,
283 F: Fn(DiffMsg) -> HostMsg + Copy + 'static,
284{
285 let _ = to_host; // v0 no monta eventos puntuales sobre filas
286
287 let header_text = format!(
288 "diff · {} ↔ {} · +{} -{} ={} · ↑↓ scroll · n/N hunk · Esc cierra",
289 state.before_label,
290 state.after_label,
291 state.stats.inserts,
292 state.stats.deletes,
293 state.stats.equals,
294 );
295 let header = View::new(Style {
296 size: Size { width: percent(1.0_f32), height: length(HEADER_H) },
297 padding: Rect {
298 left: length(8.0_f32),
299 right: length(8.0_f32),
300 top: length(0.0_f32),
301 bottom: length(0.0_f32),
302 },
303 align_items: Some(AlignItems::CENTER),
304 flex_shrink: 0.0,
305 ..Default::default()
306 })
307 .fill(palette.bg_header)
308 .text_aligned(header_text, 10.0, palette.fg_muted, Alignment::Start);
309
310 let grid_h = (height_px - HEADER_H).max(0.0);
311 let max_rows = ((grid_h / ROW_H) as usize).max(1);
312 let end = (state.scroll + max_rows).min(state.rows.len());
313
314 let mut grid_rows: Vec<View<HostMsg>> = Vec::with_capacity(max_rows);
315 for row in &state.rows[state.scroll..end] {
316 grid_rows.push(render_row(row, palette));
317 }
318 while grid_rows.len() < max_rows {
319 // Padding visual para mantener altura constante.
320 grid_rows.push(empty_row(palette));
321 }
322
323 let mut children: Vec<View<HostMsg>> = Vec::with_capacity(1 + grid_rows.len());
324 children.push(header);
325 children.extend(grid_rows);
326
327 View::new(Style {
328 flex_direction: FlexDirection::Column,
329 size: Size { width: percent(1.0_f32), height: length(height_px) },
330 flex_shrink: 0.0,
331 ..Default::default()
332 })
333 .fill(palette.bg_panel)
334 .children(children)
335}
336
337fn render_row<HostMsg>(row: &DiffRow, palette: &DiffPalette) -> View<HostMsg>
338where
339 HostMsg: Clone + 'static,
340{
341 let (left_bg, left_fg, left_mark) = match row.kind {
342 DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
343 DiffKind::Delete => (palette.bg_delete, palette.fg_delete, "-"),
344 DiffKind::Insert => (palette.bg_empty, palette.fg_muted, " "),
345 };
346 let (right_bg, right_fg, right_mark) = match row.kind {
347 DiffKind::Equal => (palette.bg_panel, palette.fg_text, " "),
348 DiffKind::Insert => (palette.bg_insert, palette.fg_insert, "+"),
349 DiffKind::Delete => (palette.bg_empty, palette.fg_muted, " "),
350 };
351
352 let left_text = match &row.left {
353 Some(c) => format!("{:>4} {}{}", c.line_no, left_mark, c.text),
354 None => String::new(),
355 };
356 let right_text = match &row.right {
357 Some(c) => format!("{:>4} {}{}", c.line_no, right_mark, c.text),
358 None => String::new(),
359 };
360
361 let cell = |bg: Color, fg: Color, text: String| {
362 View::new(Style {
363 flex_grow: 1.0,
364 size: Size { width: percent(0.5_f32), height: length(ROW_H) },
365 padding: Rect {
366 left: length(6.0_f32),
367 right: length(6.0_f32),
368 top: length(0.0_f32),
369 bottom: length(0.0_f32),
370 },
371 align_items: Some(AlignItems::CENTER),
372 flex_shrink: 0.0,
373 ..Default::default()
374 })
375 .fill(bg)
376 .text_aligned(text, 10.5, fg, Alignment::Start)
377 };
378
379 View::new(Style {
380 flex_direction: FlexDirection::Row,
381 size: Size { width: percent(1.0_f32), height: length(ROW_H) },
382 flex_shrink: 0.0,
383 ..Default::default()
384 })
385 .children(vec![cell(left_bg, left_fg, left_text), cell(right_bg, right_fg, right_text)])
386}
387
388fn empty_row<HostMsg>(palette: &DiffPalette) -> View<HostMsg>
389where
390 HostMsg: Clone + 'static,
391{
392 View::new(Style {
393 size: Size { width: percent(1.0_f32), height: length(ROW_H) },
394 flex_shrink: 0.0,
395 ..Default::default()
396 })
397 .fill(palette.bg_panel)
398}