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}