1use ratatui::buffer::Buffer;
7use ratatui::style::{Color, Modifier};
8
9type Rgb = (u8, u8, u8);
11
12pub struct SvgOpts {
14 pub cell_w: u32,
16 pub cell_h: u32,
18 pub font_size: u32,
20 pub font_family: String,
23 pub font_face_css: String,
26 pub default_fg: Rgb,
28 pub default_bg: Rgb,
30 pub pad: u32,
32 pub rounded: bool,
35}
36
37const PANEL_RADIUS: u32 = 12;
39
40impl Default for SvgOpts {
41 fn default() -> Self {
42 Self {
43 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 default_fg: (224, 214, 240),
53 default_bg: (27, 28, 38),
54 pad: 0,
55 rounded: false,
56 }
57 }
58}
59
60fn 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
68fn 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
84pub 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
114pub struct AnimFrame {
118 pub buf: Buffer,
119 pub dur_ms: u32,
120 pub keyframe: bool,
121}
122
123pub 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 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 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 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
240fn baseline(opts: &SvgOpts) -> u32 {
243 (opts.cell_h * 4) / 5
244}
245
246fn 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
255fn 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 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 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 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 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
441fn 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
497fn 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
514fn 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
526fn 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), '\u{2502}' | '\u{250A}' => Some(vline), '\u{2574}' => Some(h_left), '\u{251C}' => Some(format!("{vline}{h_right}")), '\u{2524}' => Some(format!("{vline}{h_left}")), '\u{2514}' => Some(format!("{v_top}{h_right}")), '\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 ))), '\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 ))), '\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 ))), '\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 ))), '\u{2588}' => Some(rect(x0, y0, cw, ch)), '\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 )), '\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 '\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
641fn 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
652fn 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 _ => (229, 229, 229),
674 }
675}
676
677fn 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
711fn 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("&"),
717 '<' => out.push_str("<"),
718 '>' => out.push_str(">"),
719 '"' => out.push_str("""),
720 '\'' => out.push_str("'"),
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 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 assert!(svg.contains("><&</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 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}"); assert_eq!(
882 svg.matches("<text").count(),
883 0,
884 "no text for box glyph: {svg}"
885 );
886 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}"); 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}"); 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}"); 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); 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}"); 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); 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}"); assert_eq!(
951 svg.matches("<text").count(),
952 0,
953 "junction renders as shapes, not a fallback-font glyph: {svg}"
954 );
955 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 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 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 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 assert!(
1059 svg.contains("@keyframes pfa2{50%{opacity:1}}"),
1060 "last scene holds to 100%: {svg}"
1061 );
1062 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"); 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}