Skip to main content

ashlar_ui/
diff_view.rs

1//! A diff view over [`ashlar_diff::FileDiff`]s: unified or split, virtualized, syntax-coloured per
2//! file language, with word-level emphasis, collapsed unchanged ranges, a sticky file header,
3//! line selection and hunk navigation.
4//!
5//! A frame costs O(visible rows). The row model (files, hunk headers, lines, collapsed ranges)
6//! is built once per `(revision, mode, expansion)` and kept by the view. Lexer states run
7//! lazily within each hunk, so jumping to the end of a long diff highlights one hunk, not the
8//! whole diff. Row galleys are cached and rebuilt only when the model, the theme or the font
9//! changes.
10//!
11//! ```
12//! # use ashlar_ui::diff_view::{DiffMode, DiffView, DiffViewState};
13//! # fn ui(ui: &mut egui::Ui, state: &mut DiffViewState, files: &[ashlar_diff::FileDiff], revision: u64) {
14//! let out = DiffView::new("review.diff", state, files, revision).mode(DiffMode::Split).show(ui);
15//! for line in &out.selected_lines {
16//!     // comment on `line.file`, `line.new_no`…
17//! }
18//! # }
19//! ```
20//!
21//! Lines wider than the view scroll sideways (both sides of a split together): the horizontal
22//! wheel (Shift+wheel) over the view, or a drag on the thin bar along its bottom.
23//!
24//! Input: click selects a row, Shift-click and Shift+arrows extend, ⌘-click toggles. `N` / `F7`
25//! moves to the next hunk, `P` / Shift+`F7` to the previous one. Clicking (or Enter on) a
26//! collapsed range shows [`EXPAND_STEP`] more lines, Alt-click shows all of them. Clicking a file
27//! header (or Enter on it) folds the file; clicking the sticky header jumps to its file.
28//!
29//! Automation (visible rows only, indices 0-based): `<id>.file.<f>` (file header),
30//! `<id>.file.<f>.hunk.<h>` (hunk header), `<id>.file.<f>.hunk.<h>.line.<k>` (line `k` of the
31//! hunk; in split mode the left line, else the right), `<id>.file.<f>.gap.<g>` (collapsed range),
32//! `<id>.file.<f>.gap.<g>.line.<n>` (expanded line, `n` its new line number) and `<id>.header`
33//! (the sticky header). AccessKit: the view is a read-only document; file and hunk headers are
34//! headings, collapsed ranges buttons, lines paragraphs labelled "added 12: text".
35
36use std::collections::{HashMap, HashSet};
37use std::ops::Range;
38use std::sync::Arc;
39
40use ashlar_diff::{FileDiff, FileStatus, Gap, LineKind};
41use ashlar_syntax::{Builtin, Highlighter, Kind, State};
42use egui::accesskit::Role as NodeRole;
43use egui::text::{ByteIndex, LayoutJob, LayoutSection, TextWrapping};
44use egui::{Color32, FontId, Galley, Key, Rect, Sense, TextFormat, Ui, pos2, vec2};
45
46use crate::automation::{self, Details, Role};
47use crate::cache::{self, hash_of};
48use crate::code_view::SyntaxTheme;
49use crate::hscroll::HScroll;
50use crate::selection::Selection;
51use crate::tokens::{self, Tokens};
52use crate::virtual_list::{self, Header, MAX_ROWS, RowHeights, VirtualList};
53
54/// Lines one click on a collapsed range reveals.
55pub const EXPAND_STEP: u32 = 20;
56
57/// Bytes of one line that are laid out; the rest is not shown.
58const MAX_LINE_BYTES: usize = 2000;
59
60/// Most entries [`DiffResponse::selected_lines`] lists.
61pub const MAX_SELECTED_LINES: usize = 100_000;
62
63/// Longest walk back to the start of a hunk when computing a lexer state.
64const MAX_STATE_WALK: usize = 1_000_000;
65
66const NONE: u32 = u32::MAX;
67
68/// How lines are laid out.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
70pub enum DiffMode {
71    /// One column: removed lines above the added lines that replace them.
72    #[default]
73    Unified,
74    /// Old text left, new text right, changed lines side by side.
75    Split,
76}
77
78/// Words the view shows (localized by the app).
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct DiffLabels {
81    /// A collapsed range that can be expanded; `{n}` is the number of lines one click shows.
82    pub expand: String,
83    /// The size of a collapsed range; `{n}` is the number of hidden lines.
84    pub hidden: String,
85    /// In place of a binary file's lines.
86    pub binary: String,
87    /// Spoken before an added and a removed line's number.
88    pub added: String,
89    pub removed: String,
90}
91
92impl Default for DiffLabels {
93    fn default() -> Self {
94        DiffLabels {
95            expand: "Expand {n} lines".into(),
96            hidden: "{n} unchanged lines".into(),
97            binary: "Binary file not shown".into(),
98            added: "added".into(),
99            removed: "removed".into(),
100        }
101    }
102}
103
104/// The view's state, kept by the app: selection, expanded ranges, folded files.
105#[derive(Clone, Debug, Default)]
106pub struct DiffViewState {
107    /// Selected rows (row indices of the current layout).
108    pub selection: Selection,
109    expanded: HashMap<(usize, usize), u32>,
110    collapsed: HashSet<usize>,
111    /// Bumped on every change that alters the rows.
112    version: u64,
113    revision: Option<u64>,
114}
115
116impl DiffViewState {
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Shows `lines` more lines of collapsed range `gap` of `file` (see [`FileDiff::gaps`]).
122    pub fn expand(&mut self, file: usize, gap: usize, lines: u32) {
123        let e = self.expanded.entry((file, gap)).or_insert(0);
124        *e = e.saturating_add(lines);
125        self.version = self.version.wrapping_add(1);
126    }
127
128    /// Lines of range `gap` of `file` shown so far.
129    pub fn expanded(&self, file: usize, gap: usize) -> u32 {
130        self.expanded.get(&(file, gap)).copied().unwrap_or(0)
131    }
132
133    /// Folds or unfolds a file to its header.
134    pub fn set_file_collapsed(&mut self, file: usize, collapsed: bool) {
135        let changed = if collapsed { self.collapsed.insert(file) } else { self.collapsed.remove(&file) };
136        if changed {
137            self.version = self.version.wrapping_add(1);
138        }
139    }
140
141    pub fn is_file_collapsed(&self, file: usize) -> bool {
142        self.collapsed.contains(&file)
143    }
144}
145
146/// One selected line.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148pub struct SelectedLine {
149    pub file: usize,
150    pub kind: LineKind,
151    pub old_no: Option<u32>,
152    pub new_no: Option<u32>,
153}
154
155/// What the view reports this frame.
156pub struct DiffResponse {
157    pub response: egui::Response,
158    /// The selected lines in order (headers and collapsed ranges are left out), at most
159    /// [`MAX_SELECTED_LINES`].
160    pub selected_lines: Vec<SelectedLine>,
161    /// A collapsed range expanded this frame: `(file, gap)`.
162    pub expanded: Option<(usize, usize)>,
163    /// A file folded or unfolded this frame.
164    pub toggled_file: Option<usize>,
165    /// The hunk keyboard navigation moved to this frame: `(file, hunk)`.
166    pub hunk: Option<(usize, usize)>,
167    /// The file the sticky header shows.
168    pub top_file: Option<usize>,
169    /// Rows laid out this frame.
170    pub visible: Range<usize>,
171}
172
173/// A diff view. See the [module docs](self).
174pub struct DiffView<'a> {
175    id: &'a str,
176    state: &'a mut DiffViewState,
177    files: &'a [FileDiff],
178    revision: u64,
179    mode: DiffMode,
180    theme: Option<&'a SyntaxTheme>,
181    labels: Option<&'a DiffLabels>,
182    highlighter: &'a dyn Highlighter,
183    max_height: Option<f32>,
184}
185
186impl<'a> DiffView<'a> {
187    /// `revision` changes whenever `files` change (it keys every cache; expansions and the
188    /// selection reset when it changes).
189    pub fn new(id: &'a str, state: &'a mut DiffViewState, files: &'a [FileDiff], revision: u64) -> Self {
190        DiffView { id, state, files, revision, mode: DiffMode::Unified, theme: None, labels: None, highlighter: &Builtin, max_height: None }
191    }
192
193    pub fn mode(mut self, mode: DiffMode) -> Self {
194        self.mode = mode;
195        self
196    }
197
198    /// Syntax colours (default: [`SyntaxTheme::from_tokens`]).
199    pub fn theme(mut self, theme: &'a SyntaxTheme) -> Self {
200        self.theme = Some(theme);
201        self
202    }
203
204    pub fn labels(mut self, labels: &'a DiffLabels) -> Self {
205        self.labels = Some(labels);
206        self
207    }
208
209    /// A different highlighting engine (default: [`Builtin`]).
210    pub fn highlighter(mut self, h: &'a dyn Highlighter) -> Self {
211        self.highlighter = h;
212        self
213    }
214
215    /// Caps the view's height (default: the available height).
216    pub fn max_height(mut self, h: f32) -> Self {
217        self.max_height = Some(h);
218        self
219    }
220
221    pub fn show(self, ui: &mut Ui) -> DiffResponse {
222        show(self, ui)
223    }
224}
225
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227enum RowKind {
228    File,
229    /// In place of a binary file's lines.
230    Note,
231    Hunk,
232    Gap,
233    /// An expanded line of a collapsed range.
234    Ctx,
235    Line,
236}
237
238/// One row of the layout. `item` is the hunk (`Hunk`, `Line`) or gap (`Gap`, `Ctx`) index.
239/// `Line`: `a` and `b` are line indices in the hunk (unified: `a` only; split: left and right).
240/// `Ctx`: `a` is the new and `b` the old line number. `Gap`: `a` is the number of hidden lines.
241#[derive(Clone, Copy, Debug)]
242struct RowRef {
243    kind: RowKind,
244    file: u32,
245    item: u32,
246    a: u32,
247    b: u32,
248}
249
250#[derive(Clone, Default)]
251struct Model {
252    key: u64,
253    rows: Vec<RowRef>,
254    /// Rows of hunk headers, ascending.
255    hunk_rows: Vec<usize>,
256    /// Lexer states (old side, new side) at the start of each row, filled lazily.
257    states: Vec<Option<(State, State)>>,
258    gaps: Vec<Vec<Gap>>,
259    langs: Vec<&'static str>,
260    /// Lines added and removed per file.
261    stats: Vec<(usize, usize)>,
262    /// Digits of the largest line number.
263    digits: usize,
264    /// Bytes in the longest hunk line (starts the horizontal scroll range).
265    widest: usize,
266}
267
268fn to_u32(x: usize) -> u32 {
269    u32::try_from(x).unwrap_or(NONE)
270}
271
272impl Model {
273    fn build(key: u64, files: &[FileDiff], state: &DiffViewState, mode: DiffMode) -> Model {
274        let mut m = Model { key, ..Model::default() };
275        let mut max_no = 0u32;
276        for (fi, f) in files.iter().enumerate() {
277            if m.rows.len() >= MAX_ROWS {
278                break;
279            }
280            let file = to_u32(fi);
281            m.rows.push(RowRef { kind: RowKind::File, file, item: 0, a: 0, b: 0 });
282            m.langs.push(ashlar_syntax::lang_for_path(f.path()).unwrap_or(""));
283            m.stats.push(f.stats());
284            let gaps = f.gaps();
285            if let Some(h) = f.hunks.last() {
286                max_no = max_no.max(h.old_range().end).max(h.new_range().end);
287            }
288            max_no = max_no.max(f.source_len().unwrap_or(0));
289            if state.collapsed.contains(&fi) {
290                m.gaps.push(gaps);
291                continue;
292            }
293            if f.binary {
294                m.rows.push(RowRef { kind: RowKind::Note, file, item: 0, a: 0, b: 0 });
295            }
296            let mut gi = 0usize;
297            for (hi, h) in f.hunks.iter().enumerate() {
298                while let Some(g) = gaps.get(gi).filter(|g| g.before_hunk <= hi) {
299                    m.push_gap(file, gi, g, state.expanded(fi, gi));
300                    gi += 1;
301                }
302                if m.rows.len() >= MAX_ROWS {
303                    break;
304                }
305                m.hunk_rows.push(m.rows.len());
306                let item = to_u32(hi);
307                m.rows.push(RowRef { kind: RowKind::Hunk, file, item, a: 0, b: 0 });
308                let line = |a: usize, b: Option<usize>| RowRef { kind: RowKind::Line, file, item, a: to_u32(a), b: b.map_or(NONE, to_u32) };
309                let lines = &h.lines;
310                m.widest = lines.iter().map(|l| l.text.len()).fold(m.widest, usize::max);
311                let kind = |i: usize| lines.get(i).map(|l| l.kind);
312                let mut i = 0usize;
313                while i < lines.len() && m.rows.len() < MAX_ROWS {
314                    match (mode, kind(i)) {
315                        (DiffMode::Unified, _) => {
316                            m.rows.push(line(i, None));
317                            i += 1;
318                        }
319                        (DiffMode::Split, Some(LineKind::Context)) => {
320                            m.rows.push(line(i, Some(i)));
321                            i += 1;
322                        }
323                        (DiffMode::Split, _) => {
324                            let removed = (i..lines.len()).take_while(|&k| kind(k) == Some(LineKind::Removed)).count();
325                            let added = (i + removed..lines.len()).take_while(|&k| kind(k) == Some(LineKind::Added)).count();
326                            for k in 0..removed.max(added) {
327                                let a = if k < removed { to_u32(i + k) } else { NONE };
328                                let b = if k < added { to_u32(i + removed + k) } else { NONE };
329                                m.rows.push(RowRef { kind: RowKind::Line, file, item, a, b });
330                            }
331                            i += (removed + added).max(1);
332                        }
333                    }
334                }
335            }
336            while let Some(g) = gaps.get(gi) {
337                m.push_gap(file, gi, g, state.expanded(fi, gi));
338                gi += 1;
339            }
340            m.gaps.push(gaps);
341        }
342        m.rows.truncate(MAX_ROWS);
343        m.states = vec![None; m.rows.len()];
344        m.digits = max_no.to_string().len().max(3);
345        m
346    }
347
348    fn push_gap(&mut self, file: u32, gi: usize, g: &Gap, revealed: u32) {
349        let shown = if g.expandable { revealed.min(g.len) } else { 0 };
350        let item = to_u32(gi);
351        for k in 0..shown {
352            if self.rows.len() >= MAX_ROWS {
353                return;
354            }
355            let (a, b) = (g.new_start.saturating_add(k), g.old_start.saturating_add(k));
356            self.rows.push(RowRef { kind: RowKind::Ctx, file, item, a, b });
357        }
358        if shown < g.len {
359            self.rows.push(RowRef { kind: RowKind::Gap, file, item, a: g.len - shown, b: 0 });
360        }
361    }
362
363    /// Whether row `j` starts a run of lines (a hunk's or an expanded range's).
364    fn segment_start(&self, j: usize) -> bool {
365        let (Some(cur), Some(prev)) = (self.rows.get(j), j.checked_sub(1).and_then(|p| self.rows.get(p))) else { return true };
366        prev.kind != cur.kind || prev.file != cur.file || prev.item != cur.item
367    }
368
369    /// The lexer states at the start of row `i`, computed from the start of its run.
370    fn state(
371        &mut self,
372        files: &[FileDiff],
373        mode: DiffMode,
374        hl: &dyn Highlighter,
375        i: usize,
376        scratch: &mut Vec<ashlar_syntax::Span>,
377    ) -> (State, State) {
378        if let Some(s) = self.states.get(i).copied().flatten() {
379            return s;
380        }
381        let mut j = i;
382        let mut walked = 0usize;
383        while walked < MAX_STATE_WALK && !self.segment_start(j) && self.states.get(j).copied().flatten().is_none() {
384            j -= 1;
385            walked += 1;
386        }
387        let mut st = self.states.get(j).copied().flatten().unwrap_or_default();
388        let lang = self.rows.get(i).and_then(|r| self.langs.get(r.file as usize)).copied().unwrap_or("");
389        for r in j..i {
390            if let Some(s) = self.states.get_mut(r) {
391                *s = Some(st);
392            }
393            let (old, new) = self.rows.get(r).map_or((None, None), |row| texts(files, mode, row));
394            if let Some(t) = old {
395                scratch.clear();
396                st.0 = hl.highlight_line(lang, display(t), st.0, scratch);
397            }
398            if let Some(t) = new {
399                scratch.clear();
400                st.1 = hl.highlight_line(lang, display(t), st.1, scratch);
401            }
402        }
403        if let Some(s) = self.states.get_mut(i) {
404            *s = Some(st);
405        }
406        st
407    }
408}
409
410fn hunk_line<'f>(files: &'f [FileDiff], row: &RowRef, idx: u32) -> Option<&'f ashlar_diff::Line> {
411    if idx == NONE {
412        return None;
413    }
414    files.get(row.file as usize)?.hunks.get(row.item as usize)?.lines.get(idx as usize)
415}
416
417/// The text a row adds to the old and to the new side's lexer.
418fn texts<'f>(files: &'f [FileDiff], mode: DiffMode, row: &RowRef) -> (Option<&'f str>, Option<&'f str>) {
419    match row.kind {
420        RowKind::Ctx => {
421            let t = files.get(row.file as usize).and_then(|f| f.source_line(row.a));
422            (t, t)
423        }
424        RowKind::Line => match mode {
425            DiffMode::Unified => match hunk_line(files, row, row.a) {
426                Some(l) => {
427                    let t = Some(l.text.as_str());
428                    match l.kind {
429                        LineKind::Context => (t, t),
430                        LineKind::Removed => (t, None),
431                        LineKind::Added => (None, t),
432                    }
433                }
434                None => (None, None),
435            },
436            DiffMode::Split => (hunk_line(files, row, row.a).map(|l| l.text.as_str()), hunk_line(files, row, row.b).map(|l| l.text.as_str())),
437        },
438        _ => (None, None),
439    }
440}
441
442/// The part of a line that is laid out: no trailing `\r`, at most [`MAX_LINE_BYTES`].
443fn display(text: &str) -> &str {
444    let t = text.strip_suffix('\r').unwrap_or(text);
445    if t.len() <= MAX_LINE_BYTES {
446        return t;
447    }
448    let mut end = MAX_LINE_BYTES;
449    while end > 0 && !t.is_char_boundary(end) {
450        end -= 1;
451    }
452    t.get(..end).unwrap_or("")
453}
454
455fn clip_label(s: &str) -> &str {
456    let s = s.trim_end_matches('\r');
457    match s.char_indices().nth(160) {
458        Some((i, _)) => s.get(..i).unwrap_or(s),
459        None => s,
460    }
461}
462
463/// A syntax-coloured job with `emphasis` ranges on `emph_bg`.
464fn line_job(text: &str, spans: &[ashlar_syntax::Span], emphasis: &[Range<usize>], font: &FontId, theme: &SyntaxTheme, emph_bg: Color32) -> LayoutJob {
465    let mut sections = Vec::with_capacity(spans.len() + 2 * emphasis.len());
466    let mut e = emphasis.iter().peekable();
467    for (r, kind) in spans {
468        let color = theme.color(*kind);
469        let end = r.end.min(text.len());
470        let mut pos = r.start.min(end);
471        let mut guard = 0usize;
472        while pos < end && guard < 4 * (emphasis.len() + 1) {
473            guard += 1;
474            while e.peek().is_some_and(|x| x.end <= pos) {
475                e.next();
476            }
477            let (next, bg) = match e.peek() {
478                Some(x) if x.start <= pos => (x.end.min(end), emph_bg),
479                Some(x) => (x.start.min(end), Color32::TRANSPARENT),
480                None => (end, Color32::TRANSPARENT),
481            };
482            let next = if next <= pos || !text.is_char_boundary(next) { end } else { next };
483            sections.push(LayoutSection {
484                leading_space: 0.0,
485                byte_range: ByteIndex(pos)..ByteIndex(next),
486                format: TextFormat { font_id: font.clone(), color, background: bg, ..Default::default() },
487            });
488            pos = next;
489        }
490    }
491    LayoutJob {
492        text: text.to_owned(),
493        sections,
494        wrap: TextWrapping { max_width: f32::INFINITY, ..Default::default() },
495        break_on_newline: false,
496        ..Default::default()
497    }
498}
499
500fn file_label(f: &FileDiff) -> String {
501    match (f.status, f.old_path.as_deref(), f.new_path.as_deref()) {
502        (FileStatus::Renamed | FileStatus::Copied, Some(old), Some(new)) if old != new => format!("{old} → {new}"),
503        _ => f.path().to_owned(),
504    }
505}
506
507/// Colours of the view, derived once per frame.
508struct Palette {
509    added: Color32,
510    removed: Color32,
511    added_gutter: Color32,
512    removed_gutter: Color32,
513    added_emph: Color32,
514    removed_emph: Color32,
515    hunk: Color32,
516}
517
518impl Palette {
519    fn new(t: &Tokens) -> Palette {
520        let (ok, bad) = (t.status.success, t.status.danger);
521        Palette {
522            added: ok.gamma_multiply(0.12),
523            removed: bad.gamma_multiply(0.12),
524            added_gutter: ok.gamma_multiply(0.2),
525            removed_gutter: bad.gamma_multiply(0.2),
526            added_emph: ok.gamma_multiply(0.35),
527            removed_emph: bad.gamma_multiply(0.35),
528            hunk: t.status.info.gamma_multiply(0.1),
529        }
530    }
531
532    fn line(&self, kind: LineKind) -> (Color32, Color32, Color32) {
533        match kind {
534            LineKind::Added => (self.added, self.added_gutter, self.added_emph),
535            LineKind::Removed => (self.removed, self.removed_gutter, self.removed_emph),
536            LineKind::Context => (Color32::TRANSPARENT, Color32::TRANSPARENT, Color32::TRANSPARENT),
537        }
538    }
539}
540
541fn show(view: DiffView<'_>, ui: &mut Ui) -> DiffResponse {
542    let DiffView { id, state, files, revision, mode, theme, labels, highlighter, max_height } = view;
543    let t = tokens::current(ui.ctx());
544    let base = ui.make_persistent_id(id);
545    let installed = crate::labels::current(ui.ctx());
546    let labels = labels.unwrap_or(&installed.diff);
547    let own_theme;
548    let theme = match theme {
549        Some(th) => th,
550        None => {
551            own_theme = SyntaxTheme::from_tokens(&t);
552            &own_theme
553        }
554    };
555    if state.revision != Some(revision) {
556        state.revision = Some(revision);
557        state.expanded.clear();
558        state.collapsed.clear();
559        state.selection.clear();
560        state.version = state.version.wrapping_add(1);
561    }
562
563    // the row model, rebuilt when the files, the mode or the expansion change
564    let model_id = base.with("model");
565    let key = hash_of(&(revision, mode, state.version, files.as_ptr() as usize, files.len()));
566    let mut model =
567        ui.data_mut(|d| d.remove_temp::<Model>(model_id)).filter(|m| m.key == key).unwrap_or_else(|| Model::build(key, files, state, mode));
568    let rows = model.rows.len();
569    state.selection.clamp(rows);
570
571    let font = t.mono();
572    let (line_h, char_w) = ui.fonts_mut(|f| (f.row_height(&font), f.glyph_width(&font, '0')));
573    let (line_h, char_w) = (line_h.max(1.0) + 2.0, char_w.max(1.0));
574    let pad = t.space.sm;
575    let num_w = model.digits as f32 * char_w + 2.0 * pad;
576    let sign_w = 2.0 * char_w;
577    let style_rev = hash_of(&(theme.revision(), font.size.to_bits(), t.dark));
578    let pal = Palette::new(&t);
579
580    // keyboard: next / previous hunk
581    let top_id = base.with("top");
582    let goto_id = base.with("goto");
583    let (last_top, page): (usize, usize) = ui.data(|d| d.get_temp(top_id)).unwrap_or((0, 0));
584    let mut scroll_to: Option<usize> = ui.data_mut(|d| d.remove_temp::<usize>(goto_id)).filter(|r| *r < rows);
585    let mut moved_hunk = None;
586    if ui.memory(|m| m.has_focus(base)) {
587        let (next, prev) = ui.input(|i| {
588            let plain = !i.modifiers.command && !i.modifiers.ctrl && !i.modifiers.alt;
589            (
590                (plain && !i.modifiers.shift && i.key_pressed(Key::N)) || (!i.modifiers.shift && i.key_pressed(Key::F7)),
591                (plain && !i.modifiers.shift && i.key_pressed(Key::P)) || (i.modifiers.shift && i.key_pressed(Key::F7)),
592            )
593        });
594        let cursor = state.selection.cursor().unwrap_or(last_top);
595        let target = if next {
596            model.hunk_rows.get(model.hunk_rows.partition_point(|&r| r <= cursor)).copied()
597        } else if prev {
598            model.hunk_rows.partition_point(|&r| r < cursor).checked_sub(1).and_then(|k| model.hunk_rows.get(k)).copied()
599        } else {
600            None
601        };
602        if let Some(r) = target {
603            state.selection.select_only(r);
604            // a hunk below the view comes in near the top, not at the bottom edge
605            scroll_to = Some(if r > last_top { r.saturating_add(page.saturating_sub(3)).min(rows.saturating_sub(1)) } else { r });
606            moved_hunk = model.rows.get(r).map(|row| (row.file as usize, row.item as usize));
607        }
608    }
609
610    // long lines scroll sideways, both sides of a split together
611    let area = ui.available_rect_before_wrap();
612    let text_view = match mode {
613        DiffMode::Unified => area.width() - 2.0 * num_w - sign_w,
614        DiffMode::Split => area.width() / 2.0 - num_w - sign_w,
615    } - ui.spacing().scroll.bar_width;
616    let mut hs = HScroll::begin(ui, base.with("hx"), area, model.widest as f32 * char_w + pad, text_view);
617    let hx = hs.offset;
618    let mut list = VirtualList::new(id, rows, RowHeights::Uniform(line_h)).scroll_to(scroll_to).selection(&mut state.selection);
619    if let Some(h) = max_height {
620        list = list.max_height(h);
621    }
622    let mut header_rect = None;
623    let mut record_header = |_: &mut Ui, r: Rect| header_rect = Some(r);
624    let header = Header { height: line_h + 2.0, paint: &mut record_header };
625    let kind = virtual_list::Kind { role: Role::Document, rows: false, node: NodeRole::Document, item: NodeRole::Paragraph };
626    let mut top_row: Option<usize> = None;
627    let mut shown_rows = 0usize;
628    let mut scratch = Vec::new();
629    let ctx = ui.ctx().clone();
630
631    let out = virtual_list::show_rows(ui, base, &mut list, kind, Some(header), |ui, row| {
632        let i = row.index;
633        let Some(r) = model.rows.get(i).copied() else { return };
634        if ui.clip_rect().height() > line_h * 0.5 {
635            top_row.get_or_insert(i);
636            shown_rows += 1;
637        }
638        let rect = row.rect;
639        let painter = ui.painter().clone();
640        let fi = r.file as usize;
641        let Some(f) = files.get(fi) else { return };
642        let row_node = virtual_list::row_id(base, i);
643        let node = |role: NodeRole, label: &str| {
644            ctx.accesskit_node_builder(row_node, |n| {
645                n.set_role(role);
646                n.set_label(label);
647            });
648        };
649        let galley_at =
650            |key: (&str, usize, u8), rev: u64, job: &mut dyn FnMut() -> LayoutJob| -> Arc<Galley> { cache::galley(ui, (base, key), rev, job) };
651        let text_y = rect.top() + 1.0;
652        match r.kind {
653            RowKind::File => {
654                if !row.selected {
655                    painter.rect_filled(rect, 0, t.surface.raised);
656                }
657                painter.hline(rect.x_range(), rect.bottom() - 0.5, egui::Stroke::new(1.0, t.border));
658                let g = file_galley(ui, base, f, fi, model.stats.get(fi).copied().unwrap_or_default(), state.collapsed.contains(&fi), &t, style_rev);
659                painter.galley(pos2(rect.left() + pad, text_y), g, t.text.primary);
660                let label = file_label(f);
661                node(NodeRole::Heading, &label);
662                automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}"), &label, Role::Button, true, Details::default());
663            }
664            RowKind::Note => {
665                painter.rect_filled(rect, 0, t.surface.sunken);
666                let g =
667                    galley_at(("note", i, 0), style_rev, &mut || LayoutJob::simple_singleline(labels.binary.clone(), font.clone(), t.text.secondary));
668                painter.galley(pos2(rect.left() + pad + num_w, text_y), g, t.text.secondary);
669                node(NodeRole::Paragraph, &labels.binary);
670            }
671            RowKind::Hunk => {
672                let hi = r.item as usize;
673                let header = f.hunks.get(hi).map(|h| h.header()).unwrap_or_default();
674                if !row.selected {
675                    painter.rect_filled(rect, 0, pal.hunk);
676                }
677                let g = galley_at(("hunk", i, 0), hash_of(&(model.key, style_rev)), &mut || {
678                    LayoutJob::simple_singleline(display(&header).to_owned(), font.clone(), t.text.secondary)
679                });
680                let x = if mode == DiffMode::Unified { 2.0 * num_w } else { num_w };
681                painter.galley(pos2(rect.left() + x + pad, text_y), g, t.text.secondary);
682                node(NodeRole::Heading, &header);
683                automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.hunk.{hi}"), &header, Role::Header, true, Details::default());
684            }
685            RowKind::Gap => {
686                let gi = r.item as usize;
687                let expandable = model.gaps.get(fi).and_then(|g| g.get(gi)).is_some_and(|g| g.expandable);
688                if !row.selected {
689                    painter.rect_filled(rect, 0, t.surface.sunken);
690                }
691                let hidden = labels.hidden.replace("{n}", &r.a.to_string());
692                let label =
693                    if expandable { format!("{hidden} · {}", labels.expand.replace("{n}", &r.a.min(EXPAND_STEP).to_string())) } else { hidden };
694                let color = if expandable { t.accent } else { t.text.disabled };
695                let g = galley_at(("gap", i, 0), hash_of(&(model.key, style_rev)), &mut || {
696                    LayoutJob::simple_singleline(format!("⋯  {label}"), font.clone(), color)
697                });
698                painter.galley(pos2(rect.left() + pad, text_y), g, color);
699                ctx.accesskit_node_builder(row_node, |n| {
700                    n.set_role(if expandable { NodeRole::Button } else { NodeRole::Paragraph });
701                    n.set_label(label.as_str());
702                });
703                automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.gap.{gi}"), &label, Role::Button, expandable, Details::default());
704            }
705            RowKind::Ctx | RowKind::Line => {
706                let st = model.state(files, mode, highlighter, i, &mut scratch);
707                let lang = model.langs.get(fi).copied().unwrap_or("");
708                // the cells: (line, old number, new number, kind, lexer state, side)
709                let cells: Vec<(Option<&ashlar_diff::Line>, Option<u32>, Option<u32>, LineKind, State, u8)> = match (r.kind, mode) {
710                    (RowKind::Ctx, DiffMode::Unified) => vec![(None, Some(r.b), Some(r.a), LineKind::Context, st.1, 0)],
711                    (RowKind::Ctx, DiffMode::Split) => {
712                        vec![(None, Some(r.b), None, LineKind::Context, st.0, 0), (None, None, Some(r.a), LineKind::Context, st.1, 1)]
713                    }
714                    (_, DiffMode::Unified) => hunk_line(files, &r, r.a)
715                        .map(|l| {
716                            let s = if l.kind == LineKind::Removed { st.0 } else { st.1 };
717                            vec![(Some(l), l.old_no, l.new_no, l.kind, s, 0)]
718                        })
719                        .unwrap_or_default(),
720                    (_, DiffMode::Split) => {
721                        let left = hunk_line(files, &r, r.a);
722                        let right = hunk_line(files, &r, r.b);
723                        vec![
724                            (left, left.and_then(|l| l.old_no), None, left.map_or(LineKind::Context, |l| l.kind), st.0, 0),
725                            (right, None, right.and_then(|l| l.new_no), right.map_or(LineKind::Context, |l| l.kind), st.1, 1),
726                        ]
727                    }
728                };
729                let half = rect.width() / 2.0;
730                let mut spoken = String::new();
731                for (line, old_no, new_no, lk, lex, side) in &cells {
732                    let x0 = rect.left() + if *side == 1 { half } else { 0.0 };
733                    let cell = Rect::from_min_max(
734                        pos2(x0, rect.top()),
735                        pos2(if mode == DiffMode::Split && *side == 0 { x0 + half } else { rect.right() }, rect.bottom()),
736                    );
737                    let empty = mode == DiffMode::Split && r.kind == RowKind::Line && line.is_none();
738                    let (bg, gutter_bg, emph_bg) = pal.line(*lk);
739                    let gutter_w = if mode == DiffMode::Unified { 2.0 * num_w } else { num_w };
740                    if empty {
741                        painter.rect_filled(cell, 0, t.surface.sunken);
742                    } else if !row.selected {
743                        painter.rect_filled(cell, 0, bg);
744                        painter.rect_filled(Rect::from_min_size(cell.min, vec2(gutter_w, cell.height())), 0, gutter_bg);
745                    }
746                    if mode == DiffMode::Split && *side == 1 {
747                        painter.vline(cell.left(), cell.y_range(), egui::Stroke::new(1.0, t.border));
748                    }
749                    if empty {
750                        continue;
751                    }
752                    // numbers
753                    let mut nx = cell.left();
754                    let numbers: &[Option<u32>] = if mode == DiffMode::Unified {
755                        &[*old_no, *new_no]
756                    } else if *side == 0 {
757                        &[*old_no]
758                    } else {
759                        &[*new_no]
760                    };
761                    for n in numbers {
762                        if let Some(n) = n {
763                            let g = cache::galley(ui, (base, "num", *n), hash_of(&(font.size.to_bits(), t.dark)), || {
764                                LayoutJob::simple_singleline(n.to_string(), font.clone(), t.text.disabled)
765                            });
766                            painter.galley(pos2(nx + num_w - pad - g.size().x, text_y), g, t.text.disabled);
767                        }
768                        nx += num_w;
769                    }
770                    let sign = match lk {
771                        LineKind::Added => "+",
772                        LineKind::Removed => "-",
773                        LineKind::Context => "",
774                    };
775                    if !sign.is_empty() {
776                        let color = if *lk == LineKind::Added { t.status.success } else { t.status.danger };
777                        let g = cache::galley(ui, (base, "sign", sign), hash_of(&(font.size.to_bits(), t.dark)), || {
778                            LayoutJob::simple_singleline(sign.to_owned(), font.clone(), color)
779                        });
780                        painter.galley(pos2(nx + pad * 0.5, text_y), g, color);
781                    }
782                    // text
783                    let raw = match line {
784                        Some(l) => l.text.as_str(),
785                        None => f.source_line(r.a).unwrap_or(""),
786                    };
787                    let shown = display(raw);
788                    let emphasis: &[Range<usize>] = line.map_or(&[], |l| l.emphasis.as_slice());
789                    let rev = hash_of(&(model.key, style_rev, lex.to_bits()));
790                    let g = cache::galley(ui, (base, "text", i, *side), rev, || {
791                        scratch.clear();
792                        let _ = highlighter.highlight_line(lang, shown, *lex, &mut scratch);
793                        line_job(shown, &scratch, emphasis, &font, theme, emph_bg)
794                    });
795                    hs.see(g.size().x + pad);
796                    let text_cell = Rect::from_min_max(pos2(nx + sign_w, cell.top()), cell.max);
797                    painter.with_clip_rect(text_cell.intersect(ui.clip_rect())).galley(pos2(nx + sign_w - hx, text_y), g, theme.color(Kind::Plain));
798                    if !spoken.is_empty() {
799                        spoken.push_str(" | ");
800                    }
801                    let num = new_no.or(*old_no).map(|n| n.to_string()).unwrap_or_default();
802                    match lk {
803                        LineKind::Added => spoken.push_str(&format!("{} {num}: ", labels.added)),
804                        LineKind::Removed => spoken.push_str(&format!("{} {num}: ", labels.removed)),
805                        LineKind::Context => spoken.push_str(&format!("{num}: ")),
806                    }
807                    spoken.push_str(clip_label(shown));
808                }
809                node(NodeRole::Paragraph, &spoken);
810                let details = Details { value: None, selected: Some(row.selected) };
811                if r.kind == RowKind::Ctx {
812                    let n = r.a;
813                    automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.gap.{}.line.{n}", r.item), &spoken, Role::Row, true, details);
814                } else {
815                    let k = if r.a != NONE { r.a } else { r.b };
816                    automation::register_fmt(ui, rect, format_args!("{id}.file.{fi}.hunk.{}.line.{k}", r.item), &spoken, Role::Row, true, details);
817                }
818            }
819        }
820    });
821    let r = out.response.rect;
822    hs.end(ui, Rect::from_min_max(r.min, pos2(r.right() - ui.spacing().scroll.bar_width, r.bottom())), &t);
823    let top = top_row.unwrap_or(last_top);
824    let top_file = model.rows.get(top).map(|r| r.file as usize);
825
826    // the sticky header: the file of the top row
827    if let (Some(rect), Some(fi)) = (header_rect, top_file)
828        && let Some(f) = files.get(fi)
829    {
830        let resp = ui.interact(rect, base.with("sticky"), Sense::click());
831        let painter = ui.painter_at(rect);
832        painter.rect_filled(rect, 0, t.surface.raised);
833        painter.hline(rect.x_range(), rect.bottom() - 0.5, egui::Stroke::new(1.0, t.border_strong));
834        let g = file_galley(ui, base, f, fi, model.stats.get(fi).copied().unwrap_or_default(), state.is_file_collapsed(fi), &t, style_rev);
835        painter.galley(pos2(rect.left() + pad, rect.top() + 2.0), g, t.text.primary);
836        let label = file_label(f);
837        ui.ctx().accesskit_node_builder(resp.id, |n| {
838            n.set_role(NodeRole::Heading);
839            n.set_label(label.as_str());
840        });
841        automation::register_with(ui, &resp, &format!("{id}.header"), &label, Role::Header, Details::default());
842        if resp.clicked()
843            && let Some(file_row) = model.rows.iter().position(|r| r.file as usize == fi && r.kind == RowKind::File)
844        {
845            ui.data_mut(|d| d.insert_temp(goto_id, file_row));
846            crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
847        }
848    }
849
850    // clicks and Enter on file headers and collapsed ranges
851    let alt = ui.input(|i| i.modifiers.alt);
852    let activated = out.activated.filter(|_| out.double_clicked.is_none());
853    let mut expanded = None;
854    let mut toggled_file = None;
855    if let Some(i) = out.clicked.or(activated)
856        && let Some(r) = model.rows.get(i).copied()
857    {
858        let fi = r.file as usize;
859        match r.kind {
860            RowKind::File => {
861                let now = !state.is_file_collapsed(fi);
862                state.set_file_collapsed(fi, now);
863                toggled_file = Some(fi);
864            }
865            RowKind::Gap if model.gaps.get(fi).and_then(|g| g.get(r.item as usize)).is_some_and(|g| g.expandable) => {
866                state.expand(fi, r.item as usize, if alt { r.a } else { r.a.min(EXPAND_STEP) });
867                expanded = Some((fi, r.item as usize));
868            }
869            _ => {}
870        }
871        if toggled_file.is_some() || expanded.is_some() {
872            state.selection.select_only(i);
873            crate::wake::wake(ui.ctx(), crate::wake::Reason::INPUT);
874        }
875    }
876
877    // selected lines
878    let mut selected_lines = Vec::new();
879    for i in state.selection.iter() {
880        if selected_lines.len() >= MAX_SELECTED_LINES {
881            break;
882        }
883        let Some(r) = model.rows.get(i) else { break };
884        let file = r.file as usize;
885        match r.kind {
886            RowKind::Ctx => selected_lines.push(SelectedLine { file, kind: LineKind::Context, old_no: Some(r.b), new_no: Some(r.a) }),
887            RowKind::Line => {
888                let both = if mode == DiffMode::Split && r.a != r.b { [r.a, r.b] } else { [r.a, NONE] };
889                for idx in both {
890                    if let Some(l) = hunk_line(files, r, idx) {
891                        selected_lines.push(SelectedLine { file, kind: l.kind, old_no: l.old_no, new_no: l.new_no });
892                    }
893                }
894            }
895            _ => {}
896        }
897    }
898
899    ui.ctx().accesskit_node_builder(out.response.id, |n| {
900        n.set_role(NodeRole::Document);
901        n.set_read_only();
902    });
903    ui.data_mut(|d| {
904        d.insert_temp(top_id, (top, shown_rows));
905        d.insert_temp(model_id, model);
906    });
907    DiffResponse { response: out.response, selected_lines, expanded, toggled_file, hunk: moved_hunk, top_file, visible: out.visible }
908}
909
910/// The file header text: fold arrow, path, `+added −removed`.
911fn file_galley(
912    ui: &Ui,
913    base: egui::Id,
914    f: &FileDiff,
915    fi: usize,
916    (added, removed): (usize, usize),
917    collapsed: bool,
918    t: &Tokens,
919    style_rev: u64,
920) -> Arc<Galley> {
921    let rev = hash_of(&(style_rev, collapsed, added, removed, f.path(), f.binary));
922    cache::galley(ui, (base, "file", fi), rev, || {
923        let font = egui::TextStyle::Body.resolve(ui.style());
924        let mut job = LayoutJob::default();
925        let fmt = |color: Color32| TextFormat { font_id: font.clone(), color, ..Default::default() };
926        job.append(if collapsed { "▸ " } else { "▾ " }, 0.0, fmt(t.text.secondary));
927        job.append(&file_label(f), 0.0, fmt(t.text.primary));
928        if added > 0 {
929            job.append(&format!("+{added}"), 12.0, fmt(t.status.success));
930        }
931        if removed > 0 {
932            job.append(&format!("−{removed}"), 8.0, fmt(t.status.danger));
933        }
934        job.wrap = TextWrapping { max_width: f32::INFINITY, ..Default::default() };
935        job
936    })
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942
943    #[test]
944    fn split_pairs_removed_and_added_runs() {
945        let d = FileDiff::from_texts("a\nb\nc\nd\n", "a\nB\nC\nX\nd\n");
946        let files = [d];
947        let m = Model::build(1, &files, &DiffViewState::new(), DiffMode::Split);
948        let lines: Vec<(u32, u32)> = m.rows.iter().filter(|r| r.kind == RowKind::Line).map(|r| (r.a, r.b)).collect();
949        // a | B/b, C/c, X/- | d
950        assert_eq!(lines, vec![(0, 0), (1, 3), (2, 4), (NONE, 5), (6, 6)]);
951        let m = Model::build(1, &files, &DiffViewState::new(), DiffMode::Unified);
952        assert_eq!(m.rows.iter().filter(|r| r.kind == RowKind::Line).count(), 7);
953        assert_eq!(m.hunk_rows, vec![1]);
954    }
955
956    #[test]
957    fn gaps_expand_and_files_fold() {
958        let old: String = (1..=50).map(|i| format!("{i}\n")).collect();
959        let new = old.replacen("25\n", "x\n", 1);
960        let files = [FileDiff::from_texts(&old, &new)];
961        let mut st = DiffViewState::new();
962        let m = Model::build(1, &files, &st, DiffMode::Unified);
963        let gaps: Vec<u32> = m.rows.iter().filter(|r| r.kind == RowKind::Gap).map(|r| r.a).collect();
964        assert_eq!(gaps, vec![21, 22]);
965        st.expand(0, 0, 5);
966        let m2 = Model::build(2, &files, &st, DiffMode::Unified);
967        assert_eq!(m2.rows.len(), m.rows.len() + 5);
968        st.expand(0, 0, 100);
969        let m3 = Model::build(3, &files, &st, DiffMode::Unified);
970        assert_eq!(m3.rows.len(), m.rows.len() + 21 - 1);
971        st.set_file_collapsed(0, true);
972        assert_eq!(Model::build(4, &files, &st, DiffMode::Unified).rows.len(), 1);
973    }
974
975    #[test]
976    fn emphasis_sections_split_spans() {
977        let theme = SyntaxTheme::from_tokens(&Tokens::default());
978        let font = FontId::monospace(12.0);
979        let text = "let x = 1;";
980        let spans = ashlar_syntax::highlight(Some("rust"), text);
981        let job = line_job(text, &spans, &[4..5, 8..9], &font, &theme, Color32::RED);
982        let red: Vec<Range<usize>> =
983            job.sections.iter().filter(|s| s.format.background == Color32::RED).map(|s| s.byte_range.start.0..s.byte_range.end.0).collect();
984        assert_eq!(red, vec![4..5, 8..9]);
985        assert_eq!(job.sections.last().map(|s| s.byte_range.end.0), Some(text.len()));
986        // hostile ranges past the end
987        let job = line_job(text, &spans, &[100..200, 3..1000], &font, &theme, Color32::RED);
988        assert!(job.sections.iter().all(|s| s.byte_range.end.0 <= text.len()));
989    }
990}