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