Skip to content

Commit 2ff85b3

Browse files
committed
fix: render sparklines and status glyphs as shapes in marketing SVGs
The SVG exporter sent braille (tunnel sparklines, the snippet trend chart, spinners) and the status / icon glyphs (● ○ ◉ ◐ ✖ ⇄ ␣) to the <text> path. Neither Berkeley Mono nor JetBrains Mono ships those glyphs, so rsvg-convert substituted a fallback face that advances wider than the cell grid and ignores textLength. That bled the sparkline over the throughput readout, ran the snippet trend line past the card border, and rendered status icons in an off-brand fallback font. glyph_shape now draws all of them as crisp in-cell SVG shapes (dots, circles and stroked paths), the way box-drawing and block glyphs already are. The imagery is font-independent for everything except real text (Berkeley Mono) and the five symbols JetBrains Mono covers (✓ ⚠ ▲ ▸ ▾).
1 parent 7c9cd72 commit 2ff85b3

2 files changed

Lines changed: 247 additions & 15 deletions

File tree

src/asset_gen.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,11 @@ pub fn generate(out_dir: &Path, font_dir: Option<&Path>) -> io::Result<Vec<PathB
250250
}
251251

252252
// Berkeley Mono is the brand face; JetBrains Mono (embedded) fills the
253-
// glyphs Berkeley lacks (rounded borders, status icons, braille) the
254-
// way a terminal's font fallback would. The rasterizer resolves
255-
// Berkeley from the render environment. Full screens render as a
256-
// rounded padded panel; zoom crops stay flush cutouts.
253+
// symbol glyphs Berkeley lacks (✓ ⚠ ▲ ▸ ▾) the way a terminal's font
254+
// fallback would. Box-drawing, blocks, braille and status icons render
255+
// as crisp SVG shapes (see ui::svg_export), so they need no font. The
256+
// rasterizer resolves Berkeley from the render environment. Full screens
257+
// render as a rounded padded panel; zoom crops stay flush cutouts.
257258
let rounded = scene.crop.is_none();
258259
let opts = SvgOpts {
259260
font_family: "'Berkeley Mono','JetBrains Mono',monospace".to_string(),

src/ui/svg_export.rs

Lines changed: 242 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,8 @@ fn emit_cells(
369369
vlines.push((x, y, fg, dim));
370370
continue;
371371
}
372-
// Box-drawing and block glyphs render as crisp SVG shapes so they
373-
// tile seamlessly. Font glyphs leave gaps at non-unit line heights.
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.
374374
if let Some(shape) = glyph_shape(
375375
sym,
376376
f64::from(u32::from(x) * opts.cell_w),
@@ -523,11 +523,13 @@ fn coord(v: f64) -> String {
523523
}
524524
}
525525

526-
/// Render the box-drawing and block-element glyphs purple uses as crisp SVG
527-
/// shapes (rects and stroked arcs) so they tile seamlessly across cells. Font
528-
/// glyphs leave gaps when the row pitch differs from the glyph's line box.
529-
/// Returns `None` for glyphs that should render as `<text>` (letters, digits,
530-
/// status icons, arrows, braille).
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. ✓ ⚠ ▲ ▸ ▾).
531533
fn glyph_shape(
532534
sym: &str,
533535
x0: f64,
@@ -558,6 +560,25 @@ fn glyph_shape(
558560
coord(t),
559561
)
560562
};
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+
};
561582
let vline = rect(cx - t / 2.0, y0, t, ch);
562583
let hline = rect(x0, cy - t / 2.0, cw, t);
563584
let h_left = rect(x0, cy - t / 2.0, cw / 2.0 + t / 2.0, t);
@@ -634,6 +655,97 @@ fn glyph_shape(
634655
let w = cw * f64::from(0x2590 - cp as u32) / 8.0;
635656
Some(rect(x0, y0, w, ch))
636657
}
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+
}
637749
_ => None,
638750
}
639751
}
@@ -936,11 +1048,11 @@ mod tests {
9361048
}
9371049

9381050
#[test]
939-
fn status_glyph_stays_text() {
940-
let svg = glyph_svg(crate::ui::design::ICON_ONLINE); // has no shape, renders as text
1051+
fn symbol_without_a_shape_stays_text() {
1052+
let svg = glyph_svg(crate::ui::design::ICON_WARNING); // has no shape, renders as text
9411053
assert!(
942-
svg.contains(">\u{25CF}</text>"),
943-
"dot renders as text: {svg}"
1054+
svg.contains(">\u{26A0}</text>"),
1055+
"warning sign renders as text: {svg}"
9441056
);
9451057
}
9461058

@@ -959,6 +1071,125 @@ mod tests {
9591071
);
9601072
}
9611073

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+
9621193
#[test]
9631194
fn fg_change_breaks_the_run() {
9641195
let mut buf = Buffer::empty(Rect::new(0, 0, 2, 1));

0 commit comments

Comments
 (0)