diskwatch 0.5.6

Single-host, read-only disk diagnostics TUI. Eight tabs across devices, volumes, filesystems, IO, SMART, hot files, and insights. Sibling to netwatch and syswatch.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
//! Color themes for the diskwatch UI.
//!
//! Mirrors the theme conventions used by netwatch and syswatch: a struct of
//! named color slots, a small `by_name` lookup, and a global `RwLock<Theme>`
//! holding the active one. `palette::*` accessors delegate here, so setting a
//! theme recolors the whole UI on the next draw.
//!
//! Slot names are diskwatch's own — literal color names (`red`, `cyan`)
//! rather than the semantic names (`status_error`, `brand`) the other two
//! use — because the diskwatch UI was built against a fixed design handoff
//! that refers to them that way. Renaming them is a separate change.

use ratatui::style::Color;
use std::sync::RwLock;

#[allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct Theme {
    pub name: &'static str,

    // ── Surface / text ──────────────────────────────────
    pub bg: Color,
    pub fg: Color,
    pub dim: Color,
    pub faint: Color,

    // ── Base colors ─────────────────────────────────────
    pub red: Color,
    pub green: Color,
    pub yellow: Color,
    pub cyan: Color,
    pub magenta: Color,
    pub white: Color,

    // ── Bright variants ─────────────────────────────────
    pub br_green: Color,
    pub br_cyan: Color,
    pub br_white: Color,

    // ── Backgrounds ─────────────────────────────────────
    pub sel_bg: Color,
    pub warn_bg: Color,
    pub err_bg: Color,
    pub ok_bg: Color,
}

/// Cycle order. Matches syswatch so the two tools feel the same when a
/// user tabs through themes in both.
pub const THEME_NAMES: &[&str] = &[
    "dark",
    "light",
    "ocean",
    "solarized",
    "dracula",
    "nord",
    "terminal",
];

/// The original diskwatch palette. Hex values are byte-identical to the
/// design handoff (`source/tui/grid.jsx` constant `C`) so the JSX mockups
/// and the real terminal render the same — do not "tidy" these.
pub const fn dark() -> Theme {
    Theme {
        name: "dark",
        bg: Color::Rgb(0x0c, 0x14, 0x18),
        fg: Color::Rgb(0xc5, 0xd1, 0xd6),
        dim: Color::Rgb(0x6b, 0x80, 0x88),
        faint: Color::Rgb(0x44, 0x56, 0x60),
        red: Color::Rgb(0xff, 0x78, 0x78),
        green: Color::Rgb(0x5c, 0xd9, 0x89),
        yellow: Color::Rgb(0xf0, 0xc0, 0x60),
        cyan: Color::Rgb(0x5f, 0xdc, 0xff),
        magenta: Color::Rgb(0xd9, 0x7a, 0xff),
        white: Color::Rgb(0xe6, 0xf0, 0xf2),
        br_green: Color::Rgb(0x9a, 0xe6, 0xb4),
        br_cyan: Color::Rgb(0x86, 0xe6, 0xff),
        br_white: Color::Rgb(0xff, 0xff, 0xff),
        sel_bg: Color::Rgb(0x1a, 0x33, 0x40),
        warn_bg: Color::Rgb(0x3a, 0x2c, 0x14),
        err_bg: Color::Rgb(0x3a, 0x1c, 0x1c),
        ok_bg: Color::Rgb(0x16, 0x32, 0x1f),
    }
}

