Skip to main content

felis_render_core/
palette.rs

1//! SGR color to linear-RGBA conversion. The renderer wants linear sRGB
2//! so the surface format (`Bgra8UnormSrgb`) does the gamma encoding.
3
4use std::collections::BTreeMap;
5
6use felis_grid::Color;
7use felis_protocol::messages::ThemeChannel;
8use tracing::warn;
9
10#[derive(Debug, Clone, Copy)]
11pub struct Theme {
12    /// Default foreground (linear RGBA).
13    pub fg: [f32; 4],
14    /// Default background (linear RGBA).
15    pub bg: [f32; 4],
16    /// Sparse override of the xterm 256-color palette; `None` falls
17    /// through to [`xterm_256`]. All 256 indices are overridable, not
18    /// just the base 16: some color schemes address the low cube slots
19    /// (`16..=21`) for accent colors that programs then color output
20    /// with.
21    pub palette: [Option<[u8; 3]>; 256],
22    /// `None` means reverse video (the cursor cell's bg painted with
23    /// its original fg).
24    pub cursor: Option<[f32; 4]>,
25    /// WCAG 2 ratio every cell's fg is raised to against its bg; `1.0`
26    /// leaves colors as resolved.
27    pub minimum_contrast: f32,
28}
29
30/// Single source for [`Theme::default`] and [`configured_theme_report`],
31/// so an `OSC 10 ; ?` reply and the painted surface cannot drift.
32pub const DEFAULT_FG_SRGB: [u8; 3] = [0xE5, 0xE5, 0xE5];
33
34/// Same as `Renderer::clear_color`; what `OSC 11 ; ?` reports, so a
35/// background detector reads felis as dark.
36pub const DEFAULT_BG_SRGB: [u8; 3] = [0x0D, 0x0D, 0x12];
37
38impl Default for Theme {
39    fn default() -> Self {
40        Self {
41            fg: srgb_to_linear_rgba(DEFAULT_FG_SRGB, 1.0),
42            bg: srgb_to_linear_rgba(DEFAULT_BG_SRGB, 1.0),
43            palette: [None; 256],
44            cursor: None,
45            minimum_contrast: 1.0,
46        }
47    }
48}
49
50/// `(fg, bg, cursor)` as `SessionToDaemonMsg::ConfigureTheme` takes them.
51pub type ConfiguredThemeReport = (
52    Option<(u8, u8, u8)>,
53    Option<(u8, u8, u8)>,
54    Option<(u8, u8, u8)>,
55);
56
57/// The sRGB triples the client reports so an `OSC 10/11/12 ; ?` query
58/// answers with felis's real surface color. fg and bg always resolve,
59/// mirroring [`Theme::with_overrides`]'s fallback so the reported
60/// color is what gets painted; an unset or malformed cursor reports
61/// `None` because reverse video has no fixed color.
62#[must_use]
63#[expect(
64    clippy::tuple_array_conversions,
65    reason = "parse_hex_color yields [u8; 3]; the wire message and grid both speak (u8, u8, u8)"
66)]
67pub fn configured_theme_report(
68    fg: Option<&str>,
69    bg: Option<&str>,
70    cursor: Option<&str>,
71) -> ConfiguredThemeReport {
72    let resolve_or = |s: Option<&str>, default: [u8; 3]| -> (u8, u8, u8) {
73        let [r, g, b] = s.and_then(parse_hex_color).unwrap_or(default);
74        (r, g, b)
75    };
76    let cursor = cursor.and_then(parse_hex_color).map(|[r, g, b]| (r, g, b));
77    (
78        Some(resolve_or(fg, DEFAULT_FG_SRGB)),
79        Some(resolve_or(bg, DEFAULT_BG_SRGB)),
80        cursor,
81    )
82}
83
84/// The palette slots the client reports so an `OSC 4 ; n ; ?` query
85/// answers with the painted color: exactly the entries
86/// [`Theme::with_palette`] applies, a malformed one left out as it is
87/// left at the xterm default there.
88#[must_use]
89pub fn configured_palette_report(palette: &BTreeMap<u8, String>) -> BTreeMap<u8, (u8, u8, u8)> {
90    palette
91        .iter()
92        .filter_map(|(&i, s)| parse_hex_color(s).map(|rgb| (i, rgb.into())))
93        .collect()
94}
95
96impl Theme {
97    #[must_use]
98    pub fn with_overrides(fg: Option<&str>, bg: Option<&str>) -> Self {
99        let mut theme = Self::default();
100        if let Some(s) = fg {
101            if let Some(rgb) = parse_hex_color(s) {
102                theme.fg = srgb_to_linear_rgba(rgb, 1.0);
103            } else {
104                warn!(value = s, "theme.foreground is not #rrggbb; using default");
105            }
106        }
107        if let Some(s) = bg {
108            if let Some(rgb) = parse_hex_color(s) {
109                theme.bg = srgb_to_linear_rgba(rgb, 1.0);
110            } else {
111                warn!(value = s, "theme.background is not #rrggbb; using default");
112            }
113        }
114        theme
115    }
116
117    #[must_use]
118    pub fn with_cursor(mut self, cursor: Option<&str>) -> Self {
119        if let Some(s) = cursor {
120            if let Some(rgb) = parse_hex_color(s) {
121                self.cursor = Some(srgb_to_linear_rgba(rgb, 1.0));
122            } else {
123                warn!(
124                    value = s,
125                    "cursor.color is not #rrggbb; using reverse video"
126                );
127            }
128        }
129        self
130    }
131
132    #[must_use]
133    pub fn with_palette(mut self, palette: &BTreeMap<u8, String>) -> Self {
134        for (&i, s) in palette {
135            if let Some(rgb) = parse_hex_color(s) {
136                self.palette[usize::from(i)] = Some(rgb);
137            } else if let Some(name) = PALETTE_NAMES.get(usize::from(i)) {
138                warn!(value = %s, slot = name, "theme.palette.{name} is not #rrggbb; using default");
139            } else {
140                warn!(value = %s, slot = i, "theme.palette.indexed.{i} is not #rrggbb; using default");
141            }
142        }
143        self
144    }
145}
146
147/// `overrides` is indexed by [`ThemeChannel`]. `palette` is the OSC 4
148/// layer and wins over the configured `theme.palette` entry.
149#[must_use]
150pub fn effective_theme(
151    base: &Theme,
152    overrides: &[Option<[u8; 3]>; 3],
153    palette: &[Option<[u8; 3]>; 256],
154) -> Theme {
155    let mut t = *base;
156    for (slot, over) in t.palette.iter_mut().zip(palette.iter()) {
157        if over.is_some() {
158            *slot = *over;
159        }
160    }
161    if let Some(rgb) = overrides[ThemeChannel::Foreground as usize] {
162        t.fg = srgb_to_linear_rgba(rgb, 1.0);
163    }
164    if let Some(rgb) = overrides[ThemeChannel::Background as usize] {
165        // Keeps the base alpha (the configured `window.opacity`): an
166        // `OSC 11` must not make a translucent terminal opaque.
167        t.bg = srgb_to_linear_rgba(rgb, base.bg[3]);
168    }
169    if let Some(rgb) = overrides[ThemeChannel::Cursor as usize] {
170        t.cursor = Some(srgb_to_linear_rgba(rgb, 1.0));
171    }
172    t
173}
174
175/// Painted on cells holding UAX #9 explicit-formatting characters so a
176/// Trojan-Source attack (CVE-2021-42574) cannot hide. Hard-coded: a
177/// security signal, not a theming choice.
178pub const BIDI_MARKER_BG: [f32; 4] = [1.0, 1.0, 0.0, 1.0];
179
180pub const BIDI_MARKER_FG: [f32; 4] = [0.0, 0.0, 0.0, 1.0];
181
182/// Selection background. This and the chrome colors below are
183/// precomputed because `powf` is not `const`; each is pinned to its
184/// source hex by `precomputed_chrome_colors_match_srgb_to_linear_of_their_source_hex`.
185/// Source `#335588`.
186pub const SELECTION_BG: [f32; 4] = [0.033_104_762, 0.090_841_77, 0.246_201_36, 1.0];
187
188/// IME pre-edit background; distinct from [`SELECTION_BG`] so an
189/// adjacent selection and pre-edit do not blur together. Source
190/// `#224477`.
191pub const PREEDIT_BG: [f32; 4] = [0.015_996_292, 0.057_805_434, 0.184_474_99, 1.0];
192
193/// OSC 8 hyperlink underline. Source `#6ca6ff`.
194pub const LINK_UNDERLINE: [f32; 4] = [0.149_959_8, 0.381_326_1, 1.0, 1.0];
195
196/// Scrollback indicator track, painted only while `viewport > 0`.
197/// Source `#1d1d1d`.
198pub const SCROLLBAR_TRACK: [f32; 4] = [0.012_286_488, 0.012_286_488, 0.012_286_488, 1.0];
199
200/// Scrollback indicator thumb. Source `#5a5a5a`.
201pub const SCROLLBAR_THUMB: [f32; 4] = [0.102_241_73, 0.102_241_73, 0.102_241_73, 1.0];
202
203/// Search-match background, muted against [`SEARCH_CURRENT_BG`].
204/// Source `#554422`.
205pub const SEARCH_MATCH_BG: [f32; 4] = [0.090_841_77, 0.057_805_434, 0.015_996_292, 1.0];
206
207/// The n/N-focused search match. Source `#cc7722`.
208pub const SEARCH_CURRENT_BG: [f32; 4] = [0.603_827_4, 0.184_474_99, 0.015_996_292, 1.0];
209
210/// Search bar; distinct from [`SCROLLBAR_TRACK`] so the two bands of
211/// chrome stay separable. Source `#2a2a2a`.
212pub const SEARCH_BAR_BG: [f32; 4] = [0.023_153_365, 0.023_153_365, 0.023_153_365, 1.0];
213
214/// Confirmation bar; distinct from [`SEARCH_BAR_BG`] so the two bottom
215/// bars cannot be mistaken for each other. Source `#552222`.
216pub const CONFIRM_BAR_BG: [f32; 4] = [0.090_841_77, 0.015_996_292, 0.015_996_292, 1.0];
217
218/// In SGR-index order.
219pub const PALETTE_NAMES: [&str; 16] = [
220    "black",
221    "red",
222    "green",
223    "yellow",
224    "blue",
225    "magenta",
226    "cyan",
227    "white",
228    "bright_black",
229    "bright_red",
230    "bright_green",
231    "bright_yellow",
232    "bright_blue",
233    "bright_magenta",
234    "bright_cyan",
235    "bright_white",
236];
237
238#[must_use]
239pub fn parse_hex_color(s: &str) -> Option<[u8; 3]> {
240    let bytes = s.as_bytes();
241    if bytes.first().copied() != Some(b'#') {
242        return None;
243    }
244    match bytes.len() {
245        7 => {
246            let r = hex_byte(bytes[1], bytes[2])?;
247            let g = hex_byte(bytes[3], bytes[4])?;
248            let b = hex_byte(bytes[5], bytes[6])?;
249            Some([r, g, b])
250        }
251        _ => None,
252    }
253}
254
255const fn hex_byte(hi: u8, lo: u8) -> Option<u8> {
256    let Some(h) = hex_nibble(hi) else { return None };
257    let Some(l) = hex_nibble(lo) else { return None };
258    Some((h << 4) | l)
259}
260
261const fn hex_nibble(b: u8) -> Option<u8> {
262    match b {
263        b'0'..=b'9' => Some(b - b'0'),
264        b'a'..=b'f' => Some(b - b'a' + 10),
265        b'A'..=b'F' => Some(b - b'A' + 10),
266        _ => None,
267    }
268}
269
270pub const XTERM_PALETTE: [[u8; 3]; 16] = {
271    let mut table = [[0u8; 3]; 16];
272    let mut i = 0usize;
273    while i < table.len() {
274        table[i] = xterm_256(i as u8);
275        i += 1;
276    }
277    table
278};
279
280/// IEC 61966-2-1 sRGB → linear conversion for one component.
281#[must_use]
282pub fn srgb_byte_to_linear(component: u8) -> f32 {
283    let c = f32::from(component) / 255.0;
284    if c <= 0.040_45 {
285        c / 12.92
286    } else {
287        ((c + 0.055) / 1.055).powf(2.4)
288    }
289}
290
291#[must_use]
292pub fn srgb_to_linear_rgba(srgb: [u8; 3], alpha: f32) -> [f32; 4] {
293    [
294        srgb_byte_to_linear(srgb[0]),
295        srgb_byte_to_linear(srgb[1]),
296        srgb_byte_to_linear(srgb[2]),
297        alpha,
298    ]
299}
300
301/// The inverse of [`srgb_byte_to_linear`], for a client that writes
302/// colors into an sRGB-encoded surface itself (a DOM style).
303#[must_use]
304pub fn linear_to_srgb_byte(component: f32) -> u8 {
305    let c = component.clamp(0.0, 1.0);
306    let encoded = if c <= 0.003_130_8 {
307        c * 12.92
308    } else {
309        1.055f32.mul_add(c.powf(1.0 / 2.4), -0.055)
310    };
311    (encoded * 255.0).round() as u8
312}
313
314/// Alpha is coverage, not light, so it scales without the transfer curve.
315#[must_use]
316pub fn linear_rgba_to_srgb8(linear: [f32; 4]) -> [u8; 4] {
317    [
318        linear_to_srgb_byte(linear[0]),
319        linear_to_srgb_byte(linear[1]),
320        linear_to_srgb_byte(linear[2]),
321        (linear[3].clamp(0.0, 1.0) * 255.0).round() as u8,
322    ]
323}
324
325/// Every color a cell walk can ask for, linearized once per theme
326/// change: resolving at cell time costs a `powf` per channel per cell
327/// per frame.
328#[derive(Debug, Clone)]
329pub struct ResolvedTheme {
330    pub fg: [f32; 4],
331    pub bg: [f32; 4],
332    pub cursor: Option<[f32; 4]>,
333    pub palette: [[f32; 4]; 256],
334    pub minimum_contrast: f32,
335    straight_bg: [f32; 4],
336    srgb_linear: [f32; 256],
337}
338
339impl ResolvedTheme {
340    #[must_use]
341    pub fn new(base: &Theme) -> Self {
342        let mut srgb_linear = [0.0f32; 256];
343        for (byte, slot) in srgb_linear.iter_mut().enumerate() {
344            *slot = srgb_byte_to_linear(byte as u8);
345        }
346        let mut palette = [[0.0f32; 4]; 256];
347        for (index, slot) in palette.iter_mut().enumerate() {
348            let rgb = base.palette[index].unwrap_or_else(|| xterm_256(index as u8));
349            *slot = [
350                srgb_linear[usize::from(rgb[0])],
351                srgb_linear[usize::from(rgb[1])],
352                srgb_linear[usize::from(rgb[2])],
353                1.0,
354            ];
355        }
356        Self {
357            fg: base.fg,
358            bg: base.bg,
359            cursor: base.cursor,
360            palette,
361            minimum_contrast: base.minimum_contrast,
362            straight_bg: base.bg,
363            srgb_linear,
364        }
365    }
366
367    /// Names the straight-alpha color a premultiplied `bg` was made
368    /// from: contrast is judged against the color the user configured,
369    /// and a fully transparent `bg` premultiplies to black.
370    #[must_use]
371    pub const fn with_straight_bg(mut self, straight_bg: [f32; 4]) -> Self {
372        self.straight_bg = straight_bg;
373        self
374    }
375
376    /// The color `bg` is judged against for contrast: the straight
377    /// default background where `bg` is the default background.
378    #[must_use]
379    #[allow(
380        clippy::float_cmp,
381        reason = "identity with the theme's own bg value, not a numeric closeness test"
382    )]
383    pub fn contrast_ground(&self, bg: [f32; 4]) -> [f32; 4] {
384        if bg == self.bg { self.straight_bg } else { bg }
385    }
386
387    fn linear_rgb(&self, rgb: [u8; 3]) -> [f32; 4] {
388        [
389            self.srgb_linear[usize::from(rgb[0])],
390            self.srgb_linear[usize::from(rgb[1])],
391            self.srgb_linear[usize::from(rgb[2])],
392            1.0,
393        ]
394    }
395}
396
397#[must_use]
398pub fn resolve_fg(color: Color, theme: &ResolvedTheme) -> [f32; 4] {
399    resolve(color, theme, theme.fg)
400}
401
402#[must_use]
403pub fn resolve_bg(color: Color, theme: &ResolvedTheme) -> [f32; 4] {
404    resolve(color, theme, theme.bg)
405}
406
407fn resolve(color: Color, theme: &ResolvedTheme, theme_default: [f32; 4]) -> [f32; 4] {
408    match color {
409        Color::Default => theme_default,
410        Color::Indexed(i) => theme.palette[usize::from(i)],
411        Color::Rgb(r, g, b) => theme.linear_rgb([r, g, b]),
412    }
413}
414
415/// Array-shaped view of [`felis_grid::default_palette_color`], which
416/// owns the table: the grid answers `OSC 4` queries from it, so a
417/// second copy here would let a queried color and the painted one
418/// disagree.
419#[must_use]
420// The lint's `.into()` is not const, and const is what lets
421// `XTERM_PALETTE` project from this instead of being a second table.
422#[allow(clippy::tuple_array_conversions)]
423pub const fn xterm_256(index: u8) -> [u8; 3] {
424    let (r, g, b) = felis_grid::default_palette_color(index);
425    [r, g, b]
426}
427
428#[cfg(test)]
429mod tests {
430    #![allow(clippy::float_cmp)]
431
432    use super::*;
433
434    const NO_PALETTE: [Option<[u8; 3]>; 256] = [None; 256];
435
436    fn approx(a: [f32; 4], b: [f32; 4]) -> bool {
437        a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-4)
438    }
439
440    #[test]
441    fn srgb_zero_is_linear_zero() {
442        assert!((srgb_byte_to_linear(0) - 0.0).abs() < 1e-6);
443    }
444
445    #[test]
446    fn srgb_max_is_linear_one() {
447        assert!((srgb_byte_to_linear(255) - 1.0).abs() < 1e-4);
448    }
449
450    #[test]
451    fn linear_to_srgb_byte_inverts_srgb_byte_to_linear_for_every_byte() {
452        for byte in 0..=u8::MAX {
453            assert_eq!(linear_to_srgb_byte(srgb_byte_to_linear(byte)), byte);
454        }
455    }
456
457    #[test]
458    fn linear_rgba_to_srgb8_encodes_color_and_scales_alpha() {
459        let linear = srgb_to_linear_rgba([0x12, 0x80, 0xFE], 0.5);
460        assert_eq!(linear_rgba_to_srgb8(linear), [0x12, 0x80, 0xFE, 128]);
461    }
462
463    #[test]
464    fn srgb_low_uses_linear_segment() {
465        let c8 = 4u8;
466        let expected = (f32::from(c8) / 255.0) / 12.92;
467        assert!((srgb_byte_to_linear(c8) - expected).abs() < 1e-6);
468    }
469
470    #[test]
471    fn default_color_uses_theme() {
472        let theme = ResolvedTheme::new(&Theme::default());
473        assert_eq!(resolve_fg(Color::Default, &theme), theme.fg);
474        assert_eq!(resolve_bg(Color::Default, &theme), theme.bg);
475    }
476
477    #[test]
478    fn cube_index_uses_the_xterm_ramp() {
479        assert_eq!(xterm_256(196), [255, 0, 0]);
480        assert_eq!(xterm_256(21), [0, 0, 255]);
481        assert_eq!(xterm_256(16), [0, 0, 0]);
482        assert_eq!(xterm_256(231), [255, 255, 255]);
483    }
484
485    #[test]
486    fn grayscale_index_steps_evenly() {
487        assert_eq!(xterm_256(232), [8, 8, 8]);
488        assert_eq!(xterm_256(255), [238, 238, 238]);
489    }
490
491    #[test]
492    fn xterm_first_16_match_the_legacy_table() {
493        // Literal pins too, so a changed table row fails, not just a
494        // broken projection.
495        for i in 0..16u8 {
496            assert_eq!(xterm_256(i), XTERM_PALETTE[usize::from(i)]);
497        }
498        assert_eq!(xterm_256(0), [0, 0, 0]);
499        assert_eq!(xterm_256(1), [205, 0, 0]);
500        assert_eq!(xterm_256(7), [229, 229, 229]);
501        assert_eq!(xterm_256(8), [127, 127, 127]);
502        assert_eq!(xterm_256(12), [92, 92, 255]);
503        assert_eq!(xterm_256(15), [255, 255, 255]);
504    }
505
506    #[test]
507    fn resolved_theme_tables_match_the_per_call_transform_bit_for_bit() {
508        let overrides = BTreeMap::from([(1u8, "#ff5555".to_owned())]);
509        let theme = ResolvedTheme::new(&Theme::default().with_palette(&overrides));
510        for index in 0..=255u8 {
511            let srgb = if index == 1 {
512                [0xFF, 0x55, 0x55]
513            } else {
514                xterm_256(index)
515            };
516            assert_eq!(
517                resolve_fg(Color::Indexed(index), &theme),
518                srgb_to_linear_rgba(srgb, 1.0),
519            );
520            assert_eq!(
521                resolve_bg(Color::Indexed(index), &theme),
522                srgb_to_linear_rgba(srgb, 1.0),
523            );
524            let rgb = [index, index / 2, !index];
525            assert_eq!(
526                resolve_fg(Color::Rgb(rgb[0], rgb[1], rgb[2]), &theme),
527                srgb_to_linear_rgba(rgb, 1.0),
528            );
529        }
530    }
531
532    fn assert_const_matches_hex(label: &str, computed: [f32; 4], hex: [u8; 3]) {
533        let from_bytes = srgb_to_linear_rgba(hex, 1.0);
534        for (a, b) in computed.iter().zip(from_bytes.iter()) {
535            assert!(
536                (a - b).abs() < 1e-3,
537                "{label}: drift: precomputed {computed:?} vs computed {from_bytes:?}",
538            );
539        }
540    }
541
542    #[test]
543    fn precomputed_chrome_colors_match_srgb_to_linear_of_their_source_hex() {
544        for (label, computed, hex) in [
545            ("LINK_UNDERLINE", LINK_UNDERLINE, [0x6c, 0xa6, 0xff]),
546            ("PREEDIT_BG", PREEDIT_BG, [0x22, 0x44, 0x77]),
547            ("SELECTION_BG", SELECTION_BG, [0x33, 0x55, 0x88]),
548            ("SCROLLBAR_TRACK", SCROLLBAR_TRACK, [0x1d, 0x1d, 0x1d]),
549            ("SCROLLBAR_THUMB", SCROLLBAR_THUMB, [0x5a, 0x5a, 0x5a]),
550            ("SEARCH_MATCH_BG", SEARCH_MATCH_BG, [0x55, 0x44, 0x22]),
551            ("SEARCH_CURRENT_BG", SEARCH_CURRENT_BG, [0xcc, 0x77, 0x22]),
552            ("SEARCH_BAR_BG", SEARCH_BAR_BG, [0x2a, 0x2a, 0x2a]),
553            ("CONFIRM_BAR_BG", CONFIRM_BAR_BG, [0x55, 0x22, 0x22]),
554        ] {
555            assert_const_matches_hex(label, computed, hex);
556        }
557    }
558
559    #[test]
560    fn configured_report_falls_back_to_renderer_defaults_when_unset() {
561        // fg / bg must report the painted defaults, not `None`: else the
562        // daemon falls back to xterm white and a background detector
563        // flips to a light scheme.
564        let (fg, bg, cursor) = configured_theme_report(None, None, None);
565        assert_eq!(fg, Some((0xE5, 0xE5, 0xE5)));
566        assert_eq!(bg, Some((0x0D, 0x0D, 0x12)));
567        assert_eq!(cursor, None);
568    }
569
570    #[test]
571    fn configured_report_uses_each_configured_color() {
572        let (fg, bg, cursor) =
573            configured_theme_report(Some("#102030"), Some("#abcdef"), Some("#ffaa00"));
574        assert_eq!(fg, Some((0x10, 0x20, 0x30)));
575        assert_eq!(bg, Some((0xAB, 0xCD, 0xEF)));
576        assert_eq!(cursor, Some((0xFF, 0xAA, 0x00)));
577    }
578
579    #[test]
580    fn configured_report_malformed_fg_bg_fall_back_to_defaults() {
581        let (fg, bg, cursor) =
582            configured_theme_report(Some("not-a-color"), Some("xyz"), Some("nope"));
583        assert_eq!(fg, Some((0xE5, 0xE5, 0xE5)));
584        assert_eq!(bg, Some((0x0D, 0x0D, 0x12)));
585        assert_eq!(cursor, None);
586    }
587
588    #[test]
589    fn configured_palette_report_is_the_painted_palette() {
590        let config = BTreeMap::from([
591            (1, "#ff5555".to_owned()),
592            (17, "#5e81ac".to_owned()),
593            (18, "not-a-color".to_owned()),
594        ]);
595        let painted = Theme::default().with_palette(&config).palette;
596        let reported = configured_palette_report(&config);
597        for index in 0..=u8::MAX {
598            assert_eq!(
599                reported.get(&index).map(|&rgb| <[u8; 3]>::from(rgb)),
600                painted[usize::from(index)],
601                "slot {index}",
602            );
603        }
604    }
605
606    #[test]
607    fn configured_report_defaults_match_theme_default_linear() {
608        let t = Theme::default();
609        assert_eq!(srgb_to_linear_rgba(DEFAULT_FG_SRGB, 1.0), t.fg);
610        assert_eq!(srgb_to_linear_rgba(DEFAULT_BG_SRGB, 1.0), t.bg);
611    }
612
613    #[test]
614    fn parse_hex_color_rejects_malformed() {
615        assert_eq!(parse_hex_color(""), None);
616        assert_eq!(parse_hex_color("abcdef"), None, "missing #");
617        assert_eq!(parse_hex_color("#ab"), None, "too short");
618        assert_eq!(parse_hex_color("#abc"), None, "CSS shorthand not accepted");
619        assert_eq!(parse_hex_color("#abcd"), None, "4 digits not supported");
620        assert_eq!(parse_hex_color("#abcde"), None, "5 digits not supported");
621        assert_eq!(parse_hex_color("#abcdefab"), None, "alpha not supported");
622        assert_eq!(parse_hex_color("#zzzzzz"), None, "non-hex digits");
623        assert_eq!(parse_hex_color("#abcde "), None, "trailing space");
624    }
625
626    #[test]
627    fn theme_with_overrides_overlays_only_provided_channels() {
628        let theme = Theme::with_overrides(Some("#ff0000"), None);
629        let expected_fg = srgb_to_linear_rgba([0xFF, 0, 0], 1.0);
630        assert!(approx(theme.fg, expected_fg));
631        assert!(approx(theme.bg, Theme::default().bg));
632    }
633
634    #[test]
635    fn theme_with_overrides_falls_back_on_malformed_string() {
636        let theme = Theme::with_overrides(Some("not-a-color"), Some("#101010"));
637        assert!(approx(theme.fg, Theme::default().fg));
638        assert!(approx(
639            theme.bg,
640            srgb_to_linear_rgba([0x10, 0x10, 0x10], 1.0)
641        ));
642    }
643
644    #[test]
645    fn with_cursor_some_valid_hex_stores_linear_rgba() {
646        let theme = Theme::default().with_cursor(Some("#ffaa00"));
647        let expected = srgb_to_linear_rgba([0xFF, 0xAA, 0x00], 1.0);
648        assert_eq!(theme.cursor, Some(expected));
649    }
650
651    #[test]
652    fn with_cursor_none_keeps_reverse_video_default() {
653        let theme = Theme::default().with_cursor(None);
654        assert_eq!(theme.cursor, None);
655    }
656
657    #[test]
658    fn with_cursor_malformed_string_falls_back_to_none() {
659        let theme = Theme::default().with_cursor(Some("not-a-color"));
660        assert_eq!(theme.cursor, None);
661    }
662
663    #[test]
664    fn with_cursor_overwrites_a_previously_set_cursor() {
665        let theme = Theme::default()
666            .with_cursor(Some("#ffaa00"))
667            .with_cursor(Some("#00ff00"));
668        let expected = srgb_to_linear_rgba([0x00, 0xFF, 0x00], 1.0);
669        assert_eq!(theme.cursor, Some(expected));
670    }
671
672    #[test]
673    fn theme_palette_default_is_all_none() {
674        let theme = Theme::default();
675        for slot in theme.palette {
676            assert!(slot.is_none());
677        }
678    }
679
680    #[test]
681    fn with_palette_overrides_slot_one_for_resolve_indexed() {
682        let overrides = BTreeMap::from([(1u8, "#ff5555".to_owned())]);
683        let theme = ResolvedTheme::new(&Theme::default().with_palette(&overrides));
684        let expected = srgb_to_linear_rgba([0xFF, 0x55, 0x55], 1.0);
685        assert!(approx(resolve_fg(Color::Indexed(1), &theme), expected));
686        let xterm_green = srgb_to_linear_rgba(XTERM_PALETTE[2], 1.0);
687        assert!(approx(resolve_fg(Color::Indexed(2), &theme), xterm_green));
688    }
689
690    #[test]
691    fn with_palette_overrides_extended_cube_index() {
692        let overrides = BTreeMap::from([(16u8, "#d08770".to_owned())]);
693        let theme = ResolvedTheme::new(&Theme::default().with_palette(&overrides));
694        let expected = srgb_to_linear_rgba([0xD0, 0x87, 0x70], 1.0);
695        assert!(approx(resolve_fg(Color::Indexed(16), &theme), expected));
696        let xterm_200 = srgb_to_linear_rgba(xterm_256(200), 1.0);
697        assert!(approx(resolve_fg(Color::Indexed(200), &theme), xterm_200));
698    }
699
700    #[test]
701    fn malformed_palette_entry_keeps_xterm_default() {
702        let overrides = BTreeMap::from([(1u8, "not-a-color".to_owned())]);
703        let theme = ResolvedTheme::new(&Theme::default().with_palette(&overrides));
704        let xterm_red = srgb_to_linear_rgba(XTERM_PALETTE[1], 1.0);
705        assert!(approx(resolve_fg(Color::Indexed(1), &theme), xterm_red));
706    }
707
708    #[test]
709    fn palette_names_match_sgr_order() {
710        assert_eq!(PALETTE_NAMES[0], "black");
711        assert_eq!(PALETTE_NAMES[7], "white");
712        assert_eq!(PALETTE_NAMES[8], "bright_black");
713        assert_eq!(PALETTE_NAMES[15], "bright_white");
714    }
715
716    #[test]
717    fn effective_theme_with_no_overrides_equals_base() {
718        let base = Theme::default();
719        let eff = effective_theme(&base, &[None; 3], &NO_PALETTE);
720        assert!(approx(eff.fg, base.fg));
721        assert!(approx(eff.bg, base.bg));
722        assert_eq!(eff.cursor, base.cursor);
723    }
724
725    #[test]
726    fn effective_theme_lifts_each_channel_independently() {
727        let base = Theme::default();
728        let mut overrides = [None; 3];
729
730        overrides[ThemeChannel::Foreground as usize] = Some([0xAB, 0x00, 0x00]);
731        let eff = effective_theme(&base, &overrides, &NO_PALETTE);
732        assert!(approx(eff.fg, srgb_to_linear_rgba([0xAB, 0x00, 0x00], 1.0)));
733        assert!(approx(eff.bg, base.bg));
734        assert_eq!(eff.cursor, None);
735
736        overrides = [None; 3];
737        overrides[ThemeChannel::Background as usize] = Some([0x00, 0xCD, 0x00]);
738        let eff = effective_theme(&base, &overrides, &NO_PALETTE);
739        assert!(approx(eff.fg, base.fg));
740        assert!(approx(eff.bg, srgb_to_linear_rgba([0x00, 0xCD, 0x00], 1.0)));
741
742        overrides = [None; 3];
743        overrides[ThemeChannel::Cursor as usize] = Some([0x00, 0x00, 0xEF]);
744        let eff = effective_theme(&base, &overrides, &NO_PALETTE);
745        assert_eq!(
746            eff.cursor,
747            Some(srgb_to_linear_rgba([0x00, 0x00, 0xEF], 1.0))
748        );
749    }
750
751    #[test]
752    fn effective_theme_bg_override_keeps_configured_opacity() {
753        let base = Theme {
754            bg: srgb_to_linear_rgba(DEFAULT_BG_SRGB, 0.8),
755            ..Theme::default()
756        };
757        let mut overrides = [None; 3];
758        overrides[ThemeChannel::Background as usize] = Some([0x00, 0xCD, 0x00]);
759        let eff = effective_theme(&base, &overrides, &NO_PALETTE);
760        assert!(
761            (eff.bg[3] - 0.8).abs() < 1e-6,
762            "opacity must survive OSC 11"
763        );
764        let want = srgb_to_linear_rgba([0x00, 0xCD, 0x00], 0.8);
765        assert!(approx(eff.bg, want));
766    }
767
768    #[test]
769    fn effective_theme_runtime_cursor_overrides_configured_cursor() {
770        let base = Theme {
771            cursor: Some([0.1, 0.1, 0.1, 1.0]),
772            ..Theme::default()
773        };
774        let mut overrides = [None; 3];
775        overrides[ThemeChannel::Cursor as usize] = Some([0xFF, 0xAA, 0x00]);
776        let eff = effective_theme(&base, &overrides, &NO_PALETTE);
777        assert_eq!(
778            eff.cursor,
779            Some(srgb_to_linear_rgba([0xFF, 0xAA, 0x00], 1.0))
780        );
781    }
782
783    /// The `OSC 4 ; idx ; ?` reply (daemon, from grid state) and the
784    /// painted color (client, from the override layer) must agree.
785    #[test]
786    fn osc_4_query_reply_and_the_painted_color_agree() {
787        use felis_grid::{Grid, PtyEffect};
788
789        let mut grid = Grid::new(2, 4);
790        felis_vt::Parser::new().advance(&mut grid, b"\x1b]4;3;#aabbcc\x1b\\\x1b]4;3;?\x1b\\");
791
792        let reply = grid
793            .take_pty_effects()
794            .into_iter()
795            .find_map(|e| match e {
796                PtyEffect::Response(bytes) => Some(bytes),
797                _ => None,
798            })
799            .expect("OSC 4 ? enqueues a reply");
800        assert_eq!(
801            std::str::from_utf8(&reply).unwrap(),
802            "\x1b]4;3;rgb:aaaa/bbbb/cccc\x1b\\"
803        );
804
805        let mut runtime = NO_PALETTE;
806        let deltas = grid.take_palette_dirty();
807        assert!(!deltas.reset_all);
808        for entry in deltas.entries {
809            runtime[usize::from(entry.index)] = entry.rgb.map(<[u8; 3]>::from);
810        }
811        let theme = ResolvedTheme::new(&effective_theme(&Theme::default(), &[None; 3], &runtime));
812        assert!(approx(
813            resolve_fg(Color::Indexed(3), &theme),
814            srgb_to_linear_rgba([0xAA, 0xBB, 0xCC], 1.0),
815        ));
816    }
817
818    #[test]
819    fn osc_4_layer_wins_over_config_and_falls_back_to_it_on_reset() {
820        let mut base = Theme::default();
821        base.palette[3] = Some([0x11, 0x22, 0x33]);
822
823        let mut runtime = NO_PALETTE;
824        runtime[3] = Some([0xAA, 0xBB, 0xCC]);
825        let with_override = ResolvedTheme::new(&effective_theme(&base, &[None; 3], &runtime));
826        assert!(approx(
827            resolve_fg(Color::Indexed(3), &with_override),
828            srgb_to_linear_rgba([0xAA, 0xBB, 0xCC], 1.0),
829        ));
830
831        let after_reset = ResolvedTheme::new(&effective_theme(&base, &[None; 3], &NO_PALETTE));
832        assert!(approx(
833            resolve_fg(Color::Indexed(3), &after_reset),
834            srgb_to_linear_rgba([0x11, 0x22, 0x33], 1.0),
835        ));
836    }
837
838    proptest::proptest! {
839        #[test]
840        fn parse_hex_round_trips_any_rgb(r: u8, g: u8, b: u8) {
841            let s = format!("#{r:02x}{g:02x}{b:02x}");
842            let parsed = parse_hex_color(&s);
843            proptest::prop_assert_eq!(parsed, Some([r, g, b]));
844        }
845
846        #[test]
847        fn parse_hex_uppercase_round_trips(r: u8, g: u8, b: u8) {
848            let s = format!("#{r:02X}{g:02X}{b:02X}");
849            proptest::prop_assert_eq!(parse_hex_color(&s), Some([r, g, b]));
850        }
851
852        #[test]
853        fn parse_hex_rejects_wrong_lengths(prefix in "[#]?", n in 0usize..8) {
854            let body: String = (0..n).map(|i| (b'0' + (i as u8 % 10)) as char).collect();
855            let candidate = format!("{prefix}{body}");
856            let len = candidate.len();
857            let starts_with_hash = candidate.starts_with('#');
858            if !(starts_with_hash && len == 7) {
859                proptest::prop_assert_eq!(parse_hex_color(&candidate), None);
860            }
861        }
862
863        #[test]
864        fn palette_overrides_round_trip(rgbs in proptest::array::uniform16(proptest::array::uniform3(proptest::num::u8::ANY))) {
865            let mut overrides: BTreeMap<u8, String> = BTreeMap::new();
866            for (i, rgb) in rgbs.iter().enumerate() {
867                let [r, g, b] = *rgb;
868                #[allow(clippy::cast_possible_truncation)]
869                overrides.insert(i as u8, format!("#{r:02x}{g:02x}{b:02x}"));
870            }
871            let theme = ResolvedTheme::new(&Theme::default().with_palette(&overrides));
872            for (i, rgb) in rgbs.iter().enumerate() {
873                let expected = srgb_to_linear_rgba(*rgb, 1.0);
874                #[allow(clippy::cast_possible_truncation)]
875                let got = resolve_fg(Color::Indexed(i as u8), &theme);
876                proptest::prop_assert!(approx(got, expected),
877                    "palette slot {i} did not round-trip: got {got:?}, want {expected:?}");
878            }
879        }
880    }
881}