1use 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 pub fg: [f32; 4],
14 pub bg: [f32; 4],
16 pub palette: [Option<[u8; 3]>; 256],
22 pub cursor: Option<[f32; 4]>,
25 pub minimum_contrast: f32,
28}
29
30pub const DEFAULT_FG_SRGB: [u8; 3] = [0xE5, 0xE5, 0xE5];
33
34pub 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
50pub type ConfiguredThemeReport = (
52 Option<(u8, u8, u8)>,
53 Option<(u8, u8, u8)>,
54 Option<(u8, u8, u8)>,
55);
56
57#[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#[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#[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 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
175pub 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
182pub const SELECTION_BG: [f32; 4] = [0.033_104_762, 0.090_841_77, 0.246_201_36, 1.0];
187
188pub const PREEDIT_BG: [f32; 4] = [0.015_996_292, 0.057_805_434, 0.184_474_99, 1.0];
192
193pub const LINK_UNDERLINE: [f32; 4] = [0.149_959_8, 0.381_326_1, 1.0, 1.0];
195
196pub const SCROLLBAR_TRACK: [f32; 4] = [0.012_286_488, 0.012_286_488, 0.012_286_488, 1.0];
199
200pub const SCROLLBAR_THUMB: [f32; 4] = [0.102_241_73, 0.102_241_73, 0.102_241_73, 1.0];
202
203pub const SEARCH_MATCH_BG: [f32; 4] = [0.090_841_77, 0.057_805_434, 0.015_996_292, 1.0];
206
207pub const SEARCH_CURRENT_BG: [f32; 4] = [0.603_827_4, 0.184_474_99, 0.015_996_292, 1.0];
209
210pub const SEARCH_BAR_BG: [f32; 4] = [0.023_153_365, 0.023_153_365, 0.023_153_365, 1.0];
213
214pub const CONFIRM_BAR_BG: [f32; 4] = [0.090_841_77, 0.015_996_292, 0.015_996_292, 1.0];
217
218pub 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#[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#[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#[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#[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 #[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 #[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#[must_use]
420#[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 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 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 #[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}