/// Light theme, for terminals with a pale background.
///
/// The mapping needs care in one place: `white` and `br_white` are the
/// *emphasis* slots — the strongest available text — so on a light
/// background they go to near-black rather than to white. Wiring them
/// literally would make selected rows and SMART headers invisible.
///
/// Contrast against the near-white bg (#f5f5f2):
///   fg    #1e1e1e ≈ 14.5:1 AAA · dim #5a5a5a ≈ 5.7:1 AA
///   faint #a0a0a0 ≈  2.5:1 — chrome only (borders), intentionally quiet
pub const fn light() -> Theme {
    Theme {
        name: "light",
        bg: Color::Rgb(0xf5, 0xf5, 0xf2),
        fg: Color::Rgb(30, 30, 30),
        dim: Color::Rgb(90, 90, 90),
        faint: Color::Rgb(160, 160, 160),
        red: Color::Rgb(180, 30, 30),
        green: Color::Rgb(0, 120, 50),
        yellow: Color::Rgb(170, 110, 0),
        cyan: Color::Rgb(0, 100, 160),
        magenta: Color::Rgb(120, 60, 160),
        // Emphasis on a light bg is darker, not lighter.
        white: Color::Rgb(0, 0, 0),
        br_green: Color::Rgb(0, 150, 65),
        br_cyan: Color::Rgb(0, 130, 200),
        br_white: Color::Rgb(0, 0, 0),
        sel_bg: Color::Rgb(220, 230, 240),
        warn_bg: Color::Rgb(0xfa, 0xf0, 0xd6),
        err_bg: Color::Rgb(0xfa, 0xdc, 0xdc),
        ok_bg: Color::Rgb(0xdc, 0xea, 0xf7),
    }
}

/// Apple Terminal.app "Ocean" profile — deep blue background, with the
/// bright ANSI variants preferred for legibility against it.
pub const fn ocean() -> Theme {
    Theme {
        name: "ocean",
        bg: Color::Rgb(0x22, 0x4F, 0xBC),
        fg: Color::Rgb(0xFF, 0xFF, 0xFF),
        // Ocean's bright-black (#818383) fails AA on this bg, so chrome
        // uses a lighter neutral. dim and faint land on the same value:
        // anything fainter is unreadable over the blue.
        dim: Color::Rgb(0xB5, 0xB6, 0xB7),
        faint: Color::Rgb(0xB5, 0xB6, 0xB7),
        red: Color::Rgb(0xFC, 0x39, 0x1F),
        green: Color::Rgb(0x31, 0xE7, 0x22),
        yellow: Color::Rgb(0xEA, 0xEC, 0x23),
        cyan: Color::Rgb(0x14, 0xF0, 0xF0),
        magenta: Color::Rgb(0xFF, 0x40, 0xFF),
        white: Color::Rgb(0xCB, 0xCC, 0xCD),
        br_green: Color::Rgb(0x31, 0xE7, 0x22),
        br_cyan: Color::Rgb(0x14, 0xF0, 0xF0),
        br_white: Color::Rgb(0xFF, 0xFF, 0xFF),
        sel_bg: Color::Rgb(0x21, 0x6D, 0xFF),
        warn_bg: Color::Rgb(0x3A, 0x4A, 0x12),
        err_bg: Color::Rgb(0x4A, 0x1F, 0x1F),
        ok_bg: Color::Rgb(0x1A, 0x3A, 0x6E),
    }
}

/// Ethan Schoonover's Solarized Dark, using the canonical base/accent hexes.
pub const fn solarized() -> Theme {
    Theme {
        name: "solarized",
        bg: Color::Rgb(0, 43, 54),     // base03
        fg: Color::Rgb(131, 148, 150), // base0
        // base00, not base01. Solarized reserves base01 for de-emphasized
        // comments and it lands at 2.8:1 on base03 — under the 3:1 floor.
        // diskwatch leans on `dim` for real content (units, secondary
        // values), so it takes the next step up at 3.4:1.
        dim: Color::Rgb(101, 123, 131), // base00
        faint: Color::Rgb(62, 84, 92),  // between base02 and base01
        red: Color::Rgb(220, 50, 47),
        green: Color::Rgb(133, 153, 0),
        yellow: Color::Rgb(181, 137, 0),
        cyan: Color::Rgb(42, 161, 152),
        magenta: Color::Rgb(211, 54, 130),
        white: Color::Rgb(147, 161, 161), // base1
        br_green: Color::Rgb(152, 175, 0),
        br_cyan: Color::Rgb(58, 180, 170),
        br_white: Color::Rgb(238, 232, 213), // base2
        sel_bg: Color::Rgb(7, 54, 66),       // base02
        warn_bg: Color::Rgb(40, 36, 14),
        err_bg: Color::Rgb(50, 18, 18),
        ok_bg: Color::Rgb(10, 50, 60),
    }
}

