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