use std::fmt::Write as _;
use crate::icons;
use crate::ir::*;
use crate::shader::*;
use crate::svg_icon::IconSource;
use crate::text::metrics as text_metrics;
use crate::tokens;
use crate::tree::*;
use crate::vector::{VectorAsset, VectorSegment};
pub fn svg_from_ops(width: f32, height: f32, ops: &[DrawOp], bg: Color) -> String {
let mut s = String::new();
let _ = writeln!(
s,
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">"#
);
s.push_str(SHADOW_DEFS);
let _ = writeln!(
s,
r#"<rect width="100%" height="100%" fill="{}"/>"#,
color_svg(bg)
);
for (i, op) in ops.iter().enumerate() {
if let Some(scissor) = op.scissor() {
let clip_id = format!("clip-{i}");
let _ = writeln!(
s,
r#"<clipPath id="{clip_id}"><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}"/></clipPath><g clip-path="url(#{clip_id})">"#,
scissor.x, scissor.y, scissor.w, scissor.h,
);
emit_op(&mut s, op);
s.push_str("</g>\n");
} else {
emit_op(&mut s, op);
}
}
s.push_str("</svg>\n");
s
}
fn emit_op(s: &mut String, op: &DrawOp) {
match op {
DrawOp::Quad {
id,
rect,
shader,
uniforms,
..
} => emit_quad(s, id, *rect, shader, uniforms),
DrawOp::GlyphRun {
id,
rect,
color,
size,
family,
mono_family,
weight,
mono,
wrap,
anchor,
layout,
underline,
strikethrough,
link,
..
} => {
let (eff_color, eff_underline) = if link.is_some() {
(crate::tokens::LINK_FOREGROUND, true)
} else {
(*color, *underline)
};
emit_glyph_run(
s,
id,
*rect,
eff_color,
*size,
*family,
*mono_family,
*weight,
*mono,
*wrap,
*anchor,
layout,
eff_underline,
*strikethrough,
);
}
DrawOp::AttributedText {
id,
rect,
runs,
size,
wrap,
anchor,
layout,
..
} => {
emit_attributed_text(s, id, *rect, *size, *wrap, *anchor, runs, layout);
}
DrawOp::Icon {
id,
rect,
source,
color,
stroke_width,
..
} => emit_icon(s, id, *rect, source, *color, *stroke_width),
DrawOp::Image {
id, rect, image, ..
} => emit_image_placeholder(s, id, *rect, image),
DrawOp::AppTexture {
id, rect, texture, ..
} => emit_surface_placeholder(s, id, *rect, texture),
DrawOp::Vector {
id,
rect,
asset,
render_mode,
..
} => emit_vector_placeholder(s, id, *rect, asset, *render_mode),
DrawOp::BackdropSnapshot => {} }
}
fn emit_vector_placeholder(
s: &mut String,
id: &str,
rect: Rect,
asset: &crate::vector::VectorAsset,
render_mode: crate::vector::VectorRenderMode,
) {
let label = format!(
"Vector#{:08x} {:?} {} path{}",
asset.content_hash() as u32,
render_mode,
asset.paths.len(),
if asset.paths.len() == 1 { "" } else { "s" },
);
let _ = writeln!(
s,
r##"<rect data-node="{}" data-shader="stock::vector" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="#181818" stroke="#999" stroke-width="1" stroke-dasharray="4 2" />"##,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
);
let cx = rect.x + rect.w * 0.5;
let cy = rect.y + rect.h * 0.5;
let _ = writeln!(
s,
r##"<text x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="10" fill="#bbb">{}</text>"##,
cx,
cy,
esc(&label),
);
}
fn emit_image_placeholder(s: &mut String, id: &str, rect: Rect, image: &crate::image::Image) {
let label = image.label();
let _ = writeln!(
s,
r##"<rect data-node="{}" data-shader="stock::image" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="#444" stroke="#888" stroke-width="1" stroke-dasharray="4 2" />"##,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
);
let cx = rect.x + rect.w * 0.5;
let cy = rect.y + rect.h * 0.5;
let _ = writeln!(
s,
r##"<text x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="10" fill="#bbb">{}</text>"##,
cx,
cy,
esc(&label),
);
}
fn emit_surface_placeholder(
s: &mut String,
id: &str,
rect: Rect,
texture: &crate::surface::AppTexture,
) {
let (w, h) = texture.size_px();
let label = format!("AppTexture#{} {}x{}", texture.id().0, w, h);
let _ = writeln!(
s,
r##"<rect data-node="{}" data-shader="stock::surface" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="#222" stroke="#aaa" stroke-width="1" stroke-dasharray="6 3" />"##,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
);
let cx = rect.x + rect.w * 0.5;
let cy = rect.y + rect.h * 0.5;
let _ = writeln!(
s,
r##"<text x="{:.2}" y="{:.2}" text-anchor="middle" dominant-baseline="middle" font-family="monospace" font-size="10" fill="#bbb">{}</text>"##,
cx,
cy,
esc(&label),
);
}
fn emit_quad(s: &mut String, id: &str, rect: Rect, shader: &ShaderHandle, uniforms: &UniformBlock) {
match shader {
ShaderHandle::Stock(StockShader::RoundedRect) => {
let fill = uniforms.get("fill").and_then(as_color);
let stroke = uniforms.get("stroke").and_then(as_color);
let stroke_w = uniforms.get("stroke_width").and_then(as_f32).unwrap_or(0.0);
let radius = uniforms.get("radius").and_then(as_f32).unwrap_or(0.0);
let shadow = uniforms.get("shadow").and_then(as_f32).unwrap_or(0.0);
let focus_color = uniforms.get("focus_color").and_then(as_color);
let focus_width = uniforms.get("focus_width").and_then(as_f32).unwrap_or(0.0);
let inner = uniforms
.get("inner_rect")
.and_then(as_vec4)
.map(|v| Rect::new(v[0], v[1], v[2], v[3]))
.unwrap_or(rect);
let r = radius.min(inner.w * 0.5).min(inner.h * 0.5).max(0.0);
let fill_attr = match fill {
Some(c) => format!(r#" fill="{}""#, color_svg(c)),
None => r#" fill="none""#.to_string(),
};
let stroke_attr = match stroke {
Some(c) => format!(
r#" stroke="{}" stroke-width="{:.2}""#,
color_svg(c),
stroke_w
),
None => String::new(),
};
let filter = shadow_filter(shadow);
let _ = writeln!(
s,
r#"<rect data-node="{}" data-shader="stock::rounded_rect" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}"{}{}{} />"#,
esc(id),
inner.x,
inner.y,
inner.w,
inner.h,
r,
fill_attr,
stroke_attr,
filter
);
if focus_width > 0.0
&& let Some(fc) = focus_color
&& fc.a > 0
{
let ring_r = (r + focus_width * 0.5).max(0.0);
let _ = writeln!(
s,
r#"<rect data-node="{}.ring" data-shader="stock::rounded_rect" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="none" stroke="{}" stroke-width="{:.2}" />"#,
esc(id),
inner.x - focus_width * 0.5,
inner.y - focus_width * 0.5,
inner.w + focus_width,
inner.h + focus_width,
ring_r,
color_svg(fc),
focus_width
);
}
}
ShaderHandle::Stock(StockShader::SolidQuad) => {
let fill = uniforms
.get("fill")
.and_then(as_color)
.unwrap_or(tokens::MUTED);
let _ = writeln!(
s,
r#"<rect data-node="{}" data-shader="stock::solid_quad" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" />"#,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
color_svg(fill)
);
}
ShaderHandle::Stock(StockShader::DividerLine) => {
let fill = uniforms
.get("fill")
.and_then(as_color)
.unwrap_or(tokens::BORDER);
let _ = writeln!(
s,
r#"<rect data-node="{}" data-shader="stock::divider_line" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" />"#,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
color_svg(fill)
);
}
ShaderHandle::Stock(StockShader::Text) => {
}
ShaderHandle::Stock(StockShader::Image) => {
}
ShaderHandle::Stock(StockShader::Skeleton) => {
let inner = uniforms
.get("inner_rect")
.and_then(as_vec4)
.map(|v| Rect::new(v[0], v[1], v[2], v[3]))
.unwrap_or(rect);
let base = uniforms
.get("vec_a")
.and_then(as_color)
.unwrap_or(tokens::MUTED);
let params = uniforms.get("vec_c").and_then(as_vec4).unwrap_or([0.0; 4]);
let radius = if params[0] > 0.0 {
params[0].min(inner.w * 0.5).min(inner.h * 0.5)
} else {
tokens::RADIUS_MD.min(inner.w * 0.5).min(inner.h * 0.5)
};
let _ = writeln!(
s,
r#"<rect data-node="{}" data-shader="stock::skeleton" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" />"#,
esc(id),
inner.x,
inner.y,
inner.w,
inner.h,
radius,
color_svg(base),
);
}
ShaderHandle::Stock(StockShader::ProgressIndeterminate) => {
let inner = uniforms
.get("inner_rect")
.and_then(as_vec4)
.map(|v| Rect::new(v[0], v[1], v[2], v[3]))
.unwrap_or(rect);
let bar_color = uniforms
.get("vec_a")
.and_then(as_color)
.unwrap_or(tokens::PRIMARY);
let track_color = uniforms
.get("vec_b")
.and_then(as_color)
.unwrap_or(tokens::MUTED);
let params = uniforms.get("vec_c").and_then(as_vec4).unwrap_or([0.0; 4]);
let radius = if params[0] > 0.0 {
params[0].min(inner.w * 0.5).min(inner.h * 0.5)
} else {
tokens::RADIUS_PILL.min(inner.w * 0.5).min(inner.h * 0.5)
};
let bar_w_frac = if params[2] > 0.0 { params[2] } else { 0.35 };
let bar_w_px = inner.w * bar_w_frac;
let bar_left = inner.x + (inner.w - bar_w_px) * 0.5;
let _ = writeln!(
s,
r#"<rect data-node="{}.track" data-shader="stock::progress_indeterminate" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" />"#,
esc(id),
inner.x,
inner.y,
inner.w,
inner.h,
radius,
color_svg(track_color),
);
let _ = writeln!(
s,
r#"<rect data-node="{}" data-shader="stock::progress_indeterminate" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" rx="{:.2}" fill="{}" />"#,
esc(id),
bar_left,
inner.y,
bar_w_px,
inner.h,
radius,
color_svg(bar_color),
);
}
ShaderHandle::Stock(StockShader::Spinner) => {
let inner = uniforms
.get("inner_rect")
.and_then(as_vec4)
.map(|v| Rect::new(v[0], v[1], v[2], v[3]))
.unwrap_or(rect);
let arc_color = uniforms
.get("vec_a")
.and_then(as_color)
.unwrap_or(tokens::FOREGROUND);
let track_color = uniforms.get("vec_b").and_then(as_color);
let params = uniforms.get("vec_c").and_then(as_vec4).unwrap_or([0.0; 4]);
let thickness = if params[0] > 0.0 {
params[0]
} else {
(inner.w.min(inner.h) * 0.12).max(1.5)
};
let max_sweep = if params[1] > 0.0 {
params[1]
} else {
std::f32::consts::PI * 4.0 / 3.0 };
let cx = inner.x + inner.w * 0.5;
let cy = inner.y + inner.h * 0.5;
let outer_r = inner.w.min(inner.h) * 0.5;
let center_r = (outer_r - thickness * 0.5).max(0.0);
if let Some(track) = track_color
&& track.a > 0
{
let _ = writeln!(
s,
r#"<circle data-node="{}.track" data-shader="stock::spinner" cx="{:.2}" cy="{:.2}" r="{:.2}" fill="none" stroke="{}" stroke-width="{:.2}" />"#,
esc(id),
cx,
cy,
center_r,
color_svg(track),
thickness,
);
}
let large_arc = if max_sweep > std::f32::consts::PI {
1
} else {
0
};
let start_x = cx;
let start_y = cy - center_r;
let end_x = cx + (max_sweep.sin()) * center_r;
let end_y = cy - (max_sweep.cos()) * center_r;
let _ = writeln!(
s,
r#"<path data-node="{}" data-shader="stock::spinner" d="M {:.2} {:.2} A {:.2} {:.2} 0 {} 1 {:.2} {:.2}" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linecap="round" />"#,
esc(id),
start_x,
start_y,
center_r,
center_r,
large_arc,
end_x,
end_y,
color_svg(arc_color),
thickness,
);
}
ShaderHandle::Custom(name) => {
let mut title = format!("custom shader: {name}");
for (k, v) in uniforms {
let _ = write!(title, " {k}={}", v.debug_short());
}
let _ = writeln!(
s,
r#"<g data-node="{}" data-shader="custom::{}"><title>{}</title><rect x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="rgba(255,0,255,0.18)" stroke="rgba(255,0,255,0.5)" stroke-width="1" stroke-dasharray="3 2" /></g>"#,
esc(id),
esc(name),
esc(&title),
rect.x,
rect.y,
rect.w,
rect.h
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn emit_glyph_run(
s: &mut String,
id: &str,
rect: Rect,
color: Color,
size: f32,
family: crate::tree::FontFamily,
mono_family: crate::tree::FontFamily,
weight: FontWeight,
mono: bool,
wrap: TextWrap,
anchor: TextAnchor,
layout: &text_metrics::TextLayout,
underline: bool,
strikethrough: bool,
) {
let (x, anchor_attr) = match anchor {
TextAnchor::Start => (rect.x, "start"),
TextAnchor::Middle => (rect.center_x(), "middle"),
TextAnchor::End => (rect.right(), "end"),
};
let line_top = match wrap {
TextWrap::NoWrap => rect.y + ((rect.h - layout.height) * 0.5).max(0.0),
TextWrap::Wrap => rect.y,
};
let family = if mono {
mono_family.css_stack()
} else {
family.css_stack()
};
let weight_str = match weight {
FontWeight::Regular => "400",
FontWeight::Medium => "500",
FontWeight::Semibold => "600",
FontWeight::Bold => "700",
};
let decoration_attr = decoration_attr(underline, strikethrough);
for line in &layout.lines {
let y = line_top + line.baseline;
let _ = writeln!(
s,
r#"<text data-node="{}" data-shader="stock::text" x="{:.2}" y="{:.2}" font-family="{}" font-size="{:.2}" font-weight="{}" fill="{}" text-anchor="{}"{}>{}</text>"#,
esc(id),
x,
y,
family,
size,
weight_str,
color_svg(color),
anchor_attr,
decoration_attr,
esc(&line.text)
);
}
}
fn decoration_attr(underline: bool, strikethrough: bool) -> &'static str {
match (underline, strikethrough) {
(true, true) => r#" text-decoration="underline line-through""#,
(true, false) => r#" text-decoration="underline""#,
(false, true) => r#" text-decoration="line-through""#,
(false, false) => "",
}
}
#[allow(clippy::too_many_arguments)]
fn emit_attributed_text(
s: &mut String,
id: &str,
rect: Rect,
size: f32,
wrap: TextWrap,
anchor: TextAnchor,
runs: &[(String, crate::text::atlas::RunStyle)],
layout: &text_metrics::TextLayout,
) {
let (x, anchor_attr) = match anchor {
TextAnchor::Start => (rect.x, "start"),
TextAnchor::Middle => (rect.center_x(), "middle"),
TextAnchor::End => (rect.right(), "end"),
};
let line_top = match wrap {
TextWrap::NoWrap => rect.y + ((rect.h - layout.height) * 0.5).max(0.0),
TextWrap::Wrap => rect.y,
};
let baseline = layout
.lines
.first()
.map(|l| l.baseline)
.unwrap_or(layout.line_height * 0.8);
let y = line_top + baseline;
if matches!(anchor, TextAnchor::Start) {
let mut cursor_x = rect.x;
for (text, style) in runs {
let run_w = text_metrics::line_width(text, size, style.weight, style.mono);
if let Some(bg) = style.bg {
let _ = writeln!(
s,
r#"<rect data-node="{}.run-bg" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" fill="{}" />"#,
esc(id),
cursor_x,
line_top,
run_w.max(0.0),
layout.line_height,
color_svg(bg),
);
}
cursor_x += run_w;
}
}
let _ = write!(
s,
r#"<text data-node="{}" data-shader="stock::text" x="{:.2}" y="{:.2}" font-size="{:.2}" text-anchor="{}">"#,
esc(id),
x,
y,
size,
anchor_attr,
);
for (text, style) in runs {
let family = if style.mono {
style.mono_family.css_stack()
} else {
style.family.css_stack()
};
let weight_str = match style.weight {
FontWeight::Regular => "400",
FontWeight::Medium => "500",
FontWeight::Semibold => "600",
FontWeight::Bold => "700",
};
let style_attr = if style.italic { "italic" } else { "normal" };
let decoration_attr = decoration_attr(style.underline, style.strikethrough);
let _ = write!(
s,
r#"<tspan font-family="{}" font-weight="{}" font-style="{}" fill="{}"{}>{}</tspan>"#,
family,
weight_str,
style_attr,
color_svg(style.color),
decoration_attr,
esc(text),
);
}
s.push_str("</text>\n");
}
fn emit_icon(
s: &mut String,
id: &str,
rect: Rect,
source: &IconSource,
color: Color,
stroke_width: f32,
) {
match source {
IconSource::Builtin(name) => {
let path = icons::icon_path(*name);
let stroke = (stroke_width * 24.0 / rect.w.max(rect.h).max(1.0)).max(0.5);
let _ = writeln!(
s,
r#"<svg data-node="{}" data-icon="{}" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" viewBox="0 0 24 24" fill="none" stroke="{}" stroke-width="{:.2}" stroke-linecap="round" stroke-linejoin="round">{}</svg>"#,
esc(id),
name.name(),
rect.x,
rect.y,
rect.w,
rect.h,
color_svg(color),
stroke,
path
);
}
IconSource::Custom(svg) => {
let asset = svg.vector_asset();
let [vx, vy, vw, vh] = asset.view_box;
let _ = writeln!(
s,
r#"<svg data-node="{}" data-icon="custom" x="{:.2}" y="{:.2}" width="{:.2}" height="{:.2}" viewBox="{} {} {} {}" stroke-linecap="round" stroke-linejoin="round">"#,
esc(id),
rect.x,
rect.y,
rect.w,
rect.h,
vx,
vy,
vw,
vh,
);
emit_custom_paths(s, asset, color, stroke_width);
s.push_str("</svg>\n");
}
}
}
fn emit_custom_paths(s: &mut String, asset: &VectorAsset, current_color: Color, stroke_width: f32) {
use crate::vector::VectorColor;
for path in &asset.paths {
let d = serialize_segments(&path.segments);
if d.is_empty() {
continue;
}
let fill_attr = match path.fill {
Some(f) => match f.color {
VectorColor::Solid(c) => format!(r#"fill="{}""#, color_svg(c)),
VectorColor::CurrentColor => format!(r#"fill="{}""#, color_svg(current_color)),
VectorColor::Gradient(idx) => format!(
r#"fill="{}""#,
color_svg(gradient_fallback_color(asset, idx, current_color))
),
},
None => r#"fill="none""#.to_string(),
};
let stroke_attr = match path.stroke {
Some(st) => {
let color = match st.color {
VectorColor::Solid(c) => color_svg(c),
VectorColor::CurrentColor => color_svg(current_color),
VectorColor::Gradient(idx) => {
color_svg(gradient_fallback_color(asset, idx, current_color))
}
};
let width = if matches!(st.color, VectorColor::CurrentColor) {
stroke_width
} else {
st.width
};
format!(r#"stroke="{}" stroke-width="{:.2}""#, color, width)
}
None => String::new(),
};
let _ = writeln!(s, r#"<path d="{}" {} {}/>"#, d, fill_attr, stroke_attr);
}
}
fn gradient_fallback_color(asset: &VectorAsset, idx: u32, current_color: Color) -> Color {
use crate::vector::VectorGradient;
let stops = asset.gradients.get(idx as usize).map(|g| match g {
VectorGradient::Linear(g) => g.stops.as_slice(),
VectorGradient::Radial(g) => g.stops.as_slice(),
});
let Some(stops) = stops else {
return current_color;
};
let Some(stop) = stops.first() else {
return current_color;
};
let r = (stop.color[0].clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
let g = (stop.color[1].clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
let b = (stop.color[2].clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
let a = (stop.color[3].clamp(0.0, 1.0) * 255.0).round() as u8;
Color::rgba(r, g, b, a)
}
fn serialize_segments(segments: &[VectorSegment]) -> String {
let mut out = String::new();
for seg in segments {
if !out.is_empty() {
out.push(' ');
}
match *seg {
VectorSegment::MoveTo([x, y]) => {
let _ = write!(out, "M{:.3} {:.3}", x, y);
}
VectorSegment::LineTo([x, y]) => {
let _ = write!(out, "L{:.3} {:.3}", x, y);
}
VectorSegment::QuadTo([cx, cy], [x, y]) => {
let _ = write!(out, "Q{:.3} {:.3} {:.3} {:.3}", cx, cy, x, y);
}
VectorSegment::CubicTo([c1x, c1y], [c2x, c2y], [x, y]) => {
let _ = write!(
out,
"C{:.3} {:.3} {:.3} {:.3} {:.3} {:.3}",
c1x, c1y, c2x, c2y, x, y
);
}
VectorSegment::Close => out.push('Z'),
}
}
out
}
fn as_color(v: &UniformValue) -> Option<Color> {
match v {
UniformValue::Color(c) => Some(*c),
_ => None,
}
}
fn as_f32(v: &UniformValue) -> Option<f32> {
match v {
UniformValue::F32(f) => Some(*f),
_ => None,
}
}
fn as_vec4(v: &UniformValue) -> Option<[f32; 4]> {
match v {
UniformValue::Vec4(a) => Some(*a),
_ => None,
}
}
const SHADOW_DEFS: &str = r##"<defs>
<filter id="shadow-sm" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="2"/>
<feOffset dx="0" dy="2"/>
<feComponentTransfer><feFuncA type="linear" slope="0.20"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow-md" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="6"/>
<feOffset dx="0" dy="6"/>
<feComponentTransfer><feFuncA type="linear" slope="0.28"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow-lg" x="-20%" y="-20%" width="140%" height="140%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceAlpha" stdDeviation="14"/>
<feOffset dx="0" dy="12"/>
<feComponentTransfer><feFuncA type="linear" slope="0.32"/></feComponentTransfer>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
"##;
fn shadow_filter(shadow: f32) -> &'static str {
if shadow >= 16.0 {
r#" filter="url(#shadow-lg)""#
} else if shadow >= 6.0 {
r#" filter="url(#shadow-md)""#
} else if shadow > 0.0 {
r#" filter="url(#shadow-sm)""#
} else {
""
}
}
pub(crate) fn color_svg(c: Color) -> String {
if c.a == 255 {
format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
} else {
format!("rgba({},{},{},{:.3})", c.r, c.g, c.b, c.a as f32 / 255.0)
}
}
fn esc(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SvgIcon;
use crate::draw_ops::draw_ops;
use crate::layout::layout;
use crate::state::UiState;
use crate::tree::IconName;
const RED_CIRCLE: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="#ff0000"/></svg>"##;
fn render(el: crate::tree::El) -> String {
let mut tree = el;
let viewport = Rect::new(0.0, 0.0, 64.0, 64.0);
let mut state = UiState::default();
layout(&mut tree, &mut state, viewport);
let ops = draw_ops(&tree, &state);
svg_from_ops(viewport.w, viewport.h, &ops, Color::rgb(255, 255, 255))
}
#[test]
fn builtin_icon_renders_via_named_path() {
let svg = render(crate::icons::icon(IconName::Check));
assert!(
svg.contains(r#"data-icon="check""#),
"expected built-in `data-icon=\"check\"`, got:\n{svg}"
);
}
#[test]
fn spinner_renders_static_arc_only_by_default() {
let svg = render(crate::widgets::spinner::spinner());
assert!(
svg.contains(r#"data-shader="stock::spinner""#),
"spinner must mark its draws with the stock shader name, got:\n{svg}"
);
assert!(
svg.contains(r#"<path"#),
"spinner SVG fallback should emit the arc as a <path>, got:\n{svg}"
);
assert!(
!svg.contains(r#"<circle"#),
"default spinner has no track, so no <circle> should appear:\n{svg}"
);
}
#[test]
fn spinner_with_track_emits_both_circle_and_arc() {
let svg = render(crate::widgets::spinner::spinner_with_track(
crate::tokens::PRIMARY,
crate::tokens::MUTED,
));
assert!(
svg.contains(r#"<circle"#),
"spinner_with_track should emit a track <circle>, got:\n{svg}"
);
assert!(
svg.contains(r#"<path"#),
"spinner_with_track should emit an arc <path>, got:\n{svg}"
);
}
#[test]
fn custom_svg_icon_renders_via_re_serialised_paths() {
let custom = SvgIcon::parse(RED_CIRCLE).unwrap();
let svg = render(crate::icons::icon(custom));
assert!(
svg.contains(r#"data-icon="custom""#),
"expected `data-icon=\"custom\"` for app-supplied SVG, got:\n{svg}"
);
assert!(
svg.contains("fill=\"rgb(255,0,0)\"") || svg.contains("fill=\"#ff0000\""),
"expected the SVG fill to round-trip in the bundle, got:\n{svg}"
);
}
}