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(
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 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), '\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!(
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 ))), '\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 ))), '\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 ))), '\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 ))), '\u{2588}' => Some(rect(x0, y0, cw, ch)), '\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 )), '\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 '\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 '\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 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 '\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 '\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 '\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 '\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
753fn 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
764fn 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 _ => (229, 229, 229),
786 }
787}
788
789fn 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
823fn 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("&"),
829 '<' => out.push_str("<"),
830 '>' => out.push_str(">"),
831 '"' => out.push_str("""),
832 '\'' => out.push_str("'"),
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 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 assert!(svg.contains("><&</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 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}"); assert_eq!(
994 svg.matches("<text").count(),
995 0,
996 "no text for box glyph: {svg}"
997 );
998 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}"); 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}"); 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}"); 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); 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}"); 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); 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}"); assert_eq!(
1063 svg.matches("<text").count(),
1064 0,
1065 "junction renders as shapes, not a fallback-font glyph: {svg}"
1066 );
1067 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}"); 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}"); 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}"); 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); 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}"); 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); 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); 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); 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); 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 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 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 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 assert!(
1290 svg.contains("@keyframes pfa2{50%{opacity:1}}"),
1291 "last scene holds to 100%: {svg}"
1292 );
1293 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"); 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}