/// Dracula, using the canonical palette.
pub const fn dracula() -> Theme {
    Theme {
        name: "dracula",
        bg: Color::Rgb(40, 42, 54),
        fg: Color::Rgb(248, 248, 242),
        dim: Color::Rgb(98, 114, 164), // comment
        faint: Color::Rgb(68, 71, 90),
        red: Color::Rgb(255, 85, 85),
        green: Color::Rgb(80, 250, 123),
        yellow: Color::Rgb(241, 250, 140),
        cyan: Color::Rgb(139, 233, 253),
        magenta: Color::Rgb(189, 147, 249), // purple
        white: Color::Rgb(248, 248, 242),
        br_green: Color::Rgb(120, 255, 150),
        br_cyan: Color::Rgb(170, 240, 255),
        br_white: Color::Rgb(255, 255, 255),
        sel_bg: Color::Rgb(68, 71, 90),
        warn_bg: Color::Rgb(60, 50, 30),
        err_bg: Color::Rgb(70, 30, 30),
        ok_bg: Color::Rgb(35, 55, 70),
    }
}

/// Nord, using the canonical Polar Night / Snow Storm / Frost / Aurora sets.
pub const fn nord() -> Theme {
    Theme {
        name: "nord",
        bg: Color::Rgb(46, 52, 64),    // polar night 0
        fg: Color::Rgb(216, 222, 233), // snow storm 0
        // Polar Night 3 (#4C566A) is what Nord editor themes use for
        // comments, but it's a background shade — 1.7:1 on Polar Night 0,
        // effectively invisible for the secondary values diskwatch puts in
        // `dim`. Frost 2 is the nearest canonical color that carries text.
        dim: Color::Rgb(129, 161, 193), // frost 2
        faint: Color::Rgb(67, 76, 94),  // polar night 2
        red: Color::Rgb(191, 97, 106),  // aurora red
        green: Color::Rgb(163, 190, 140),
        yellow: Color::Rgb(235, 203, 139),
        cyan: Color::Rgb(136, 192, 208), // frost 1
        magenta: Color::Rgb(180, 142, 173),
        white: Color::Rgb(229, 233, 240),
        br_green: Color::Rgb(180, 205, 160),
        br_cyan: Color::Rgb(150, 205, 220),
        br_white: Color::Rgb(236, 239, 244),
        sel_bg: Color::Rgb(59, 66, 82),
        warn_bg: Color::Rgb(60, 56, 40),
        err_bg: Color::Rgb(60, 36, 38),
        ok_bg: Color::Rgb(48, 62, 78),
    }
}

/// Defers entirely to the terminal's own palette: ANSI slots 0–15 for color
/// and `Color::Reset` for foreground and background, so whatever the user's
/// terminal theme defines is what diskwatch renders. Nothing here is a fixed
/// RGB value — that is the whole point. Users running a system-wide theming
/// setup (pywal, matugen, a terminal profile) get a diskwatch that matches
/// the rest of their desktop without maintaining a separate palette.
///
/// Two deliberate compromises, both forced by the 16-color palette:
///
/// - `warn_bg` / `err_bg` / `ok_bg` are `Reset` rather than a tint. ANSI has
///   no "slightly red background" — the nearest option is a full-intensity
///   fill, far louder than the subtle row tint these slots exist for.
///   Severity still reads from the foreground color, which is how
///   terminal-native tools convey it anyway.
/// - `sel_bg` uses `Indexed(8)` (bright black), the only slot conventionally
///   rendered as a neutral mid-grey in both light and dark themes. A terminal
///   theme that maps slot 8 very close to its background will show a faint
///   selection bar; that is a property of the user's theme, and the
///   alternative — a saturated color slot — is worse everywhere else.
pub const fn terminal() -> Theme {
    Theme {
        name: "terminal",
        // Reset = the terminal's own background and configured foreground.
        bg: Color::Reset,
        fg: Color::Reset,
        dim: Color::Gray,
        faint: Color::DarkGray,
        red: Color::Red,
        green: Color::Green,
        yellow: Color::Yellow,
        cyan: Color::Cyan,
        magenta: Color::Magenta,
        white: Color::White,
        br_green: Color::LightGreen,
        br_cyan: Color::LightCyan,
        br_white: Color::White,
        sel_bg: Color::Indexed(8),
        warn_bg: Color::Reset,
        err_bg: Color::Reset,
        ok_bg: Color::Reset,
    }
}

