Skip to main content

codediff/tui/
theme.rs

1/*  This file is part of the CodeDiff code diffing tool.
2 *
3 *  Copyright (C) 2026 Marko Ivankovic
4 *
5 *  This program is free software: you can redistribute it and/or modify
6 *  it under the terms of the GNU Affero General Public License as published
7 *  by the Free Software Foundation, either version 3 of the License, or
8 *  (at your option) any later version.
9 *
10 *  This program is distributed in the hope that it will be useful,
11 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
12 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 *  GNU Affero General Public License for more details.
14 *
15 *  You should have received a copy of the GNU Affero General License
16 *  along with this program.  If not, see <https://www.gnu.org/licenses/>.
17 */
18use std::path::{Path, PathBuf};
19
20use ratatui::style::Color;
21use serde::{Deserialize, Serialize};
22use strum::{Display, EnumIter};
23
24/// A named palette for the colors used to paint the diff/cursor overlay (the insert/delete/
25/// move/update backgrounds, the overlay foreground, and the cross-panel cursor highlight - see
26/// `tui/widgets/code_viewer.rs`).
27///
28/// Picked explicitly by the user via the `c` theme picker (`tui/components/theme_dialog.rs`)
29/// and persisted across runs (`tui/app.rs`), since no single hardcoded palette reads well on
30/// every terminal: an all-dark band set is unreadable on a light-background one.
31///
32/// `Dracula` is the `#[default]`. The variant order below is the theme picker's display order,
33/// which is independent of which variant is the default.
34#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, EnumIter, Display)]
35pub enum OverlayTheme {
36    #[strum(to_string = "Dark")]
37    Dark,
38    #[strum(to_string = "Solarized Dark")]
39    SolarizedDark,
40    #[strum(to_string = "Solarized Light")]
41    SolarizedLight,
42    #[default]
43    #[strum(to_string = "Dracula (default)")]
44    Dracula,
45    #[strum(to_string = "Nord")]
46    Nord,
47    #[strum(to_string = "Gruvbox Dark")]
48    GruvboxDark,
49    #[strum(to_string = "Monokai")]
50    Monokai,
51    #[strum(to_string = "One Dark")]
52    OneDark,
53    /// The user's own palette, edited in the theme dialog and persisted as
54    /// [`CustomPalette`] in `.codediff.toml`.
55    ///
56    /// Unlike every other variant, this one is *not* a pure function of the enum: its colors come
57    /// from `custom_palette()`, process-global state loaded once at startup and updated when the
58    /// dialog commits an edit. That asymmetry is deliberate and contained - `palette()` stays the
59    /// single resolution point, so every existing call site keeps working unchanged rather than
60    /// threading a palette through `render_minimap`, the widgets and the help modal.
61    #[strum(to_string = "Custom")]
62    Custom,
63}
64
65/// A user-edited palette, stored as `#rrggbb` strings so the config file is readable and
66/// hand-editable. Parsed via [`parse_hex_color`]; anything unparseable falls back to the
67/// corresponding Dracula color rather than failing the load, so a typo in a hand-edited config
68/// costs one wrong color instead of the whole theme.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct CustomPalette {
71    pub insert_bg: String,
72    pub delete_bg: String,
73    pub move_bg: String,
74    pub update_bg: String,
75    pub overlay_fg: String,
76    pub cross_highlight_bg: String,
77    pub search_bg: String,
78    pub before_title_fg: String,
79    pub after_title_fg: String,
80}
81
82impl Default for CustomPalette {
83    /// Dracula, the shipped default theme - so "switch to Custom" starts from what the user was
84    /// already looking at rather than from an empty or arbitrary palette.
85    fn default() -> Self {
86        Self::from_palette(&OverlayTheme::Dracula.palette())
87    }
88}
89
90impl CustomPalette {
91    /// Snapshot an existing palette as editable hex - what "editing a preset forks it to Custom"
92    /// does.
93    pub fn from_palette(palette: &OverlayPalette) -> Self {
94        Self {
95            insert_bg: format_hex_color(palette.insert_bg),
96            delete_bg: format_hex_color(palette.delete_bg),
97            move_bg: format_hex_color(palette.move_bg),
98            update_bg: format_hex_color(palette.update_bg),
99            overlay_fg: format_hex_color(palette.overlay_fg),
100            cross_highlight_bg: format_hex_color(palette.cross_highlight_bg),
101            search_bg: format_hex_color(palette.search_bg),
102            before_title_fg: format_hex_color(palette.before_title_fg),
103            after_title_fg: format_hex_color(palette.after_title_fg),
104        }
105    }
106
107    /// Resolve back to concrete colors, falling back per field (see the struct's doc comment).
108    pub fn to_palette(&self) -> OverlayPalette {
109        let fallback = OverlayTheme::Dracula.palette();
110        let at = |hex: &str, default: Color| parse_hex_color(hex).unwrap_or(default);
111        OverlayPalette {
112            insert_bg: at(&self.insert_bg, fallback.insert_bg),
113            delete_bg: at(&self.delete_bg, fallback.delete_bg),
114            move_bg: at(&self.move_bg, fallback.move_bg),
115            update_bg: at(&self.update_bg, fallback.update_bg),
116            overlay_fg: at(&self.overlay_fg, fallback.overlay_fg),
117            cross_highlight_bg: at(&self.cross_highlight_bg, fallback.cross_highlight_bg),
118            search_bg: at(&self.search_bg, fallback.search_bg),
119            before_title_fg: at(&self.before_title_fg, fallback.before_title_fg),
120            after_title_fg: at(&self.after_title_fg, fallback.after_title_fg),
121        }
122    }
123}
124
125/// `#rrggbb` (or bare `rrggbb`) to a `Color`. `None` for anything else - including the named and
126/// indexed `Color` variants, which have no hex form; a preset using `Color::Red` for a panel
127/// title round-trips through [`format_hex_color`]'s ANSI table instead.
128pub fn parse_hex_color(text: &str) -> Option<Color> {
129    let hex = text.trim().trim_start_matches('#');
130    if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
131        return None;
132    }
133    let channel = |range: std::ops::Range<usize>| u8::from_str_radix(&hex[range], 16).ok();
134    Some(Color::Rgb(channel(0..2)?, channel(2..4)?, channel(4..6)?))
135}
136
137/// A `Color` as `#rrggbb`, for display and for the config file.
138///
139/// The 16 named ANSI colors have no true RGB value - the terminal decides what they look like -
140/// so they are rendered at their conventional xterm values purely so the dialog has something to
141/// show and edit. Editing one produces a real `Color::Rgb`, which is why a preset's panel title
142/// (`Color::Red`) becomes a concrete `#cd0000` the moment it is forked into Custom.
143pub fn format_hex_color(color: Color) -> String {
144    let (r, g, b) = match color {
145        Color::Rgb(r, g, b) => (r, g, b),
146        Color::Black => (0, 0, 0),
147        Color::Red => (205, 0, 0),
148        Color::Green => (0, 205, 0),
149        Color::Yellow => (205, 205, 0),
150        Color::Blue => (0, 0, 238),
151        Color::Magenta => (205, 0, 205),
152        Color::Cyan => (0, 205, 205),
153        Color::Gray => (229, 229, 229),
154        Color::DarkGray => (127, 127, 127),
155        Color::LightRed => (255, 0, 0),
156        Color::LightGreen => (0, 255, 0),
157        Color::LightYellow => (255, 255, 0),
158        Color::LightBlue => (92, 92, 255),
159        Color::LightMagenta => (255, 0, 255),
160        Color::LightCyan => (0, 255, 255),
161        Color::White => (255, 255, 255),
162        _ => (0, 0, 0),
163    };
164    format!("#{r:02x}{g:02x}{b:02x}")
165}
166
167/// Process-global custom palette - see [`OverlayTheme::Custom`] for why this is not threaded
168/// through call sites. Written once at startup from the config and again whenever the theme
169/// dialog commits an edit.
170static CUSTOM_PALETTE: std::sync::RwLock<Option<CustomPalette>> = std::sync::RwLock::new(None);
171
172/// The current custom palette, defaulting to Dracula's colors if none has been loaded or saved.
173pub fn custom_palette() -> CustomPalette {
174    CUSTOM_PALETTE
175        .read()
176        .ok()
177        .and_then(|guard| guard.clone())
178        .unwrap_or_default()
179}
180
181/// Replace the in-memory custom palette (the theme dialog's live preview path). Persisting is
182/// separate - see `save_custom_palette`.
183pub fn set_custom_palette(palette: CustomPalette) {
184    if let Ok(mut guard) = CUSTOM_PALETTE.write() {
185        *guard = Some(palette);
186    }
187}
188
189/// The concrete colors making up one [`OverlayTheme`].
190pub struct OverlayPalette {
191    pub insert_bg: Color,
192    pub delete_bg: Color,
193    pub move_bg: Color,
194    pub update_bg: Color,
195    pub overlay_fg: Color,
196    pub cross_highlight_bg: Color,
197    /// Search-match highlight (the `/` modal's results). A separate color from
198    /// `cross_highlight_bg`: sharing one would make "search hit" and "counterpart of the cursor"
199    /// indistinguishable while a search is active, with no way to tell which blue block the
200    /// `>`/`<` keys step to next. Every theme uses its own orange accent - the one hue none of
201    /// the four diff bands or the blue/cyan cursor highlight occupy.
202    pub search_bg: Color,
203    /// Foreground for the "Before" panel title, and its "After" counterpart below. Hardcoded as
204    /// `Color::Red`/`Color::Green` in `diff_viewer` until 2026-08-24; moved here so the custom
205    /// theme can change them. Every *preset* keeps exactly those two values, so presets look
206    /// identical to before - only `OverlayTheme::Custom` can vary them.
207    pub before_title_fg: Color,
208    pub after_title_fg: Color,
209}
210
211/// The `before_title_fg`/`after_title_fg` every preset uses - the colors the panel titles had
212/// when they were hardcoded in `diff_viewer::draw`.
213pub const PRESET_BEFORE_TITLE_FG: Color = Color::Red;
214pub const PRESET_AFTER_TITLE_FG: Color = Color::Green;
215
216impl OverlayPalette {
217    /// The background that paints `operation`, or `None` for `Identical` and the `NotYetSet`
218    /// sentinel, which keep plain syntax highlighting. The one operation-to-colour table for
219    /// every ratatui renderer (the code viewer's overlay, the diff viewer's minimap bands).
220    pub fn background_for(&self, operation: &crate::diff::text::TextOperation) -> Option<Color> {
221        use crate::diff::text::TextOperation;
222        match operation {
223            TextOperation::Insert => Some(self.insert_bg),
224            TextOperation::Delete => Some(self.delete_bg),
225            TextOperation::Move => Some(self.move_bg),
226            TextOperation::Update => Some(self.update_bg),
227            TextOperation::Identical | TextOperation::NotYetSet => None,
228        }
229    }
230}
231
232impl OverlayTheme {
233    /// The colors for this theme.
234    ///
235    /// `SolarizedDark`/`SolarizedLight` aren't invented RGB literals: each band color is the
236    /// canonical Solarized (Ethan Schoonover) accent - green/red/yellow/magenta - alpha-blended
237    /// toward that variant's own Solarized base color (`base03` for dark, `base3` for light), so
238    /// the result is still recognizably "Solarized" rather than a clashing overlay. `overlay_fg`
239    /// is likewise a Solarized base shade chosen for contrast against its own bands: light text
240    /// (`base2`) for the dark variant, dark text (`base02`) for the light one - the light variant
241    /// being the one that answers "too dark on a light terminal".
242    pub fn palette(self) -> OverlayPalette {
243        match self {
244            OverlayTheme::Custom => custom_palette().to_palette(),
245            OverlayTheme::Dark => OverlayPalette {
246                insert_bg: Color::Rgb(20, 60, 20),
247                delete_bg: Color::Rgb(70, 20, 20),
248                move_bg: Color::Rgb(47, 47, 47), // grey at the purple's own weight
249                update_bg: Color::Rgb(70, 60, 10),
250                overlay_fg: Color::Rgb(225, 225, 225),
251                cross_highlight_bg: Color::Rgb(40, 90, 200),
252                search_bg: Color::Rgb(160, 90, 10),
253                before_title_fg: PRESET_BEFORE_TITLE_FG,
254                after_title_fg: PRESET_AFTER_TITLE_FG,
255            },
256            OverlayTheme::SolarizedDark => OverlayPalette {
257                insert_bg: Color::Rgb(53, 87, 32),
258                delete_bg: Color::Rgb(88, 46, 51),
259                move_bg: Color::Rgb(72, 72, 72), // grey at the purple's own weight
260                update_bg: Color::Rgb(72, 81, 32),
261                overlay_fg: Color::Rgb(238, 232, 213),
262                cross_highlight_bg: Color::Rgb(23, 101, 148),
263                // Solarized orange blended 0.4 toward base03, same vividness as the cursor blue.
264                search_bg: Color::Rgb(122, 62, 35),
265                before_title_fg: PRESET_BEFORE_TITLE_FG,
266                after_title_fg: PRESET_AFTER_TITLE_FG,
267            },
268            OverlayTheme::SolarizedLight => OverlayPalette {
269                insert_bg: Color::Rgb(205, 209, 136),
270                delete_bg: Color::Rgb(240, 168, 155),
271                move_bg: Color::Rgb(198, 198, 198), // grey at the pink's own weight
272                update_bg: Color::Rgb(224, 202, 136),
273                overlay_fg: Color::Rgb(7, 54, 66),
274                cross_highlight_bg: Color::Rgb(124, 182, 217),
275                // Solarized orange blended 0.4 toward base3, same vividness as the cursor blue.
276                search_bg: Color::Rgb(223, 143, 104),
277                before_title_fg: PRESET_BEFORE_TITLE_FG,
278                after_title_fg: PRESET_AFTER_TITLE_FG,
279            },
280            // The five palettes below all follow the same recipe, which the hand-picked
281            // Solarized variants above also follow:
282            // each band is that theme's own canonical accent color (from its official public
283            // spec/palette - not invented) blended 60% toward the theme's own background via
284            // `blend_toward_base`, and `cross_highlight_bg` is blended only 40% toward it so it
285            // stays visibly more vivid than the bands - reserved for "where the cursor is," not
286            // just "what changed." `overlay_fg` is always the theme's own canonical foreground
287            // color, unblended, since text needs to stay maximally readable.
288            OverlayTheme::Dracula => {
289                // https://draculatheme.com/spec
290                let bg = (40, 42, 54);
291                OverlayPalette {
292                    insert_bg: blend_toward_base((80, 250, 123), bg, 0.6), // green
293                    delete_bg: blend_toward_base((255, 85, 85), bg, 0.6),  // red
294                    // The one band that is deliberately *not* this theme's canonical accent (see
295                    // the note above): a plain grey, because a move is the one operation that
296                    // changes no code. Purple read as loud as insert/delete/update and pulled the
297                    // eye to the thing that needs the least attention. Blended toward the
298                    // background at the same 0.6 as its neighbours, so it sits at their weight
299                    // rather than glowing: #aaaaaa over Dracula's base lands on Rgb(92, 93, 100).
300                    move_bg: blend_toward_base((170, 170, 170), bg, 0.6), // grey, #aaaaaa
301                    update_bg: blend_toward_base((241, 250, 140), bg, 0.6), // yellow
302                    overlay_fg: Color::Rgb(248, 248, 242),                // foreground
303                    cross_highlight_bg: blend_toward_base((139, 233, 253), bg, 0.4), // cyan
304                    search_bg: blend_toward_base((255, 184, 108), bg, 0.4), // orange
305                    before_title_fg: PRESET_BEFORE_TITLE_FG,
306                    after_title_fg: PRESET_AFTER_TITLE_FG,
307                }
308            }
309            OverlayTheme::Nord => {
310                // https://www.nordtheme.com/docs/colors-and-palettes - nord0 (bg), nord6
311                // (brightest snow storm, fg), nord11/13/14/15 (aurora accents), nord9 (frost blue)
312                let bg = (46, 52, 64);
313                OverlayPalette {
314                    insert_bg: blend_toward_base((163, 190, 140), bg, 0.6), // nord14, green
315                    delete_bg: blend_toward_base((191, 97, 106), bg, 0.6),  // nord11, red
316                    move_bg: blend_toward_base((170, 170, 170), bg, 0.6),   // grey, #aaaaaa
317                    update_bg: blend_toward_base((235, 203, 139), bg, 0.6), // nord13, yellow
318                    overlay_fg: Color::Rgb(236, 239, 244),                  // nord6
319                    cross_highlight_bg: blend_toward_base((129, 161, 193), bg, 0.4), // nord9
320                    search_bg: blend_toward_base((208, 135, 112), bg, 0.4), // nord12, orange
321                    before_title_fg: PRESET_BEFORE_TITLE_FG,
322                    after_title_fg: PRESET_AFTER_TITLE_FG,
323                }
324            }
325            OverlayTheme::GruvboxDark => {
326                // https://github.com/morhetz/gruvbox - bg0, fg1, and the "bright" accent row
327                let bg = (40, 40, 40);
328                OverlayPalette {
329                    insert_bg: blend_toward_base((184, 187, 38), bg, 0.6), // bright green
330                    delete_bg: blend_toward_base((251, 73, 52), bg, 0.6),  // bright red
331                    move_bg: blend_toward_base((170, 170, 170), bg, 0.6),  // grey, #aaaaaa
332                    update_bg: blend_toward_base((250, 189, 47), bg, 0.6), // bright yellow
333                    overlay_fg: Color::Rgb(235, 219, 178),                 // fg1
334                    cross_highlight_bg: blend_toward_base((131, 165, 152), bg, 0.4), // bright blue
335                    search_bg: blend_toward_base((254, 128, 25), bg, 0.4), // bright orange
336                    before_title_fg: PRESET_BEFORE_TITLE_FG,
337                    after_title_fg: PRESET_AFTER_TITLE_FG,
338                }
339            }
340            OverlayTheme::Monokai => {
341                // Canonical Sublime Text "Monokai" (monokai.tmTheme) accents and background.
342                let bg = (39, 40, 34);
343                OverlayPalette {
344                    insert_bg: blend_toward_base((166, 226, 46), bg, 0.6), // green
345                    delete_bg: blend_toward_base((249, 38, 114), bg, 0.6), // pink/red
346                    move_bg: blend_toward_base((170, 170, 170), bg, 0.6),  // grey, #aaaaaa
347                    update_bg: blend_toward_base((230, 219, 116), bg, 0.6), // yellow
348                    overlay_fg: Color::Rgb(248, 248, 242),                 // foreground
349                    cross_highlight_bg: blend_toward_base((102, 217, 239), bg, 0.4), // cyan
350                    search_bg: blend_toward_base((253, 151, 31), bg, 0.4), // orange
351                    before_title_fg: PRESET_BEFORE_TITLE_FG,
352                    after_title_fg: PRESET_AFTER_TITLE_FG,
353                }
354            }
355            OverlayTheme::OneDark => {
356                // Atom's "One Dark" (atom-one-dark-syntax) accents and background - one of the
357                // most widely ported editor themes, independent of the Atom editor itself.
358                let bg = (40, 44, 52);
359                OverlayPalette {
360                    insert_bg: blend_toward_base((152, 195, 121), bg, 0.6), // green
361                    delete_bg: blend_toward_base((224, 108, 117), bg, 0.6), // red
362                    move_bg: blend_toward_base((170, 170, 170), bg, 0.6),   // grey, #aaaaaa
363                    update_bg: blend_toward_base((229, 192, 123), bg, 0.6), // yellow
364                    overlay_fg: Color::Rgb(171, 178, 191),                  // foreground
365                    cross_highlight_bg: blend_toward_base((97, 175, 239), bg, 0.4), // blue
366                    search_bg: blend_toward_base((209, 154, 102), bg, 0.4), // orange
367                    before_title_fg: PRESET_BEFORE_TITLE_FG,
368                    after_title_fg: PRESET_AFTER_TITLE_FG,
369                }
370            }
371        }
372    }
373}
374
375/// Blends `accent` toward `base` by `base_weight` (`0.0` = pure accent, `1.0` = pure base). Both
376/// are `(r, g, b)` triples rather than `Color`, since every caller works from a plain canonical
377/// hex triple. See `OverlayTheme::palette`'s doc comment on the five themes that use this.
378fn blend_toward_base(accent: (u8, u8, u8), base: (u8, u8, u8), base_weight: f32) -> Color {
379    let mix = |a: u8, b: u8| -> u8 {
380        (a as f32 * (1.0 - base_weight) + b as f32 * base_weight).round() as u8
381    };
382    Color::Rgb(
383        mix(accent.0, base.0),
384        mix(accent.1, base.1),
385        mix(accent.2, base.2),
386    )
387}
388
389/// How the before/after panels should be laid out - the persisted counterpart of
390/// `DiffViewer`'s width-based auto choice. Lives here (not in `diff_viewer.rs`) because this
391/// module owns the config file both settings persist to; `DiffViewer` consumes it.
392#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393pub enum PanelLayout {
394    /// Pick dual/single from the terminal width (`SINGLE_PANEL_THRESHOLD`) - today's behavior.
395    #[default]
396    Auto,
397    /// Always side-by-side, regardless of width.
398    Dual,
399    /// Always one panel at a time (`Tab` switches), regardless of width.
400    Single,
401}
402
403impl PanelLayout {
404    /// The next mode in the `v` key's `Auto -> Dual -> Single -> Auto` cycle.
405    pub fn next(self) -> Self {
406        match self {
407            PanelLayout::Auto => PanelLayout::Dual,
408            PanelLayout::Dual => PanelLayout::Single,
409            PanelLayout::Single => PanelLayout::Auto,
410        }
411    }
412
413    /// Short label for the footer/title, e.g. `[layout: dual]`.
414    pub fn label(self) -> &'static str {
415        match self {
416            PanelLayout::Auto => "auto",
417            PanelLayout::Dual => "dual",
418            PanelLayout::Single => "single",
419        }
420    }
421}
422
423/// How many recently diffed file pairs to remember (see `record_recent_pair`) - capped at the
424/// nine digit keys the empty-start screen offers for reopening them.
425const MAX_RECENT_PAIRS: usize = 9;
426
427/// On-disk representation of the persisted settings. A dedicated struct (rather than
428/// persisting `OverlayTheme` directly) so the config file has named fields. Every field carries
429/// `#[serde(default)]` so a config written by an older build still parses.
430#[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
431struct ThemeConfig {
432    /// Falls back to the default theme rather than failing the parse.
433    ///
434    /// `#[serde(default)]` alone is not enough, and that is the trap: it covers a *missing* field,
435    /// while an unknown enum *value* - a config written by a newer codediff, or hand-edited with a
436    /// typo - is a hard error that fails the whole document, silently reverting every other
437    /// setting in the file. `theme_or_default` matches the name against the variants this build
438    /// actually has and shrugs at anything else.
439    #[serde(default, deserialize_with = "theme_or_default")]
440    theme: OverlayTheme,
441    #[serde(default)]
442    layout: PanelLayout,
443    #[serde(default)]
444    recent_pairs: Vec<(PathBuf, PathBuf)>,
445    /// The user's edited palette, used when `theme` is `OverlayTheme::Custom`. Kept even while a
446    /// preset is selected, so switching back to Custom restores the edits rather than resetting.
447    #[serde(default)]
448    custom_palette: CustomPalette,
449    /// Syntax-highlighting theme name, one of syntect's built-ins (see `syntax_theme_names`).
450    /// Empty means "whatever the code viewer defaults to".
451    #[serde(default)]
452    syntax_theme: String,
453    /// Whether the node highlight (the `H` key) is on. `bool`'s `Default` is `false`, which is
454    /// deliberately also this feature's shipped default - see `load_node_highlight`.
455    #[serde(default)]
456    node_highlight: bool,
457    /// Which parts of the diff to paint (the `M` key) - see `crate::diff::text::RenderOptions`.
458    /// Defaults to `RenderOptions::FULL`, so a config file with no entry for it gets the fullest
459    /// rendering rather than an empty one.
460    #[serde(default)]
461    render_options: crate::diff::text::RenderOptions,
462}
463
464/// Deserialize a theme name, falling back to the default for anything this build does not know.
465///
466/// Re-runs the enum's own `Deserialize` over the name rather than comparing against `Display`.
467/// The two disagree: serde writes the variant identifier (`SolarizedLight`), while strum's
468/// `Display` gives the picker label (`Solarized Light`, `Dracula (default)`). Matching on the
469/// label would reject every theme codediff has ever written to disk - which is exactly what the
470/// first version of this function did, caught by
471/// `a_pre_existing_render_options_table_without_whole_pair_updates_still_loads`.
472///
473/// A value that is not a string at all still fails, at which point the file is structurally wrong
474/// rather than merely naming something unfamiliar, and [`update_config`]'s refusal to overwrite an
475/// unparseable file is what protects the user's settings.
476fn theme_or_default<'de, D>(deserializer: D) -> Result<OverlayTheme, D::Error>
477where
478    D: serde::Deserializer<'de>,
479{
480    use serde::de::IntoDeserializer;
481    let name = String::deserialize(deserializer)?;
482    let as_value: serde::de::value::StrDeserializer<serde::de::value::Error> =
483        name.as_str().into_deserializer();
484    Ok(OverlayTheme::deserialize(as_value).unwrap_or_default())
485}
486
487/// The environment variable that overrides every other config layer.
488pub const CONFIG_ENV: &str = "CODEDIFF_CONFIG";
489
490/// The project-level config file's name, looked for at or above the current directory.
491const PROJECT_CONFIG: &str = ".codediff.toml";
492
493/// The config file to read and write, resolved in this order:
494///
495/// 1. `$CODEDIFF_CONFIG`, if set and non-empty. Authoritative: no walk-up, no fallback. This is
496///    the seam tests use, so they never touch a real user's settings.
497/// 2. The nearest `.codediff.toml` at or above the current directory, **if one already exists**.
498/// 3. `$XDG_CONFIG_HOME/codediff/config.toml`, else `$HOME/.config/codediff/config.toml`.
499///
500/// Layer 2 is only ever *used*, never *created*. Until 2026-09-10 the path was unconditionally
501/// `./.codediff.toml`, so codediff dropped a dotfile into whatever directory it happened to run
502/// in - including, memorably, a checkout of the VS Code extension, where its own integration test
503/// spawning codediff littered the repository.
504///
505/// The walk-up matters for `git difftool` and `GIT_EXTERNAL_DIFF`, which git runs with the working
506/// directory set to the repository root: without it, a project config would be found only when
507/// codediff was invoked from that exact directory and not from any subdirectory of it.
508///
509/// `pub(crate)` so `app.rs` tests can clean up after a setter that writes for real.
510pub(crate) fn config_path() -> PathBuf {
511    // Checked before the test redirect below, so a test that wants a specific file still wins.
512    if let Ok(explicit) = std::env::var(CONFIG_ENV)
513        && !explicit.is_empty()
514    {
515        return PathBuf::from(explicit);
516    }
517    #[cfg(test)]
518    {
519        test_config_path()
520    }
521    #[cfg(not(test))]
522    {
523        nearest_project_config().unwrap_or_else(user_config_path)
524    }
525}
526
527/// A throwaway config for the test build, so no test can write a developer's real settings.
528///
529/// Needed because the code that persists settings is ordinary production code that many tests
530/// reach incidentally - `handle_diff_ready` records a recent pair, `apply_render_options` and the
531/// panel-layout and node-highlight toggles each save - so isolating them one at a time misses the
532/// ones nobody thought of. On 2026-09-10 a full run rewrote this repository's own
533/// `.codediff.toml`, dropping a recent pair and flipping every render option, and only two of the
534/// responsible tests had been spotted by inspection.
535///
536/// Done here rather than in the test harness because `cargo-nextest` 0.9.143 ignores an `[env]`
537/// table in `.config/nextest.toml` ("unknown configuration key"), and a `Makefile`-level variable
538/// would not cover a bare `cargo test`. Keyed by process id so a threaded `cargo test` run does
539/// not have two tests fighting over one file.
540#[cfg(test)]
541fn test_config_path() -> PathBuf {
542    std::env::temp_dir().join(format!("codediff-test-config-{}.toml", std::process::id()))
543}
544
545/// The nearest existing `.codediff.toml`, walking up from the current directory to the root.
546///
547/// Unreachable in the test build, where `config_path` short-circuits to `test_config_path` - the
548/// walk itself is covered through `nearest_project_config_from`.
549#[cfg_attr(test, allow(dead_code))]
550fn nearest_project_config() -> Option<PathBuf> {
551    nearest_project_config_from(&std::env::current_dir().ok()?)
552}
553
554/// The walk itself, parameterized by starting directory so it is testable without changing the
555/// process's working directory out from under every other test.
556fn nearest_project_config_from(start: &Path) -> Option<PathBuf> {
557    start.ancestors().find_map(|directory| {
558        let candidate = directory.join(PROJECT_CONFIG);
559        candidate.is_file().then_some(candidate)
560    })
561}
562
563/// `$XDG_CONFIG_HOME/codediff/config.toml`, else `$HOME/.config/codediff/config.toml`.
564///
565/// Resolved from the environment rather than through the `dirs`/`directories` crate deliberately:
566/// a new dependency means regenerating the 293-crate `CRATES=` block every Gentoo ebuild bump
567/// reads, which is a real cost for two `std::env::var` calls. Falling back to `./.codediff.toml`
568/// when neither variable is set is what happens on a system with no HOME at all, rather than
569/// writing to a path that resolves to the filesystem root.
570#[cfg_attr(test, allow(dead_code))]
571fn user_config_path() -> PathBuf {
572    user_config_path_from(
573        std::env::var("XDG_CONFIG_HOME").ok(),
574        std::env::var("HOME").ok(),
575    )
576}
577
578/// The resolution itself, taking the two variables as arguments so it is testable without mutating
579/// the process environment.
580fn user_config_path_from(xdg_config_home: Option<String>, home: Option<String>) -> PathBuf {
581    if let Some(xdg) = xdg_config_home.filter(|value| !value.is_empty()) {
582        return PathBuf::from(xdg).join("codediff").join("config.toml");
583    }
584    if let Some(home) = home.filter(|value| !value.is_empty()) {
585        return PathBuf::from(home)
586            .join(".config")
587            .join("codediff")
588            .join("config.toml");
589    }
590    PathBuf::from(PROJECT_CONFIG)
591}
592
593/// The last config parse failure, for the TUI to surface. `None` once a load succeeds.
594///
595/// Without something to surface it, a parse failure is entirely silent: `unwrap_or_default()`
596/// turns it into a fresh set of defaults, and the next setting the user changes writes those
597/// defaults over the file - one bad line costing every other setting in it, with nothing on
598/// screen to say so.
599static CONFIG_ERROR: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
600
601/// The current config parse error, if the last load hit one.
602pub fn config_error() -> Option<String> {
603    CONFIG_ERROR.read().ok().and_then(|held| held.clone())
604}
605
606/// The file exists but does not parse, so its contents must not be overwritten - see
607/// [`update_config`]. A unit error rather than a two-variant enum because the `Ok` side carries a
608/// `ThemeConfig`, and an enum pairing that with an empty variant is all payload and no tag.
609#[derive(Debug)]
610struct Unreadable;
611
612/// Read the config, distinguishing "absent" from "present but broken".
613///
614/// Absent is not an error: confy yields defaults for a file that is not there, and writing over
615/// nothing loses nothing. Present-but-unparseable is recorded in [`CONFIG_ERROR`] for the TUI to
616/// show and refuses to become a `ThemeConfig` anyone might write back.
617fn read_config(path: &Path) -> Result<ThemeConfig, Unreadable> {
618    match confy::load_path::<ThemeConfig>(path) {
619        Ok(config) => {
620            if let Ok(mut held) = CONFIG_ERROR.write() {
621                *held = None;
622            }
623            Ok(config)
624        }
625        Err(error) => {
626            if !path.exists() {
627                return Ok(ThemeConfig::default());
628            }
629            if let Ok(mut held) = CONFIG_ERROR.write() {
630                *held = Some(format!("{}: {error}", path.display()));
631            }
632            Err(Unreadable)
633        }
634    }
635}
636
637/// Read-modify-write one setting, resolving the config path exactly once.
638///
639/// Refuses to write when the existing file does not parse. A setter written as
640/// `load_from(config_path())` - which silently yields defaults on a parse error - followed by
641/// `save_to(config_path(), ...)` overwrites every other setting with a default whenever the file
642/// does not parse. Resolving the path once also matters because it is layered: two calls could
643/// otherwise read one layer and write another.
644fn update_config(mutate: impl FnOnce(&mut ThemeConfig)) {
645    let path = config_path();
646    let Ok(mut config) = read_config(&path) else {
647        return;
648    };
649    mutate(&mut config);
650    save_to(path, config);
651}
652
653/// Load the persisted theme choice, or `OverlayTheme::default()` if the config file doesn't
654/// exist yet or fails to parse.
655///
656/// Uses `confy` rather than `config-rs` (the most-downloaded Rust config crate by a wide
657/// margin): `config-rs` is read-only and has no way to write a choice back to disk, which
658/// `save_overlay_theme` below needs to do. `confy` exists specifically for this round-trip -
659/// load a small struct, store it back to an exact path - at the cost of being a much smaller,
660/// less general-purpose library.
661pub fn load_overlay_theme() -> OverlayTheme {
662    load_from(config_path()).theme
663}
664
665/// Persist the user's theme choice for future runs. Failures (e.g. a read-only working
666/// directory) are non-fatal: the choice simply won't survive a restart. Load-modify-save so the
667/// other persisted settings in the same file survive the write.
668pub fn save_overlay_theme(theme: OverlayTheme) {
669    update_config(|config| config.theme = theme);
670}
671
672/// Load the persisted panel-layout choice (the `v` key), or `PanelLayout::Auto` if the config
673/// file doesn't exist yet or fails to parse.
674pub fn load_panel_layout() -> PanelLayout {
675    load_from(config_path()).layout
676}
677
678/// Persist the panel-layout choice, preserving the other settings in the same file - same
679/// non-fatal failure semantics as `save_overlay_theme`.
680pub fn save_panel_layout(layout: PanelLayout) {
681    update_config(|config| config.layout = layout);
682}
683
684/// The persisted custom palette, or Dracula's colors if none was ever saved.
685pub fn load_custom_palette() -> CustomPalette {
686    load_from(config_path()).custom_palette
687}
688
689/// Persist the custom palette *and* install it as the live one, so the caller cannot save a
690/// palette the running process isn't using.
691pub fn save_custom_palette(palette: CustomPalette) {
692    set_custom_palette(palette.clone());
693    update_config(|config| config.custom_palette = palette);
694}
695
696/// The persisted render options (the `M` key), or `RenderOptions::FULL` if none was ever chosen.
697pub fn load_render_options() -> crate::diff::text::RenderOptions {
698    load_from(config_path()).render_options
699}
700
701/// Persist the render options, preserving the other settings in the same file - same non-fatal
702/// failure semantics as `save_overlay_theme`.
703pub fn save_render_options(options: crate::diff::text::RenderOptions) {
704    update_config(|config| config.render_options = options);
705}
706
707/// The persisted syntax-highlighting theme name, or `None` if the user never picked one.
708pub fn load_syntax_theme() -> Option<String> {
709    let name = load_from(config_path()).syntax_theme;
710    (!name.is_empty()).then_some(name)
711}
712
713/// Persist the syntax-highlighting theme choice.
714pub fn save_syntax_theme(name: &str) {
715    update_config(|config| config.syntax_theme = name.to_string());
716}
717
718/// Whether the node highlight is enabled (the `H` key), defaulting to **off**.
719///
720/// Off by default because it is a constant, cursor-following repaint: every cursor movement
721/// recolors the range under the cursor and its counterpart on the other panel, which reads as
722/// flicker while navigating and obscures the diff coloring underneath it - the thing the user is
723/// actually there to read. It stays available for the case it was built for, answering "what does
724/// this specific node map to", which is a question you ask occasionally rather than continuously.
725pub fn load_node_highlight() -> bool {
726    load_from(config_path()).node_highlight
727}
728
729/// Persist the node-highlight toggle, preserving the other settings in the same file - same
730/// non-fatal failure semantics as `save_overlay_theme`.
731pub fn save_node_highlight(enabled: bool) {
732    update_config(|config| config.node_highlight = enabled);
733}
734
735/// Whether a path is one of the throwaway files a VCS materializes to hand to a diff tool.
736///
737/// `git difftool` and `GIT_EXTERNAL_DIFF` write each side to something like
738/// `/tmp/git-blob-AbC123/file.rs` and delete it the moment the tool exits, so recording such a
739/// pair produces an entry that is dead before it is ever offered. jj does the same with its own
740/// temp directory.
741fn is_throwaway(path: &Path) -> bool {
742    path.starts_with(std::env::temp_dir())
743}
744
745/// The recently diffed file pairs, most recent first - offered on the empty-start screen as
746/// digit shortcuts (`tui::app::draw_viewer`).
747///
748/// Entries whose files have since disappeared are dropped rather than offered: a recents list is
749/// only useful if selecting an item works. It also drops any `/tmp/git-blob-*` pairs already on
750/// the list; `record_recent_pair` refuses to add new ones.
751///
752/// The list is **per-user**, like the rest of the config.
753pub fn load_recent_pairs() -> Vec<(PathBuf, PathBuf)> {
754    let mut pairs = load_from(config_path()).recent_pairs;
755    pairs.retain(|(before, after)| before.exists() && after.exists());
756    pairs
757}
758
759/// Record a successfully diffed pair at the front of the recent list (deduplicated, capped at
760/// [`MAX_RECENT_PAIRS`]), preserving the other settings in the same file. Same non-fatal failure
761/// semantics as the other save functions.
762///
763/// A pair with a throwaway side is not recorded at all - see [`is_throwaway`]. Filtering these out
764/// only on read would leave every `git difftool` invocation still writing one, and now into the
765/// user-level config rather than a directory-local file.
766pub fn record_recent_pair(before: &Path, after: &Path) {
767    if is_throwaway(before) || is_throwaway(after) {
768        return;
769    }
770    let pair = (before.to_path_buf(), after.to_path_buf());
771    update_config(|config| {
772        config.recent_pairs.retain(|existing| existing != &pair);
773        config.recent_pairs.insert(0, pair);
774        config.recent_pairs.truncate(MAX_RECENT_PAIRS);
775    });
776}
777
778/// `load_overlay_theme`/`save_overlay_theme`, parameterized by path so tests can exercise the
779/// round-trip against a temp file instead of mutating the process's actual working directory.
780///
781/// Getters use this and fall back to defaults, which is right for a *read*: showing default colors
782/// beats refusing to start. Writes go through [`update_config`] instead, which refuses to clobber
783/// a file it could not parse.
784fn load_from(path: PathBuf) -> ThemeConfig {
785    read_config(&path).unwrap_or_default()
786}
787
788fn save_to(path: PathBuf, config: ThemeConfig) {
789    let _ = confy::store_path(path, config);
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use strum::IntoEnumIterator;
796
797    /// A config naming a theme this build does not know must not cost every other setting in the
798    /// file. Before `theme` gained `#[serde(default)]`, the unknown value failed the whole parse,
799    /// `unwrap_or_default()` produced a blank config, and the next setting the user touched wrote
800    /// that blank over their file.
801    #[test]
802    fn an_unknown_theme_name_does_not_discard_the_rest_of_the_config() {
803        let file = tempfile::NamedTempFile::new().expect("temp file");
804        std::fs::write(
805            file.path(),
806            "theme = \"NotATheme\"\nsyntax_theme = \"base16-ocean.dark\"\nnode_highlight = true\n",
807        )
808        .expect("write config");
809
810        let config = load_from(file.path().to_path_buf());
811
812        assert_eq!(config.theme, OverlayTheme::default());
813        assert_eq!(config.syntax_theme, "base16-ocean.dark");
814        assert!(config.node_highlight);
815    }
816
817    /// The load/save asymmetry that lets one bad line destroy a whole config: reading with
818    /// `unwrap_or_default()` and then writing the result back.
819    #[test]
820    fn a_file_that_does_not_parse_is_never_overwritten() {
821        let file = tempfile::NamedTempFile::new().expect("temp file");
822        let garbage = "this is not toml = = =\n";
823        std::fs::write(file.path(), garbage).expect("write config");
824
825        let path = file.path().to_path_buf();
826        assert!(read_config(&path).is_err());
827        assert!(config_error().is_some(), "the failure must be reportable");
828
829        // What a setter does now.
830        if let Ok(mut config) = read_config(&path) {
831            config.node_highlight = true;
832            save_to(path.clone(), config);
833        }
834
835        assert_eq!(
836            std::fs::read_to_string(file.path()).expect("read back"),
837            garbage,
838            "an unparseable config must be left exactly as the user wrote it"
839        );
840    }
841
842    /// `$CODEDIFF_CONFIG` is authoritative: no walk-up, no user-level fallback. Tests rely on this
843    /// to stay off a real user's settings.
844    #[test]
845    fn the_environment_override_wins_over_every_other_layer() {
846        let file = tempfile::NamedTempFile::new().expect("temp file");
847        unsafe { std::env::set_var(CONFIG_ENV, file.path()) };
848        assert_eq!(config_path(), file.path());
849        unsafe { std::env::remove_var(CONFIG_ENV) };
850    }
851
852    /// A pair whose files no longer exist is dead weight in a recents list - selecting it fails.
853    /// This also clears any `/tmp/git-blob-*` entries already on the list.
854    #[test]
855    fn recent_pairs_drops_entries_whose_files_are_gone() {
856        let alive = tempfile::NamedTempFile::new().expect("temp file");
857        let file = tempfile::NamedTempFile::new().expect("temp config");
858        save_to(
859            file.path().to_path_buf(),
860            ThemeConfig {
861                recent_pairs: vec![
862                    (alive.path().to_path_buf(), alive.path().to_path_buf()),
863                    (
864                        PathBuf::from("/tmp/git-blob-deleted/before.rs"),
865                        PathBuf::from("/tmp/git-blob-deleted/after.rs"),
866                    ),
867                ],
868                ..Default::default()
869            },
870        );
871
872        unsafe { std::env::set_var(CONFIG_ENV, file.path()) };
873        let pairs = load_recent_pairs();
874        unsafe { std::env::remove_var(CONFIG_ENV) };
875
876        assert_eq!(pairs.len(), 1);
877        assert_eq!(pairs[0].0, alive.path());
878    }
879
880    /// The fix, as opposed to the migration above: a VCS temp file is never recorded in the first
881    /// place. Filtering only on read would leave every `git difftool` run still writing one.
882    #[test]
883    fn a_throwaway_vcs_path_is_recognised() {
884        let temp = std::env::temp_dir().join("git-blob-AbC123").join("main.rs");
885        assert!(is_throwaway(&temp));
886        assert!(!is_throwaway(Path::new(
887            "/home/someone/src/project/main.rs"
888        )));
889    }
890
891    /// The walk-up is what makes a project config work under `git difftool` and from any
892    /// subdirectory. git runs a difftool with the working directory set to the repository root,
893    /// but codediff is just as often invoked from somewhere below it.
894    #[test]
895    fn a_project_config_is_found_from_a_subdirectory() {
896        let root = tempfile::tempdir().expect("temp dir");
897        let nested = root.path().join("src").join("tui");
898        std::fs::create_dir_all(&nested).expect("create dirs");
899        let config = root.path().join(PROJECT_CONFIG);
900        std::fs::write(&config, "node_highlight = true\n").expect("write config");
901
902        assert_eq!(nearest_project_config_from(&nested), Some(config.clone()));
903        assert_eq!(nearest_project_config_from(root.path()), Some(config));
904    }
905
906    /// The nearest one wins, so a project can override a config further up the tree.
907    #[test]
908    fn the_nearest_project_config_wins() {
909        let root = tempfile::tempdir().expect("temp dir");
910        let inner = root.path().join("inner");
911        std::fs::create_dir_all(&inner).expect("create dirs");
912        std::fs::write(root.path().join(PROJECT_CONFIG), "").expect("outer");
913        std::fs::write(inner.join(PROJECT_CONFIG), "").expect("inner");
914
915        assert_eq!(
916            nearest_project_config_from(&inner),
917            Some(inner.join(PROJECT_CONFIG))
918        );
919    }
920
921    /// A directory with no config above it must not invent one - that is what dropped a
922    /// `.codediff.toml` into every directory codediff was ever run in, including a checkout of the
923    /// VS Code extension, where codediff's own integration test littered the repository.
924    #[test]
925    fn no_project_config_means_none_is_created() {
926        let root = tempfile::tempdir().expect("temp dir");
927        let nested = root.path().join("a").join("b");
928        std::fs::create_dir_all(&nested).expect("create dirs");
929
930        // `/tmp` itself could in principle hold one, so only assert about the temp tree.
931        let found = nearest_project_config_from(&nested);
932        assert!(
933            found.is_none_or(|path| !path.starts_with(root.path())),
934            "nothing under the temp root should have been found or created"
935        );
936        assert!(!nested.join(PROJECT_CONFIG).exists());
937    }
938
939    /// The user-level config lives two directories deep in a path that will not exist on a fresh
940    /// machine (`~/.config/codediff/config.toml`). If saving did not create those directories,
941    /// every setting would silently fail to persist for anyone without a project config - which is
942    /// now the default case, since a project config is never created implicitly.
943    #[test]
944    fn saving_creates_the_directories_the_user_config_lives_in() {
945        let home = tempfile::tempdir().expect("temp dir");
946        let path = home
947            .path()
948            .join(".config")
949            .join("codediff")
950            .join("config.toml");
951        assert!(!path.exists());
952
953        save_to(
954            path.clone(),
955            ThemeConfig {
956                node_highlight: true,
957                ..Default::default()
958            },
959        );
960
961        assert!(
962            path.is_file(),
963            "config was not written to {}",
964            path.display()
965        );
966        assert!(load_from(path).node_highlight);
967    }
968
969    #[test]
970    fn the_user_config_path_follows_xdg_then_home() {
971        assert_eq!(
972            user_config_path_from(Some("/x/config".into()), Some("/home/me".into())),
973            PathBuf::from("/x/config/codediff/config.toml"),
974            "XDG_CONFIG_HOME wins when it is set"
975        );
976        assert_eq!(
977            user_config_path_from(None, Some("/home/me".into())),
978            PathBuf::from("/home/me/.config/codediff/config.toml")
979        );
980        // An empty variable is not a choice; treating it as one would produce a path rooted at the
981        // filesystem root.
982        assert_eq!(
983            user_config_path_from(Some(String::new()), Some("/home/me".into())),
984            PathBuf::from("/home/me/.config/codediff/config.toml")
985        );
986        // No HOME at all: keep the pre-2026-09-10 behaviour rather than writing to `/`.
987        assert_eq!(
988            user_config_path_from(None, None),
989            PathBuf::from(PROJECT_CONFIG)
990        );
991    }
992
993    #[test]
994    fn save_then_load_round_trips_the_chosen_theme() {
995        let file = tempfile::NamedTempFile::new().expect("temp file");
996        save_to(
997            file.path().to_path_buf(),
998            ThemeConfig {
999                theme: OverlayTheme::SolarizedLight,
1000                ..Default::default()
1001            },
1002        );
1003        assert_eq!(
1004            load_from(file.path().to_path_buf()).theme,
1005            OverlayTheme::SolarizedLight
1006        );
1007    }
1008
1009    /// The node highlight persists, and - the part that matters - a config file with no entry for
1010    /// the setting loads as **off**, not as "unset means always on". The exact hazard
1011    /// `RenderOptions::whole_pair_updates`'s own `#[serde(default)]` exists to prevent: a
1012    /// `[render_options]` table that carries the other keys but not this one. Without the
1013    /// attribute, `confy`'s deserialization of the whole file fails on the missing key, and
1014    /// `load_from`'s `.unwrap_or_default()` would silently reset *everything* - theme, syntax
1015    /// theme, node highlight, all of it - not just this one option.
1016    #[test]
1017    fn a_pre_existing_render_options_table_without_whole_pair_updates_still_loads() {
1018        let file = tempfile::NamedTempFile::new().expect("temp file");
1019        // What `save_to` would have written before `whole_pair_updates` existed - a real
1020        // `[render_options]` table missing only the new key, not a file missing the table
1021        // entirely (`render_options` itself is already `#[serde(default)]`, which is a different,
1022        // already-covered case).
1023        std::fs::write(
1024            file.path(),
1025            "theme = \"SolarizedLight\"\n\n[render_options]\nleading_whitespace = true\nstructural_punctuation = true\n",
1026        )
1027        .expect("write legacy config");
1028
1029        let loaded = load_from(file.path().to_path_buf());
1030
1031        assert_eq!(
1032            loaded.theme,
1033            OverlayTheme::SolarizedLight,
1034            "the rest of the config must survive, not silently reset"
1035        );
1036        assert!(loaded.render_options.leading_whitespace);
1037        assert!(loaded.render_options.structural_punctuation);
1038        assert!(
1039            !loaded.render_options.whole_pair_updates,
1040            "the missing key must default to false (narrow), not fail the whole file"
1041        );
1042        assert!(
1043            loaded.render_options.paint_reindent_only_moves,
1044            "the missing key must default to true (paint it) - every release before this field \
1045             existed always painted a reindented Move, same polarity reasoning as \
1046             whole_pair_updates's own default above"
1047        );
1048    }
1049
1050    #[test]
1051    fn node_highlight_round_trips_and_defaults_to_off_for_an_older_config() {
1052        let file = tempfile::NamedTempFile::new().expect("temp file");
1053        save_to(
1054            file.path().to_path_buf(),
1055            ThemeConfig {
1056                node_highlight: true,
1057                ..Default::default()
1058            },
1059        );
1060        assert!(load_from(file.path().to_path_buf()).node_highlight);
1061
1062        // Exactly what a config written by a build predating this setting looks like.
1063        std::fs::write(file.path(), "theme = \"Default\"\n").expect("write legacy config");
1064        assert!(
1065            !load_from(file.path().to_path_buf()).node_highlight,
1066            "a config with no node_highlight key must load as off"
1067        );
1068    }
1069
1070    #[test]
1071    fn load_from_a_missing_file_falls_back_to_default_without_erroring() {
1072        let dir = tempfile::tempdir().expect("temp dir");
1073        let path = dir.path().join("does-not-exist.toml");
1074        assert_eq!(load_from(path), ThemeConfig::default());
1075    }
1076
1077    #[test]
1078    fn blend_toward_base_interpolates_correctly() {
1079        let accent = (200, 100, 0);
1080        let base = (0, 100, 200);
1081
1082        assert_eq!(
1083            blend_toward_base(accent, base, 0.0),
1084            Color::Rgb(200, 100, 0)
1085        );
1086        assert_eq!(
1087            blend_toward_base(accent, base, 1.0),
1088            Color::Rgb(0, 100, 200)
1089        );
1090        assert_eq!(
1091            blend_toward_base(accent, base, 0.5),
1092            Color::Rgb(100, 100, 100)
1093        );
1094    }
1095
1096    /// Every theme picker option (including the five palettes derived via `blend_toward_base`)
1097    /// must actually resolve to a distinct set of colors from the built-in `Dark` theme -
1098    /// otherwise "more color schemes" would just be more names for the same look.
1099    #[test]
1100    fn every_added_theme_is_visually_distinct_from_dark() {
1101        let dark = OverlayTheme::Dark.palette();
1102        for theme in OverlayTheme::iter().filter(|&t| t != OverlayTheme::Dark) {
1103            let p = theme.palette();
1104            assert!(
1105                p.insert_bg != dark.insert_bg
1106                    || p.delete_bg != dark.delete_bg
1107                    || p.move_bg != dark.move_bg
1108                    || p.update_bg != dark.update_bg,
1109                "{theme:?} is identical to Dark"
1110            );
1111        }
1112    }
1113
1114    /// A move changes no code - it is the one band that reports relocation rather than an edit -
1115    /// so the shipped default paints it neutral instead of giving it a fourth loud hue competing
1116    /// with insert/delete/update for the eye. Asserted as "reads as grey" rather than as an exact
1117    /// triple, so the value can still be retuned against the background without the intent
1118    /// silently reverting to a color.
1119    #[test]
1120    fn every_themes_move_band_is_grey_rather_than_a_hue() {
1121        for theme in OverlayTheme::iter() {
1122            // `Custom` is whatever the user saved; it defaults to Dracula but they may have
1123            // deliberately painted moves any colour they like, and that is theirs to choose.
1124            if theme == OverlayTheme::Custom {
1125                continue;
1126            }
1127            let Color::Rgb(r, g, b) = theme.palette().move_bg else {
1128                panic!("{theme:?}: expected an rgb move band");
1129            };
1130            let spread = r.max(g).max(b) - r.min(g).min(b);
1131            assert!(
1132                spread <= 12,
1133                "{theme:?}: move_bg should read as grey, got rgb({r}, {g}, {b}) with channel \
1134                 spread {spread}"
1135            );
1136        }
1137    }
1138
1139    /// Every theme's bands must actually be distinct colors - otherwise the picker would offer
1140    /// a "choice" that doesn't change anything visible.
1141    #[test]
1142    fn every_theme_has_visually_distinct_bands() {
1143        for theme in OverlayTheme::iter() {
1144            let p = theme.palette();
1145            let bands = [p.insert_bg, p.delete_bg, p.move_bg, p.update_bg];
1146            for (i, a) in bands.iter().enumerate() {
1147                for b in &bands[i + 1..] {
1148                    assert_ne!(a, b, "{theme:?}: two bands share a color");
1149                }
1150            }
1151        }
1152    }
1153
1154    /// The search highlight exists precisely to be distinguishable from both the four diff bands
1155    /// and the cursor cross-highlight (see `OverlayPalette::search_bg`'s doc comment) - a theme
1156    /// where it collides with any of them has silently reintroduced the ambiguity it fixes.
1157    #[test]
1158    fn every_themes_search_color_is_distinct_from_bands_and_cursor_highlight() {
1159        for theme in OverlayTheme::iter() {
1160            let p = theme.palette();
1161            for (name, other) in [
1162                ("insert_bg", p.insert_bg),
1163                ("delete_bg", p.delete_bg),
1164                ("move_bg", p.move_bg),
1165                ("update_bg", p.update_bg),
1166                ("cross_highlight_bg", p.cross_highlight_bg),
1167            ] {
1168                assert_ne!(
1169                    p.search_bg, other,
1170                    "{theme:?}: search_bg collides with {name}"
1171                );
1172            }
1173        }
1174    }
1175
1176    #[test]
1177    fn panel_layout_cycle_visits_all_three_modes_and_returns() {
1178        assert_eq!(PanelLayout::Auto.next(), PanelLayout::Dual);
1179        assert_eq!(PanelLayout::Dual.next(), PanelLayout::Single);
1180        assert_eq!(PanelLayout::Single.next(), PanelLayout::Auto);
1181    }
1182
1183    /// `save_overlay_theme`/`save_panel_layout` are load-modify-save specifically so one setting's
1184    /// write can't clobber the other back to default - exercised here via the path-parameterized
1185    /// helpers they both delegate to.
1186    #[test]
1187    fn saving_one_setting_preserves_the_other() {
1188        let file = tempfile::NamedTempFile::new().expect("temp file");
1189        let path = file.path().to_path_buf();
1190        save_to(
1191            path.clone(),
1192            ThemeConfig {
1193                theme: OverlayTheme::Nord,
1194                layout: PanelLayout::Single,
1195                ..Default::default()
1196            },
1197        );
1198
1199        let mut config = load_from(path.clone());
1200        config.theme = OverlayTheme::Dracula;
1201        save_to(path.clone(), config);
1202
1203        let reloaded = load_from(path);
1204        assert_eq!(reloaded.theme, OverlayTheme::Dracula);
1205        assert_eq!(
1206            reloaded.layout,
1207            PanelLayout::Single,
1208            "changing the theme must not reset the layout"
1209        );
1210    }
1211}