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