catcher 0.13.1

A minimal, local-first markdown notes TUI over plain files
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
//! The one place colours live, so preview and live preview agree.
//!
//! A neutral grey chassis with a single accent. Hue is never decoration: it
//! appears in exactly three places — the top-level heading, a checked task,
//! and the status bar when catcher is talking about itself. Everything else
//! is a step on the grey ramp, which is why the ramp never reaches pure black
//! or pure white at either end: text that hits #ffffff on someone's custom
//! background looks like a bug, not emphasis.
//!
//! The two palettes are the same structure at both polarities, not an
//! inversion — the code background goes *darker* than the page in light mode,
//! because "raised" means more contrast with the ground, not lighter.

use ratatui::style::{Color, Modifier, Style};
use std::sync::RwLock;

/// Which polarity the terminal is showing.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Mode {
    #[default]
    Dark,
    Light,
}

/// Every colour catcher can draw with, at one polarity. Each field is
/// settable by name from the settings file, so a user who wants their own
/// hue for links or headings sets that one field and inherits the rest.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Palette {
    /// The one hue: h1, a checked mark, status-bar state.
    pub accent: Color,
    /// The brightest step — keys, group headings, anything that must lead.
    pub bright: Color,
    /// Structure that should recede: struck tasks, the status-bar path.
    pub grey: Color,
    /// `##` headings. The complement of the accent, so the two top
    /// levels can never be mistaken for one another.
    pub heading: Color,
    /// Markers, rules, quotes: present but never read first.
    pub dim: Color,
    /// Links, which lean on the underline rather than the colour.
    pub link: Color,
    /// Inline code: a tint of the accent and no box, so a file name in a
    /// sentence reads as a name rather than a patch on the page.
    pub code: Color,
    /// Behind a fenced code block.
    pub code_bg: Color,
    /// The text on that background. Code is the one construct that paints
    /// its own ground, so it is also the one that cannot leave its
    /// foreground to the terminal: prose inherits whatever colour the
    /// terminal is already using and looks right either way, but a dark
    /// chip under a light terminal's ink is black on black. Both halves
    /// are set together or neither is legible.
    pub code_fg: Color,
    /// Panel borders at rest.
    pub border: Color,
    /// Destructive confirmation, and a `[[link]]` that names no note —
    /// the two things the eye must not slide past. Recolour it and both
    /// move together.
    pub danger: Color,
    /// The ground a highlight or an inverted heading sits its text on.
    pub ground: Color,
}

/// The colour field names the settings file accepts, in the order the
/// settings document lists them. The single source of truth: a name that
/// isn't here can't be set and isn't documented.
pub const COLOR_KEYS: [&str; 12] = [
    "accent", "bright", "grey", "heading", "dim", "link", "code", "code_bg", "code_fg", "border",
    "danger", "ground",
];

impl Palette {
    /// Set one field by its settings-file name. Returns false for a name
    /// that isn't a colour, so the caller can report the typo.
    pub fn set(&mut self, key: &str, color: Color) -> bool {
        match key {
            "accent" => self.accent = color,
            "bright" => self.bright = color,
            "grey" => self.grey = color,
            "heading" => self.heading = color,
            "dim" => self.dim = color,
            "link" => self.link = color,
            "code" => self.code = color,
            "code_bg" => self.code_bg = color,
            "code_fg" => self.code_fg = color,
            "border" => self.border = color,
            "danger" => self.danger = color,
            "ground" => self.ground = color,
            _ => return false,
        }
        true
    }

    pub fn get(&self, key: &str) -> Option<Color> {
        Some(match key {
            "accent" => self.accent,
            "bright" => self.bright,
            "grey" => self.grey,
            "heading" => self.heading,
            "dim" => self.dim,
            "link" => self.link,
            "code" => self.code,
            "code_bg" => self.code_bg,
            "code_fg" => self.code_fg,
            "border" => self.border,
            "danger" => self.danger,
            "ground" => self.ground,
            _ => return None,
        })
    }
}

