| 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 |