返回 CodeWhale
color_compat.rs
根目录 / crates / tui / src / tui / color_compat.rs
1 //! Terminal color compatibility shim.
2 //!
3 //! Ratatui's crossterm backend emits truecolor SGR for every `Color::Rgb`
4 //! cell. That is correct for truecolor terminals, but macOS Terminal.app often
5 //! advertises only `xterm-256color`; sending `38;2` / `48;2` there can render
6 //! as stray green/cyan backgrounds. This backend adapts every cell to the
7 //! detected color depth before handing it to crossterm.
8
9 use std::fmt::Write as _;
10 use std::fs::{self, File, OpenOptions};
11 use std::io::{self, Write};
12
13 use ratatui::{
14 backend::{Backend, ClearType, CrosstermBackend, WindowSize},
15 buffer::Cell,
16 layout::{Position, Size},
17 };
18
19 use codewhale_palette::{self as palette, ColorDepth, PaletteMode, ThemeId, UiTheme};
20
21 const RENDER_DEBUG_ENV: &str = "CODEWHALE_TUI_DEBUG";
22 const ASCII_SAFE_ENV: &str = "CODEWHALE_ASCII_SAFE";
23 const RENDER_DEBUG_SAMPLE_LIMIT: usize = 24;
24
25 #[derive(Debug)]
26 pub(crate) struct ColorCompatBackend<W: Write> {
27 inner: CrosstermBackend<W>,
28 depth: ColorDepth,
29 palette_mode: PaletteMode,
30 /// Currently active named theme. `System`/`Whale`/`WhaleLight` make the
31 /// theme remap a no-op (those rely on the dark/light pipeline); the
32 /// community presets (Catppuccin, Tokyo Night, Dracula, Gruvbox) trigger
33 /// a per-cell rewrite of dark-palette constants → preset slots.
34 theme_id: ThemeId,
35 /// Resolved active `UiTheme`, *including* any user `background_color`
36 /// override (`UiTheme::with_background_color`). The cell remap reads
37 /// target slots from this struct, not from `theme_id.ui_theme()`, so
38 /// `theme = "tokyo-night"` + `background_color = "#000000"` lands as a
39 /// pure-black surface instead of being overwritten back to
40 /// tokyo-night's `#16161e` by the remap.
41 active_ui_theme: UiTheme,
42 /// During a resize event the terminal emulator may report stale dimensions
43 /// for a brief window (observed on macOS Terminal.app and Windows ConHost).
44 /// Forcing the expected size prevents ratatui's internal `autoresize` from
45 /// shrinking the viewport back to the stale dimension inside `draw()`.
46 forced_size: Option<Size>,
47 /// Cached terminal size from `crossterm::terminal::size()`, set after
48 /// re-entering alt-screen to avoid stale buffer dimensions on Windows.
49 /// Used as the primary fallback in `size()` before falling through to
50 /// the live crossterm query.
51 terminal_size: Option<Size>,
52 /// The last position the cursor was explicitly moved to.
53 ///
54 /// ratatui-core >= 0.1.1 issues a CPR query inside `Terminal::clear()`
55 /// (`backend.get_cursor_position()` → `ESC[6n`) to snapshot and restore
56 /// the cursor. With our input event loop already reading stdin, the
57 /// reply is consumed as input and the query times out —
58 /// ratatui/ratatui#2483, #2640. #2640's workaround for apps with a live
59 /// event loop is to answer from tracked state, which this backend does:
60 /// `get_cursor_position()` never touches the terminal. Only
61 /// `set_cursor_position()` updates the tracker; raw writes that move
62 /// the cursor out-of-band leave it behind, but every such path is
63 /// followed by a full repaint that repositions the cursor itself.
64 tracked_cursor: Position,
65 render_debug: Option<RenderDebugLog>,
66 ascii_safe: bool,
67 /// The terminal's own background, when detection measured one
68 /// (`BackgroundSource::Osc11` or a resolvable `COLORFGBG` index). This is
69 /// the surface a `Color::Reset` cell is really drawn on, so it is what the
70 /// contrast floor reasons against. `None` means "no evidence" and disables
71 /// the floor for unpainted cells rather than guessing.
72 detected_background: Option<ratatui::style::Color>,
73 }
74
75 impl<W: Write> ColorCompatBackend<W> {
76 pub(crate) fn new(writer: W, depth: ColorDepth, palette_mode: PaletteMode) -> Self {
77 Self {
78 inner: CrosstermBackend::new(writer),
79 depth,
80 palette_mode,
81 theme_id: ThemeId::System,
82 // Default to whatever System resolves to right now — it stays a
83 // no-op for the remap since `theme_id` is also System, so this
84 // initial value only matters once `set_theme` flips both fields
85 // to a community preset.
86 active_ui_theme: UiTheme::detect(),
87 forced_size: None,
88 terminal_size: None,
89 tracked_cursor: Position::ORIGIN,
90 render_debug: RenderDebugLog::from_env(),
91 ascii_safe: ascii_safe_enabled(),
92 detected_background: None,
93 }
94 }
95
96 /// Build a second backend over `writer`, carrying every terminal fact this
97 /// one already negotiated (colour depth, palette, theme, measured
98 /// background, cached size).
99 ///
100 /// The screen-mode switch needs this: stock ratatui cannot change an
101 /// existing `Terminal`'s viewport, so `/inline` and `/fullscreen` rebuild
102 /// the terminal — and the rebuilt one must not re-detect a palette or
103 /// forget an in-session `/theme` choice.
104 pub(crate) fn respawn<W2: Write>(&self, writer: W2) -> ColorCompatBackend<W2> {
105 ColorCompatBackend {
106 inner: CrosstermBackend::new(writer),
107 depth: self.depth,
108 palette_mode: self.palette_mode,
109 theme_id: self.theme_id,
110 active_ui_theme: self.active_ui_theme,
111 // Deliberately not carried: a size forced for one resize frame is
112 // scoped to that frame, and the new terminal measures its own.
113 forced_size: None,
114 terminal_size: self.terminal_size,
115 tracked_cursor: self.tracked_cursor,
116 render_debug: RenderDebugLog::from_env(),
117 ascii_safe: self.ascii_safe,
118 detected_background: self.detected_background,
119 }
120 }
121
122 /// Record the measured terminal background. See the field docs.
123 pub(crate) fn set_detected_background(&mut self, color: Option<ratatui::style::Color>) {
124 self.detected_background = color;
125 }
126
127 pub(crate) fn force_size(&mut self, size: Size) {
128 self.forced_size = Some(size);
129 }
130
131 pub(crate) fn clear_forced_size(&mut self) {
132 self.forced_size = None;
133 }
134
135 pub(crate) fn set_terminal_size(&mut self, size: Size) {
136 self.terminal_size = Some(size);
137 }
138
139 pub(crate) fn set_palette_mode(&mut self, palette_mode: PaletteMode) {
140 self.palette_mode = palette_mode;
141 }
142
143 pub(crate) fn set_theme(&mut self, theme_id: ThemeId, ui_theme: UiTheme) {
144 self.theme_id = theme_id;
145 self.active_ui_theme = ui_theme;
146 }
147 }
148
149 impl<W: Write> Write for ColorCompatBackend<W> {
150 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
151 self.inner.write(buf)
152 }
153
154 fn flush(&mut self) -> io::Result<()> {
155 Write::flush(&mut self.inner)
156 }
157 }
158
159 impl<W: Write> Backend for ColorCompatBackend<W> {
160 type Error = io::Error;
161
162 fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
163 where
164 I: Iterator<Item = (u16, u16, &'a Cell)>,
165 {
166 let adapted = content
167 .map(|(x, y, cell)| {
168 let mut cell = cell.clone();
169 adapt_cell_colors(
170 &mut cell,
171 self.depth,
172 self.palette_mode,
173 self.theme_id,
174 &self.active_ui_theme,
175 self.detected_background,
176 );
177 if self.ascii_safe {
178 adapt_cell_symbol_for_ascii(&mut cell);
179 }
180 (x, y, cell)
181 })
182 .collect::<Vec<_>>();
183 let viewport = if self.render_debug.is_some() {
184 self.size().ok()
185 } else {
186 None
187 };
188 if let Some(render_debug) = &mut self.render_debug {
189 render_debug.record(viewport, &adapted);
190 }
191 // #3029: Emit OSC 8 hyperlinks out-of-band through the backend's
192 // Write impl. ratatui's buffer pipeline strips ESC bytes, so the
193 // open/close sequences must be interleaved with the cell stream
194 // here. OSC 8 is stateful and last-writer-wins: every cell painted
195 // between an open and the next close links to that open's target,
196 // so each region's cells must be bracketed by their OWN open/close
197 // pair — never batched.
198 let mut frame_links = crate::tui::osc8::take_frame_links();
199 if frame_links.is_empty() || !crate::tui::osc8::enabled() {
200 self.inner
201 .draw(adapted.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
202 return Ok(());
203 }
204 // Deterministic region lookup when regions are adjacent/overlapping:
205 // the first (top-left-most) region wins.
206 frame_links.sort_unstable_by_key(|link| (link.row, link.col_start));
207 let region_for = |x: u16, y: u16| -> Option<usize> {
208 frame_links
209 .iter()
210 .position(|link| y == link.row && x >= link.col_start && x <= link.col_end)
211 };
212
213 // Walk the diff in its original order and split it into runs at
214 // region boundaries, so the visible byte stream stays identical to
215 // a no-link render apart from the inserted OSC 8 sequences.
216 let mut idx = 0;
217 while idx < adapted.len() {
218 let current_region = region_for(adapted[idx].0, adapted[idx].1);
219 let run_start = idx;
220 while idx < adapted.len()
221 && region_for(adapted[idx].0, adapted[idx].1) == current_region
222 {
223 idx += 1;
224 }
225 let run = &adapted[run_start..idx];
226 if let Some(region_idx) = current_region {
227 crate::tui::osc8::write_osc8_open(self, &frame_links[region_idx].target)?;
228 self.inner
229 .draw(run.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
230 crate::tui::osc8::write_osc8_close(self)?;
231 } else {
232 self.inner
233 .draw(run.iter().map(|(x, y, cell)| (*x, *y, cell)))?;
234 }
235 }
236 Ok(())
237 }
238
239 fn append_lines(&mut self, n: u16) -> io::Result<()> {
240 self.inner.append_lines(n)
241 }
242
243 fn hide_cursor(&mut self) -> io::Result<()> {
244 self.inner.hide_cursor()
245 }
246
247 fn show_cursor(&mut self) -> io::Result<()> {
248 self.inner.show_cursor()
249 }
250
251 fn get_cursor_position(&mut self) -> io::Result<Position> {
252 // Answer from tracked state instead of issuing a CPR query that
253 // races the input event loop — see `tracked_cursor`.
254 Ok(self.tracked_cursor)
255 }
256
257 fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
258 let position = position.into();
259 self.tracked_cursor = position;
260 self.inner.set_cursor_position(position)
261 }
262
263 fn clear(&mut self) -> io::Result<()> {
264 self.inner.clear()
265 }
266
267 fn clear_region(&mut self, clear_type: ClearType) -> io::Result<()> {
268 self.inner.clear_region(clear_type)
269 }
270
271 fn size(&self) -> io::Result<Size> {
272 // forced_size takes priority: it is set during resize events to prevent
273 // ratatui's autoresize from shrinking the viewport back to a stale
274 // dimension. terminal_size is the cached real terminal size used as a
275 // fallback after alt-screen re-entry (Windows buffer width workaround).
276 if let Some(size) = self.forced_size.or(self.terminal_size) {
277 return Ok(size);
278 }
279 self.inner.size()
280 }
281
282 fn window_size(&mut self) -> io::Result<WindowSize> {
283 self.inner.window_size()
284 }
285
286 fn flush(&mut self) -> io::Result<()> {
287 Backend::flush(&mut self.inner)
288 }
289 }
290
291 #[derive(Debug)]
292 struct RenderDebugLog {
293 file: File,
294 frame: u64,
295 }
296
297 impl RenderDebugLog {
298 fn from_env() -> Option<Self> {
299 if !render_debug_enabled_from_value(std::env::var(RENDER_DEBUG_ENV).ok().as_deref()) {
300 return None;
301 }
302
303 let log_dir = crate::runtime_log::log_directory()?;
304 if let Err(err) = fs::create_dir_all(&log_dir) {
305 tracing::debug!(?err, "failed to create TUI render debug log directory");
306 return None;
307 }
308 let path = log_dir.join("tui-render.log");
309 let file = OpenOptions::new()
310 .create(true)
311 .append(true)
312 .open(&path)
313 .map_err(|err| {
314 tracing::debug!(?err, path = %path.display(), "failed to open TUI render debug log");
315 err
316 })
317 .ok()?;
318
319 Some(Self { file, frame: 0 })
320 }
321
322 fn record(&mut self, viewport: Option<Size>, diff: &[(u16, u16, Cell)]) {
323 self.frame = self.frame.saturating_add(1);
324 let sample = diff
325 .iter()
326 .take(RENDER_DEBUG_SAMPLE_LIMIT)
327 .map(|(x, y, _)| (*x, *y))
328 .collect::<Vec<_>>();
329 let line = render_debug_line(self.frame, viewport, diff.len(), &sample);
330 let _ = self.file.write_all(line.as_bytes());
331 }
332 }
333
334 fn render_debug_enabled_from_value(value: Option<&str>) -> bool {
335 env_flag_enabled(value)
336 }
337
338 fn env_flag_enabled(value: Option<&str>) -> bool {
339 matches!(
340 value.map(str::trim).map(str::to_ascii_lowercase).as_deref(),
341 Some("1" | "true" | "yes" | "on")
342 )
343 }
344
345 /// Whether terminal chrome must use portable ASCII spellings. Text producers
346 /// that would otherwise compose multi-cell Unicode labels share this decision
347 /// with the backend's single-cell glyph adapter.
348 #[must_use]
349 pub(crate) fn ascii_safe_enabled() -> bool {
350 env_flag_enabled(std::env::var(ASCII_SAFE_ENV).ok().as_deref())
351 }
352
353 /// Narrow every CodeWhale-authored decorative glyph to a semantic ASCII
354 /// alternative. Scope is deliberate: box drawing, block elements (whale
355 /// mark, meters, rails), braille state markers, geometric role/state marks,
356 /// arrows, and typographic chrome. Language text — CJK labels, accented
357 /// letters, user and model content outside those decorative classes —
358 /// passes through untouched.
359 pub(crate) fn adapt_cell_symbol_for_ascii(cell: &mut Cell) {
360 // Braille: preserve the rising-fill signal instead of collapsing every
361 // working/verifying frame to one glyph.
362 let mut chars = cell.symbol().chars();
363 if let (Some(ch), None) = (chars.next(), chars.next())
364 && let Some(replacement) = crate::tui::glyphs::braille_ascii_fallback(ch)
365 {
366 cell.set_symbol(replacement);
367 return;
368 }
369 if let Some(replacement) = crate::tui::glyphs::ascii_fallback(cell.symbol()) {
370 cell.set_symbol(replacement);
371 }
372 }
373
374 fn render_debug_line(
375 frame: u64,
376 viewport: Option<Size>,
377 diff_cells: usize,
378 sample: &[(u16, u16)],
379 ) -> String {
380 let mut line = String::new();
381 match viewport {
382 Some(size) => {
383 let _ = write!(
384 &mut line,
385 "frame={frame} size={}x{} diff_cells={diff_cells} sample=",
386 size.width, size.height
387 );
388 }
389 None => {
390 let _ = write!(
391 &mut line,
392 "frame={frame} size=unknown diff_cells={diff_cells} sample="
393 );
394 }
395 }
396 for (index, (x, y)) in sample.iter().enumerate() {
397 if index > 0 {
398 line.push(',');
399 }
400 let _ = write!(&mut line, "{x}:{y}");
401 }
402 line.push('\n');
403 line
404 }
405
406 /// Apply the WCAG contrast floor to a cell that is about to be drawn.
407 ///
408 /// This runs *after* the palette-mode remap and *before* depth downsampling,
409 /// because the floor has to reason about the color the user will actually see
410 /// while it is still full-precision RGB.
411 ///
412 /// Two guards keep the blast radius at exactly the #4833 failure:
413 ///
414 /// - Presets that own their own palette (`theme_remap_active`: Terminal,
415 /// Catppuccin, Matrix, …) are exempt. Their authors tuned those pairs, some
416 /// deliberately below 4.5:1, and a user who typed `/theme matrix` asked for
417 /// that. The floor guards the auto-detected default path.
418 /// - Only text cells are clamped; frame chrome keeps its intended weight.
419 /// See [`palette::symbol_needs_text_contrast`].
420 fn enforce_cell_contrast(
421 cell: &mut Cell,
422 theme_id: ThemeId,
423 detected_background: Option<ratatui::style::Color>,
424 ) {
425 if palette::theme_remap_active(theme_id) || !palette::symbol_needs_text_contrast(cell.symbol())
426 {
427 return;
428 }
429 let Some(surface) = palette::effective_surface(cell.bg, detected_background) else {
430 return;
431 };
432 cell.fg = palette::enforce_contrast(cell.fg, surface, palette::AA_BODY_CONTRAST);
433 }
434
435 fn adapt_cell_colors(
436 cell: &mut Cell,
437 depth: ColorDepth,
438 palette_mode: PaletteMode,
439 theme_id: ThemeId,
440 ui_theme: &UiTheme,
441 detected_background: Option<ratatui::style::Color>,
442 ) {
443 let source_fg = cell.fg;
444 // Stage 1: community-theme remap (dark palette → preset slots). No-op
445 // for System / Whale / WhaleLight so legacy dark/light flows are
446 // untouched. Runs *before* the palette-mode remap so a light terminal
447 // running e.g. Catppuccin still routes the preset colors through the
448 // light adaptation below (rare combo, but the sequencing is the same).
449 cell.fg = palette::adapt_fg_for_theme(cell.fg, theme_id, ui_theme);
450 cell.bg = palette::adapt_bg_for_theme(cell.bg, theme_id, ui_theme);
451 // Stage 2: legacy dark↔light remap.
452 let original_bg = cell.bg;
453 cell.fg = palette::adapt_fg_for_palette_mode(cell.fg, original_bg, palette_mode);
454 cell.bg = palette::adapt_bg_for_palette_mode(cell.bg, palette_mode);
455 // Stage 2.5: contrast floor. Stages 1 and 2 are equality whitelists — a
456 // token nobody listed reaches here unadapted, which is exactly how
457 // near-white body text ends up on a near-white terminal (#4833). This
458 // stage is membership-independent: it looks at the pair that will be
459 // rendered and lifts it if the numbers fail.
460 enforce_cell_contrast(cell, theme_id, detected_background);
461 // Stage 3: depth (truecolor / 256 / 16) downsampling.
462 cell.fg = palette::adapt_fg_for_depth(source_fg, cell.fg, depth, ui_theme);
463 cell.bg = palette::adapt_bg(cell.bg, depth);
464 if depth == ColorDepth::Monochrome {
465 cell.underline_color = ratatui::style::Color::Reset;
466 }
467 }
468
469 #[cfg(test)]
470 mod tests {
471 use std::{cell::RefCell, env, ffi::OsString, fs, io::Write, rc::Rc};
472
473 use ratatui::backend::Backend;
474 use ratatui::{buffer::Cell, style::Color};
475
476 use super::*;
477 use crate::test_support::lock_test_env;
478
479 #[derive(Clone, Default)]
480 struct SharedWriter(Rc<RefCell<Vec<u8>>>);
481
482 impl Write for SharedWriter {
483 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
484 self.0.borrow_mut().extend_from_slice(buf);
485 Ok(buf.len())
486 }
487
488 fn flush(&mut self) -> io::Result<()> {
489 Ok(())
490 }
491 }
492
493 struct EnvRestore {
494 key: &'static str,
495 value: Option<OsString>,
496 }
497
498 impl EnvRestore {
499 fn capture(key: &'static str) -> Self {
500 Self {
501 key,
502 value: env::var_os(key),
503 }
504 }
505 }
506
507 impl Drop for EnvRestore {
508 fn drop(&mut self) {
509 // SAFETY: environment mutation is serialized by lock_test_env.
510 unsafe {
511 match &self.value {
512 Some(value) => env::set_var(self.key, value),
513 None => env::remove_var(self.key),
514 }
515 }
516 }
517 }
518
519 #[test]
520 fn adapts_rgb_cells_to_indexed_on_ansi256() {
521 let mut cell = Cell::default();
522 cell.set_fg(Color::Rgb(53, 120, 229));
523 cell.set_bg(Color::Rgb(11, 21, 38));
524
525 adapt_cell_colors(
526 &mut cell,
527 ColorDepth::Ansi256,
528 PaletteMode::Dark,
529 ThemeId::System,
530 &palette::UI_THEME,
531 None,
532 );
533
534 assert!(matches!(cell.fg, Color::Indexed(_)));
535 assert!(matches!(cell.bg, Color::Indexed(_)));
536 }
537
538 #[test]
539 fn leaves_truecolor_cells_unchanged() {
540 let mut cell = Cell::default();
541 cell.set_fg(Color::Rgb(53, 120, 229));
542 cell.set_bg(Color::Rgb(11, 21, 38));
543
544 adapt_cell_colors(
545 &mut cell,
546 ColorDepth::TrueColor,
547 PaletteMode::Dark,
548 ThemeId::System,
549 &palette::UI_THEME,
550 None,
551 );
552
553 assert_eq!(cell.fg, Color::Rgb(53, 120, 229));
554 assert_eq!(cell.bg, Color::Rgb(11, 21, 38));
555 }
556
557 #[test]
558 fn ascii_safe_symbol_adapter_preserves_meaning_with_narrow_glyphs() {
559 for (rich, safe) in [
560 ("─", "-"),
561 ("│", "|"),
562 ("┌", "+"),
563 ("▶", ">"),
564 ("▷", ">"),
565 ("▼", "v"),
566 ("✓", "Y"),
567 ("✕", "X"),
568 ] {
569 let mut cell = Cell::default();
570 cell.set_symbol(rich);
571 adapt_cell_symbol_for_ascii(&mut cell);
572 assert_eq!(cell.symbol(), safe, "{rich} should map to {safe}");
573 assert!(cell.symbol().is_ascii());
574 }
575 }
576
577 #[test]
578 fn monochrome_backend_suppresses_every_color_but_keeps_text_modifiers() {
579 use ratatui::style::{Modifier, Style};
580
581 let sgr = regex::Regex::new(r"\x1b\[([0-9;:]*)m").unwrap();
582 for theme_id in palette::SELECTABLE_THEMES {
583 let theme = theme_id.ui_theme();
584 let writer = SharedWriter::default();
585 let capture = writer.0.clone();
586 let mut backend =
587 ColorCompatBackend::new(writer.clone(), ColorDepth::Monochrome, theme.mode);
588 backend.set_theme(*theme_id, theme);
589 let modifiers = Modifier::BOLD | Modifier::UNDERLINED | Modifier::REVERSED;
590 let mut cell = Cell::default();
591 cell.set_symbol("x").set_style(
592 Style::default()
593 .fg(theme.accent_primary)
594 .bg(Color::Indexed(4))
595 .underline_color(Color::Red)
596 .add_modifier(modifiers),
597 );
598 let mut adapted = cell.clone();
599 adapt_cell_colors(
600 &mut adapted,
601 ColorDepth::Monochrome,
602 theme.mode,
603 *theme_id,
604 &theme,
605 None,
606 );
607 assert_eq!(
608 (adapted.fg, adapted.bg, adapted.underline_color),
609 (Color::Reset, Color::Reset, Color::Reset)
610 );
611 assert_eq!(adapted.modifier, modifiers);
612
613 // Screen-mode switches must carry the same color policy.
614 let mut backend = backend.respawn(writer);
615 backend.draw(std::iter::once((0, 0, &cell))).unwrap();
616 let output = String::from_utf8_lossy(&capture.borrow()).to_string();
617 assert!(output.contains('x'), "{theme_id:?}: {output:?}");
618 for codes in sgr.captures_iter(&output) {
619 for code in codes[1]
620 .split([';', ':'])
621 .filter_map(|code| code.parse::<u16>().ok())
622 {
623 assert!(
624 !matches!(code, 30..=38 | 40..=48 | 58 | 90..=97 | 100..=107),
625 "{theme_id:?} emitted color SGR: {output:?}"
626 );
627 }
628 }
629 }
630 }
631
632 #[test]
633 fn ansi256_backend_output_does_not_emit_truecolor_sgr() {
634 let writer = SharedWriter::default();
635 let capture = writer.0.clone();
636 let mut backend = ColorCompatBackend::new(writer, ColorDepth::Ansi256, PaletteMode::Dark);
637 let mut cell = Cell::default();
638 cell.set_symbol("x")
639 .set_fg(Color::Rgb(53, 120, 229))
640 .set_bg(Color::Rgb(11, 21, 38));
641
642 backend.draw(std::iter::once((0, 0, &cell))).unwrap();
643
644 let output = String::from_utf8_lossy(&capture.borrow()).to_string();
645 assert!(!output.contains("38;2;"), "{output:?}");
646 assert!(!output.contains("48;2;"), "{output:?}");
647 }
648
649 #[test]
650 fn light_palette_maps_dark_cells_before_depth_adaptation() {
651 let mut cell = Cell::default();
652 cell.set_fg(Color::White);
653 cell.set_bg(palette::WHALE_BG);
654
655 adapt_cell_colors(
656 &mut cell,
657 ColorDepth::TrueColor,
658 PaletteMode::Light,
659 ThemeId::WhaleLight,
660 &palette::LIGHT_UI_THEME,
661 None,
662 );
663
664 assert_eq!(cell.fg, palette::LIGHT_TEXT_BODY);
665 // The whale pair's shell is terminal-owned: a direct WHALE_BG paint
666 // follows LIGHT_UI_THEME.surface_bg (Reset), not a painted surface.
667 assert_eq!(cell.bg, Color::Reset);
668 }
669
670 #[test]
671 fn grayscale_palette_maps_hued_cells_before_depth_adaptation() {
672 let mut cell = Cell::default();
673 cell.set_fg(palette::WHALE_ACTION);
674 cell.set_bg(palette::WHALE_BG);
675
676 adapt_cell_colors(
677 &mut cell,
678 ColorDepth::TrueColor,
679 PaletteMode::Grayscale,
680 ThemeId::Grayscale,
681 &palette::GRAYSCALE_UI_THEME,
682 None,
683 );
684
685 assert_eq!(cell.fg, palette::GRAYSCALE_TEXT_SOFT);
686 assert_eq!(cell.bg, palette::GRAYSCALE_SURFACE);
687 }
688
689 #[test]
690 fn community_theme_remap_honors_background_color_override() {
691 // Tokyo Night + a custom black surface: the remap must rewrite
692 // `palette::WHALE_BG` to the *active* UiTheme's overridden
693 // surface, not to tokyo-night's default surface.
694 let active = palette::TOKYO_NIGHT_UI_THEME.with_background_color(Color::Rgb(0, 0, 0));
695 let mut cell = Cell::default();
696 cell.set_bg(palette::WHALE_BG);
697
698 adapt_cell_colors(
699 &mut cell,
700 ColorDepth::TrueColor,
701 PaletteMode::Dark,
702 ThemeId::TokyoNight,
703 &active,
704 None,
705 );
706
707 assert_eq!(cell.bg, Color::Rgb(0, 0, 0));
708 }
709
710 #[test]
711 fn terminal_and_matrix_cells_keep_effective_mode_colors() {
712 for (theme_id, theme) in [
713 (ThemeId::Terminal, palette::TERMINAL_UI_THEME),
714 (ThemeId::Matrix, palette::MATRIX_UI_THEME),
715 ] {
716 for (source, expected, role) in [
717 (palette::MODE_AGENT, theme.mode_agent, "agent"),
718 (palette::MODE_PLAN, theme.mode_plan, "plan"),
719 (palette::MODE_OPERATE, theme.mode_operate, "operate"),
720 (palette::MODE_YOLO, theme.mode_yolo, "full access"),
721 ] {
722 let mut cell = Cell::default();
723 cell.set_fg(source);
724 adapt_cell_colors(
725 &mut cell,
726 ColorDepth::TrueColor,
727 theme.mode,
728 theme_id,
729 &theme,
730 None,
731 );
732 assert_eq!(
733 cell.fg,
734 expected,
735 "theme '{}' rendered the {role} token through the wrong slot",
736 theme_id.name(),
737 );
738 }
739 }
740 }
741
742 fn rendered_foreground(
743 source: Color,
744 depth: ColorDepth,
745 theme_id: ThemeId,
746 theme: &UiTheme,
747 ) -> Color {
748 let mut cell = Cell::default();
749 cell.set_fg(source);
750 adapt_cell_colors(&mut cell, depth, theme.mode, theme_id, theme, None);
751 cell.fg
752 }
753
754 #[test]
755 fn grayscale_modes_are_identity_safe_for_raw_and_direct_cells() {
756 let theme = palette::GRAYSCALE_UI_THEME;
757 let roles = [
758 ("act", palette::MODE_AGENT, theme.mode_agent, Color::Blue),
759 ("plan", palette::MODE_PLAN, theme.mode_plan, Color::Magenta),
760 (
761 "operate",
762 palette::MODE_OPERATE,
763 theme.mode_operate,
764 Color::LightMagenta,
765 ),
766 (
767 "full access",
768 palette::MODE_YOLO,
769 theme.mode_yolo,
770 Color::Red,
771 ),
772 ];
773
774 for depth in [
775 ColorDepth::TrueColor,
776 ColorDepth::Ansi256,
777 ColorDepth::Ansi16,
778 ] {
779 let mut outputs = Vec::new();
780 for (name, raw, direct, ansi16) in roles {
781 let expected = if depth == ColorDepth::Ansi16 {
782 ansi16
783 } else {
784 palette::adapt_color(direct, depth)
785 };
786 let raw_output = rendered_foreground(raw, depth, ThemeId::Grayscale, &theme);
787 let direct_output = rendered_foreground(direct, depth, ThemeId::Grayscale, &theme);
788 assert_eq!(raw_output, expected, "raw {name} at {depth:?}");
789 assert_eq!(direct_output, expected, "direct {name} at {depth:?}");
790 outputs.push((name, raw_output));
791 }
792 for (index, (left_name, left)) in outputs.iter().enumerate() {
793 for (right_name, right) in outputs.iter().skip(index + 1) {
794 assert_ne!(
795 left, right,
796 "grayscale {depth:?} merged {left_name} and {right_name}"
797 );
798 }
799 }
800 }
801 }
802
803 #[test]
804 fn ansi16_uses_complete_semantic_role_matrix_for_whale_dark_and_light() {
805 let expected = [
806 ("action", Color::LightBlue),
807 ("live", Color::LightCyan),
808 ("human", Color::LightYellow),
809 ("warning", Color::Yellow),
810 ("danger", Color::LightRed),
811 ("success", Color::LightGreen),
812 ("act mode", Color::Blue),
813 ("plan mode", Color::Magenta),
814 ("operate mode", Color::LightMagenta),
815 ("full-access mode", Color::Red),
816 ];
817 let raw = [
818 palette::WHALE_ACTION,
819 palette::WHALE_LIVE,
820 palette::WHALE_HUMAN,
821 palette::STATUS_WARNING,
822 palette::WHALE_ERROR,
823 palette::STATUS_SUCCESS,
824 palette::MODE_AGENT,
825 palette::MODE_PLAN,
826 palette::MODE_OPERATE,
827 palette::MODE_YOLO,
828 ];
829
830 for (theme_id, theme) in [
831 (ThemeId::Whale, palette::UI_THEME),
832 (ThemeId::WhaleLight, palette::LIGHT_UI_THEME),
833 ] {
834 let direct = [
835 theme.accent_primary,
836 theme.status_working,
837 theme.accent_action,
838 theme.warning,
839 theme.error_fg,
840 theme.success,
841 theme.mode_agent,
842 theme.mode_plan,
843 theme.mode_operate,
844 theme.mode_yolo,
845 ];
846 for (source_kind, sources) in [("raw", raw), ("direct", direct)] {
847 let outputs = sources
848 .into_iter()
849 .zip(expected)
850 .map(|(source, (name, expected_color))| {
851 let output =
852 rendered_foreground(source, ColorDepth::Ansi16, theme_id, &theme);
853 assert_eq!(
854 output,
855 expected_color,
856 "{} {source_kind} {name}",
857 theme_id.name(),
858 );
859 (name, output)
860 })
861 .collect::<Vec<_>>();
862 for (index, (left_name, left)) in outputs.iter().enumerate() {
863 for (right_name, right) in outputs.iter().skip(index + 1) {
864 assert_ne!(
865 left,
866 right,
867 "{} {source_kind} matrix merged {left_name} and {right_name}",
868 theme_id.name(),
869 );
870 }
871 }
872 }
873 }
874 }
875
876 #[test]
877 fn backend_palette_mode_can_follow_runtime_theme_changes() {
878 let writer = SharedWriter::default();
879 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
880
881 assert_eq!(backend.palette_mode, PaletteMode::Dark);
882 backend.set_palette_mode(PaletteMode::Light);
883 assert_eq!(backend.palette_mode, PaletteMode::Light);
884 backend.set_palette_mode(PaletteMode::Grayscale);
885 assert_eq!(backend.palette_mode, PaletteMode::Grayscale);
886 }
887
888 #[test]
889 fn render_debug_env_parser_accepts_truthy_values_only() {
890 assert!(!render_debug_enabled_from_value(None));
891 assert!(!render_debug_enabled_from_value(Some("")));
892 assert!(!render_debug_enabled_from_value(Some("0")));
893 assert!(!render_debug_enabled_from_value(Some("false")));
894 assert!(render_debug_enabled_from_value(Some("1")));
895 assert!(render_debug_enabled_from_value(Some("true")));
896 assert!(render_debug_enabled_from_value(Some("YES")));
897 assert!(render_debug_enabled_from_value(Some("on")));
898 }
899
900 #[test]
901 fn render_debug_line_records_frame_size_and_diff_sample() {
902 let line = render_debug_line(7, Some(Size::new(80, 24)), 42, &[(0, 0), (12, 3), (79, 23)]);
903
904 assert_eq!(
905 line,
906 "frame=7 size=80x24 diff_cells=42 sample=0:0,12:3,79:23\n"
907 );
908 }
909
910 #[test]
911 fn backend_writes_render_debug_log_when_enabled() {
912 let _lock = lock_test_env();
913 let tmp = tempfile::tempdir().expect("tempdir");
914 let _home = EnvRestore::capture("HOME");
915 let _userprofile = EnvRestore::capture("USERPROFILE");
916 let _debug = EnvRestore::capture(RENDER_DEBUG_ENV);
917
918 // SAFETY: environment mutation is serialized by lock_test_env.
919 unsafe {
920 env::set_var("HOME", tmp.path());
921 env::set_var("USERPROFILE", "");
922 env::set_var(RENDER_DEBUG_ENV, "1");
923 }
924
925 let writer = SharedWriter::default();
926 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
927 let mut cell = Cell::default();
928 cell.set_symbol("x");
929 backend.draw(std::iter::once((3, 4, &cell))).unwrap();
930
931 let log_path = tmp
932 .path()
933 .join(".codewhale")
934 .join("logs")
935 .join("tui-render.log");
936 let body = fs::read_to_string(log_path).expect("render debug log");
937 assert!(body.contains("frame=1"), "{body}");
938 assert!(body.contains("diff_cells=1"), "{body}");
939 assert!(body.contains("sample=3:4"), "{body}");
940 }
941
942 #[test]
943 fn size_returns_terminal_size_when_set() {
944 let writer = SharedWriter::default();
945 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
946
947 backend.set_terminal_size(Size::new(120, 40));
948 assert_eq!(backend.size().unwrap(), Size::new(120, 40));
949 }
950
951 #[test]
952 fn forced_size_takes_priority_over_terminal_size() {
953 let writer = SharedWriter::default();
954 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
955
956 // forced_size is set during resize events to temporarily override the
957 // cached terminal_size — it must win to prevent viewport shrinking.
958 backend.set_terminal_size(Size::new(120, 40));
959 backend.force_size(Size::new(80, 25));
960 assert_eq!(backend.size().unwrap(), Size::new(80, 25));
961 }
962
963 #[test]
964 fn size_falls_back_to_forced_size_when_terminal_size_unset() {
965 let writer = SharedWriter::default();
966 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
967
968 backend.force_size(Size::new(80, 25));
969 assert_eq!(backend.size().unwrap(), Size::new(80, 25));
970 }
971
972 // ── #3029: OSC 8 emission through the backend byte stream ──────────────
973
974 fn row_cells(symbols: &str) -> Vec<(u16, u16, Cell)> {
975 symbols
976 .chars()
977 .enumerate()
978 .map(|(i, ch)| {
979 let mut cell = Cell::default();
980 cell.set_symbol(&ch.to_string());
981 (u16::try_from(i).unwrap(), 0u16, cell)
982 })
983 .collect()
984 }
985
986 #[test]
987 fn osc8_open_close_bracket_only_their_region_cells() {
988 use crate::tui::osc8::LinkRegion;
989
990 // Baseline: identical cells, no link regions.
991 let baseline_writer = SharedWriter::default();
992 let baseline_capture = baseline_writer.0.clone();
993 let mut baseline =
994 ColorCompatBackend::new(baseline_writer, ColorDepth::TrueColor, PaletteMode::Dark);
995 let cells = row_cells("ABCDE");
996 baseline
997 .draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
998 .unwrap();
999 let baseline_out = String::from_utf8_lossy(&baseline_capture.borrow()).to_string();
1000
1001 // Linked render: columns 2..=3 ("CD") carry one link region.
1002 crate::tui::osc8::set_frame_links(vec![LinkRegion {
1003 row: 0,
1004 col_start: 2,
1005 col_end: 3,
1006 target: "https://example.test/1".to_string(),
1007 }]);
1008 let writer = SharedWriter::default();
1009 let capture = writer.0.clone();
1010 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
1011 let cells = row_cells("ABCDE");
1012 backend
1013 .draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
1014 .unwrap();
1015 let out = String::from_utf8_lossy(&capture.borrow()).to_string();
1016
1017 let open = "\x1b]8;;https://example.test/1\x1b\\";
1018 let close = "\x1b]8;;\x1b\\";
1019 assert_eq!(out.matches(open).count(), 1, "exactly one open: {out:?}");
1020 assert_eq!(out.matches(close).count(), 1, "exactly one close: {out:?}");
1021
1022 // The open must precede the first linked glyph and the close must sit
1023 // between the last linked glyph and the first glyph after the region.
1024 let open_at = out.find(open).expect("open present");
1025 let close_at = out.find(close).expect("close present");
1026 let c_at = out.find('C').expect("glyph C");
1027 let d_at = out.find('D').expect("glyph D");
1028 let e_at = out.find('E').expect("glyph E");
1029 assert!(open_at < c_at, "open before linked cells: {out:?}");
1030 assert!(d_at < close_at, "close after linked cells: {out:?}");
1031 assert!(
1032 close_at < e_at,
1033 "cells after the region must not inherit the link: {out:?}"
1034 );
1035
1036 // Visible glyph stream is unchanged by link insertion.
1037 let mut baseline_visible = String::new();
1038 crate::tui::osc8::strip_ansi_into(&baseline_out, &mut baseline_visible);
1039 let mut linked_visible = String::new();
1040 crate::tui::osc8::strip_ansi_into(&out, &mut linked_visible);
1041 assert_eq!(
1042 baseline_visible, linked_visible,
1043 "link emission must not move or alter visible cells"
1044 );
1045 }
1046
1047 #[test]
1048 fn osc8_two_regions_link_to_their_own_targets() {
1049 use crate::tui::osc8::LinkRegion;
1050
1051 crate::tui::osc8::set_frame_links(vec![
1052 LinkRegion {
1053 row: 0,
1054 col_start: 0,
1055 col_end: 1,
1056 target: "https://example.test/first".to_string(),
1057 },
1058 LinkRegion {
1059 row: 0,
1060 col_start: 3,
1061 col_end: 4,
1062 target: "https://example.test/second".to_string(),
1063 },
1064 ]);
1065 let writer = SharedWriter::default();
1066 let capture = writer.0.clone();
1067 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
1068 let cells = row_cells("ABZCD");
1069 backend
1070 .draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
1071 .unwrap();
1072 let out = String::from_utf8_lossy(&capture.borrow()).to_string();
1073
1074 let first = "\x1b]8;;https://example.test/first\x1b\\";
1075 let second = "\x1b]8;;https://example.test/second\x1b\\";
1076 let close = "\x1b]8;;\x1b\\";
1077 assert_eq!(out.matches(first).count(), 1, "{out:?}");
1078 assert_eq!(out.matches(second).count(), 1, "{out:?}");
1079 assert_eq!(out.matches(close).count(), 2, "{out:?}");
1080
1081 // Pre-#3029-audit bug: both opens were emitted before any cell, so
1082 // the whole frame linked to the LAST region's target. Each region's
1083 // open must close before the next region's open begins.
1084 let first_at = out.find(first).expect("first open");
1085 let first_close_at = out[first_at..].find(close).expect("first close") + first_at;
1086 let second_at = out.find(second).expect("second open");
1087 assert!(
1088 first_close_at < second_at,
1089 "region one must close before region two opens: {out:?}"
1090 );
1091 // The unlinked middle glyph sits between the two link spans.
1092 let z_at = out.find('Z').expect("unlinked glyph");
1093 assert!(first_close_at < z_at && z_at < second_at, "{out:?}");
1094 }
1095
1096 /// #3029 end-to-end: a long bare URL hard-wraps at narrow width while its
1097 /// full target travels beside every visible chunk. No escape payload enters
1098 /// the buffer, and the backend re-emits one OSC 8 pair per row without
1099 /// altering the visible byte stream.
1100 #[test]
1101 fn osc8_metadata_feeds_backend_for_every_wrapped_url_chunk() {
1102 use crate::tui::{markdown_render, osc8};
1103 use ratatui::buffer::Buffer;
1104 use ratatui::layout::Rect;
1105 use ratatui::style::Style;
1106 use ratatui::widgets::{Paragraph, Widget};
1107 use unicode_width::UnicodeWidthStr;
1108
1109 let target = "https://example.test/a/very/long/path/that/wraps/across/rows";
1110 let rendered = markdown_render::render_markdown_tagged(target, 12, Style::default());
1111 assert!(rendered.len() > 2, "fixture must wrap at narrow width");
1112 let lines = rendered
1113 .iter()
1114 .map(|rendered| rendered.line.clone())
1115 .collect::<Vec<_>>();
1116 let line_links = rendered
1117 .iter()
1118 .map(|rendered| rendered.links.clone())
1119 .collect::<Vec<_>>();
1120 let visible = lines
1121 .iter()
1122 .map(|line| {
1123 line.spans
1124 .iter()
1125 .map(|span| span.content.as_ref())
1126 .collect::<String>()
1127 })
1128 .collect::<Vec<_>>();
1129 assert_eq!(visible.concat(), target);
1130
1131 let area = Rect::new(3, 2, 12, u16::try_from(lines.len()).unwrap());
1132 let mut buf = Buffer::empty(area);
1133 Paragraph::new(lines).render(area, &mut buf);
1134
1135 // Visible cells start at the area's real x offset and contain exactly
1136 // the URL chunks — never the historical `]8;;` payload bytes.
1137 for (row_index, text) in visible.iter().enumerate() {
1138 let y = area.y + u16::try_from(row_index).unwrap();
1139 let row = (0..u16::try_from(text.width()).unwrap())
1140 .map(|offset| buf[(area.x + offset, y)].symbol().to_string())
1141 .collect::<String>();
1142 assert_eq!(row, *text);
1143 }
1144 assert!((area.y..area.bottom()).all(|y| {
1145 (area.x..area.right()).all(|x| {
1146 let symbol = buf[(x, y)].symbol();
1147 !symbol.contains('\x1b') && !symbol.contains("]8;;")
1148 })
1149 }));
1150
1151 let regions = osc8::link_regions_for_lines(area, &line_links);
1152 assert_eq!(regions.len(), rendered.len());
1153 for ((region, text), row_index) in regions.iter().zip(&visible).zip(0u16..) {
1154 assert_eq!(region.row, area.y + row_index);
1155 assert_eq!(region.col_start, area.x);
1156 assert_eq!(
1157 region.col_end,
1158 area.x + u16::try_from(text.width()).unwrap() - 1
1159 );
1160 assert_eq!(region.target, target);
1161 }
1162
1163 let buf_ref = &buf;
1164 let cells = (area.y..area.bottom())
1165 .flat_map(|y| (area.x..area.right()).map(move |x| (x, y, buf_ref[(x, y)].clone())))
1166 .collect::<Vec<_>>();
1167
1168 // Capture an unlinked baseline from the exact same cells.
1169 let _ = osc8::take_frame_links();
1170 let baseline_writer = SharedWriter::default();
1171 let baseline_capture = baseline_writer.0.clone();
1172 let mut baseline =
1173 ColorCompatBackend::new(baseline_writer, ColorDepth::TrueColor, PaletteMode::Dark);
1174 baseline
1175 .draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
1176 .unwrap();
1177
1178 osc8::set_frame_links(regions);
1179 let writer = SharedWriter::default();
1180 let capture = writer.0.clone();
1181 let mut backend = ColorCompatBackend::new(writer, ColorDepth::TrueColor, PaletteMode::Dark);
1182 backend
1183 .draw(cells.iter().map(|(x, y, cell)| (*x, *y, cell)))
1184 .unwrap();
1185 let out = String::from_utf8_lossy(&capture.borrow()).to_string();
1186
1187 let open = format!("\x1b]8;;{target}\x1b\\");
1188 let close = "\x1b]8;;\x1b\\";
1189 assert_eq!(
1190 out.matches(open.as_str()).count(),
1191 rendered.len(),
1192 "each row reopens the full target: {out:?}"
1193 );
1194 assert_eq!(out.matches(close).count(), rendered.len());
1195
1196 let baseline_out = String::from_utf8_lossy(&baseline_capture.borrow()).to_string();
1197 let mut baseline_visible = String::new();
1198 osc8::strip_ansi_into(&baseline_out, &mut baseline_visible);
1199 let mut linked_visible = String::new();
1200 osc8::strip_ansi_into(&out, &mut linked_visible);
1201 assert_eq!(
1202 linked_visible, baseline_visible,
1203 "OSC 8 insertion must not move or alter any rendered cell"
1204 );
1205 }
1206
1207 /// Render one cell the way `draw()` does and hand back the foreground.
1208 fn cell_fg_after_adaptation(
1209 symbol: &str,
1210 fg: Color,
1211 bg: Color,
1212 palette_mode: PaletteMode,
1213 theme_id: ThemeId,
1214 detected_background: Option<Color>,
1215 ) -> Color {
1216 let mut cell = Cell::default();
1217 cell.set_symbol(symbol).set_fg(fg).set_bg(bg);
1218 adapt_cell_colors(
1219 &mut cell,
1220 ColorDepth::TrueColor,
1221 palette_mode,
1222 theme_id,
1223 &theme_id.ui_theme(),
1224 detected_background,
1225 );
1226 cell.fg
1227 }
1228
1229 /// #4833. A white terminal that reports no `COLORFGBG` was detected as
1230 /// Dark, so the light whitelist never ran and ivory body text landed on a
1231 /// near-white surface. With the background measured, the contrast floor
1232 /// catches it even when the palette mode is still Dark.
1233 #[test]
1234 fn measured_light_background_lifts_body_text_off_the_surface() {
1235 let white = Color::Rgb(0xFF, 0xFF, 0xFF);
1236 let rendered = cell_fg_after_adaptation(
1237 "x",
1238 palette::TEXT_BODY,
1239 Color::Reset,
1240 PaletteMode::Dark,
1241 ThemeId::System,
1242 Some(white),
1243 );
1244
1245 assert_ne!(
1246 rendered,
1247 palette::TEXT_BODY,
1248 "body text must not pass through unadapted onto a white surface"
1249 );
1250 let ratio = palette::contrast_ratio(rendered, white).unwrap();
1251 assert!(
1252 ratio >= palette::AA_BODY_CONTRAST,
1253 "rendered body text is {ratio}:1 against the measured surface"
1254 );
1255 }
1256
1257 /// The no-regression half of #4833: on a measured dark terminal every
1258 /// rendered cell comes out byte-identical to what v0.9.1 emitted.
1259 #[test]
1260 fn measured_dark_background_changes_nothing() {
1261 for surface in [
1262 Color::Rgb(0x00, 0x00, 0x00),
1263 Color::Rgb(0x1E, 0x1E, 0x1E),
1264 palette::WHALE_BG,
1265 ] {
1266 for token in [
1267 palette::TEXT_BODY,
1268 palette::TEXT_HINT,
1269 palette::TEXT_TOOL_OUTPUT,
1270 palette::WHALE_ACTION,
1271 palette::WHALE_HUMAN,
1272 palette::STATUS_ERROR,
1273 palette::DIFF_ADDED,
1274 // Frame chrome sits below 4.5:1 by design and must survive.
1275 palette::BORDER_COLOR,
1276 ] {
1277 let symbol = if token == palette::BORDER_COLOR {
1278 "\u{2500}"
1279 } else {
1280 "x"
1281 };
1282 assert_eq!(
1283 cell_fg_after_adaptation(
1284 symbol,
1285 token,
1286 Color::Reset,
1287 PaletteMode::Dark,
1288 ThemeId::System,
1289 Some(surface),
1290 ),
1291 token,
1292 "{token:?} was rewritten on measured dark surface {surface:?}"
1293 );
1294 }
1295 }
1296 }
1297
1298 /// No measurement means no intervention: an unpainted cell on a terminal
1299 /// we could not query renders exactly as before.
1300 #[test]
1301 fn unknown_background_leaves_unpainted_cells_alone() {
1302 assert_eq!(
1303 cell_fg_after_adaptation(
1304 "x",
1305 palette::TEXT_BODY,
1306 Color::Reset,
1307 PaletteMode::Dark,
1308 ThemeId::System,
1309 None,
1310 ),
1311 palette::TEXT_BODY
1312 );
1313 }
1314
1315 /// Frame chrome keeps its intended weight even where the floor is active.
1316 #[test]
1317 fn contrast_floor_skips_frame_chrome_on_a_light_surface() {
1318 let white = Color::Rgb(0xFF, 0xFF, 0xFF);
1319 assert_eq!(
1320 cell_fg_after_adaptation(
1321 "\u{2502}",
1322 palette::LIGHT_BORDER,
1323 Color::Reset,
1324 PaletteMode::Light,
1325 ThemeId::WhaleLight,
1326 Some(white),
1327 ),
1328 palette::LIGHT_BORDER
1329 );
1330 }
1331
1332 /// Presets own their palette. A user who chose Matrix asked for its
1333 /// deliberately dim greens; the floor must not repaint them.
1334 #[test]
1335 fn explicit_presets_are_exempt_from_the_floor() {
1336 let matrix = ThemeId::Matrix.ui_theme();
1337 assert_eq!(
1338 cell_fg_after_adaptation(
1339 "x",
1340 matrix.text_muted,
1341 Color::Reset,
1342 PaletteMode::Dark,
1343 ThemeId::Matrix,
1344 Some(Color::Rgb(0x00, 0x00, 0x00)),
1345 ),
1346 matrix.text_muted
1347 );
1348 }
1349 }
1350
1350 lines RUST