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, block, braille and status glyphs render as crisp SVG
373            // shapes (see glyph_shape) so they tile seamlessly and need no font.
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, block, braille and status glyphs purple uses as
527/// crisp SVG shapes (rects, circles and stroked paths) so they tile seamlessly
528/// and never depend on a fallback font. Font glyphs leave gaps at non-unit line
529/// heights, and a fallback face for braille / status symbols advances wider than
530/// the grid and bleeds into the next column. Returns `None` for glyphs that
531/// render as `<text>` (letters, digits and the symbol glyphs the embedded font
532/// covers, e.g. ✓ ⚠ ▲ ▸ ▾).
533fn glyph_shape(
534    sym: &str,
535    x0: f64,
536    y0: f64,
537    cw: f64,
538    ch: f64,
539    fill: &str,
540    dim: bool,
541) -> Option<String> {
542    let t = (cw / 8.0).max(1.0);
543    let cx = x0 + cw / 2.0;
544    let cy = y0 + ch / 2.0;
545    let r = (cw * 0.4).min(cw / 2.0).min(ch / 2.0);
546    let fop = dim_fill_attr(dim);
547    let sop = if dim { " stroke-opacity=\"0.6\"" } else { "" };
548    let rect = |x: f64, y: f64, w: f64, h: f64| {
549        format!(
550            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\"{fop}/>",
551            coord(x),
552            coord(y),
553            coord(w),
554            coord(h),
555        )
556    };
557    let stroke = |d: String| {
558        format!(
559            "<path d=\"{d}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{}\"{sop}/>",
560            coord(t),
561        )
562    };
563    // Status-dot radius, shared by the circle family so every dot matches.
564    let dot_r = (cw * 0.34).min(ch / 2.0);
565    let disc = |rr: f64| {
566        format!(
567            "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"{fill}\"{fop}/>",
568            coord(cx),
569            coord(cy),
570            coord(rr),
571        )
572    };
573    let ring = |rr: f64| {
574        format!(
575            "<circle cx=\"{}\" cy=\"{}\" r=\"{}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{}\"{sop}/>",
576            coord(cx),
577            coord(cy),
578            coord(rr),
579            coord(t),
580        )
581    };
582    let vline = rect(cx - t / 2.0, y0, t, ch);
583    let hline = rect(x0, cy - t / 2.0, cw, t);
584    let h_left = rect(x0, cy - t / 2.0, cw / 2.0 + t / 2.0, t);
585    let h_right = rect(cx - t / 2.0, cy - t / 2.0, cw / 2.0 + t / 2.0, t);
586    let v_top = rect(cx - t / 2.0, y0, t, ch / 2.0 + t / 2.0);
587    let cp = sym.chars().next()?;
588    match cp {
589        '\u{2500}' => Some(hline),                       // ─
590        '\u{2502}' | '\u{250A}' => Some(vline),          // │ ┊
591        '\u{2574}' => Some(h_left),                      // ╴
592        '\u{251C}' => Some(format!("{vline}{h_right}")), // ├
593        '\u{2524}' => Some(format!("{vline}{h_left}")),  // ┤
594        '\u{2514}' => Some(format!("{v_top}{h_right}")), // └ (tree branch)
595        // Rounded corners: a horizontal stub into a quarter arc into a vertical stub.
596        '\u{256D}' => Some(stroke(format!(
597            "M {},{} H {} A {},{} 0 0 0 {},{} V {}",
598            coord(x0 + cw),
599            coord(cy),
600            coord(cx + r),
601            coord(r),
602            coord(r),
603            coord(cx),
604            coord(cy + r),
605            coord(y0 + ch),
606        ))), // ╭
607        '\u{256E}' => Some(stroke(format!(
608            "M {},{} H {} A {},{} 0 0 1 {},{} V {}",
609            coord(x0),
610            coord(cy),
611            coord(cx - r),
612            coord(r),
613            coord(r),
614            coord(cx),
615            coord(cy + r),
616            coord(y0 + ch),
617        ))), // ╮
618        '\u{2570}' => Some(stroke(format!(
619            "M {},{} H {} A {},{} 0 0 1 {},{} V {}",
620            coord(x0 + cw),
621            coord(cy),
622            coord(cx + r),
623            coord(r),
624            coord(r),
625            coord(cx),
626            coord(cy - r),
627            coord(y0),
628        ))), // ╰
629        '\u{256F}' => Some(stroke(format!(
630            "M {},{} H {} A {},{} 0 0 0 {},{} V {}",
631            coord(x0),
632            coord(cy),
633            coord(cx - r),
634            coord(r),
635            coord(r),
636            coord(cx),
637            coord(cy - r),
638            coord(y0),
639        ))), // ╯
640        '\u{2588}' => Some(rect(x0, y0, cw, ch)), // █
641        '\u{2591}' => Some(format!(
642            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"{fill}\" fill-opacity=\"0.28\"/>",
643            coord(x0),
644            coord(y0),
645            coord(cw),
646            coord(ch),
647        )), // ░
648        // Lower blocks ▁..▇: a bottom-anchored bar of n eighths.
649        '\u{2581}'..='\u{2587}' => {
650            let h = ch * f64::from(cp as u32 - 0x2580) / 8.0;
651            Some(rect(x0, y0 + ch - h, cw, h))
652        }
653        // Left blocks ▉..▏ (and ▌): a left-anchored bar of n eighths.
654        '\u{2589}'..='\u{258F}' => {
655            let w = cw * f64::from(0x2590 - cp as u32) / 8.0;
656            Some(rect(x0, y0, w, ch))
657        }
658        // Braille patterns (sparklines, trend charts, spinners): a 2x4 dot
659        // grid. Drawn as crisp in-cell dots, never font glyphs. rsvg renders
660        // a fallback braille face wider than the grid advance and ignores
661        // textLength, so on the text path the dots bleed into the next column.
662        '\u{2800}'..='\u{28FF}' => {
663            let bits = cp as u32 - 0x2800;
664            let (sub_w, sub_h) = (cw / 2.0, ch / 4.0);
665            let (dot_w, dot_h) = (sub_w * 0.6, sub_h * 0.6);
666            let (gx, gy) = ((sub_w - dot_w) / 2.0, (sub_h - dot_h) / 2.0);
667            // Bit layout per column, top row to bottom row.
668            const COL_BITS: [[u32; 4]; 2] = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]];
669            let mut dots = String::new();
670            for (col, col_bits) in COL_BITS.iter().enumerate() {
671                for (row, bit) in col_bits.iter().enumerate() {
672                    if bits & bit != 0 {
673                        dots.push_str(&rect(
674                            x0 + col as f64 * sub_w + gx,
675                            y0 + row as f64 * sub_h + gy,
676                            dot_w,
677                            dot_h,
678                        ));
679                    }
680                }
681            }
682            Some(dots)
683        }
684        // Heavy multiplication X (ICON_ERROR): two diagonal strokes. Neither
685        // brand face ships this glyph, so draw it instead of leaking to a
686        // system fallback font in the rasterised imagery.
687        '\u{2716}' => {
688            let s = cw.min(ch) * 0.72;
689            let (xl, xr) = (cx - s / 2.0, cx + s / 2.0);
690            let (yt, yb) = (cy - s / 2.0, cy + s / 2.0);
691            Some(format!(
692                "<path d=\"M {},{} L {},{} M {},{} L {},{}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{}\" stroke-linecap=\"round\"{sop}/>",
693                coord(xl),
694                coord(yt),
695                coord(xr),
696                coord(yb),
697                coord(xr),
698                coord(yt),
699                coord(xl),
700                coord(yb),
701                coord(t * 1.5),
702            ))
703        }
704        // Left-right arrows (tunnel indicator U+21C4): top line points right,
705        // bottom line points left. Also absent from both brand faces.
706        '\u{21C4}' => {
707            let xl = coord(x0 + cw * 0.12);
708            let xr = coord(x0 + cw * 0.88);
709            let yt = coord(cy - ch * 0.10);
710            let yb = coord(cy + ch * 0.10);
711            let head_r = coord(x0 + cw * 0.88 - cw * 0.24);
712            let head_l = coord(x0 + cw * 0.12 + cw * 0.24);
713            let yt_up = coord(cy - ch * 0.10 - ch * 0.06);
714            let yt_dn = coord(cy - ch * 0.10 + ch * 0.06);
715            let yb_up = coord(cy + ch * 0.10 - ch * 0.06);
716            let yb_dn = coord(cy + ch * 0.10 + ch * 0.06);
717            let sw = coord(t);
718            Some(format!(
719                "<path d=\"M {xl},{yt} H {xr} M {head_r},{yt_up} L {xr},{yt} L {head_r},{yt_dn} M {xr},{yb} H {xl} M {head_l},{yb_up} L {xl},{yb} L {head_l},{yb_dn}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{sw}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"{sop}/>",
720            ))
721        }
722        // Status circles: crisp shapes so every status dot renders identically
723        // and never depends on a fallback face. ● filled, ○ outline, ◉ ring +
724        // centre (target/fisheye), ◐ outline + filled left half (paused).
725        '\u{25CF}' => Some(disc(dot_r)),
726        '\u{25CB}' => Some(ring(dot_r)),
727        '\u{25C9}' => Some(format!("{}{}", ring(dot_r), disc(dot_r * 0.42))),
728        '\u{25D0}' => {
729            let half = format!(
730                "<path d=\"M {cxc},{top} A {rr},{rr} 0 0 0 {cxc},{bot} Z\" fill=\"{fill}\"{fop}/>",
731                cxc = coord(cx),
732                top = coord(cy - dot_r),
733                bot = coord(cy + dot_r),
734                rr = coord(dot_r),
735            );
736            Some(format!("{}{}", ring(dot_r), half))
737        }
738        // Open box (toggle / space-key hint): a squared U on the baseline.
739        '\u{2423}' => {
740            let l = coord(x0 + cw * 0.18);
741            let r2 = coord(x0 + cw * 0.82);
742            let top = coord(cy - ch * 0.10);
743            let bot = coord(cy + ch * 0.16);
744            let sw = coord(t);
745            Some(format!(
746                "<path d=\"M {l},{top} V {bot} H {r2} V {top}\" fill=\"none\" stroke=\"{fill}\" stroke-width=\"{sw}\" stroke-linecap=\"round\" stroke-linejoin=\"round\"{sop}/>",
747            ))
748        }
749        _ => None,
750    }
751}
752
753/// Resolve a ratatui [`Color`] to an RGB triple. `Color::Reset` returns
754/// `None` so the caller can fall back to the canvas default.
755fn resolve(color: Color, _opts: &SvgOpts, _is_bg: bool) -> Option<Rgb> {
756    match color {
757        Color::Reset => None,
758        Color::Rgb(r, g, b) => Some((r, g, b)),
759        Color::Indexed(i) => Some(xterm256(i)),
760        named => Some(ansi16(named)),
761    }
762}
763
764/// Standard VS Code terminal palette for the 16 named ANSI colors. These are
765/// fallbacks: in truecolor render mode purple's cells carry `Color::Rgb`.
766fn ansi16(c: Color) -> Rgb {
767    match c {
768        Color::Black => (0, 0, 0),
769        Color::Red => (205, 49, 49),
770        Color::Green => (13, 188, 121),
771        Color::Yellow => (229, 229, 16),
772        Color::Blue => (36, 114, 200),
773        Color::Magenta => (188, 63, 188),
774        Color::Cyan => (17, 168, 205),
775        Color::Gray => (229, 229, 229),
776        Color::DarkGray => (102, 102, 102),
777        Color::LightRed => (241, 76, 76),
778        Color::LightGreen => (35, 209, 139),
779        Color::LightYellow => (245, 245, 67),
780        Color::LightBlue => (59, 142, 234),
781        Color::LightMagenta => (214, 112, 214),
782        Color::LightCyan => (41, 184, 219),
783        Color::White => (229, 229, 229),
784        // Reset/Rgb/Indexed handled by the caller.
785        _ => (229, 229, 229),
786    }
787}
788
789/// Map an xterm 256-color index to RGB (16 base + 6x6x6 cube + grayscale ramp).
790fn xterm256(i: u8) -> Rgb {
791    match i {
792        0 => (0, 0, 0),
793        1 => (205, 49, 49),
794        2 => (13, 188, 121),
795        3 => (229, 229, 16),
796        4 => (36, 114, 200),
797        5 => (188, 63, 188),
798        6 => (17, 168, 205),
799        7 => (229, 229, 229),
800        8 => (102, 102, 102),
801        9 => (241, 76, 76),
802        10 => (35, 209, 139),
803        11 => (245, 245, 67),
804        12 => (59, 142, 234),
805        13 => (214, 112, 214),
806        14 => (41, 184, 219),
807        15 => (255, 255, 255),
808        16..=231 => {
809            let n = i - 16;
810            let r = n / 36;
811            let g = (n % 36) / 6;
812            let b = n % 6;
813            let step = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
814            (step(r), step(g), step(b))
815        }
816        232..=255 => {
817            let v = 8 + (i - 232) * 10;
818            (v, v, v)
819        }
820    }
821}
822
823/// Escape the five XML-significant characters for text content.
824fn xml_escape(s: &str) -> String {
825    let mut out = String::with_capacity(s.len());
826    for ch in s.chars() {
827        match ch {
828            '&' => out.push_str("&amp;"),
829            '<' => out.push_str("&lt;"),
830            '>' => out.push_str("&gt;"),
831            '"' => out.push_str("&quot;"),
832            '\'' => out.push_str("&apos;"),
833            _ => out.push(ch),
834        }
835    }
836    out
837}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842    use ratatui::layout::Rect;
843    use ratatui::style::{Modifier, Style};
844
845    fn test_opts() -> SvgOpts {
846        SvgOpts {
847            cell_w: 10,
848            cell_h: 20,
849            font_size: 16,
850            font_family: "'Mono'".to_string(),
851            font_face_css: String::new(),
852            default_fg: (224, 214, 240),
853            default_bg: (10, 10, 20),
854            ..SvgOpts::default()
855        }
856    }
857
858    #[test]
859    fn empty_buffer_emits_sized_svg_root_with_background() {
860        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
861        let svg = buffer_to_svg(&buf, &test_opts());
862        assert!(svg.contains("<svg"), "has svg root: {svg}");
863        assert!(svg.contains("width=\"20\""), "width = cols*cell_w: {svg}");
864        assert!(svg.contains("height=\"20\""), "height = rows*cell_h: {svg}");
865        assert!(
866            svg.contains("xmlns=\"http://www.w3.org/2000/svg\""),
867            "has xmlns: {svg}"
868        );
869        assert!(
870            svg.contains("fill=\"#0a0a14\""),
871            "background fill present: {svg}"
872        );
873    }
874
875    #[test]
876    fn cell_with_background_emits_positioned_rect() {
877        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 2));
878        buf[(1, 1)].set_symbol(" ").set_bg(Color::Rgb(0, 240, 255));
879        let svg = buffer_to_svg(&buf, &test_opts());
880        assert!(
881            svg.contains("<rect x=\"10\" y=\"20\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
882            "positioned cell bg rect: {svg}"
883        );
884    }
885
886    #[test]
887    fn glyph_emits_text_run_and_space_does_not() {
888        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
889        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(255, 0, 0));
890        let svg = buffer_to_svg(&buf, &test_opts());
891        assert!(svg.contains(">A</text>"), "glyph A rendered as text: {svg}");
892        assert!(svg.contains("fill=\"#ff0000\""), "glyph fg color: {svg}");
893        assert_eq!(
894            svg.matches("<text").count(),
895            1,
896            "only non-space cells get text: {svg}"
897        );
898    }
899
900    #[test]
901    fn embeds_font_face_and_sets_text_font() {
902        let buf = Buffer::empty(Rect::new(0, 0, 1, 1));
903        let mut opts = test_opts();
904        opts.font_face_css =
905            "@font-face{font-family:'Mono';src:url(data:font/woff2;base64,AAAA)}".to_string();
906        let svg = buffer_to_svg(&buf, &opts);
907        assert!(svg.contains("<defs><style>"), "has defs/style: {svg}");
908        assert!(
909            svg.contains("@font-face{font-family:'Mono'"),
910            "embeds font-face: {svg}"
911        );
912        assert!(
913            svg.contains("font-family:'Mono'") && svg.contains("font-size:16px"),
914            "sets text font rule: {svg}"
915        );
916    }
917
918    #[test]
919    fn bold_modifier_sets_font_weight() {
920        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
921        buf[(0, 0)]
922            .set_symbol("A")
923            .set_style(Style::new().add_modifier(Modifier::BOLD));
924        let svg = buffer_to_svg(&buf, &test_opts());
925        assert!(svg.contains("font-weight=\"bold\""), "bold: {svg}");
926    }
927
928    #[test]
929    fn dim_modifier_sets_fill_opacity() {
930        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
931        buf[(0, 0)]
932            .set_symbol("A")
933            .set_style(Style::new().add_modifier(Modifier::DIM));
934        let svg = buffer_to_svg(&buf, &test_opts());
935        assert!(svg.contains("fill-opacity=\"0.6\""), "dim: {svg}");
936    }
937
938    #[test]
939    fn reversed_modifier_swaps_fg_and_bg() {
940        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
941        buf[(0, 0)].set_symbol("A").set_style(
942            Style::new()
943                .fg(Color::Rgb(0, 240, 255))
944                .bg(Color::Rgb(10, 10, 20))
945                .add_modifier(Modifier::REVERSED),
946        );
947        let svg = buffer_to_svg(&buf, &test_opts());
948        // Reversed: the bg rect takes the fg color, the glyph takes the bg color.
949        assert!(
950            svg.contains("<rect x=\"0\" y=\"0\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
951            "reversed bg rect uses fg color: {svg}"
952        );
953        assert!(
954            svg.contains("<text x=\"0\" y=\"16\" fill=\"#0a0a14\""),
955            "reversed glyph uses bg color: {svg}"
956        );
957    }
958
959    #[test]
960    fn escapes_xml_special_glyphs() {
961        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
962        buf[(0, 0)].set_symbol("<");
963        buf[(1, 0)].set_symbol("&");
964        let svg = buffer_to_svg(&buf, &test_opts());
965        // Adjacent default-style glyphs merge into one run.
966        assert!(svg.contains(">&lt;&amp;</text>"), "escapes < and &: {svg}");
967    }
968
969    #[test]
970    fn contiguous_same_style_glyphs_merge_into_one_text_run() {
971        let mut buf = Buffer::empty(Rect::new(0, 0, 4, 1));
972        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
973        buf[(1, 0)].set_symbol("B").set_fg(Color::Rgb(0, 240, 255));
974        buf[(2, 0)].set_symbol("C").set_fg(Color::Rgb(0, 240, 255));
975        let svg = buffer_to_svg(&buf, &test_opts());
976        assert!(svg.contains(">ABC</text>"), "merged run text: {svg}");
977        assert_eq!(svg.matches("<text").count(), 1, "one text per run: {svg}");
978        // textLength spans the whole run (3 cells * 10px).
979        assert!(svg.contains("textLength=\"30\""), "run textLength: {svg}");
980    }
981
982    fn glyph_svg(symbol: &str) -> String {
983        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
984        buf[(0, 0)]
985            .set_symbol(symbol)
986            .set_fg(Color::Rgb(0, 240, 255));
987        buffer_to_svg(&buf, &test_opts())
988    }
989
990    #[test]
991    fn vertical_box_glyph_draws_full_height_rect_not_text() {
992        let svg = glyph_svg("\u{2502}"); // │
993        assert_eq!(
994            svg.matches("<text").count(),
995            0,
996            "no text for box glyph: {svg}"
997        );
998        // t = cell_w/8 = 1.25, full cell height 20.
999        assert!(
1000            svg.contains("width=\"1.25\"") && svg.contains("height=\"20\""),
1001            "full-height thin rect: {svg}"
1002        );
1003        assert!(svg.contains("fill=\"#00f0ff\""), "uses fg color: {svg}");
1004    }
1005
1006    #[test]
1007    fn horizontal_box_glyph_draws_full_width_rect() {
1008        let svg = glyph_svg("\u{2500}"); // ─
1009        assert!(
1010            svg.contains("width=\"10\"") && svg.contains("height=\"1.25\""),
1011            "full-width thin rect: {svg}"
1012        );
1013    }
1014
1015    #[test]
1016    fn full_block_fills_the_cell() {
1017        let svg = glyph_svg("\u{2588}"); // █
1018        assert!(
1019            svg.contains("<rect x=\"0\" y=\"0\" width=\"10\" height=\"20\" fill=\"#00f0ff\"/>"),
1020            "full cell rect: {svg}"
1021        );
1022    }
1023
1024    #[test]
1025    fn lower_half_block_anchors_to_the_bottom() {
1026        let svg = glyph_svg("\u{2584}"); // ▄ = 4/8 from the bottom
1027        assert!(
1028            svg.contains("<rect x=\"0\" y=\"10\" width=\"10\" height=\"10\" fill=\"#00f0ff\"/>"),
1029            "bottom-half rect: {svg}"
1030        );
1031    }
1032
1033    #[test]
1034    fn left_half_block_anchors_to_the_left() {
1035        let svg = glyph_svg(crate::ui::design::HOST_HIGHLIGHT); // ▌ = 4/8 from the left
1036        assert!(
1037            svg.contains("<rect x=\"0\" y=\"0\" width=\"5\" height=\"20\" fill=\"#00f0ff\"/>"),
1038            "left-half rect: {svg}"
1039        );
1040    }
1041
1042    #[test]
1043    fn rounded_corner_draws_an_arc_path() {
1044        let svg = glyph_svg("\u{256D}"); // ╭
1045        assert!(svg.contains("<path d=\"M "), "path present: {svg}");
1046        assert!(svg.contains(" A "), "arc command present: {svg}");
1047        assert!(svg.contains("stroke=\"#00f0ff\""), "stroked in fg: {svg}");
1048    }
1049
1050    #[test]
1051    fn symbol_without_a_shape_stays_text() {
1052        let svg = glyph_svg(crate::ui::design::ICON_WARNING); // ⚠ has no shape, renders as text
1053        assert!(
1054            svg.contains(">\u{26A0}</text>"),
1055            "warning sign renders as text: {svg}"
1056        );
1057    }
1058
1059    #[test]
1060    fn box_drawing_up_right_junction_draws_shapes_not_text() {
1061        let svg = glyph_svg("\u{2514}"); // └ (design::TREE_BRANCH, the providers tree leaf)
1062        assert_eq!(
1063            svg.matches("<text").count(),
1064            0,
1065            "junction renders as shapes, not a fallback-font glyph: {svg}"
1066        );
1067        // Up arm (top-half vertical) + right arm (right-half horizontal): two fg rects.
1068        assert!(
1069            svg.matches("fill=\"#00f0ff\"").count() >= 2,
1070            "two stroke rects for the up and right arms: {svg}"
1071        );
1072    }
1073
1074    #[test]
1075    fn braille_renders_as_in_cell_dots_not_text() {
1076        let svg = glyph_svg("\u{28FF}"); // ⣿ all eight dots raised
1077        assert_eq!(
1078            svg.matches("<text").count(),
1079            0,
1080            "braille renders as dot shapes, not a fallback-font glyph: {svg}"
1081        );
1082        assert_eq!(
1083            svg.matches("fill=\"#00f0ff\"").count(),
1084            8,
1085            "eight dots for the full braille cell: {svg}"
1086        );
1087    }
1088
1089    #[test]
1090    fn braille_top_left_dot_sits_in_the_top_left_sub_cell() {
1091        let svg = glyph_svg("\u{2801}"); // ⠁ dot 1 only
1092        // sub-cell 5x5, dot 0.6 of it (3x3) centred -> offset 1,1.
1093        assert!(
1094            svg.contains("<rect x=\"1\" y=\"1\" width=\"3\" height=\"3\" fill=\"#00f0ff\"/>"),
1095            "single top-left dot: {svg}"
1096        );
1097        assert_eq!(
1098            svg.matches("fill=\"#00f0ff\"").count(),
1099            1,
1100            "exactly one dot: {svg}"
1101        );
1102    }
1103
1104    #[test]
1105    fn braille_bottom_right_dot_stays_within_the_cell() {
1106        let svg = glyph_svg("\u{2880}"); // dot 8 (bit 0x80) -> bottom-right sub-cell
1107        // x+w = 6+3 = 9 <= cell_w(10), y+h = 16+3 = 19 <= cell_h(20): no bleed.
1108        assert!(
1109            svg.contains("<rect x=\"6\" y=\"16\" width=\"3\" height=\"3\" fill=\"#00f0ff\"/>"),
1110            "bottom-right dot stays inside the cell bounds: {svg}"
1111        );
1112    }
1113
1114    #[test]
1115    fn error_x_renders_as_strokes_not_text() {
1116        let svg = glyph_svg(crate::ui::design::ICON_ERROR); // ✖, absent from both brand faces
1117        assert_eq!(
1118            svg.matches("<text").count(),
1119            0,
1120            "error X renders as shapes, not a system-fallback glyph: {svg}"
1121        );
1122        assert!(svg.contains("<path d=\"M "), "two diagonal strokes: {svg}");
1123        assert!(svg.contains("stroke=\"#00f0ff\""), "stroked in fg: {svg}");
1124    }
1125
1126    #[test]
1127    fn tunnel_arrows_render_as_strokes_not_text() {
1128        let svg = glyph_svg("\u{21C4}"); // ⇄ tunnel indicator, absent from both brand faces
1129        assert_eq!(
1130            svg.matches("<text").count(),
1131            0,
1132            "tunnel arrows render as shapes, not a system-fallback glyph: {svg}"
1133        );
1134        assert!(svg.contains("<path d=\"M "), "arrow path present: {svg}");
1135        assert!(svg.contains("stroke=\"#00f0ff\""), "stroked in fg: {svg}");
1136    }
1137
1138    #[test]
1139    fn status_circles_render_as_shapes_not_text() {
1140        use crate::ui::design::{ICON_ONLINE, ICON_PAUSED, ICON_STOPPED, ICON_TARGET};
1141        for g in [ICON_ONLINE, ICON_STOPPED, ICON_TARGET, ICON_PAUSED] {
1142            let svg = glyph_svg(g);
1143            assert_eq!(
1144                svg.matches("<text").count(),
1145                0,
1146                "status circle {g} renders as shapes, not a fallback glyph: {svg}"
1147            );
1148            assert!(
1149                svg.contains("<circle"),
1150                "status circle {g} has a circle: {svg}"
1151            );
1152        }
1153    }
1154
1155    #[test]
1156    fn online_dot_is_one_filled_circle() {
1157        let svg = glyph_svg(crate::ui::design::ICON_ONLINE); // ● dot_r = cell_w(10)*0.34 = 3.4
1158        assert!(
1159            svg.contains("<circle cx=\"5\" cy=\"10\" r=\"3.4\" fill=\"#00f0ff\"/>"),
1160            "centred filled dot: {svg}"
1161        );
1162        assert_eq!(
1163            svg.matches("<circle").count(),
1164            1,
1165            "exactly one circle: {svg}"
1166        );
1167    }
1168
1169    #[test]
1170    fn stopped_dot_is_an_outline_circle() {
1171        let svg = glyph_svg(crate::ui::design::ICON_STOPPED); // ○
1172        assert!(svg.contains("fill=\"none\""), "outline only: {svg}");
1173        assert!(svg.contains("stroke=\"#00f0ff\""), "stroked in fg: {svg}");
1174    }
1175
1176    #[test]
1177    fn paused_dot_fills_its_left_half() {
1178        let svg = glyph_svg(crate::ui::design::ICON_PAUSED); // ◐ outline ring + left semicircle
1179        assert!(svg.contains("<circle"), "outline ring present: {svg}");
1180        assert!(
1181            svg.contains("A 3.4,3.4 0 0 0"),
1182            "left-half arc (sweep flag 0): {svg}"
1183        );
1184    }
1185
1186    #[test]
1187    fn space_hint_renders_as_a_squared_u_not_text() {
1188        let svg = glyph_svg(crate::ui::design::TOGGLE_HINT); // ␣ open box
1189        assert_eq!(svg.matches("<text").count(), 0, "no fallback glyph: {svg}");
1190        assert!(svg.contains("<path d=\"M "), "squared-U path: {svg}");
1191    }
1192
1193    #[test]
1194    fn fg_change_breaks_the_run() {
1195        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1196        buf[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
1197        buf[(1, 0)].set_symbol("B").set_fg(Color::Rgb(255, 0, 0));
1198        let svg = buffer_to_svg(&buf, &test_opts());
1199        assert_eq!(
1200            svg.matches("<text").count(),
1201            2,
1202            "two runs on fg change: {svg}"
1203        );
1204    }
1205
1206    fn anim_frame(buf: Buffer, dur_ms: u32, keyframe: bool) -> AnimFrame {
1207        AnimFrame {
1208            buf,
1209            dur_ms,
1210            keyframe,
1211        }
1212    }
1213
1214    #[test]
1215    fn animated_svg_emits_scene_groups_with_step_end_windows() {
1216        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1217        a[(0, 0)].set_symbol("A");
1218        let mut b = a.clone();
1219        b[(1, 0)].set_symbol("B");
1220        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1221        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1222        assert!(svg.contains("<svg"), "svg root: {svg}");
1223        assert!(
1224            svg.contains("<g class=\"pf pf0"),
1225            "group for frame 0: {svg}"
1226        );
1227        assert!(
1228            svg.contains("<g class=\"pf pf1"),
1229            "group for frame 1: {svg}"
1230        );
1231        assert!(svg.contains("step-end"), "discrete timing: {svg}");
1232        assert!(svg.contains("infinite"), "looping: {svg}");
1233        assert!(svg.contains("@keyframes"), "keyframes present: {svg}");
1234        assert!(svg.contains("2000ms"), "total duration is the sum: {svg}");
1235    }
1236
1237    #[test]
1238    fn delta_frame_emits_only_changed_cells_and_erases_cleared_ones() {
1239        // Frame 0: "AB". Frame 1: "A" stays, "B" cleared to a space.
1240        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1241        a[(0, 0)].set_symbol("A").set_fg(Color::Rgb(0, 240, 255));
1242        a[(1, 0)].set_symbol("B").set_fg(Color::Rgb(0, 240, 255));
1243        let mut b = a.clone();
1244        b[(1, 0)].set_symbol(" ").set_fg(Color::Reset);
1245        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1246        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1247
1248        let delta = svg.split("<g class=\"pf pf1").nth(1).expect("delta group");
1249        assert!(
1250            !delta.contains(">A</text>"),
1251            "unchanged cell not re-emitted: {delta}"
1252        );
1253        assert!(
1254            delta.contains("<rect x=\"10\" y=\"0\" width=\"10\" height=\"20\" fill=\"#0a0a14\"/>"),
1255            "cleared cell gets a default-bg erasure rect: {delta}"
1256        );
1257        assert!(
1258            !delta.contains("<text"),
1259            "space emits no text in the delta: {delta}"
1260        );
1261    }
1262
1263    #[test]
1264    fn keyframe_hides_previous_scene_and_carries_fallback_stack() {
1265        // Scene 1: keyframe + delta (the fallback). Scene 2: keyframe.
1266        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1267        a[(0, 0)].set_symbol("A");
1268        let mut b = a.clone();
1269        b[(1, 0)].set_symbol("B");
1270        let mut c = Buffer::empty(Rect::new(0, 0, 2, 1));
1271        c[(0, 0)].set_symbol("C");
1272        let frames = vec![
1273            anim_frame(a, 1000, true),
1274            anim_frame(b, 1000, false),
1275            anim_frame(c, 2000, true),
1276        ];
1277        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1278
1279        // Scene 1 frames stay visible until the scene-2 keyframe at 50%.
1280        assert!(
1281            svg.contains("@keyframes pfa0{0%{opacity:1}50%{opacity:0}}"),
1282            "keyframe 0 window runs to the next keyframe: {svg}"
1283        );
1284        assert!(
1285            svg.contains("@keyframes pfa1{25%{opacity:1}50%{opacity:0}}"),
1286            "delta stays visible until its scene ends: {svg}"
1287        );
1288        // Scene 2 runs to the end of the loop.
1289        assert!(
1290            svg.contains("@keyframes pfa2{50%{opacity:1}}"),
1291            "last scene holds to 100%: {svg}"
1292        );
1293        // Fallback marks the whole scene-1 stack, not scene 2.
1294        assert!(
1295            svg.contains("<g class=\"pf pf0 pf-fb\">"),
1296            "fb on f0: {svg}"
1297        );
1298        assert!(
1299            svg.contains("<g class=\"pf pf1 pf-fb\">"),
1300            "fb on f1: {svg}"
1301        );
1302        assert!(svg.contains("<g class=\"pf pf2\">"), "no fb on f2: {svg}");
1303        assert!(
1304            svg.contains("@media (prefers-reduced-motion:reduce){.panim .pf{animation:none}}"),
1305            "reduced motion turns animation off: {svg}"
1306        );
1307    }
1308
1309    #[test]
1310    fn adjacent_same_bg_cells_merge_into_one_rect() {
1311        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1312        for x in 0..3 {
1313            buf[(x, 0)].set_symbol(" ").set_bg(Color::Rgb(0, 240, 255));
1314        }
1315        let svg = buffer_to_svg(&buf, &test_opts());
1316        assert!(
1317            svg.contains("<rect x=\"0\" y=\"0\" width=\"30\" height=\"20\" fill=\"#00f0ff\"/>"),
1318            "one merged run rect: {svg}"
1319        );
1320        assert_eq!(
1321            svg.matches("fill=\"#00f0ff\"").count(),
1322            1,
1323            "no per-cell rects: {svg}"
1324        );
1325    }
1326
1327    #[test]
1328    fn delta_skips_erasure_for_cells_that_were_already_blank() {
1329        let mut a = Buffer::empty(Rect::new(0, 0, 2, 1));
1330        a[(0, 0)].set_symbol("A");
1331        let mut b = a.clone();
1332        b[(1, 0)].set_symbol("B"); // appears on a blank cell: nothing to erase
1333        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1334        let svg = frames_to_animated_svg(&frames, 0, &test_opts());
1335        let delta = svg.split("<g class=\"pf pf1").nth(1).expect("delta group");
1336        assert!(delta.contains(">B</text>"), "new glyph painted: {delta}");
1337        assert!(
1338            !delta.contains("<rect"),
1339            "no erasure rect over a blank cell: {delta}"
1340        );
1341    }
1342
1343    #[test]
1344    fn consecutive_horizontal_line_glyphs_merge_into_one_rect() {
1345        let mut buf = Buffer::empty(Rect::new(0, 0, 3, 1));
1346        for x in 0..3 {
1347            buf[(x, 0)]
1348                .set_symbol("\u{2500}")
1349                .set_fg(Color::Rgb(0, 240, 255));
1350        }
1351        let svg = buffer_to_svg(&buf, &test_opts());
1352        assert!(
1353            svg.contains(
1354                "<rect x=\"0\" y=\"9.38\" width=\"30\" height=\"1.25\" fill=\"#00f0ff\"/>"
1355            ),
1356            "one merged horizontal rect: {svg}"
1357        );
1358        assert_eq!(
1359            svg.matches("fill=\"#00f0ff\"").count(),
1360            1,
1361            "no per-cell line rects: {svg}"
1362        );
1363    }
1364
1365    #[test]
1366    fn vertical_line_glyphs_merge_across_rows() {
1367        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 3));
1368        for y in 0..3 {
1369            buf[(0, y)]
1370                .set_symbol("\u{2502}")
1371                .set_fg(Color::Rgb(0, 240, 255));
1372        }
1373        let svg = buffer_to_svg(&buf, &test_opts());
1374        assert!(
1375            svg.contains(
1376                "<rect x=\"4.38\" y=\"0\" width=\"1.25\" height=\"60\" fill=\"#00f0ff\"/>"
1377            ),
1378            "one merged vertical rect: {svg}"
1379        );
1380        assert_eq!(
1381            svg.matches("fill=\"#00f0ff\"").count(),
1382            1,
1383            "no per-cell line rects: {svg}"
1384        );
1385    }
1386
1387    #[test]
1388    fn dim_line_glyphs_honor_the_dim_modifier_like_text_does() {
1389        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1390        for x in 0..2 {
1391            buf[(x, 0)].set_symbol("\u{2500}").set_style(
1392                Style::new()
1393                    .fg(Color::Rgb(224, 214, 240))
1394                    .add_modifier(Modifier::DIM),
1395            );
1396        }
1397        let svg = buffer_to_svg(&buf, &test_opts());
1398        assert!(
1399            svg.contains("width=\"20\"") && svg.contains("fill-opacity=\"0.6\""),
1400            "merged dim line rect carries reduced opacity: {svg}"
1401        );
1402    }
1403
1404    #[test]
1405    fn dim_and_bright_line_runs_do_not_merge() {
1406        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1407        buf[(0, 0)].set_symbol("\u{2500}").set_style(
1408            Style::new()
1409                .fg(Color::Rgb(224, 214, 240))
1410                .add_modifier(Modifier::DIM),
1411        );
1412        buf[(1, 0)]
1413            .set_symbol("\u{2500}")
1414            .set_fg(Color::Rgb(224, 214, 240));
1415        let svg = buffer_to_svg(&buf, &test_opts());
1416        assert_eq!(
1417            svg.matches("fill=\"#e0d6f0\"").count(),
1418            2,
1419            "two separate rects when dim differs: {svg}"
1420        );
1421    }
1422
1423    #[test]
1424    fn dim_rounded_corner_stroke_honors_the_dim_modifier() {
1425        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1426        buf[(0, 0)].set_symbol("\u{256D}").set_style(
1427            Style::new()
1428                .fg(Color::Rgb(224, 214, 240))
1429                .add_modifier(Modifier::DIM),
1430        );
1431        let svg = buffer_to_svg(&buf, &test_opts());
1432        assert!(
1433            svg.contains("stroke-opacity=\"0.6\""),
1434            "dim arc stroke carries reduced opacity: {svg}"
1435        );
1436    }
1437
1438    #[test]
1439    fn padding_grows_the_canvas_and_offsets_the_cell_grid() {
1440        let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1441        buf[(0, 0)].set_symbol("A");
1442        let mut opts = test_opts();
1443        opts.pad = 10;
1444        let svg = buffer_to_svg(&buf, &opts);
1445        assert!(svg.contains("width=\"40\""), "20 grid + 2*10 pad: {svg}");
1446        assert!(svg.contains("height=\"40\""), "20 grid + 2*10 pad: {svg}");
1447        assert!(
1448            svg.contains("<g transform=\"translate(10,10)\">"),
1449            "cell grid shifts by the padding: {svg}"
1450        );
1451    }
1452
1453    #[test]
1454    fn rounded_mode_draws_a_bare_rounded_panel() {
1455        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1456        let mut opts = test_opts();
1457        opts.pad = 10;
1458        opts.rounded = true;
1459        let svg = buffer_to_svg(&buf, &opts);
1460        assert!(svg.contains("rx=\"12\""), "rounded panel corners: {svg}");
1461        for light in ["#ff5f57", "#febc2e", "#28c840"] {
1462            assert!(!svg.contains(light), "no traffic light {light}: {svg}");
1463        }
1464        assert!(svg.contains("height=\"40\""), "grid + padding only: {svg}");
1465        assert!(
1466            svg.contains("<g transform=\"translate(10,10)\">"),
1467            "grid offset is just the padding: {svg}"
1468        );
1469    }
1470
1471    #[test]
1472    fn no_padding_keeps_legacy_flush_output() {
1473        let buf = Buffer::empty(Rect::new(0, 0, 2, 1));
1474        let svg = buffer_to_svg(&buf, &test_opts());
1475        assert!(
1476            !svg.contains("<g transform="),
1477            "flush render emits no translate group: {svg}"
1478        );
1479    }
1480
1481    #[test]
1482    #[should_panic(expected = "share one size")]
1483    fn animated_svg_rejects_mismatched_frame_sizes() {
1484        let a = Buffer::empty(Rect::new(0, 0, 2, 1));
1485        let b = Buffer::empty(Rect::new(0, 0, 3, 1));
1486        let frames = vec![anim_frame(a, 1000, true), anim_frame(b, 1000, false)];
1487        frames_to_animated_svg(&frames, 0, &test_opts());
1488    }
1489
1490    #[test]
1491    fn reset_fg_uses_default_and_reset_bg_emits_no_cell_rect() {
1492        let mut buf = Buffer::empty(Rect::new(0, 0, 1, 1));
1493        buf[(0, 0)].set_symbol("A");
1494        let svg = buffer_to_svg(&buf, &test_opts());
1495        assert!(
1496            svg.contains("<text x=\"0\" y=\"16\" fill=\"#e0d6f0\""),
1497            "reset fg falls back to default_fg: {svg}"
1498        );
1499        assert_eq!(
1500            svg.matches("<rect").count(),
1501            1,
1502            "reset bg emits only the canvas rect: {svg}"
1503        );
1504    }
1505}