tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main modules/diff-viewer/src/lib.rs Rust · 398 líneas · 13.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}