hewdiff 0.5.0

High-performance review-first terminal diff viewer with PR-style comments
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Centralized UI color palette.
//!
//! The whole UI palette is *derived from the active syntect theme* (see
//! [`Theme::from_syntect`]): the chrome (sidebar, borders, headers, cursor
//! line, status) and background are computed from the theme's background,
//! foreground, selection, and a few scope colors. So switching the default
//! theme is a one-line change in [`crate::ui::highlight`] — the chrome follows
//! automatically, no hand-authored parallel palette to keep in sync.
//!
//! At startup [`init_theme`] derives the palette and resolves it against the
//! detected terminal: on truecolor the RGB values pass through; otherwise each
//! `Rgb` is downsampled to the nearest xterm-256 index ([`adapt_color`]) so the
//! look degrades gracefully instead of being mangled by tmux's own conversion.
//! Access the active palette through [`theme()`].

use ratatui::style::Color;
use std::sync::OnceLock;
use syntect::highlighting::{Color as SynColor, Highlighter as SynHighlighter, Theme as SynTheme};
use syntect::parsing::Scope;

/// Semantic colors for the diff viewer's chrome and text.
#[derive(Clone, Copy)]
pub struct Theme {
    /// The global background painted behind every pane.
    pub bg: Color,

    // Diff line backgrounds.
    /// Added line tint.
    pub add_bg: Color,
    /// Removed line tint.
    pub del_bg: Color,
    /// Current (cursor) line when its pane is focused (diff or sidebar).
    pub cursor_bg: Color,
    /// Current/selected line when its pane is *not* focused.
    pub unfocus_bg: Color,
    /// File-header row background.
    pub file_header_bg: Color,
    /// Inline comment box background.
    pub comment_bg: Color,

    // Borders, dividers, scrollbars.
    /// Very dark chrome (split column divider).
    pub subtle: Color,
    /// Scrollbar thumb.
    pub scrollbar_thumb: Color,
    /// Diff panel border while focused.
    pub border_focus: Color,
    /// Diff panel border while unfocused (dim but visible).
    pub border_unfocus: Color,

    // Text / foreground.
    /// Default body text.
    pub text: Color,
    /// Emphasized text (current file, headers, comment-box border).
    pub text_strong: Color,
    /// Muted text (gutter numbers, status line, context sign, resolved dot).
    pub muted: Color,
    /// Faint structural text (directory / hunk headers, sidebar labels).
    pub faint: Color,
    /// Accent (rename status, comment author).
    pub accent: Color,
    /// Attention (modified status, open-comment dot).
    pub warn: Color,
    /// Additions (status letter, `+` sign, counts).
    pub added: Color,
    /// Deletions (status letter, `-` sign, counts).
    pub removed: Color,
    /// Placeholder where no marker is drawn.
    pub none: Color,
}

/// Fallback palette returned by [`theme`] before [`init_theme`] installs the
/// derived one (e.g. any code path — including tests — that reads the palette
/// pre-init). A neutral dark theme; the real palette is derived from the active
/// syntect theme at startup.
static FALLBACK: Theme = Theme {
    bg: Color::Rgb(26, 27, 38),
    add_bg: Color::Rgb(32, 44, 38),
    del_bg: Color::Rgb(55, 32, 42),
    cursor_bg: Color::Rgb(54, 74, 124),
    unfocus_bg: Color::Rgb(41, 46, 66),
    file_header_bg: Color::Rgb(41, 46, 66),
    comment_bg: Color::Rgb(31, 35, 53),
    subtle: Color::Rgb(41, 46, 66),
    scrollbar_thumb: Color::Rgb(86, 95, 137),
    border_focus: Color::Rgb(122, 162, 247),
    border_unfocus: Color::Rgb(86, 95, 137),
    text: Color::Rgb(169, 177, 214),
    text_strong: Color::Rgb(192, 202, 245),
    muted: Color::Rgb(86, 95, 137),
    faint: Color::Rgb(115, 122, 162),
    accent: Color::Rgb(125, 207, 255),
    warn: Color::Rgb(224, 175, 104),
    added: Color::Rgb(158, 206, 106),
    removed: Color::Rgb(247, 118, 142),
    none: Color::Reset,
};

/// Whether the active terminal was detected as truecolor-capable. Drives the
/// dynamic downsampling of syntect token colors in [`adapt_color`]. Defaults to
/// `true` (no downsampling) until [`init_theme`] runs — so unit tests and any
/// pre-init access keep authored colors.
static TRUECOLOR: OnceLock<bool> = OnceLock::new();
/// The palette resolved for the active terminal (see [`init_theme`]).
static ACTIVE: OnceLock<Theme> = OnceLock::new();