pub const DARK: Palette = Palette {
    accent: Color::Rgb(0xff, 0x9e, 0x64),
    bright: Color::Rgb(0xe1, 0xe1, 0xe1),
    grey: Color::Rgb(0x78, 0x78, 0x78),
    heading: Color::Rgb(0x8f, 0xb4, 0xd9),
    dim: Color::Rgb(0x82, 0x82, 0x82),
    link: Color::Rgb(0xb4, 0xb4, 0xb4),
    code: Color::Rgb(0xd9, 0xa2, 0x7a),
    code_bg: Color::Rgb(0x1c, 0x1c, 0x1c),
    code_fg: Color::Rgb(0xe1, 0xe1, 0xe1),
    border: Color::Rgb(0x32, 0x32, 0x37),
    danger: Color::Rgb(0xf7, 0x76, 0x8e),
    ground: Color::Rgb(0x14, 0x14, 0x14),
};

pub const LIGHT: Palette = Palette {
    accent: Color::Rgb(0xb8, 0x5c, 0x18),
    bright: Color::Rgb(0x26, 0x26, 0x26),
    grey: Color::Rgb(0x55, 0x55, 0x55),
    heading: Color::Rgb(0x3d, 0x6a, 0x99),
    dim: Color::Rgb(0x8d, 0x8d, 0x8d),
    link: Color::Rgb(0x5a, 0x58, 0x52),
    code: Color::Rgb(0x8a, 0x4a, 0x14),
    code_bg: Color::Rgb(0xe2, 0xe2, 0xe2),
    code_fg: Color::Rgb(0x26, 0x26, 0x26),
    border: Color::Rgb(0xc8, 0xc8, 0xcd),
    danger: Color::Rgb(0xcd, 0x30, 0x48),
    ground: Color::Rgb(0xee, 0xee, 0xee),
};

/// The palette in force. A lock rather than a `OnceLock`: settings are
/// edited inside the app now, and a saved change has to be visible on the
/// very next frame without a restart.
static PALETTE: RwLock<Palette> = RwLock::new(DARK);
static BOLD_HEADINGS: RwLock<bool> = RwLock::new(true);
/// The polarity the terminal was found to be showing at startup, for a
/// `theme: auto` setting. Dark until a probe says otherwise: the far more
/// common terminal, and the palette the app always used to assume.
static DETECTED: RwLock<Mode> = RwLock::new(Mode::Dark);

/// Record what the terminal reported about its own background.
pub fn set_detected(mode: Mode) {
    if let Ok(mut w) = DETECTED.write() {
        *w = mode;
    }
}

/// Whether the terminal's background agreed with the system appearance at
/// startup — the sign that it follows the system, and will flip with it.
static FOLLOWS_SYSTEM: RwLock<bool> = RwLock::new(false);

pub fn set_follows_system(on: bool) {
    if let Ok(mut w) = FOLLOWS_SYSTEM.write() {
        *w = on;
    }
}

pub fn follows_system() -> bool {
    FOLLOWS_SYSTEM.read().map(|b| *b).unwrap_or(false)
}

/// The operating system's own appearance, where it has one to ask about.
/// macOS only for now: `AppleInterfaceStyle` is `Dark` or unset.
pub fn system_mode() -> Option<Mode> {
    if !cfg!(target_os = "macos") {
        return None;
    }
    let out = std::process::Command::new("defaults")
        .args(["read", "-g", "AppleInterfaceStyle"])
        .output()
        .ok()?;
    Some(if String::from_utf8_lossy(&out.stdout).trim() == "Dark" {
        Mode::Dark
    } else {
        Mode::Light
    })
}

/// The polarity a `theme: auto` setting resolves to.
pub fn detected() -> Mode {
    DETECTED.read().map(|m| *m).unwrap_or(Mode::Dark)
}

