| 1 | //! Render components off-screen for snapshots, the gallery and host tests. |
| 2 | //! |
| 3 | //! Styles are recorded by role, not by hex, so a token value change does not |
| 4 | //! rewrite every snapshot, but painting `Muted` where `Danger` belongs does. |
| 5 | |
| 6 | use std::fmt::Write as _; |
| 7 | |
| 8 | use ratatui::{buffer::Buffer, layout::Rect}; |
| 9 | use ratatui_core::style::{Color, Modifier, Style}; |
| 10 | |
| 11 | use crate::{Caps, Role, Theme, color::ColorDepth, detect::Appearance, text, theme::Ground}; |
| 12 | |
| 13 | /// A terminal to render for. |
| 14 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 15 | pub enum Profile { |
| 16 | /// Truecolor on a dark ground, in the blue ombre (the default). |
| 17 | DarkTrue, |
| 18 | /// Truecolor on a dark ground, with the graphite token grounds. |
| 19 | DarkGraphite, |
| 20 | LightTrue, |
| 21 | Dark256, |
| 22 | Light256, |
| 23 | /// 16 colors on a measured dark ground. |
| 24 | Ansi16, |
| 25 | /// Truecolor, but nothing measured the ground. |
| 26 | UnknownGround, |
| 27 | /// `NO_COLOR`. |
| 28 | NoColor, |
| 29 | /// `NO_COLOR` plus `CODEWHALE_ASCII_SAFE`. |
| 30 | Ascii, |
| 31 | } |
| 32 | |
| 33 | impl Profile { |
| 34 | pub const ALL: [Profile; 9] = [ |
| 35 | Profile::DarkTrue, |
| 36 | Profile::DarkGraphite, |
| 37 | Profile::LightTrue, |
| 38 | Profile::Dark256, |
| 39 | Profile::Light256, |
| 40 | Profile::Ansi16, |
| 41 | Profile::UnknownGround, |
| 42 | Profile::NoColor, |
| 43 | Profile::Ascii, |
| 44 | ]; |
| 45 | |
| 46 | #[must_use] |
| 47 | pub const fn name(self) -> &'static str { |
| 48 | match self { |
| 49 | Profile::DarkTrue => "dark-truecolor", |
| 50 | Profile::DarkGraphite => "dark-graphite", |
| 51 | Profile::LightTrue => "light-truecolor", |
| 52 | Profile::Dark256 => "dark-256", |
| 53 | Profile::Light256 => "light-256", |
| 54 | Profile::Ansi16 => "ansi-16", |
| 55 | Profile::UnknownGround => "unknown-ground", |
| 56 | Profile::NoColor => "no-color", |
| 57 | Profile::Ascii => "ascii", |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | #[must_use] |
| 62 | pub fn from_name(name: &str) -> Option<Self> { |
| 63 | Self::ALL.into_iter().find(|p| p.name() == name) |
| 64 | } |
| 65 | |
| 66 | #[must_use] |
| 67 | pub const fn caps(self) -> Caps { |
| 68 | let (depth, appearance, ascii) = match self { |
| 69 | Profile::DarkTrue | Profile::DarkGraphite => { |
| 70 | (ColorDepth::TrueColor, Appearance::Dark, false) |
| 71 | } |
| 72 | Profile::LightTrue => (ColorDepth::TrueColor, Appearance::Light, false), |
| 73 | Profile::Dark256 => (ColorDepth::Ansi256, Appearance::Dark, false), |
| 74 | Profile::Light256 => (ColorDepth::Ansi256, Appearance::Light, false), |
| 75 | Profile::Ansi16 => (ColorDepth::Ansi16, Appearance::Dark, false), |
| 76 | Profile::UnknownGround => (ColorDepth::TrueColor, Appearance::Unknown, false), |
| 77 | Profile::NoColor => (ColorDepth::Monochrome, Appearance::Dark, false), |
| 78 | Profile::Ascii => (ColorDepth::Monochrome, Appearance::Dark, true), |
| 79 | }; |
| 80 | Caps { |
| 81 | depth, |
| 82 | ascii, |
| 83 | appearance, |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | #[must_use] |
| 88 | pub const fn theme(self) -> Theme { |
| 89 | let theme = Theme::new(self.caps()); |
| 90 | match self { |
| 91 | Profile::DarkGraphite => theme.ground(Ground::Graphite), |
| 92 | _ => theme, |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// The widths a component is checked at: a narrow terminal, the classic 80 |
| 98 | /// columns, and a wide one. |
| 99 | pub const WIDTHS: [u16; 3] = [40, 80, 120]; |
| 100 | |
| 101 | /// A component painted for one profile at one width: what a component test |
| 102 | /// looks at. |
| 103 | pub struct Frame { |
| 104 | pub name: String, |
| 105 | pub profile: Profile, |
| 106 | pub theme: Theme, |
| 107 | pub buf: Buffer, |
| 108 | } |
| 109 | |
| 110 | impl Frame { |
| 111 | #[must_use] |
| 112 | pub fn new(name: impl Into<String>, profile: Profile, buf: Buffer) -> Self { |
| 113 | Self { |
| 114 | name: name.into(), |
| 115 | profile, |
| 116 | theme: profile.theme(), |
| 117 | buf, |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | #[must_use] |
| 122 | pub fn width(&self) -> u16 { |
| 123 | self.buf.area.width |
| 124 | } |
| 125 | |
| 126 | #[must_use] |
| 127 | pub fn height(&self) -> u16 { |
| 128 | self.buf.area.height |
| 129 | } |
| 130 | |
| 131 | /// `name · profile · 80x3` (no name when the frame has none), for failure |
| 132 | /// messages and dump headers. |
| 133 | #[must_use] |
| 134 | pub fn label(&self) -> String { |
| 135 | let (profile, width, height) = (self.profile.name(), self.width(), self.height()); |
| 136 | if self.name.is_empty() { |
| 137 | format!("{profile} · {width}x{height}") |
| 138 | } else { |
| 139 | format!("{} · {profile} · {width}x{height}", self.name) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | /// The glyphs, one line per row ([`text()`]). |
| 144 | #[must_use] |
| 145 | pub fn text(&self) -> String { |
| 146 | text(&self.buf) |
| 147 | } |
| 148 | |
| 149 | /// The glyphs and the role each run was painted with ([`styled`]). |
| 150 | #[must_use] |
| 151 | pub fn styled(&self) -> String { |
| 152 | styled(&self.buf, &self.theme) |
| 153 | } |
| 154 | |
| 155 | /// Every rule this frame breaks; empty when it keeps them all. See |
| 156 | /// [`rule_violations`]. |
| 157 | #[must_use] |
| 158 | pub fn violations(&self) -> Vec<String> { |
| 159 | rule_violations(self) |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Paint `paint` for each of `profiles` at each of `widths`, `height` rows |
| 164 | /// tall, on the theme's `Background` as the gallery paints it. |
| 165 | pub fn frames_for( |
| 166 | name: &str, |
| 167 | profiles: &[Profile], |
| 168 | widths: &[u16], |
| 169 | height: u16, |
| 170 | paint: impl Fn(Rect, &mut Buffer, &Theme), |
| 171 | ) -> Vec<Frame> { |
| 172 | let mut frames = Vec::new(); |
| 173 | for profile in profiles { |
| 174 | let theme = profile.theme(); |
| 175 | for width in widths { |
| 176 | let buf = render(*width, height, |area, buf| { |
| 177 | buf.set_style(area, theme.bg(Role::Background)); |
| 178 | paint(area, buf, &theme); |
| 179 | }); |
| 180 | frames.push(Frame::new(name, *profile, buf)); |
| 181 | } |
| 182 | } |
| 183 | frames |
| 184 | } |
| 185 | |
| 186 | /// The one-call render for component tests: `paint` at 40, 80 and 120 |
| 187 | /// columns ([`WIDTHS`]) in every [`Profile`], `height` rows tall. |
| 188 | pub fn frames(height: u16, paint: impl Fn(Rect, &mut Buffer, &Theme)) -> Vec<Frame> { |
| 189 | frames_for("", &Profile::ALL, &WIDTHS, height, paint) |
| 190 | } |
| 191 | |
| 192 | /// Fail, listing every broken rule, if any frame of `paint` (as [`frames`]) |
| 193 | /// breaks a rule ([`rule_violations`]). |
| 194 | pub fn assert_rules(height: u16, paint: impl Fn(Rect, &mut Buffer, &Theme)) { |
| 195 | assert_frames_keep_the_rules(&frames(height, paint)); |
| 196 | } |
| 197 | |
| 198 | /// Fail, listing every broken rule, if any of `frames` breaks one. |
| 199 | pub fn assert_frames_keep_the_rules(frames: &[Frame]) { |
| 200 | let mut broken: Vec<String> = frames.iter().flat_map(Frame::violations).collect(); |
| 201 | broken.dedup(); |
| 202 | assert!(broken.is_empty(), "{}", broken.join("\n")); |
| 203 | } |
| 204 | |
| 205 | /// The text to hand `insta::assert_snapshot!` for a component: the design's |
| 206 | /// matrix. `DarkTrue` at every width with the role of each run (`styled`), |
| 207 | /// then `NoColor` and `Ascii` at every width as glyphs only (`text`). |
| 208 | /// Painting the wrong role changes it; a token value change does not. |
| 209 | pub fn snapshot(height: u16, paint: impl Fn(Rect, &mut Buffer, &Theme)) -> String { |
| 210 | let mut out = String::new(); |
| 211 | for frame in frames_for("", &[Profile::DarkTrue], &WIDTHS, height, &paint) { |
| 212 | let _ = writeln!(out, "== {}\n{}", frame.label(), frame.styled()); |
| 213 | } |
| 214 | for frame in frames_for( |
| 215 | "", |
| 216 | &[Profile::NoColor, Profile::Ascii], |
| 217 | &WIDTHS, |
| 218 | height, |
| 219 | &paint, |
| 220 | ) { |
| 221 | let _ = writeln!(out, "== {}\n{}\n", frame.label(), frame.text()); |
| 222 | } |
| 223 | out |
| 224 | } |
| 225 | |
| 226 | /// Like [`snapshot`], but every profile styled: what the gallery's own |
| 227 | /// snapshots record. Large; prefer [`snapshot`] for a new component. |
| 228 | pub fn snapshot_all_profiles(height: u16, paint: impl Fn(Rect, &mut Buffer, &Theme)) -> String { |
| 229 | let mut out = String::new(); |
| 230 | for frame in frames(height, paint) { |
| 231 | let _ = writeln!(out, "== {}\n{}\n", frame.label(), frame.styled()); |
| 232 | } |
| 233 | out |
| 234 | } |
| 235 | |
| 236 | /// What each profile may put on the screen. |
| 237 | #[must_use] |
| 238 | pub fn color_allowed(profile: Profile, color: Color) -> bool { |
| 239 | match (profile, color) { |
| 240 | (_, Color::Reset) => true, |
| 241 | (Profile::NoColor | Profile::Ascii, _) => false, |
| 242 | (Profile::Ansi16 | Profile::UnknownGround, Color::Rgb(..) | Color::Indexed(_)) => false, |
| 243 | (Profile::Dark256 | Profile::Light256, Color::Rgb(..)) => false, |
| 244 | (Profile::Dark256 | Profile::Light256, Color::Indexed(i)) => i >= 16, |
| 245 | // Truecolor on a known ground paints exact RGB: tokens, and the |
| 246 | // whale's ombre and the horizon's fade blended from them. |
| 247 | (Profile::DarkTrue | Profile::DarkGraphite | Profile::LightTrue, c) => { |
| 248 | !matches!(c, Color::Indexed(_)) |
| 249 | } |
| 250 | (Profile::Dark256 | Profile::Light256, _) => true, |
| 251 | _ => true, |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | /// The rules every frame keeps, as the list of those `frame` breaks: |
| 256 | /// |
| 257 | /// - at most two native surface hairlines (a row of `─` covering more |
| 258 | /// than half the width), and no `═`, `≈` or `∿` rule; |
| 259 | /// - authored ASCII-safe output contains only ASCII; native text retains its |
| 260 | /// source punctuation, including three-dot truncation; |
| 261 | /// - only the colors the profile may show ([`color_allowed`]), and no ground |
| 262 | /// painted where the terminal cannot show one. |
| 263 | #[must_use] |
| 264 | pub fn rule_violations(frame: &Frame) -> Vec<String> { |
| 265 | let mut broken = Vec::new(); |
| 266 | let (profile, theme) = (frame.profile, &frame.theme); |
| 267 | let text = frame.text(); |
| 268 | let at = frame.label(); |
| 269 | if profile == Profile::Ascii && !text.is_ascii() { |
| 270 | broken.push(format!("{at}: non-ASCII glyph in ASCII-safe output")); |
| 271 | } |
| 272 | for banned in ['═', '≈', '∿'] { |
| 273 | if text.contains(banned) { |
| 274 | broken.push(format!("{at}: `{banned}` rule")); |
| 275 | } |
| 276 | } |
| 277 | let rules = text |
| 278 | .lines() |
| 279 | // A horizon runs from the left edge; a panel's edge starts with a |
| 280 | // corner. |
| 281 | .filter(|l| { |
| 282 | l.starts_with('─') |
| 283 | && l.chars().filter(|c| *c == '─').count() * 2 > usize::from(frame.width()) |
| 284 | }) |
| 285 | .count(); |
| 286 | if rules > 2 { |
| 287 | broken.push(format!("{at}: {rules} horizons; two per frame")); |
| 288 | } |
| 289 | for cell in frame.buf.content() { |
| 290 | for color in [cell.fg, cell.bg] { |
| 291 | if !color_allowed(profile, color) { |
| 292 | broken.push(format!("{at}: {color:?} is not allowed here")); |
| 293 | break; |
| 294 | } |
| 295 | } |
| 296 | } |
| 297 | if !theme.paints_grounds() && frame.buf.content().iter().any(|c| c.bg != Color::Reset) { |
| 298 | broken.push(format!("{at}: painted a ground the terminal cannot show")); |
| 299 | } |
| 300 | broken |
| 301 | } |
| 302 | |
| 303 | /// Paint into a fresh `width` x `height` buffer. |
| 304 | pub fn render(width: u16, height: u16, paint: impl FnOnce(Rect, &mut Buffer)) -> Buffer { |
| 305 | let area = Rect::new(0, 0, width, height); |
| 306 | let mut buf = Buffer::empty(area); |
| 307 | paint(area, &mut buf); |
| 308 | buf |
| 309 | } |
| 310 | |
| 311 | /// Visit each drawn cell once, skipping the cells a wide glyph covers. |
| 312 | fn cells(buf: &Buffer, mut visit: impl FnMut(u16, u16, &str, Style)) { |
| 313 | let area = buf.area; |
| 314 | for y in area.top()..area.bottom() { |
| 315 | let mut x = area.left(); |
| 316 | while x < area.right() { |
| 317 | let cell = &buf[(x, y)]; |
| 318 | let symbol = cell.symbol(); |
| 319 | visit(x, y, symbol, cell.style()); |
| 320 | x += u16::try_from(text::width(symbol).max(1)).unwrap_or(1); |
| 321 | } |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | /// The glyphs, one line per row, trailing spaces trimmed. |
| 326 | #[must_use] |
| 327 | pub fn text(buf: &Buffer) -> String { |
| 328 | let mut rows = vec![String::new(); usize::from(buf.area.height)]; |
| 329 | cells(buf, |_, y, symbol, _| { |
| 330 | rows[usize::from(y - buf.area.y)].push_str(symbol) |
| 331 | }); |
| 332 | rows.iter() |
| 333 | .map(|r| r.trim_end()) |
| 334 | .collect::<Vec<_>>() |
| 335 | .join("\n") |
| 336 | } |
| 337 | |
| 338 | /// The role a color was painted as. Roles that collapse to one color at |
| 339 | /// this depth are all named (`Background|Surface`), so a collapse shows in |
| 340 | /// the snapshot instead of hiding behind whichever role comes first. |
| 341 | fn color_name(color: Color, theme: &Theme, ground: bool) -> String { |
| 342 | let roles = theme.roles_of(color, ground); |
| 343 | if !roles.is_empty() { |
| 344 | return roles |
| 345 | .iter() |
| 346 | .map(|r| format!("{r:?}")) |
| 347 | .collect::<Vec<_>>() |
| 348 | .join("|"); |
| 349 | } |
| 350 | match color { |
| 351 | Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), |
| 352 | Color::Indexed(i) => format!("idx{i}"), |
| 353 | other => format!("{other:?}"), |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | fn describe(style: Style, theme: &Theme) -> String { |
| 358 | let mut parts = Vec::new(); |
| 359 | if let Some(fg) = style.fg.filter(|c| *c != Color::Reset) { |
| 360 | parts.push(format!("fg={}", color_name(fg, theme, false))); |
| 361 | } |
| 362 | if let Some(bg) = style.bg.filter(|c| *c != Color::Reset) { |
| 363 | parts.push(format!("bg={}", color_name(bg, theme, true))); |
| 364 | } |
| 365 | for (m, name) in [ |
| 366 | (Modifier::BOLD, "bold"), |
| 367 | (Modifier::DIM, "dim"), |
| 368 | (Modifier::ITALIC, "italic"), |
| 369 | (Modifier::UNDERLINED, "underline"), |
| 370 | (Modifier::REVERSED, "reversed"), |
| 371 | ] { |
| 372 | if style.add_modifier.contains(m) { |
| 373 | parts.push(name.to_string()); |
| 374 | } |
| 375 | } |
| 376 | parts.join(" ") |
| 377 | } |
| 378 | |
| 379 | /// The glyphs, then one line per run of identical style: |
| 380 | /// `r3 c2..9 fg=Live bold`. Unstyled runs are left out. |
| 381 | #[must_use] |
| 382 | pub fn styled(buf: &Buffer, theme: &Theme) -> String { |
| 383 | let mut out = text(buf); |
| 384 | out.push_str("\n---\n"); |
| 385 | let mut run: Option<(u16, u16, u16, String)> = None; |
| 386 | let flush = |run: &mut Option<(u16, u16, u16, String)>, out: &mut String| { |
| 387 | if let Some((y, x0, x1, d)) = run.take() |
| 388 | && !d.is_empty() |
| 389 | { |
| 390 | let _ = writeln!(out, "r{y} c{x0}..{x1} {d}"); |
| 391 | } |
| 392 | }; |
| 393 | cells(buf, |x, y, symbol, style| { |
| 394 | let d = describe(style, theme); |
| 395 | let w = u16::try_from(text::width(symbol).max(1)).unwrap_or(1); |
| 396 | match &mut run { |
| 397 | Some((ry, _, x1, rd)) if *ry == y && *rd == d && *x1 == x => *x1 = x + w, |
| 398 | _ => { |
| 399 | flush(&mut run, &mut out); |
| 400 | run = Some((y, x, x + w, d)); |
| 401 | } |
| 402 | } |
| 403 | }); |
| 404 | flush(&mut run, &mut out); |
| 405 | out |
| 406 | } |
| 407 | |
| 408 | fn sgr_color(color: Color, ground: bool) -> String { |
| 409 | let base = if ground { 40 } else { 30 }; |
| 410 | match color { |
| 411 | Color::Reset => format!("{}", base + 9), |
| 412 | Color::Rgb(r, g, b) => format!("{};2;{r};{g};{b}", base + 8), |
| 413 | Color::Indexed(i) => format!("{};5;{i}", base + 8), |
| 414 | named => { |
| 415 | let (n, bright) = match named { |
| 416 | Color::Black => (0, false), |
| 417 | Color::Red => (1, false), |
| 418 | Color::Green => (2, false), |
| 419 | Color::Yellow => (3, false), |
| 420 | Color::Blue => (4, false), |
| 421 | Color::Magenta => (5, false), |
| 422 | Color::Cyan => (6, false), |
| 423 | Color::Gray => (7, false), |
| 424 | Color::DarkGray => (0, true), |
| 425 | Color::LightRed => (1, true), |
| 426 | Color::LightGreen => (2, true), |
| 427 | Color::LightYellow => (3, true), |
| 428 | Color::LightBlue => (4, true), |
| 429 | Color::LightMagenta => (5, true), |
| 430 | Color::LightCyan => (6, true), |
| 431 | _ => (7, true), |
| 432 | }; |
| 433 | format!("{}", if bright { base + 60 + n } else { base + n }) |
| 434 | } |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | /// The buffer as text with ANSI SGR escapes, for `cat` in any terminal. |
| 439 | #[must_use] |
| 440 | pub fn ansi(buf: &Buffer) -> String { |
| 441 | let mut out = String::new(); |
| 442 | let mut last: Option<Style> = None; |
| 443 | let mut row = buf.area.y; |
| 444 | cells(buf, |_, y, symbol, style| { |
| 445 | if y != row { |
| 446 | out.push_str("\x1b[0m\n"); |
| 447 | row = y; |
| 448 | last = None; |
| 449 | } |
| 450 | if last != Some(style) { |
| 451 | let mut codes = vec!["0".to_string()]; |
| 452 | for (m, code) in [ |
| 453 | (Modifier::BOLD, "1"), |
| 454 | (Modifier::DIM, "2"), |
| 455 | (Modifier::ITALIC, "3"), |
| 456 | (Modifier::UNDERLINED, "4"), |
| 457 | (Modifier::REVERSED, "7"), |
| 458 | ] { |
| 459 | if style.add_modifier.contains(m) { |
| 460 | codes.push(code.into()); |
| 461 | } |
| 462 | } |
| 463 | if let Some(fg) = style.fg { |
| 464 | codes.push(sgr_color(fg, false)); |
| 465 | } |
| 466 | if let Some(bg) = style.bg { |
| 467 | codes.push(sgr_color(bg, true)); |
| 468 | } |
| 469 | let _ = write!(out, "\x1b[{}m", codes.join(";")); |
| 470 | last = Some(style); |
| 471 | } |
| 472 | out.push_str(symbol); |
| 473 | }); |
| 474 | out.push_str("\x1b[0m\n"); |
| 475 | out |
| 476 | } |
| 477 | |
| 478 | /// The actual terminal buffer as a standalone SVG, at 10 × 20 pixels per cell. |
| 479 | /// |
| 480 | /// Unicode glyphs stay at their terminal coordinates; a wide glyph occupies its |
| 481 | /// full cell span and its covered cells are skipped. RGB colors are exact; |
| 482 | /// indexed and named colors use the conventional xterm palette. `Reset` (or an |
| 483 | /// unset color) uses the supplied theme's exact `Foreground` / `Background` |
| 484 | /// tokens: an off-screen buffer cannot know a user's terminal defaults. |
| 485 | /// |
| 486 | /// Bold, dim, italic, reverse, hidden, underline and strikeout are preserved. |
| 487 | /// Blink is captured in its visible phase. The SVG embeds no fonts or external |
| 488 | /// resources; the viewer needs a monospace font containing the drawn glyphs. |
| 489 | #[must_use] |
| 490 | pub fn svg(buf: &Buffer, theme: &Theme) -> String { |
| 491 | const CELL_W: u32 = 10; |
| 492 | const CELL_H: u32 = 20; |
| 493 | let width = u32::from(buf.area.width) * CELL_W; |
| 494 | let height = u32::from(buf.area.height) * CELL_H; |
| 495 | let default_fg = theme.token_hex(crate::Role::Foreground); |
| 496 | let default_bg = theme.token_hex(crate::Role::Background); |
| 497 | let mut out = format!( |
| 498 | "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\" role=\"img\"><title>Ratatui buffer, {} columns by {} rows</title><rect width=\"100%\" height=\"100%\" fill=\"#{default_bg:06x}\"/>\n", |
| 499 | width.max(1), |
| 500 | height.max(1), |
| 501 | width.max(1), |
| 502 | height.max(1), |
| 503 | buf.area.width, |
| 504 | buf.area.height, |
| 505 | ); |
| 506 | |
| 507 | // Paint backgrounds before any ink. Row runs reduce the file size while |
| 508 | // retaining the fill underneath blank and wide cells. |
| 509 | for y in buf.area.top()..buf.area.bottom() { |
| 510 | let mut start = buf.area.left(); |
| 511 | let mut background = None; |
| 512 | let flush = |out: &mut String, start: u16, end: u16, color: Option<u32>| { |
| 513 | if let Some(color) = color.filter(|color| *color != default_bg) { |
| 514 | let x = u32::from(start - buf.area.x) * CELL_W; |
| 515 | let y = u32::from(y - buf.area.y) * CELL_H; |
| 516 | let span = u32::from(end - start) * CELL_W; |
| 517 | let _ = writeln!( |
| 518 | out, |
| 519 | "<rect x=\"{x}\" y=\"{y}\" width=\"{span}\" height=\"{CELL_H}\" fill=\"#{color:06x}\"/>" |
| 520 | ); |
| 521 | } |
| 522 | }; |
| 523 | let mut x = buf.area.left(); |
| 524 | while x < buf.area.right() { |
| 525 | let cell = &buf[(x, y)]; |
| 526 | let (_, bg) = svg_inks(cell.style(), default_fg, default_bg); |
| 527 | if background != Some(bg) { |
| 528 | flush(&mut out, start, x, background); |
| 529 | start = x; |
| 530 | background = Some(bg); |
| 531 | } |
| 532 | x += svg_span(cell.symbol(), buf.area.right() - x); |
| 533 | } |
| 534 | flush(&mut out, start, x, background); |
| 535 | } |
| 536 | |
| 537 | out.push_str("<g font-family=\"'DejaVu Sans Mono','Cascadia Mono','SFMono-Regular',Consolas,'Liberation Mono',monospace\" font-size=\"16\" xml:space=\"preserve\">\n"); |
| 538 | svg_cells(buf, |x, y, symbol, span, style| { |
| 539 | let mods = style.add_modifier; |
| 540 | if mods.contains(Modifier::HIDDEN) { |
| 541 | return; |
| 542 | } |
| 543 | let symbol = svg_escape(symbol); |
| 544 | let span = u32::from(span); |
| 545 | let pixel_x = u32::from(x - buf.area.x) * CELL_W; |
| 546 | let pixel_y = u32::from(y - buf.area.y) * CELL_H; |
| 547 | let (fg, _) = svg_inks(style, default_fg, default_bg); |
| 548 | let opacity = if mods.contains(Modifier::DIM) { |
| 549 | " opacity=\"0.55\"" |
| 550 | } else { |
| 551 | "" |
| 552 | }; |
| 553 | if !symbol.trim().is_empty() { |
| 554 | let bold = if mods.contains(Modifier::BOLD) { |
| 555 | " font-weight=\"700\"" |
| 556 | } else { |
| 557 | "" |
| 558 | }; |
| 559 | let italic = if mods.contains(Modifier::ITALIC) { |
| 560 | " font-style=\"italic\"" |
| 561 | } else { |
| 562 | "" |
| 563 | }; |
| 564 | let baseline = pixel_y + 15; |
| 565 | let length = span * CELL_W; |
| 566 | let _ = writeln!( |
| 567 | out, |
| 568 | "<text x=\"{pixel_x}\" y=\"{baseline}\" fill=\"#{fg:06x}\" textLength=\"{length}\" lengthAdjust=\"spacingAndGlyphs\"{bold}{italic}{opacity}>{symbol}</text>" |
| 569 | ); |
| 570 | } |
| 571 | for (modifier, offset, color) in [ |
| 572 | ( |
| 573 | Modifier::UNDERLINED, |
| 574 | 18, |
| 575 | svg_color(style.underline_color.unwrap_or(Color::Reset), fg), |
| 576 | ), |
| 577 | (Modifier::CROSSED_OUT, 10, fg), |
| 578 | ] { |
| 579 | if mods.contains(modifier) { |
| 580 | let line_y = pixel_y + offset; |
| 581 | let right = pixel_x + span * CELL_W; |
| 582 | let _ = writeln!( |
| 583 | out, |
| 584 | "<path d=\"M{pixel_x} {line_y}H{right}\" stroke=\"#{color:06x}\" stroke-width=\"1\"{opacity}/>" |
| 585 | ); |
| 586 | } |
| 587 | } |
| 588 | }); |
| 589 | out.push_str("</g></svg>\n"); |
| 590 | out |
| 591 | } |
| 592 | |
| 593 | fn svg_span(symbol: &str, remaining: u16) -> u16 { |
| 594 | u16::try_from(text::width(&text::display_safe(symbol)).max(1)) |
| 595 | .unwrap_or(remaining) |
| 596 | .min(remaining) |
| 597 | } |
| 598 | |
| 599 | fn svg_cells(buf: &Buffer, mut visit: impl FnMut(u16, u16, &str, u16, Style)) { |
| 600 | for y in buf.area.top()..buf.area.bottom() { |
| 601 | let mut x = buf.area.left(); |
| 602 | while x < buf.area.right() { |
| 603 | let cell = &buf[(x, y)]; |
| 604 | let span = svg_span(cell.symbol(), buf.area.right() - x); |
| 605 | visit(x, y, cell.symbol(), span, cell.style()); |
| 606 | x += span; |
| 607 | } |
| 608 | } |
| 609 | } |
| 610 | |
| 611 | fn svg_inks(style: Style, default_fg: u32, default_bg: u32) -> (u32, u32) { |
| 612 | let mut fg = svg_color(style.fg.unwrap_or(Color::Reset), default_fg); |
| 613 | let mut bg = svg_color(style.bg.unwrap_or(Color::Reset), default_bg); |
| 614 | if style.add_modifier.contains(Modifier::REVERSED) { |
| 615 | std::mem::swap(&mut fg, &mut bg); |
| 616 | } |
| 617 | (fg, bg) |
| 618 | } |
| 619 | |
| 620 | fn svg_color(color: Color, fallback: u32) -> u32 { |
| 621 | const ANSI: [u32; 16] = [ |
| 622 | 0x000000, 0x800000, 0x008000, 0x808000, 0x000080, 0x800080, 0x008080, 0xc0c0c0, 0x808080, |
| 623 | 0xff0000, 0x00ff00, 0xffff00, 0x0000ff, 0xff00ff, 0x00ffff, 0xffffff, |
| 624 | ]; |
| 625 | let index = match color { |
| 626 | Color::Reset => return fallback, |
| 627 | Color::Rgb(r, g, b) => return (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b), |
| 628 | Color::Indexed(i) => i, |
| 629 | Color::Black => 0, |
| 630 | Color::Red => 1, |
| 631 | Color::Green => 2, |
| 632 | Color::Yellow => 3, |
| 633 | Color::Blue => 4, |
| 634 | Color::Magenta => 5, |
| 635 | Color::Cyan => 6, |
| 636 | Color::Gray => 7, |
| 637 | Color::DarkGray => 8, |
| 638 | Color::LightRed => 9, |
| 639 | Color::LightGreen => 10, |
| 640 | Color::LightYellow => 11, |
| 641 | Color::LightBlue => 12, |
| 642 | Color::LightMagenta => 13, |
| 643 | Color::LightCyan => 14, |
| 644 | Color::White => 15, |
| 645 | }; |
| 646 | match index { |
| 647 | 0..=15 => ANSI[usize::from(index)], |
| 648 | 16..=231 => { |
| 649 | const CUBE: [u32; 6] = [0, 95, 135, 175, 215, 255]; |
| 650 | let i = usize::from(index - 16); |
| 651 | (CUBE[i / 36] << 16) | (CUBE[(i / 6) % 6] << 8) | CUBE[i % 6] |
| 652 | } |
| 653 | _ => { |
| 654 | let gray = 8 + u32::from(index - 232) * 10; |
| 655 | gray * 0x010101 |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | |
| 660 | fn svg_escape(value: &str) -> String { |
| 661 | let mut out = String::new(); |
| 662 | for c in text::display_safe(value).chars() { |
| 663 | match c { |
| 664 | '&' => out.push_str("&"), |
| 665 | '<' => out.push_str("<"), |
| 666 | '>' => out.push_str(">"), |
| 667 | '"' => out.push_str("""), |
| 668 | '\'' => out.push_str("'"), |
| 669 | '\u{fffe}' | '\u{ffff}' => out.push('\u{fffd}'), |
| 670 | c => out.push(c), |
| 671 | } |
| 672 | } |
| 673 | out |
| 674 | } |
| 675 | |
| 676 | #[cfg(test)] |
| 677 | mod svg_tests { |
| 678 | use super::*; |
| 679 | |
| 680 | #[test] |
| 681 | fn svg_keeps_wide_and_combining_glyphs_at_buffer_coordinates() { |
| 682 | let mut buf = Buffer::empty(Rect::new(4, 8, 4, 1)); |
| 683 | buf.set_string(4, 8, "鲸e\u{301}<", Style::default()); |
| 684 | let out = svg(&buf, &Profile::DarkTrue.theme()); |
| 685 | assert!(out.contains("viewBox=\"0 0 40 20\"")); |
| 686 | assert!(out.contains("<text x=\"0\" y=\"15\"")); |
| 687 | assert!(out.contains("textLength=\"20\" lengthAdjust=\"spacingAndGlyphs\">鲸</text>")); |
| 688 | assert!(out.contains("<text x=\"20\" y=\"15\"")); |
| 689 | assert!(out.contains(">e\u{301}</text>")); |
| 690 | assert!(out.contains("<text x=\"30\" y=\"15\"")); |
| 691 | assert!(out.contains("textLength=\"10\" lengthAdjust=\"spacingAndGlyphs\"><</text>")); |
| 692 | assert!(!out.contains("<text x=\"10\"")); |
| 693 | } |
| 694 | |
| 695 | #[test] |
| 696 | fn svg_preserves_inks_reverse_and_visible_modifiers() { |
| 697 | let buf = render(2, 1, |_, buf| { |
| 698 | buf[(0, 0)].set_symbol("A").set_style( |
| 699 | Style::default() |
| 700 | .fg(Color::Rgb(1, 2, 3)) |
| 701 | .bg(Color::Indexed(21)) |
| 702 | .underline_color(Color::Indexed(196)) |
| 703 | .add_modifier( |
| 704 | Modifier::REVERSED |
| 705 | | Modifier::BOLD |
| 706 | | Modifier::DIM |
| 707 | | Modifier::ITALIC |
| 708 | | Modifier::UNDERLINED |
| 709 | | Modifier::CROSSED_OUT, |
| 710 | ), |
| 711 | ); |
| 712 | buf[(1, 0)] |
| 713 | .set_symbol("B") |
| 714 | .set_style(Style::default().add_modifier(Modifier::HIDDEN)); |
| 715 | }); |
| 716 | let out = svg(&buf, &Profile::LightTrue.theme()); |
| 717 | assert!(out.contains("width=\"10\" height=\"20\" fill=\"#010203\"")); |
| 718 | assert!(out.contains("fill=\"#0000ff\" textLength=\"10\"")); |
| 719 | assert!(out.contains("font-weight=\"700\" font-style=\"italic\" opacity=\"0.55\"")); |
| 720 | assert!(out.contains("d=\"M0 18H10\" stroke=\"#ff0000\"")); |
| 721 | assert!(out.contains("d=\"M0 10H10\" stroke=\"#0000ff\"")); |
| 722 | assert!(!out.contains(">B</text>")); |
| 723 | } |
| 724 | |
| 725 | #[test] |
| 726 | fn svg_escapes_untrusted_xml_and_illegal_controls() { |
| 727 | assert_eq!( |
| 728 | svg_escape("<&>\"'\x1b\n\u{202e}\u{ffff}"), |
| 729 | "<&>"'\u{fffd}" |
| 730 | ); |
| 731 | } |
| 732 | |
| 733 | #[test] |
| 734 | fn svg_resolves_reset_against_each_theme_and_all_palette_ranges() { |
| 735 | let buf = render(1, 1, |_, buf| { |
| 736 | buf[(0, 0)].set_symbol("x"); |
| 737 | }); |
| 738 | for profile in [Profile::DarkTrue, Profile::LightTrue, Profile::NoColor] { |
| 739 | let theme = profile.theme(); |
| 740 | let out = svg(&buf, &theme); |
| 741 | assert!(out.contains(&format!( |
| 742 | "fill=\"#{:06x}\"", |
| 743 | theme.token_hex(crate::Role::Background) |
| 744 | ))); |
| 745 | assert!(out.contains(&format!( |
| 746 | "fill=\"#{:06x}\" textLength", |
| 747 | theme.token_hex(crate::Role::Foreground) |
| 748 | ))); |
| 749 | } |
| 750 | assert_eq!(svg_color(Color::Indexed(16), 0), 0x000000); |
| 751 | assert_eq!(svg_color(Color::Indexed(231), 0), 0xffffff); |
| 752 | assert_eq!(svg_color(Color::Indexed(232), 0), 0x080808); |
| 753 | assert_eq!(svg_color(Color::Indexed(255), 0), 0xeeeeee); |
| 754 | } |
| 755 | } |
| 756 | |
| 757 | #[cfg(test)] |
| 758 | mod tests { |
| 759 | use super::*; |
| 760 | use crate::{HorizonRule, Paint}; |
| 761 | |
| 762 | #[test] |
| 763 | fn frames_cover_every_profile_at_every_width() { |
| 764 | let frames = frames(2, |_, _, _| {}); |
| 765 | assert_eq!(frames.len(), Profile::ALL.len() * WIDTHS.len()); |
| 766 | for profile in Profile::ALL { |
| 767 | let widths: Vec<u16> = frames |
| 768 | .iter() |
| 769 | .filter(|f| f.profile == profile) |
| 770 | .map(Frame::width) |
| 771 | .collect(); |
| 772 | assert_eq!(widths, WIDTHS, "{}", profile.name()); |
| 773 | } |
| 774 | assert!(frames.iter().all(|f| f.height() == 2)); |
| 775 | } |
| 776 | |
| 777 | #[test] |
| 778 | fn the_rules_catch_what_they_name() { |
| 779 | let two_horizons = |area: Rect, buf: &mut Buffer, theme: &Theme| { |
| 780 | for y in 0..3 { |
| 781 | HorizonRule::new().paint( |
| 782 | Rect { |
| 783 | y, |
| 784 | height: 1, |
| 785 | ..area |
| 786 | }, |
| 787 | buf, |
| 788 | theme, |
| 789 | ); |
| 790 | } |
| 791 | }; |
| 792 | let broken: Vec<String> = frames(3, two_horizons) |
| 793 | .iter() |
| 794 | .flat_map(Frame::violations) |
| 795 | .collect(); |
| 796 | assert!( |
| 797 | broken.iter().any(|b| b.contains("3 horizons")), |
| 798 | "{broken:?}" |
| 799 | ); |
| 800 | |
| 801 | let dots = |area: Rect, buf: &mut Buffer, _: &Theme| { |
| 802 | buf.set_string(area.x, area.y, "Loading...", Style::default()); |
| 803 | }; |
| 804 | let broken: Vec<String> = frames(1, dots).iter().flat_map(Frame::violations).collect(); |
| 805 | assert!( |
| 806 | broken.is_empty(), |
| 807 | "native three-dot truncation is allowed: {broken:?}" |
| 808 | ); |
| 809 | // In ASCII-safe output `...` is honest. |
| 810 | assert!( |
| 811 | !broken |
| 812 | .iter() |
| 813 | .any(|b| b.contains("ascii") && b.contains("ellipsis")), |
| 814 | "{broken:?}" |
| 815 | ); |
| 816 | |
| 817 | let raw = |area: Rect, buf: &mut Buffer, _: &Theme| { |
| 818 | buf.set_string( |
| 819 | area.x, |
| 820 | area.y, |
| 821 | "x", |
| 822 | Style::default().fg(Color::Rgb(1, 2, 3)), |
| 823 | ); |
| 824 | }; |
| 825 | let broken: Vec<String> = frames(1, raw).iter().flat_map(Frame::violations).collect(); |
| 826 | assert!(broken.iter().any(|b| b.contains("no-color")), "{broken:?}"); |
| 827 | } |
| 828 | |
| 829 | #[test] |
| 830 | fn the_snapshot_text_names_every_width_and_profile_it_records() { |
| 831 | let text = snapshot(1, |area, buf, theme| { |
| 832 | buf.set_string(area.x, area.y, "hi", theme.fg(Role::Live)); |
| 833 | }); |
| 834 | for expected in [ |
| 835 | "== dark-truecolor · 40x1", |
| 836 | "== dark-truecolor · 80x1", |
| 837 | "== dark-truecolor · 120x1", |
| 838 | "== no-color · 40x1", |
| 839 | "== ascii · 120x1", |
| 840 | "fg=Live", |
| 841 | ] { |
| 842 | assert!(text.contains(expected), "missing {expected}:\n{text}"); |
| 843 | } |
| 844 | } |
| 845 | } |
| 846 |