/// Derive the chrome palette from the active `syntect` theme, then resolve it
/// for the detected terminal and cache it. Call once at startup, before the
/// first render. Idempotent: later calls are ignored.
pub fn init_theme(syntect_theme: &SynTheme, truecolor: bool) {
    let derived = Theme::from_syntect(syntect_theme);
    let _ = TRUECOLOR.set(truecolor);
    let _ = ACTIVE.set(derived.adapt(truecolor));
}

/// The active palette. Falls back to [`FALLBACK`] if [`init_theme`] hasn't run
/// (e.g. in tests). Crucially this *reads* `ACTIVE` without initializing it — a
/// pre-init call must not lock in `FALLBACK` and prevent a later [`init_theme`]
/// from installing the derived palette.
pub fn theme() -> &'static Theme {
    ACTIVE.get().unwrap_or(&FALLBACK)
}

/// Adapt a *dynamically produced* color (e.g. a syntect token's RGB) to the
/// active terminal: pass truecolor through, otherwise downsample `Rgb` to the
/// nearest xterm-256 index. Named/indexed colors are returned unchanged.
pub fn adapt_color(c: Color) -> Color {
    down(c, *TRUECOLOR.get().unwrap_or(&true))
}

/// Linearly blend two RGB triples (`t` in 0..=1: 0 = `a`, 1 = `b`).
fn mix(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) {
    let f = |x: u8, y: u8| {
        (x as f32 + (y as f32 - x as f32) * t)
            .round()
            .clamp(0.0, 255.0) as u8
    };
    (f(a.0, b.0), f(a.1, b.1), f(a.2, b.2))
}

/// Composite `c` (with its alpha) over an opaque `bg`. Theme `selection` /
/// `line_highlight` colors are often semi-transparent tints meant to sit over
/// the background.
fn over(bg: (u8, u8, u8), c: SynColor) -> (u8, u8, u8) {
    mix(bg, (c.r, c.g, c.b), c.a as f32 / 255.0)
}

/// Squared distance between two RGB triples (perceptual-ish, unweighted).
fn dist2(a: (u8, u8, u8), b: (u8, u8, u8)) -> i32 {
    let d = |x: u8, y: u8| (x as i32 - y as i32).pow(2);
    d(a.0, b.0) + d(a.1, b.1) + d(a.2, b.2)
}

impl Theme {
    /// Derive the full chrome palette from a `syntect` theme: background and
    /// foreground come from the theme settings; selection/line-highlight drive
    /// the cursor line; a handful of scope colors (comment, keyword, string,
    /// markup.inserted/deleted) drive the accents and diff tints. Everything
    /// else is a tint of bg toward fg, so any theme yields a coherent UI.
    pub fn from_syntect(syn: &SynTheme) -> Theme {
        let s = &syn.settings;
        let rgb = |c: SynColor| (c.r, c.g, c.b);
        let bg = s.background.map(rgb).unwrap_or((26, 27, 38));
        let fg = s.foreground.map(rgb).unwrap_or((192, 202, 245));

        // Resolve a scope's foreground; `None` when the theme doesn't give it a
        // color distinct from the default foreground (so we can fall back).
        let hl = SynHighlighter::new(syn);
        let scope = |name: &str| -> Option<(u8, u8, u8)> {
            let sc = Scope::new(name).ok()?;
            let c = hl.style_for_stack(&[sc]).foreground;
            let t = (c.r, c.g, c.b);
            (t != fg).then_some(t)
        };

        let comment = scope("comment").unwrap_or_else(|| mix(bg, fg, 0.45));
        let accent = s.accent.map(rgb).or_else(|| scope("keyword")).unwrap_or(fg);
        // A warm amber for "needs attention" chrome (modified-file marker, open
        // comment dot, comment author). Prefer a numeric/constant scope over
        // `string`: many vivid themes color strings green, which would collide
        // with the green `added` marker and leave file statuses A/M
        // indistinguishable. Fall back to string, then a fixed amber.
        let warn = scope("constant.numeric")
            .or_else(|| scope("string"))
            .unwrap_or((224, 175, 104));
        let added = scope("markup.inserted")
            .or_else(|| scope("diff.inserted"))
            .unwrap_or((158, 206, 106));
        let removed = scope("markup.deleted")
            .or_else(|| scope("diff.deleted"))
            .unwrap_or((247, 118, 142));

        // Focused current line: the theme's line-highlight/selection over bg.
        // If that's too close to bg to notice, fall back to a clear bg->fg mix.
        let sel = s
            .line_highlight
            .or(s.selection)
            .map(|c| over(bg, c))
            .unwrap_or_else(|| mix(bg, fg, 0.22));
        // Min squared per-channel distance for the focused line to read as
        // distinct from bg; below it we fall back to a clear bg->fg mix.
        const CURSOR_BG_MIN_DIST2: i32 = 30 * 30;
        let cursor_bg = if dist2(sel, bg) < CURSOR_BG_MIN_DIST2 {
            mix(bg, fg, 0.22)
        } else {
            sel
        };

        let t = |x: (u8, u8, u8)| Color::Rgb(x.0, x.1, x.2);
        Theme {
            bg: t(bg),
            add_bg: t(mix(bg, added, 0.22)),
            del_bg: t(mix(bg, removed, 0.22)),
            cursor_bg: t(cursor_bg),
            unfocus_bg: t(mix(bg, fg, 0.10)),
            file_header_bg: t(mix(bg, fg, 0.12)),
            comment_bg: t(mix(bg, fg, 0.05)),
            subtle: t(mix(bg, fg, 0.12)),
            scrollbar_thumb: t(comment),
            border_focus: t(accent),
            // Unfocused borders sit well below the bright accent focus border so
            // a selected comment box (or the focused diff panel) reads clearly
            // against unselected ones: a dim neutral grey, not the lighter
            // comment hue (which was too close to the accent at a glance).
            border_unfocus: t(mix(bg, fg, 0.28)),
            text: t(mix(fg, bg, 0.12)),
            text_strong: t(fg),
            // Secondary / "disabled" UI text (line numbers, status, dates) is a
            // bg→fg blend, deliberately decoupled from the (often vividly
            // colored) comment scope so it isn't tinted by the comment hue. It
            // still carries whatever subtle tint bg/fg themselves have, which
            // keeps it consistent with the rest of the chrome.
            muted: t(mix(bg, fg, 0.45)),
            faint: t(mix(bg, fg, 0.32)),
            accent: t(accent),
            warn: t(warn),
            added: t(added),
            removed: t(removed),
            none: Color::Reset,
        }
    }