/// Which way a background colour runs, by relative luminance. The
/// components are 8-bit; anything brighter than mid-grey is light.
pub fn mode_of_background(r: u8, g: u8, b: u8) -> Mode {
    let lum = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
    if lum > 127.5 {
        Mode::Light
    } else {
        Mode::Dark
    }
}

/// The built-in palette for a polarity, before any user overrides.
pub fn base(mode: Mode) -> Palette {
    match mode {
        Mode::Dark => DARK,
        Mode::Light => LIGHT,
    }
}

/// Install the palette for the run. Called at startup and again every time
/// the settings are saved.
pub fn set_palette(p: Palette) {
    if let Ok(mut w) = PALETTE.write() {
        *w = p;
    }
}

pub fn set_bold_headings(on: bool) {
    if let Ok(mut w) = BOLD_HEADINGS.write() {
        *w = on;
    }
}

pub fn palette() -> Palette {
    PALETTE.read().map(|p| *p).unwrap_or(DARK)
}

fn bold() -> Modifier {
    let on = BOLD_HEADINGS.read().map(|b| *b).unwrap_or(true);
    if on {
        Modifier::BOLD
    } else {
        Modifier::empty()
    }
}

/// Body text is never coloured: it inherits whatever foreground the
/// terminal is already using, so a custom Ghostty theme keeps its own
/// idea of what plain prose looks like.
pub const PLAIN: Style = Style::new();

/// Every heading level a terminal can tell apart without a change of
/// size: the accent leads, `##` takes its complement, `###` the bright
/// step, and anything deeper is weight alone.
pub fn heading(level: usize) -> Style {
    match level {
        1 => Style::new().fg(palette().accent).add_modifier(bold()),
        2 => Style::new().fg(palette().heading).add_modifier(bold()),
        3 => Style::new().fg(palette().bright).add_modifier(bold()),
        _ => Style::new().add_modifier(bold()),
    }
}

/// Quoted text reads in the normal foreground: the rail is the signal,
/// and bold or code inside the quote should look like bold or code.
pub fn quote() -> Style {
    Style::new()
}
pub fn marker() -> Style {
    Style::new().fg(palette().dim)
}
/// Secondary text that still has to be read: the status-bar path, struck
/// tasks. One step darker than `marker`, which is for chrome.
pub fn grey() -> Style {
    Style::new().fg(palette().grey)
}
/// Inline code: tinted ink, no background. The box that a fence gets
/// nearly vanished at terminal contrast inside a sentence; a hue does not.
pub fn inline_code() -> Style {
    Style::new().fg(palette().code)
}
/// A fenced block carries no hue of its own — the raised background is
/// the signal. It states its foreground anyway: see `code_fg`.
pub fn code() -> Style {
    Style::new().fg(palette().code_fg).bg(palette().code_bg)
}
pub fn link() -> Style {
    Style::new()
        .fg(palette().link)
        .add_modifier(Modifier::UNDERLINED)
}
pub fn highlight() -> Style {
    Style::new().fg(palette().ground).bg(palette().accent)
}
pub fn done() -> Style {
    Style::new().fg(palette().accent)
}
/// The text of a finished task: struck through, in `grey` rather than
/// `dim`. It is still content you sometimes need to read, so it sits one
/// step above hints and markers.
pub fn done_text() -> Style {
    Style::new()
        .fg(palette().grey)
        .add_modifier(Modifier::CROSSED_OUT)
}
/// An inline `#tag`: the accent, like a heading, because a tag is a
/// heading of sorts — it names what the note is about.
pub fn tag() -> Style {
    Style::new().fg(palette().accent)
}
/// Status-bar state, panel titles: catcher talking about itself.
pub fn state() -> Style {
    Style::new().fg(palette().accent)
}
pub fn border() -> Style {
    Style::new().fg(palette().border)
}
pub fn danger() -> Style {
    Style::new().fg(palette().danger)
}
pub fn bright() -> Style {
    Style::new().fg(palette().bright)
}
/// The marker on a folded heading: the accent, so a closed section reads
/// as the one thing on the page that is asking to be opened.
pub fn fold() -> Style {
    Style::new().fg(palette().accent)
}