pub fn by_name(name: &str) -> Theme {
    match name.to_lowercase().as_str() {
        // "system" and "ansi" are what users coming from other TUIs tend to
        // reach for; accept both rather than silently falling back to dark
        // and looking like the feature is missing.
        "terminal" | "system" | "ansi" => terminal(),
        "light" => light(),
        "ocean" => ocean(),
        "solarized" => solarized(),
        "dracula" => dracula(),
        "nord" => nord(),
        _ => dark(),
    }
}

/// The theme diskwatch uses when the user has not asked for another one.
/// `main.rs` wires this into the `--theme` flag's default, and `ACTIVE`
/// below is initialised to the same palette, so the two cannot drift.
pub const DEFAULT_THEME: &str = "terminal";

/// Initialised to [`DEFAULT_THEME`]'s palette so anything reading the theme
/// before the CLI sets it agrees with what the flag advertises. This has to
/// name the constructor directly — `by_name` is not a `const fn` — which is
/// why `default_matches_the_static_initialiser` guards the pair.
static ACTIVE: RwLock<Theme> = RwLock::new(terminal());

pub fn active() -> Theme {
    *ACTIVE.read().expect("theme lock poisoned")
}

pub fn set_by_name(name: &str) {
    *ACTIVE.write().expect("theme lock poisoned") = by_name(name);
}

pub fn name() -> &'static str {
    active().name
}

/// Advance to the next built-in theme, wrapping. Returns the new name.
pub fn cycle() -> &'static str {
    let current = name();
    let i = THEME_NAMES.iter().position(|n| *n == current).unwrap_or(0);
    let next = THEME_NAMES[(i + 1) % THEME_NAMES.len()];
    set_by_name(next);
    next
}