    /// Build a copy with every `Rgb` field downsampled for a non-truecolor
    /// terminal (a no-op when `truecolor`). Named colors are preserved.
    fn adapt(&self, truecolor: bool) -> Theme {
        let d = |c: Color| down(c, truecolor);
        Theme {
            bg: d(self.bg),
            add_bg: d(self.add_bg),
            del_bg: d(self.del_bg),
            cursor_bg: d(self.cursor_bg),
            unfocus_bg: d(self.unfocus_bg),
            file_header_bg: d(self.file_header_bg),
            comment_bg: d(self.comment_bg),
            subtle: d(self.subtle),
            scrollbar_thumb: d(self.scrollbar_thumb),
            border_focus: d(self.border_focus),
            border_unfocus: d(self.border_unfocus),
            text: d(self.text),
            text_strong: d(self.text_strong),
            muted: d(self.muted),
            faint: d(self.faint),
            accent: d(self.accent),
            warn: d(self.warn),
            added: d(self.added),
            removed: d(self.removed),
            none: d(self.none),
        }
    }
}

/// Downsample a single `Rgb` color to the nearest xterm-256 index when the
/// terminal isn't truecolor; pass everything else through untouched.
fn down(c: Color, truecolor: bool) -> Color {
    match c {
        Color::Rgb(r, g, b) if !truecolor => Color::Indexed(rgb_to_ansi256(r, g, b)),
        other => other,
    }
}

