Skip to main content

purple_ssh/ui/
svg_export.rs

1//! Render a ratatui `Buffer` to a standalone SVG (one rect per cell
2//! background, one text run per glyph). Used by `purple gen-assets` to emit
3//! crisp, on-brand screenshots that regenerate from the live binary, so
4//! marketing imagery never goes stale.
5
6use ratatui::buffer::Buffer;
7use ratatui::style::{Color, Modifier};
8
9/// An 8-bit-per-channel RGB triple.
10type Rgb = (u8, u8, u8);
11
12/// Rendering options for [`buffer_to_svg`].
13pub struct SvgOpts {
14    /// Pixel width of one terminal column.
15    pub cell_w: u32,
16    /// Pixel height of one terminal row.
17    pub cell_h: u32,
18    /// Font size in pixels.
19    pub font_size: u32,
20    /// Full CSS `font-family` value the `<text>` elements reference, quotes
21    /// and fallbacks included (e.g. `"'Berkeley Mono','JetBrains Mono',monospace"`).
22    pub font_family: String,
23    /// A complete `@font-face { ... }` CSS block embedding the monospace font
24    /// as base64, or empty to fall back to the system `font_family`.
25    pub font_face_css: String,
26    /// Foreground used for cells whose `fg` is `Color::Reset`.
27    pub default_fg: Rgb,
28    /// Background used for the canvas and cells whose `bg` is `Color::Reset`.
29    pub default_bg: Rgb,
30    /// Empty margin around the cell grid, in pixels.
31    pub pad: u32,
32    /// Render the canvas as a rounded panel on a transparent background
33    /// instead of a flush rectangle.
34    pub rounded: bool,
35}
36
37/// Panel corner radius when [`SvgOpts::rounded`] is on.
38const PANEL_RADIUS: u32 = 12;
39
40impl Default for SvgOpts {
41    fn default() -> Self {
42        Self {
43            // cell_w is exactly 0.6 * font_size, the advance width of
44            // Berkeley Mono and JetBrains Mono, so textLength never has to
45            // stretch or squeeze glyphs to fit the grid.
46            cell_w: 9,
47            cell_h: 19,
48            font_size: 15,
49            font_family: "'JetBrains Mono',monospace".to_string(),
50            font_face_css: String::new(),
51            // Soft slate terminal background with the lavender foreground.
52            default_fg: (224, 214, 240),
53            default_bg: (27, 28, 38),
54            pad: 0,
55            rounded: false,
56        }
57    }
58}
59
60/// Canvas metrics for a grid of `cols` x `rows` cells: full width, full
61/// height and the top offset of the cell grid.
62fn canvas(opts: &SvgOpts, cols: u16, rows: u16) -> (u32, u32, u32) {
63    let w = u32::from(cols) * opts.cell_w + 2 * opts.pad;
64    let h = u32::from(rows) * opts.cell_h + 2 * opts.pad;
65    (w, h, opts.pad)
66}
67
68/// Emit the canvas background: a flush rect, or a rounded panel on a
69/// transparent canvas when rounded is on.
70fn emit_canvas(s: &mut String, opts: &SvgOpts, w: u32, h: u32) {
71    if opts.rounded {
72        s.push_str(&format!(
73            "<rect width=\"{w}\" height=\"{h}\" rx=\"{PANEL_RADIUS}\" ry=\"{PANEL_RADIUS}\" fill=\"{}\"/>",
74            hex(opts.default_bg)
75        ));
76    } else {
77        s.push_str(&format!(
78            "<rect width=\"{w}\" height=\"{h}\" fill=\"{}\"/>",
79            hex(opts.default_bg)
80        ));
81    }
82}
83
84/// Serialise `buf` to a standalone SVG string.
85pub fn buffer_to_svg(buf: &Buffer, opts: &SvgOpts) -> String {
86    let area = buf.area;
87    let (w, h, top) = canvas(opts, area.width, area.height);
88    let baseline = baseline(opts);
89
90    let mut s = String::new();
91    s.push_str(&format!(
92        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">"
93    ));
94    s.push_str("<defs><style>");
95    s.push_str(&opts.font_face_css);
96    s.push_str(&format!(
97        "text{{font-family:{};font-size:{}px;white-space:pre;}}",
98        opts.font_family, opts.font_size
99    ));
100    s.push_str("</style></defs>");
101    emit_canvas(&mut s, opts, w, h);
102    let shifted = opts.pad > 0 || top > 0;
103    if shifted {
104        s.push_str(&format!("<g transform=\"translate({},{top})\">", opts.pad));
105    }
106    emit_cells(&mut s, buf, opts, baseline, &|_, _| true, None);
107    if shifted {
108        s.push_str("</g>");
109    }
110    s.push_str("</svg>");
111    s
112}
113
114/// One frame of an animated SVG: the buffer, how long it holds, and whether
115/// it is a self-contained keyframe (starts a scene, hides everything before
116/// it) or a delta painted over the frames since the last keyframe.
117pub struct AnimFrame {
118    pub buf: Buffer,
119    pub dur_ms: u32,
120    pub keyframe: bool,
121}
122
123/// Serialise `frames` to one looping animated SVG. Scene switching is pure
124/// CSS (`step-end` opacity windows), so the animation needs no JavaScript.
125/// `fallback_keyframe` names the scene shown when animations are off
126/// (prefers-reduced-motion); its whole frame stack stays visible.
127///
128/// Panics when `frames` is empty, dimensions differ between frames, the
129/// first frame is not a keyframe or `fallback_keyframe` is not a keyframe.
130pub fn frames_to_animated_svg(
131    frames: &[AnimFrame],
132    fallback_keyframe: usize,
133    opts: &SvgOpts,
134) -> String {
135    assert!(!frames.is_empty(), "frames_to_animated_svg: no frames");
136    assert!(
137        frames[0].keyframe,
138        "frames_to_animated_svg: first frame must be a keyframe"
139    );
140    assert!(
141        frames.iter().all(|f| f.buf.area == frames[0].buf.area),
142        "frames_to_animated_svg: all frames must share one size"
143    );
144    assert!(
145        frames.get(fallback_keyframe).is_some_and(|f| f.keyframe),
146        "frames_to_animated_svg: fallback must point at a keyframe"
147    );
148
149    let area = frames[0].buf.area;
150    let (w, h, top) = canvas(opts, area.width, area.height);
151    let baseline = baseline(opts);
152    let total: u32 = frames.iter().map(|f| f.dur_ms).sum();
153
154    // Window per frame: [own start, start of the next keyframe). A delta
155    // stays visible until its scene ends so later deltas stack on top.
156    let starts: Vec<u32> = frames
157        .iter()
158        .scan(0u32, |acc, f| {
159            let s = *acc;
160            *acc += f.dur_ms;
161            Some(s)
162        })
163        .collect();
164    let scene_end = |i: usize| -> u32 {
165        frames[i + 1..]
166            .iter()
167            .position(|f| f.keyframe)
168            .map_or(total, |off| starts[i + 1 + off])
169    };
170    let fallback_end = scene_end(fallback_keyframe);
171
172    let mut s = String::new();
173    s.push_str(&format!(
174        "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"panim\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\">"
175    ));
176    s.push_str("<defs><style>");
177    s.push_str(&opts.font_face_css);
178    s.push_str(&format!(
179        ".panim text{{font-family:{};font-size:{}px;white-space:pre;}}",
180        opts.font_family, opts.font_size
181    ));
182    s.push_str(".panim .pf{opacity:0}.panim .pf-fb{opacity:1}");
183    for (i, _) in frames.iter().enumerate() {
184        let (start, end) = (starts[i], scene_end(i));
185        if start == 0 && end == total {
186            // Visible the whole loop: no animation needed.
187            s.push_str(&format!(".panim .pf{i}{{opacity:1}}"));
188            continue;
189        }
190        s.push_str(&format!(
191            ".panim .pf{i}{{animation:pfa{i} {total}ms step-end infinite}}"
192        ));
193        let on = pct(start, total);
194        let off = pct(end, total);
195        if start == 0 {
196            s.push_str(&format!(
197                "@keyframes pfa{i}{{0%{{opacity:1}}{off}%{{opacity:0}}}}"
198            ));
199        } else if end == total {
200            s.push_str(&format!("@keyframes pfa{i}{{{on}%{{opacity:1}}}}"));
201        } else {
202            s.push_str(&format!(
203                "@keyframes pfa{i}{{{on}%{{opacity:1}}{off}%{{opacity:0}}}}"
204            ));
205        }
206    }
207    // Last so it wins the equal-specificity tie against the .pf{i} rules.
208    s.push_str("@media (prefers-reduced-motion:reduce){.panim .pf{animation:none}}");
209    s.push_str("</style></defs>");
210    emit_canvas(&mut s, opts, w, h);
211    let shifted = opts.pad > 0 || top > 0;
212    if shifted {
213        s.push_str(&format!("<g transform=\"translate({},{top})\">", opts.pad));
214    }
215
216    for (i, frame) in frames.iter().enumerate() {
217        let fb = if starts[i] >= starts[fallback_keyframe] && starts[i] < fallback_end {
218            " pf-fb"
219        } else {
220            ""
221        };
222        s.push_str(&format!("<g class=\"pf pf{i}{fb}\">"));
223        if frame.keyframe {
224            emit_cells(&mut s, &frame.buf, opts, baseline, &|_, _| true, None);
225        } else {
226            let prev = &frames[i - 1].buf;
227            let mask = |x: u16, y: u16| frame.buf[(x, y)] != prev[(x, y)];
228            emit_cells(&mut s, &frame.buf, opts, baseline, &mask, Some(prev));
229        }
230        s.push_str("</g>");
231    }
232
233    if shifted {
234        s.push_str("</g>");
235    }
236    s.push_str("</svg>");
237    s
238}
239
240/// Glyph baseline within a cell row: ~80% down sits the glyph nicely for a
241/// monospace font sized near the cell height.
242fn baseline(opts: &SvgOpts) -> u32 {
243    (opts.cell_h * 4) / 5
244}
245
246/// Percentage of `at` within `total`, trimmed to 4 decimals.
247fn pct(at: u32, total: u32) -> String {
248    let v = f64::from(at) * 100.0 / f64::from(total);
249    format!("{v:.4}")
250        .trim_end_matches('0')
251        .trim_end_matches('.')
252        .to_string()
253}
254
255/// Emit the cell backgrounds and glyph runs for every cell where `mask`
256/// returns true. With `prev` (delta frames), a cell whose new background is
257/// the canvas default still gets a rect when the previous frame left visible
258/// paint there (erasure); blank-to-glyph transitions skip it.
259fn emit_cells(
260    s: &mut String,
261    buf: &Buffer,
262    opts: &SvgOpts,
263    baseline: u32,
264    mask: &dyn Fn(u16, u16) -> bool,
265    prev: Option<&Buffer>,
266) {
267    let area = buf.area;
268    let needs_erase = |x: u16, y: u16| {
269        prev.is_some_and(|p| {
270            let cell = &p[(x, y)];
271            let sym = cell.symbol();
272            let (_, bg) = effective_colors(cell, opts);
273            !(sym.is_empty() || sym == " ") || bg.is_some_and(|c| c != opts.default_bg)
274        })
275    };
276
277    // Backgrounds first (so glyphs paint on top), merging contiguous cells
278    // that share one color into a single rect.
279    for y in 0..area.height {
280        let mut run: Option<(u16, Rgb, u32)> = None;
281        let flush = |s: &mut String, run: Option<(u16, Rgb, u32)>| {
282            if let Some((start_x, rgb, len)) = run {
283                s.push_str(&format!(
284                    "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"/>",
285                    u32::from(start_x) * opts.cell_w,
286                    u32::from(y) * opts.cell_h,
287                    len * opts.cell_w,
288                    opts.cell_h,
289                    hex(rgb),
290                ));
291            }
292        };
293        for x in 0..area.width {
294            if !mask(x, y) {
295                flush(s, run.take());
296                continue;
297            }
298            let cell = &buf[(x, y)];
299            let (_, bg) = effective_colors(cell, opts);
300            let rgb = bg.unwrap_or(opts.default_bg);
301            if rgb == opts.default_bg && !needs_erase(x, y) {
302                flush(s, run.take());
303                continue;
304            }
305            match &mut run {
306                Some((_, c, len)) if *c == rgb => *len += 1,
307                _ => {
308                    flush(s, run.take());
309                    run = Some((x, rgb, 1));
310                }
311            }
312        }
313        flush(s, run.take());
314    }
315
316    // Glyphs: merge contiguous cells sharing one style into a single <text>
317    // run to keep the file small. Spaces and style changes break a run.
318    // Straight line glyphs merge too: ─ runs horizontally within a row,
319    // │ and ┊ runs vertically across rows (collected, emitted at the end).
320    let t = (f64::from(opts.cell_w) / 8.0).max(1.0);
321    let mut vlines: Vec<(u16, u16, Rgb, bool)> = Vec::new();
322    for y in 0..area.height {
323        let mut run: Option<Run> = None;
324        let mut hrun: Option<(u16, u32, Rgb, bool)> = None;
325        let flush_h = |s: &mut String, hrun: Option<(u16, u32, Rgb, bool)>| {
326            if let Some((start_x, len, fill, dim)) = hrun {
327                let cy = f64::from(u32::from(y) * opts.cell_h) + f64::from(opts.cell_h) / 2.0;
328                s.push_str(&format!(
329                    "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"{}/>",
330                    coord(f64::from(u32::from(start_x) * opts.cell_w)),
331                    coord(cy - t / 2.0),
332                    coord(f64::from(len * opts.cell_w)),
333                    coord(t),
334                    hex(fill),
335                    dim_fill_attr(dim),
336                ));
337            }
338        };
339        for x in 0..area.width {
340            if !mask(x, y) {
341                flush_run(s, run.take(), opts, baseline);
342                flush_h(s, hrun.take());
343                continue;
344            }
345            let cell = &buf[(x, y)];
346            let sym = cell.symbol();
347            if sym.is_empty() || sym == " " {
348                flush_run(s, run.take(), opts, baseline);
349                flush_h(s, hrun.take());
350                continue;
351            }
352            let (fg, _) = effective_colors(cell, opts);
353            let dim = cell.modifier.contains(Modifier::DIM);
354            let cp = sym.chars().next().unwrap_or(' ');
355            if cp == '\u{2500}' {
356                flush_run(s, run.take(), opts, baseline);
357                match &mut hrun {
358                    Some((_, len, fill, d)) if *fill == fg && *d == dim => *len += 1,
359                    _ => {
360                        flush_h(s, hrun.take());
361                        hrun = Some((x, 1, fg, dim));
362                    }
363                }
364                continue;
365            }
366            if cp == '\u{2502}' || cp == '\u{250A}' {
367                flush_run(s, run.take(), opts, baseline);
368                flush_h(s, hrun.take());
369                vlines.push((x, y, fg, dim));
370                continue;
371            }
372            // Box-drawing and block glyphs render as crisp SVG shapes so they
373            // tile seamlessly. Font glyphs leave gaps at non-unit line heights.
374            if let Some(shape) = glyph_shape(
375                sym,
376                f64::from(u32::from(x) * opts.cell_w),
377                f64::from(u32::from(y) * opts.cell_h),
378                f64::from(opts.cell_w),
379                f64::from(opts.cell_h),
380                &hex(fg),
381                dim,
382            ) {
383                flush_run(s, run.take(), opts, baseline);
384                flush_h(s, hrun.take());
385                s.push_str(&shape);
386                continue;
387            }
388            flush_h(s, hrun.take());
389            let style = GlyphStyle {
390                fg,
391                bold: cell.modifier.contains(Modifier::BOLD),
392                dim: cell.modifier.contains(Modifier::DIM),
393                italic: cell.modifier.contains(Modifier::ITALIC),
394                underline: cell.modifier.contains(Modifier::UNDERLINED),
395            };
396            match &mut run {
397                Some(acc) if acc.style == style => {
398                    acc.text.push_str(&xml_escape(sym));
399                    acc.len += 1;
400                }
401                _ => {
402                    flush_run(s, run.take(), opts, baseline);
403                    run = Some(Run {
404                        start_x: x,
405                        row: y,
406                        len: 1,
407                        style,
408                        text: xml_escape(sym),
409                    });
410                }
411            }
412        }
413        flush_run(s, run.take(), opts, baseline);
414        flush_h(s, hrun.take());
415    }
416
417    // Vertical line runs: consecutive rows in one column with one color
418    // collapse into a single full-height rect.
419    vlines.sort_unstable();
420    let mut i = 0;
421    while i < vlines.len() {
422        let (x, y0, fill, dim) = vlines[i];
423        let mut len: u16 = 1;
424        while vlines.get(i + usize::from(len)) == Some(&(x, y0 + len, fill, dim)) {
425            len += 1;
426        }
427        let cx = f64::from(u32::from(x) * opts.cell_w) + f64::from(opts.cell_w) / 2.0;
428        s.push_str(&format!(
429            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{}\"{}/>",
430            coord(cx - t / 2.0),
431            coord(f64::from(u32::from(y0) * opts.cell_h)),
432            coord(t),
433            coord(f64::from(u32::from(len) * opts.cell_h)),
434            hex(fill),
435            dim_fill_attr(dim),
436        ));
437        i += usize::from(len);
438    }
439}
440
441/// Shapes share the text DIM treatment: reduced opacity over the canvas.
442fn dim_fill_attr(dim: bool) -> &'static str {
443    if dim { " fill-opacity=\"0.6\"" } else { "" }
444}
445
446#[derive(PartialEq)]
447struct GlyphStyle {
448    fg: Rgb,
449    bold: bool,
450    dim: bool,
451    italic: bool,
452    underline: bool,
453}
454
455struct Run {
456    start_x: u16,
457    row: u16,
458    len: u32,
459    style: GlyphStyle,
460    text: String,
461}
462
463fn flush_run(s: &mut String, run: Option<Run>, opts: &SvgOpts, baseline: u32) {
464    let Some(run) = run else {
465        return;
466    };
467    let weight = if run.style.bold {
468        " font-weight=\"bold\""
469    } else {
470        ""
471    };
472    let opacity = if run.style.dim {
473        " fill-opacity=\"0.6\""
474    } else {
475        ""
476    };
477    let italic = if run.style.italic {
478        " font-style=\"italic\""
479    } else {
480        ""
481    };
482    let underline = if run.style.underline {
483        " text-decoration=\"underline\""
484    } else {
485        ""
486    };
487    s.push_str(&format!(
488        "<text x=\"{}\" y=\"{}\" fill=\"{}\"{weight}{opacity}{italic}{underline} textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\">{}</text>",
489        u32::from(run.start_x) * opts.cell_w,
490        u32::from(run.row) * opts.cell_h + baseline,
491        hex(run.style.fg),
492        run.len * opts.cell_w,
493        run.text,
494    ));
495}
496
497/// Effective `(fg, Option<bg>)` for a cell, applying the `REVERSED` modifier
498/// (swap foreground and background). `bg == None` means "use the canvas
499/// default" (a `Color::Reset` background that was not reversed).
500fn effective_colors(cell: &ratatui::buffer::Cell, opts: &SvgOpts) -> (Rgb, Option<Rgb>) {
501    let base_fg = resolve(cell.fg, opts, false).unwrap_or(opts.default_fg);
502    let base_bg = resolve(cell.bg, opts, true);
503    if cell.modifier.contains(Modifier::REVERSED) {
504        (base_bg.unwrap_or(opts.default_bg), Some(base_fg))
505    } else {
506        (base_fg, base_bg)
507    }
508}
509
510fn hex((r, g, b): Rgb) -> String {
511    format!("#{r:02x}{g:02x}{b:02x}")
512}
513
514/// Compact coordinate: whole numbers without a decimal, otherwise 2 dp trimmed.
515fn coord(v: f64) -> String {
516    if (v.round() - v).abs() < 1e-6 {
517        format!("{}", v.round() as i64)
518    } else {
519        format!("{v:.2}")
520            .trim_end_matches('0')
521            .trim_end_matches('.')
522            .to_string()
523    }
524}
525
526/// Render the box-drawing and block-element glyphs purple uses as crisp SVG
527/// shapes (rects and stroked arcs) so they tile seamlessly across cells. Font
528/// glyphs leave gaps when the row pitch differs from the glyph's line box.
529/// Returns `None` for glyphs that should render as `<text>` (letters, digits,
530/// status icons, arrows, braille).
531fn glyph_shape(
532    sym: &str,
533    x0: f64,
534    y0: f64,
535    cw: f64,
536    ch: f64,
537    fill: &str,
538    dim: bool,
539) -> Option<String> {
540    let t = (cw / 8.0).max(1.0);
541    let cx = x0 + cw / 2.0;
542    let cy = y0 + ch / 2.0;
543    let r = (cw * 0.4).min(cw / 2.0).min(ch / 2.0);
544    let fop = dim_fill_attr(dim);
545    let sop = if dim { " stroke-opacity=\"0.6\"" } else { "" };
546    let rect = |x: f64, y: f64, w: f64, h: f64| {
547        format!(
548            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\"{fop}/>",
549            coord(x),
550            coord(y),
551            coord(w),
552            coord(h),
553        )
554    };
555    let stroke = |d: String| {
556        format!(
557            "<path d=\"{d}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{}\"{sop}/>",
558            coord(t),
559        )
560    };
561    let vline = rect(cx - t / 2.0, y0, t, ch);
562    let hline = rect(x0, cy - t / 2.0, cw, t);
563    let h_left = rect(x0, cy - t / 2.0, cw / 2.0 + t / 2.0, t);
564    let h_right = rect(cx - t / 2.0, cy - t / 2.0, cw / 2.0 + t / 2.0, t);
565    let v_top = rect(cx - t / 2.0, y0, t, ch / 2.0 + t / 2.0);
566    let cp = sym.chars().next()?;
567    match cp {
568        '\u{2500}' => Some(hline),                       // ─
569        '\u{2502}' | '\u{250A}' => Some(vline),          // │ ┊
570        '\u{2574}' => Some(h_left),                      // ╴
571        '\u{251C}' => Some(format!("{vline}{h_right}")), // ├
572        '\u{2524}' => Some(format!("{vline}{h_left}")),  // ┤
573        '\u{2514}' => Some(format!("{v_top}{h_right}")), // └ (tree branch)
574        // Rounded corners: a horizontal stub into a quarter arc into a vertical stub.
575        '\u{256D}' => Some(stroke(format!(
576            "M {},{} H {} A {},{} 0 0 0 {},{} V {}",
577            coord(x0 + cw),
578            coord(cy),
579            coord(cx + r),
580            coord(r),
581            coord(r),
582            coord(cx),
583            coord(cy + r),
584            coord(y0 + ch),
585        ))), // ╭
586        '\u{256E}' => Some(stroke(format!(
587            "M {},{} H {} A {},{} 0 0 1 {},{} V {}",
588            coord(x0),
589            coord(cy),
590            coord(cx - r),
591            coord(r),
592            coord(r),
593            coord(cx),
594            coord(cy + r),
595            coord(y0 + ch),
596        ))), // ╮
597        '\u{2570}' => Some(stroke(format!(
598            "M {},{} H {} A {},{} 0 0 1 {},{} V {}",
599            coord(x0 + cw),
600            coord(cy),
601            coord(cx + r),
602            coord(r),
603            coord(r),
604            coord(cx),
605            coord(cy - r),
606            coord(y0),
607        ))), // ╰
608        '\u{256F}' => Some(stroke(format!(
609            "M {},{} H {} A {},{} 0 0 0 {},{} V {}",
610            coord(x0),
611            coord(cy),
612            coord(cx - r),
613            coord(r),
614            coord(r),
615            coord(cx),
616            coord(cy - r),
617            coord(y0),
618        ))), // ╯
619        '\u{2588}' => Some(rect(x0, y0, cw, ch)), // █
620        '\u{2591}' => Some(format!(
621            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\" fill-opacity=\"0.28\"/>",
622            coord(x0),
623            coord(y0),
624            coord(cw),
625            coord(ch),
626        )), // ░
627        // Lower blocks ▁..▇: a bottom-anchored bar of n eighths.
628        '\u{2581}'..='\u{2587}' => {
629            let h = ch * f64::from(cp as u32 - 0x2580) / 8.0;
630            Some(rect(x0, y0 + ch - h, cw, h))
631        }
632        // Left blocks ▉..▏ (and ▌): a left-anchored bar of n eighths.
633        '\u{2589}'..='\u{258F}' => {
634            let w = cw * f64::from(0x2590 - cp as u32) / 8.0;
635            Some(rect(x0, y0, w, ch))
636        }
637        _ => None,
638    }
639}
640
641/// Resolve a ratatui [`Color`] to an RGB triple. `Color::Reset` returns
642/// `None` so the caller can fall back to the canvas default.
643fn resolve(color: Color, _opts: &SvgOpts, _is_bg: bool) -> Option<Rgb> {
644    match color {
645        Color::Reset => None,
646        Color::Rgb(r, g, b) => Some((r, g, b)),
647        Color::Indexed(i) => Some(xterm256(i)),
648        named => Some(ansi16(named)),
649    }
650}
651
652/// Standard VS Code terminal palette for the 16 named ANSI colors. These are
653/// fallbacks: in truecolor render mode purple's cells carry `Color::Rgb`.
654fn ansi16(c: Color) -> Rgb {
655    match c {
656        Color::Black => (0, 0, 0),
657        Color::Red => (205, 49, 49),
658        Color::Green => (13, 188, 121),
659        Color::Yellow => (229, 229, 16),
660        Color::Blue => (36, 114, 200),
661        Color::Magenta => (188, 63, 188),
662        Color::Cyan => (17, 168, 205),
663        Color::Gray => (229, 229, 229),
664        Color::DarkGray => (102, 102, 102),
665        Color::LightRed => (241, 76, 76),
666        Color::LightGreen => (35, 209, 139),
667        Color::LightYellow => (245, 245, 67),
668        Color::LightBlue => (59, 142, 234),
669        Color::LightMagenta => (214, 112, 214),
670        Color::LightCyan => (41, 184, 219),
671        Color::White => (229, 229, 229),
672        // Reset/Rgb/Indexed handled by the caller.
673        _ => (229, 229, 229),
674    }
675}
676
677/// Map an xterm 256-color index to RGB (16 base + 6x6x6 cube + grayscale ramp).
678fn xterm256(i: u8) -> Rgb {
679    match i {
680        0 => (0, 0, 0),
681        1 => (205, 49, 49),
682        2 => (13, 188, 121),
683        3 => (229, 229, 16),
684        4 => (36, 114, 200),
685        5 => (188, 63, 188),
686        6 => (17, 168, 205),
687        7 => (229, 229, 229),
688        8 => (102, 102, 102),
689        9 => (241, 76, 76),
690        10 => (35, 209, 139),
691        11 => (245, 245, 67),
692        12 => (59, 142, 234),
693        13 => (214, 112, 214),
694        14 => (41, 184, 219),
695        15 => (255, 255, 255),
696        16..=231 => {
697            let n = i - 16;
698            let r = n / 36;
699            let g = (n % 36) / 6;
700            let b = n % 6;
701            let step = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
702            (step(r), step(g), step(b))
703        }
704        232..=255 => {
705            let v = 8 + (i - 232) * 10;
706            (v, v, v)
707        }
708    }
709}
710
711/// Escape the five XML-significant characters for text content.
712fn xml_escape(s: &str) -> String {
713    let mut out = String::with_capacity(s.len());
714    for ch in s.chars() {
715        match ch {
716            '&' => out.push_str("&amp;"),
717            '<' => out.push_str("&lt;"),
718            '>' => out.push_str("&gt;"),
719            '"' => out.push_str("&quot;"),
720            '\'' => out.push_str("&apos;"),
721            _ => out.push(ch),
722        }
723    }
724    out
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use ratatui::layout::Rect;
731    use ratatui::style::{Modifier, Style};
732
733    fn test_opts() -> SvgOpts {
734        SvgOpts {
735            cell_w: 10,
736            cell_h: 20,
737            font_size: 16,
738            font_family: "'Mono'".to_string(),
739            font_face_css: String::new(),
740            default_fg: (224, 214, 240),
741            default_bg: (10, 10, 20),
742            ..SvgOpts::default()
743        }
744    }
745
746    #[test]
747    fn empty_buffer_emits_sized_svg_root_with_background() {
748        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
749        let svg = buffer_to_svg(&buf, &test_opts());
750        assert!(svg.contains("<svg"), "has svg root: {svg}");
751        assert!(svg.contains("width=\"20\""), "width = cols*cell_w: {svg}");
752        assert!(svg.contains("height=\"20\""), "height = rows*cell_h: {svg}");
753        assert!(
754            svg.contains("xmlns=\"http://www.w3.org/2000/svg\""),
755            "has xmlns: {svg}"
756        );
757        assert!(
758            svg.contains("fill=\"#0a0a14\""),
759            "background fill present: {svg}"
760        );
761    }
762
763    #[test]
764    fn cell_with_background_emits_positioned_rect() {
765        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 2));
766        buf[(1, 1)].set_symbol(" ").set_bg(Color::Rgb(0, 240, 255));
767        let svg = buffer_to_svg(&buf, &test_opts());
768        assert!(
769            svg.contains("<rect x=\"10\" y=\"20\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
770            "positioned cell bg rect: {svg}"
771        );
772    }
773
774    #[test]
775    fn glyph_emits_text_run_and_space_does_not() {
776        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
777        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(255, 0, 0));
778        let svg = buffer_to_svg(&buf, &test_opts());
779        assert!(svg.contains(">A</text>"), "glyph A rendered as text: {svg}");
780        assert!(svg.contains("fill=\"#ff0000\""), "glyph fg color: {svg}");
781        assert_eq!(
782            svg.matches("<text").count(),
783            1,
784            "only non-space cells get text: {svg}"
785        );
786    }
787
788    #[test]
789    fn embeds_font_face_and_sets_text_font() {
790        let buf = Buffer::empty(Rect::new(0, 0, 1, 1));
791        let mut opts = test_opts();
792        opts.font_face_css =
793            "@font-face{font-family:'Mono';src:url(data:font/woff2;base64,AAAA)}".to_string();
794        let svg = buffer_to_svg(&buf, &opts);
795        assert!(svg.contains("<defs><style>"), "has defs/style: {svg}");
796        assert!(
797            svg.contains("@font-face{font-family:'Mono'"),
798            "embeds font-face: {svg}"
799        );
800        assert!(
801            svg.contains("font-family:'Mono'") && svg.contains("font-size:16px"),
802            "sets text font rule: {svg}"
803        );
804    }
805
806    #[test]
807    fn bold_modifier_sets_font_weight() {
808        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
809        buf[(0, 0)]
810            .set_symbol("A")
811            .set_style(Style::new().add_modifier(Modifier::BOLD));
812        let svg = buffer_to_svg(&buf, &test_opts());
813        assert!(svg.contains("font-weight=\"bold\""), "bold: {svg}");
814    }
815
816    #[test]
817    fn dim_modifier_sets_fill_opacity() {
818        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
819        buf[(0, 0)]
820            .set_symbol("A")
821            .set_style(Style::new().add_modifier(Modifier::DIM));
822        let svg = buffer_to_svg(&buf, &test_opts());
823        assert!(svg.contains("fill-opacity=\"0.6\""), "dim: {svg}");
824    }
825
826    #[test]
827    fn reversed_modifier_swaps_fg_and_bg() {
828        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
829        buf[(0, 0)].set_symbol("A").set_style(
830            Style::new()
831                .fg(Color::Rgb(0, 240, 255))
832                .bg(Color::Rgb(10, 10, 20))
833                .add_modifier(Modifier::REVERSED),
834        );
835        let svg = buffer_to_svg(&buf, &test_opts());
836        // Reversed: the bg rect takes the fg color, the glyph takes the bg color.
837        assert!(
838            svg.contains("<rect x=\"0\" y=\"0\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
839            "reversed bg rect uses fg color: {svg}"
840        );
841        assert!(
842            svg.contains("<text x=\"0\" y=\"16\" fill=\"#0a0a14\""),
843            "reversed glyph uses bg color: {svg}"
844        );
845    }
846
847    #[test]
848    fn escapes_xml_special_glyphs() {
849        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
850        buf[(0, 0)].set_symbol("<");
851        buf[(1, 0)].set_symbol("&");
852        let svg = buffer_to_svg(&buf, &test_opts());
853        // Adjacent default-style glyphs merge into one run.
854        assert!(svg.contains(">&lt;&amp;</text>"), "escapes < and &: {svg}");
855    }
856
857    #[test]
858    fn contiguous_same_style_glyphs_merge_into_one_text_run() {
859        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
860        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
861        buf[(1, 0)].set_symbol("B").set_fg(Color::Rgb(0, 240, 255));
862        buf[(2, 0)].set_symbol("C").set_fg(Color::Rgb(0, 240, 255));
863        let svg = buffer_to_svg(&buf, &test_opts());
864        assert!(svg.contains(">ABC</text>"), "merged run text: {svg}");
865        assert_eq!(svg.matches("<text").count(), 1, "one text per run: {svg}");
866        // textLength spans the whole run (3 cells * 10px).
867        assert!(svg.contains("textLength=\"30\""), "run textLength: {svg}");
868    }
869
870    fn glyph_svg(symbol: &str) -> String {
871        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
872        buf[(0, 0)]
873            .set_symbol(symbol)
874            .set_fg(Color::Rgb(0, 240, 255));
875        buffer_to_svg(&buf, &test_opts())
876    }
877
878    #[test]
879    fn vertical_box_glyph_draws_full_height_rect_not_text() {
880        let svg = glyph_svg("\u{2502}"); // │
881        assert_eq!(
882            svg.matches("<text").count(),
883            0,
884            "no text for box glyph: {svg}"
885        );
886        // t = cell_w/8 = 1.25, full cell height 20.
887        assert!(
888            svg.contains("width=\"1.25\"") && svg.contains("height=\"20\""),
889            "full-height thin rect: {svg}"
890        );
891        assert!(svg.contains("fill=\"#00f0ff\""), "uses fg color: {svg}");
892    }
893
894    #[test]
895    fn horizontal_box_glyph_draws_full_width_rect() {
896        let svg = glyph_svg("\u{2500}"); // ─
897        assert!(
898            svg.contains("width=\"10\"") && svg.contains("height=\"1.25\""),
899            "full-width thin rect: {svg}"
900        );
901    }
902
903    #[test]
904    fn full_block_fills_the_cell() {
905        let svg = glyph_svg("\u{2588}"); // █
906        assert!(
907            svg.contains("<rect x=\"0\" y=\"0\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
908            "full cell rect: {svg}"
909        );
910    }
911
912    #[test]
913    fn lower_half_block_anchors_to_the_bottom() {
914        let svg = glyph_svg("\u{2584}"); // ▄ = 4/8 from the bottom
915        assert!(
916            svg.contains("<rect x=\"0\" y=\"10\" width=\"10\" height=\"10\" fill=\"#00f0ff\"/>"),
917            "bottom-half rect: {svg}"
918        );
919    }
920
921    #[test]
922    fn left_half_block_anchors_to_the_left() {
923        let svg = glyph_svg(crate::ui::design::HOST_HIGHLIGHT); // ▌ = 4/8 from the left
924        assert!(
925            svg.contains("<rect x=\"0\" y=\"0\" width=\"5\" height=\"20\" fill=\"#00f0ff\"/>"),
926            "left-half rect: {svg}"
927        );
928    }
929
930    #[test]
931    fn rounded_corner_draws_an_arc_path() {
932        let svg = glyph_svg("\u{256D}"); // ╭
933        assert!(svg.contains("<path d=\"M "), "path present: {svg}");
934        assert!(svg.contains(" A "), "arc command present: {svg}");
935        assert!(svg.contains("stroke=\"#00f0ff\""), "stroked in fg: {svg}");
936    }
937
938    #[test]
939    fn status_glyph_stays_text() {
940        let svg = glyph_svg(crate::ui::design::ICON_ONLINE); // ● has no shape, renders as text
941        assert!(
942            svg.contains(">\u{25CF}</text>"),
943            "dot renders as text: {svg}"
944        );
945    }
946
947    #[test]
948    fn box_drawing_up_right_junction_draws_shapes_not_text() {
949        let svg = glyph_svg("\u{2514}"); // └ (design::TREE_BRANCH, the providers tree leaf)
950        assert_eq!(
951            svg.matches("<text").count(),
952            0,
953            "junction renders as shapes, not a fallback-font glyph: {svg}"
954        );
955        // Up arm (top-half vertical) + right arm (right-half horizontal): two fg rects.
956        assert!(
957            svg.matches("fill=\"#00f0ff\"").count() >= 2,
958            "two stroke rects for the up and right arms: {svg}"
959        );
960    }
961
962    #[test]
963    fn fg_change_breaks_the_run() {
964        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
965        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
966        buf[(1, 0)].set_symbol("B").set_fg(Color::Rgb(255, 0, 0));
967        let svg = buffer_to_svg(&buf, &test_opts());
968        assert_eq!(
969            svg.matches("<text").count(),
970            2,
971            "two runs on fg change: {svg}"
972        );
973    }
974
975    fn anim_frame(buf: Buffer, dur_ms: u32, keyframe: bool) -> AnimFrame {
976        AnimFrame {
977            buf,
978            dur_ms,
979            keyframe,
980        }
981    }
982
983    #[test]
984    fn animated_svg_emits_scene_groups_with_step_end_windows() {
985        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
986        a[(0, 0)].set_symbol("A");
987        let mut b = a.clone();
988        b[(1, 0)].set_symbol("B");
989        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
990        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
991        assert!(svg.contains("<svg"), "svg root: {svg}");
992        assert!(
993            svg.contains("<g class=\"pf pf0"),
994            "group for frame 0: {svg}"
995        );
996        assert!(
997            svg.contains("<g class=\"pf pf1"),
998            "group for frame 1: {svg}"
999        );
1000        assert!(svg.contains("step-end"), "discrete timing: {svg}");
1001        assert!(svg.contains("infinite"), "looping: {svg}");
1002        assert!(svg.contains("@keyframes"), "keyframes present: {svg}");
1003        assert!(svg.contains("2000ms"), "total duration is the sum: {svg}");
1004    }
1005
1006    #[test]
1007    fn delta_frame_emits_only_changed_cells_and_erases_cleared_ones() {
1008        // Frame 0: "AB". Frame 1: "A" stays, "B" cleared to a space.
1009        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1010        a[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
1011        a[(1, 0)].set_symbol("B").set_fg(Color::Rgb(0, 240, 255));
1012        let mut b = a.clone();
1013        b[(1, 0)].set_symbol(" ").set_fg(Color::Reset);
1014        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1015        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1016
1017        let delta = svg.split("<g class=\"pf pf1").nth(1).expect("delta group");
1018        assert!(
1019            !delta.contains(">A</text>"),
1020            "unchanged cell not re-emitted: {delta}"
1021        );
1022        assert!(
1023            delta.contains("<rect x=\"10\" y=\"0\" width=\"10\" height=\"20\" fill=\"#0a0a14\"/>"),
1024            "cleared cell gets a default-bg erasure rect: {delta}"
1025        );
1026        assert!(
1027            !delta.contains("<text"),
1028            "space emits no text in the delta: {delta}"
1029        );
1030    }
1031
1032    #[test]
1033    fn keyframe_hides_previous_scene_and_carries_fallback_stack() {
1034        // Scene 1: keyframe + delta (the fallback). Scene 2: keyframe.
1035        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1036        a[(0, 0)].set_symbol("A");
1037        let mut b = a.clone();
1038        b[(1, 0)].set_symbol("B");
1039        let mut c = Buffer::empty(Rect::new(0, 0, 2, 1));
1040        c[(0, 0)].set_symbol("C");
1041        let frames = vec![
1042            anim_frame(a, 1000, true),
1043            anim_frame(b, 1000, false),
1044            anim_frame(c, 2000, true),
1045        ];
1046        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1047
1048        // Scene 1 frames stay visible until the scene-2 keyframe at 50%.
1049        assert!(
1050            svg.contains("@keyframes pfa0{0%{opacity:1}50%{opacity:0}}"),
1051            "keyframe 0 window runs to the next keyframe: {svg}"
1052        );
1053        assert!(
1054            svg.contains("@keyframes pfa1{25%{opacity:1}50%{opacity:0}}"),
1055            "delta stays visible until its scene ends: {svg}"
1056        );
1057        // Scene 2 runs to the end of the loop.
1058        assert!(
1059            svg.contains("@keyframes pfa2{50%{opacity:1}}"),
1060            "last scene holds to 100%: {svg}"
1061        );
1062        // Fallback marks the whole scene-1 stack, not scene 2.
1063        assert!(
1064            svg.contains("<g class=\"pf pf0 pf-fb\">"),
1065            "fb on f0: {svg}"
1066        );
1067        assert!(
1068            svg.contains("<g class=\"pf pf1 pf-fb\">"),
1069            "fb on f1: {svg}"
1070        );
1071        assert!(svg.contains("<g class=\"pf pf2\">"), "no fb on f2: {svg}");
1072        assert!(
1073            svg.contains("@media (prefers-reduced-motion:reduce){.panim .pf{animation:none}}"),
1074            "reduced motion turns animation off: {svg}"
1075        );
1076    }
1077
1078    #[test]
1079    fn adjacent_same_bg_cells_merge_into_one_rect() {
1080        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1081        for x in 0..3 {
1082            buf[(x, 0)].set_symbol(" ").set_bg(Color::Rgb(0, 240, 255));
1083        }
1084        let svg = buffer_to_svg(&buf, &test_opts());
1085        assert!(
1086            svg.contains("<rect x=\"0\" y=\"0\" width=\"30\" height=\"20\" fill=\"#00f0ff\"/>"),
1087            "one merged run rect: {svg}"
1088        );
1089        assert_eq!(
1090            svg.matches("fill=\"#00f0ff\"").count(),
1091            1,
1092            "no per-cell rects: {svg}"
1093        );
1094    }
1095
1096    #[test]
1097    fn delta_skips_erasure_for_cells_that_were_already_blank() {
1098        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1099        a[(0, 0)].set_symbol("A");
1100        let mut b = a.clone();
1101        b[(1, 0)].set_symbol("B"); // appears on a blank cell: nothing to erase
1102        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1103        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1104        let delta = svg.split("<g class=\"pf pf1").nth(1).expect("delta group");
1105        assert!(delta.contains(">B</text>"), "new glyph painted: {delta}");
1106        assert!(
1107            !delta.contains("<rect"),
1108            "no erasure rect over a blank cell: {delta}"
1109        );
1110    }
1111
1112    #[test]
1113    fn consecutive_horizontal_line_glyphs_merge_into_one_rect() {
1114        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1115        for x in 0..3 {
1116            buf[(x, 0)]
1117                .set_symbol("\u{2500}")
1118                .set_fg(Color::Rgb(0, 240, 255));
1119        }
1120        let svg = buffer_to_svg(&buf, &test_opts());
1121        assert!(
1122            svg.contains(
1123                "<rect x=\"0\" y=\"9.38\" width=\"30\" height=\"1.25\" fill=\"#00f0ff\"/>"
1124            ),
1125            "one merged horizontal rect: {svg}"
1126        );
1127        assert_eq!(
1128            svg.matches("fill=\"#00f0ff\"").count(),
1129            1,
1130            "no per-cell line rects: {svg}"
1131        );
1132    }
1133
1134    #[test]
1135    fn vertical_line_glyphs_merge_across_rows() {
1136        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1137        for y in 0..3 {
1138            buf[(0, y)]
1139                .set_symbol("\u{2502}")
1140                .set_fg(Color::Rgb(0, 240, 255));
1141        }
1142        let svg = buffer_to_svg(&buf, &test_opts());
1143        assert!(
1144            svg.contains(
1145                "<rect x=\"4.38\" y=\"0\" width=\"1.25\" height=\"60\" fill=\"#00f0ff\"/>"
1146            ),
1147            "one merged vertical rect: {svg}"
1148        );
1149        assert_eq!(
1150            svg.matches("fill=\"#00f0ff\"").count(),
1151            1,
1152            "no per-cell line rects: {svg}"
1153        );
1154    }
1155
1156    #[test]
1157    fn dim_line_glyphs_honor_the_dim_modifier_like_text_does() {
1158        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1159        for x in 0..2 {
1160            buf[(x, 0)].set_symbol("\u{2500}").set_style(
1161                Style::new()
1162                    .fg(Color::Rgb(224, 214, 240))
1163                    .add_modifier(Modifier::DIM),
1164            );
1165        }
1166        let svg = buffer_to_svg(&buf, &test_opts());
1167        assert!(
1168            svg.contains("width=\"20\"") && svg.contains("fill-opacity=\"0.6\""),
1169            "merged dim line rect carries reduced opacity: {svg}"
1170        );
1171    }
1172
1173    #[test]
1174    fn dim_and_bright_line_runs_do_not_merge() {
1175        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1176        buf[(0, 0)].set_symbol("\u{2500}").set_style(
1177            Style::new()
1178                .fg(Color::Rgb(224, 214, 240))
1179                .add_modifier(Modifier::DIM),
1180        );
1181        buf[(1, 0)]
1182            .set_symbol("\u{2500}")
1183            .set_fg(Color::Rgb(224, 214, 240));
1184        let svg = buffer_to_svg(&buf, &test_opts());
1185        assert_eq!(
1186            svg.matches("fill=\"#e0d6f0\"").count(),
1187            2,
1188            "two separate rects when dim differs: {svg}"
1189        );
1190    }
1191
1192    #[test]
1193    fn dim_rounded_corner_stroke_honors_the_dim_modifier() {
1194        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1195        buf[(0, 0)].set_symbol("\u{256D}").set_style(
1196            Style::new()
1197                .fg(Color::Rgb(224, 214, 240))
1198                .add_modifier(Modifier::DIM),
1199        );
1200        let svg = buffer_to_svg(&buf, &test_opts());
1201        assert!(
1202            svg.contains("stroke-opacity=\"0.6\""),
1203            "dim arc stroke carries reduced opacity: {svg}"
1204        );
1205    }
1206
1207    #[test]
1208    fn padding_grows_the_canvas_and_offsets_the_cell_grid() {
1209        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1210        buf[(0, 0)].set_symbol("A");
1211        let mut opts = test_opts();
1212        opts.pad = 10;
1213        let svg = buffer_to_svg(&buf, &opts);
1214        assert!(svg.contains("width=\"40\""), "20 grid + 2*10 pad: {svg}");
1215        assert!(svg.contains("height=\"40\""), "20 grid + 2*10 pad: {svg}");
1216        assert!(
1217            svg.contains("<g transform=\"translate(10,10)\">"),
1218            "cell grid shifts by the padding: {svg}"
1219        );
1220    }
1221
1222    #[test]
1223    fn rounded_mode_draws_a_bare_rounded_panel() {
1224        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1225        let mut opts = test_opts();
1226        opts.pad = 10;
1227        opts.rounded = true;
1228        let svg = buffer_to_svg(&buf, &opts);
1229        assert!(svg.contains("rx=\"12\""), "rounded panel corners: {svg}");
1230        for light in ["#ff5f57", "#febc2e", "#28c840"] {
1231            assert!(!svg.contains(light), "no traffic light {light}: {svg}");
1232        }
1233        assert!(svg.contains("height=\"40\""), "grid + padding only: {svg}");
1234        assert!(
1235            svg.contains("<g transform=\"translate(10,10)\">"),
1236            "grid offset is just the padding: {svg}"
1237        );
1238    }
1239
1240    #[test]
1241    fn no_padding_keeps_legacy_flush_output() {
1242        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1243        let svg = buffer_to_svg(&buf, &test_opts());
1244        assert!(
1245            !svg.contains("<g transform="),
1246            "flush render emits no translate group: {svg}"
1247        );
1248    }
1249
1250    #[test]
1251    #[should_panic(expected = "share one size")]
1252    fn animated_svg_rejects_mismatched_frame_sizes() {
1253        let a = Buffer::empty(Rect::new(0, 0, 2, 1));
1254        let b = Buffer::empty(Rect::new(0, 0, 3, 1));
1255        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1256        frames_to_animated_svg(&frames, 0, &test_opts());
1257    }
1258
1259    #[test]
1260    fn reset_fg_uses_default_and_reset_bg_emits_no_cell_rect() {
1261        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1262        buf[(0, 0)].set_symbol("A");
1263        let svg = buffer_to_svg(&buf, &test_opts());
1264        assert!(
1265            svg.contains("<text x=\"0\" y=\"16\" fill=\"#e0d6f0\""),
1266            "reset fg falls back to default_fg: {svg}"
1267        );
1268        assert_eq!(
1269            svg.matches("<rect").count(),
1270            1,
1271            "reset bg emits only the canvas rect: {svg}"
1272        );
1273    }
1274}