/// The ground a selected palette row sits on. Monochrome on purpose: the
/// palette is chrome over the note, and a hue here would compete with the
/// one the note itself spends on headings. `border` is the step that is
/// visible against the page at both polarities without shouting.
pub fn row() -> Style {
    Style::new().bg(palette().border)
}

/// `#rrggbb`, `#rgb`, or one of the sixteen ANSI names, as written in the
/// settings file. `None` for anything else, which the settings reader
/// reports rather than silently ignoring.
pub fn parse_color(text: &str) -> Option<Color> {
    let t = text.trim();
    if let Some(hex) = t.strip_prefix('#') {
        let digits: Vec<u32> = hex
            .chars()
            .map(|c| c.to_digit(16))
            .collect::<Option<Vec<u32>>>()?;
        return match digits.len() {
            // #rgb is the shorthand every CSS-trained hand tries first
            3 => Some(Color::Rgb(
                (digits[0] * 17) as u8,
                (digits[1] * 17) as u8,
                (digits[2] * 17) as u8,
            )),
            6 => Some(Color::Rgb(
                (digits[0] * 16 + digits[1]) as u8,
                (digits[2] * 16 + digits[3]) as u8,
                (digits[4] * 16 + digits[5]) as u8,
            )),
            _ => None,
        };
    }
    Some(match t.to_ascii_lowercase().as_str() {
        "black" => Color::Black,
        "red" => Color::Red,
        "green" => Color::Green,
        "yellow" => Color::Yellow,
        "blue" => Color::Blue,
        "magenta" => Color::Magenta,
        "cyan" => Color::Cyan,
        "white" => Color::White,
        "gray" | "grey" | "darkgray" | "darkgrey" => Color::DarkGray,
        "brightred" => Color::LightRed,
        "brightgreen" => Color::LightGreen,
        "brightyellow" => Color::LightYellow,
        "brightblue" => Color::LightBlue,
        "brightmagenta" => Color::LightMagenta,
        "brightcyan" => Color::LightCyan,
        "brightwhite" => Color::Gray,
        "default" | "terminal" => Color::Reset,
        _ => return None,
    })
}

/// A colour written back the way the settings file spells it.
pub fn color_to_string(c: Color) -> String {
    match c {
        Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
        Color::Reset => "default".to_string(),
        other => format!("{other:?}").to_lowercase(),
    }
}

pub const CHECKED: &str = "\u{2713}";
pub const UNCHECKED: &str = "\u{2610}";
pub const BULLET: &str = "\u{2022}";
/// In front of a folded heading.
pub const FOLDED: &str = "\u{25b8} ";
pub const QUOTE_BAR: &str = "\u{258c}";

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

    #[test]
    fn each_of_the_first_three_heading_levels_takes_its_own_colour() {
        for p in [DARK, LIGHT] {
            set_palette(p);
            let fg: Vec<_> = (1..=4).map(|l| heading(l).fg).collect();
            assert_eq!(fg[..3], [Some(p.accent), Some(p.heading), Some(p.bright)]);
            assert_eq!(fg[3], None);
            assert_ne!(p.accent, p.heading);
            assert_ne!(p.heading, p.bright);
        }
        set_palette(DARK);
    }

    #[test]
    fn code_states_both_halves_so_a_light_terminal_is_not_black_on_black() {
        // prose leaves its foreground to the terminal on purpose; code paints
        // its own ground and so cannot
        let c = code();
        assert!(c.bg.is_some());
        assert!(
            c.fg.is_some(),
            "code without a foreground is unreadable on a terminal whose ink matches code_bg"
        );
    }
}