/// Nearest xterm-256 palette index for a 24-bit color. Considers both the
/// 6×6×6 color cube (indices 16..=231) and the 24-step grayscale ramp
/// (232..=255), returning whichever is closest by squared Euclidean distance.
/// The first 16 slots are skipped: they're terminal-defined, so their RGB is
/// unknown and can't be matched reliably.
fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
    // Sample levels for each cube axis (xterm's standard 6-step ramp).
    const CUBE: [i32; 6] = [0, 95, 135, 175, 215, 255];
    let nearest_cube = |v: i32| -> usize {
        CUBE.iter()
            .enumerate()
            .min_by_key(|(_, &lvl)| (v - lvl).abs())
            .map(|(i, _)| i)
            .unwrap()
    };
    let (r, g, b) = (r as i32, g as i32, b as i32);
    let dist = |a: (i32, i32, i32), x: (i32, i32, i32)| {
        let (dr, dg, db) = (a.0 - x.0, a.1 - x.1, a.2 - x.2);
        dr * dr + dg * dg + db * db
    };

    // Best match within the color cube.
    let (ci, cj, ck) = (nearest_cube(r), nearest_cube(g), nearest_cube(b));
    let cube_rgb = (CUBE[ci], CUBE[cj], CUBE[ck]);
    let cube_idx = 16 + 36 * ci + 6 * cj + ck;

    // Best match within the grayscale ramp: gray N (0..=23) is 8 + 10*N. Round
    // to the *nearest* step (+5 before the floor-divide) rather than truncating,
    // so e.g. avg 17 picks N=1 (18) not N=0 (8).
    let avg = (r + g + b) / 3;
    let gray_n = (((avg - 8).max(0) + 5) / 10).clamp(0, 23);
    let gray_v = 8 + 10 * gray_n;
    let gray_idx = 232 + gray_n as usize;

    if dist((r, g, b), cube_rgb) <= dist((r, g, b), (gray_v, gray_v, gray_v)) {
        cube_idx as u8
    } else {
        gray_idx as u8
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn truecolor_passes_rgb_through_unchanged() {
        assert_eq!(down(Color::Rgb(20, 42, 24), true), Color::Rgb(20, 42, 24));
        // Named colors are never touched, in either mode.
        assert_eq!(down(Color::Red, false), Color::Red);
        assert_eq!(down(Color::Reset, false), Color::Reset);
    }

    #[test]
    fn non_truecolor_downsamples_rgb_to_indexed() {
        // Pure colors land on their cube corners.
        assert_eq!(down(Color::Rgb(0, 0, 0), false), Color::Indexed(16));
        assert_eq!(down(Color::Rgb(255, 255, 255), false), Color::Indexed(231));
        assert_eq!(down(Color::Rgb(255, 0, 0), false), Color::Indexed(196));
        assert_eq!(down(Color::Rgb(0, 255, 0), false), Color::Indexed(46));
        assert_eq!(down(Color::Rgb(0, 0, 255), false), Color::Indexed(21));
    }

    #[test]
    fn mid_gray_prefers_the_grayscale_ramp() {
        // A neutral gray is closer to the 24-step ramp than any cube corner.
        let idx = match down(Color::Rgb(128, 128, 128), false) {
            Color::Indexed(i) => i,
            other => panic!("expected indexed, got {other:?}"),
        };
        assert!(
            (232..=255).contains(&idx),
            "expected grayscale ramp, got {idx}"
        );
    }

    #[test]
    fn grayscale_ramp_rounds_to_nearest_step() {
        // avg 17 is closer to gray step 1 (value 18 -> index 233) than step 0
        // (value 8 -> index 232); flooring would wrongly pick 232.
        assert_eq!(rgb_to_ansi256(17, 17, 17), 233);
        // avg 12 -> step 0 (8) is nearer than step 1 (18).
        assert_eq!(rgb_to_ansi256(12, 12, 12), 232);
    }

    #[test]
    fn theme_falls_back_without_locking() {
        // No test calls `init_theme`, so `ACTIVE` stays empty and `theme()`
        // must hand back the `FALLBACK` reference itself (not a cached copy) —
        // proving a pre-init read can't lock in a palette and block a later
        // `init_theme` from installing the derived one.
        assert!(std::ptr::eq(theme(), &FALLBACK));
        assert_eq!(theme().added, FALLBACK.added);
    }

    #[test]
    fn adapt_downsamples_rgb_fields_but_keeps_non_rgb() {
        let dark = FALLBACK.adapt(false);
        // Rgb fields become Indexed...
        assert!(matches!(dark.cursor_bg, Color::Indexed(_)));
        assert!(matches!(dark.removed, Color::Indexed(_)));
        // ...while a non-Rgb color (Reset) is passed through unchanged.
        assert_eq!(dark.none, Color::Reset);
        // Truecolor adapt is a faithful pass-through.
        let bright = FALLBACK.adapt(true);
        assert_eq!(bright.cursor_bg, FALLBACK.cursor_bg);
    }

    #[test]
    fn derives_chrome_from_syntect_theme() {
        // The whole chrome palette is derived from the active syntect theme:
        // bg mirrors the theme background, and diff/accent colors are real
        // (distinct from bg).
        let syn = crate::ui::highlight::default_theme();
        let t = Theme::from_syntect(&syn);
        if let Some(bg) = syn.settings.background {
            assert_eq!(
                t.bg,
                Color::Rgb(bg.r, bg.g, bg.b),
                "bg must mirror the theme"
            );
        }
        assert_ne!(t.added, t.bg, "added must be a visible color");
        assert_ne!(t.removed, t.bg, "removed must be a visible color");
        assert_ne!(t.cursor_bg, t.bg, "focused current line must stand out");
    }
}