返回 CodeWhale
testing.rs
根目录 / vendor / codewhale-ratatui / src / testing.rs
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("&amp;"),
665 '<' => out.push_str("&lt;"),
666 '>' => out.push_str("&gt;"),
667 '"' => out.push_str("&quot;"),
668 '\'' => out.push_str("&apos;"),
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\">&lt;</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 "&lt;&amp;&gt;&quot;&apos;\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
846 lines RUST