/// Serialises tests that mutate the process-wide theme. Without it,
/// `cargo test`'s thread pool lets one test's `set_by_name` leak into
/// another's assertions — and since the default is now `terminal`, a test
/// that needs RGB to measure has to pin `dark` explicitly rather than
/// inherit it.
///
/// Crate-visible because the theme is crate-wide: `ui::braille`'s ramp tests
/// set it too, and a second module racing this one would reintroduce exactly
/// the flake this exists to prevent.
///
/// Tests that only read (`by_name`, `dark()`, `terminal()`) touch no global
/// state and need no lock.
#[cfg(test)]
static ACTIVE_THEME_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// Serialize a theme-mutating test and hand back a known starting state.
/// The guard holds the lock until the test ends.
#[cfg(test)]
pub(crate) fn exclusive_theme(name: &str) -> std::sync::MutexGuard<'static, ()> {
    // A panic in one such test poisons the mutex; that shouldn't cascade
    // into failures in the others, so recover the guard either way.
    let guard = ACTIVE_THEME_TESTS
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    set_by_name(name);
    guard
}

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

    #[test]
    fn all_builtin_themes_load() {
        for n in THEME_NAMES {
            assert_eq!(by_name(n).name, *n);
        }
    }

    #[test]
    fn unknown_falls_back_to_dark() {
        assert_eq!(by_name("nonsense").name, "dark");
        assert_eq!(by_name("").name, "dark");
    }

    #[test]
    fn dark_preserves_the_design_handoff() {
        // Guards the byte-identical contract with the JSX mockups.
        let t = dark();
        assert_eq!(t.bg, Color::Rgb(0x0c, 0x14, 0x18));
        assert_eq!(t.fg, Color::Rgb(0xc5, 0xd1, 0xd6));
        assert_eq!(t.cyan, Color::Rgb(0x5f, 0xdc, 0xff));
        assert_eq!(t.red, Color::Rgb(0xff, 0x78, 0x78));
        assert_eq!(t.sel_bg, Color::Rgb(0x1a, 0x33, 0x40));
    }

    #[test]
    fn terminal_theme_pins_no_rgb() {
        // The entire contract of this theme is that every slot resolves
        // through the terminal's own palette. Debug-formatting the whole
        // struct checks every field at once, so a slot added later can't
        // quietly acquire a fixed color without failing here.
        let rendered = format!("{:?}", terminal());
        assert!(
            !rendered.contains("Rgb"),
            "terminal theme must not pin RGB values: {rendered}"
        );
    }

    #[test]
    fn terminal_theme_never_paints_backgrounds() {
        let t = terminal();
        assert_eq!(t.bg, Color::Reset);
        assert_eq!(t.fg, Color::Reset);
        assert_eq!(t.warn_bg, Color::Reset);
        assert_eq!(t.err_bg, Color::Reset);
        assert_eq!(t.ok_bg, Color::Reset);
    }

    /// WCAG relative luminance. Only defined for concrete RGB.
    fn luminance(c: Color) -> Option<f64> {
        let (r, g, b) = match c {
            Color::Rgb(r, g, b) => (r, g, b),
            _ => return None,
        };
        let ch = |v: u8| {
            let s = v as f64 / 255.0;
            if s <= 0.03928 {
                s / 12.92
            } else {
                ((s + 0.055) / 1.055).powf(2.4)
            }
        };
        Some(0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b))
    }

    fn contrast(a: Color, b: Color) -> Option<f64> {
        let (la, lb) = (luminance(a)?, luminance(b)?);
        let (hi, lo) = if la > lb { (la, lb) } else { (lb, la) };
        Some((hi + 0.05) / (lo + 0.05))
    }

    #[test]
    fn every_theme_has_readable_body_text() {
        // The failure mode when adding a theme is text that disappears
        // into its own background — a light palette wired up with light
        // text, say. `terminal` is skipped: both slots are Reset, so the
        // contrast is whatever the user's terminal defines.
        for n in THEME_NAMES {
            let t = by_name(n);
            let Some(ratio) = contrast(t.fg, t.bg) else {
                continue;
            };
            assert!(
                ratio >= 4.5,
                "{n}: body text contrast {ratio:.1}:1 is below WCAG AA (4.5:1)"
            );
        }
    }

    #[test]
    fn every_theme_keeps_muted_text_legible() {
        // `dim` carries real content (units, secondary values), not just
        // chrome, so it gets the 3:1 large-text floor rather than being
        // allowed to fade out entirely. `faint` is borders only and is
        // deliberately exempt.
        for n in THEME_NAMES {
            let t = by_name(n);
            let Some(ratio) = contrast(t.dim, t.bg) else {
                continue;
            };
            assert!(
                ratio >= 3.0,
                "{n}: dim text contrast {ratio:.1}:1 below 3:1"
            );
        }
    }

    #[test]
    fn light_theme_emphasis_goes_darker_not_brighter() {
        // `white` / `br_white` are the "strongest text" slots. Mapping them
        // literally to white on a pale background hides selected rows and
        // SMART headers completely.
        let t = light();
        let bg = luminance(t.bg).unwrap();
        assert!(luminance(t.br_white).unwrap() < bg);
        assert!(luminance(t.white).unwrap() < bg);
    }

    #[test]
    fn cycle_visits_every_theme_and_returns_home() {
        let start = name();
        let mut seen = Vec::new();
        for _ in 0..THEME_NAMES.len() {
            seen.push(cycle());
        }
        seen.sort_unstable();
        let mut expected = THEME_NAMES.to_vec();
        expected.sort_unstable();
        assert_eq!(seen, expected);
        assert_eq!(name(), start, "a full cycle must land back where it began");
    }

    #[test]
    fn default_matches_the_static_initialiser() {
        // `ACTIVE` cannot call `by_name`, so the constructor it names and
        // DEFAULT_THEME are linked by hand. Reading the live global here
        // would race every test that sets a theme, so compare the palettes
        // themselves — pure values, no global state.
        assert_eq!(by_name(DEFAULT_THEME).name, terminal().name);
    }

    #[test]
    fn terminal_theme_accepts_common_aliases() {
        for alias in ["terminal", "system", "ansi", "TERMINAL", "System"] {
            assert_eq!(by_name(alias).name, "terminal", "alias {alias} failed");
        }
    }
}