use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use kurbo::{
Affine as KAffine, BezPath, Cap, Join, Point as KPoint, Rect as KRect, Vec2,
};
use peniko::{
Brush as PenikoBrush, Color, ColorStop, ColorStops, Extend, Gradient,
LinearGradientPosition, RadialGradientPosition,
};
use uzor_urx_core::scene::{
Dash as UrxDash, DrawCommand, FillRule, FontId, Glyph, LineCap as UrxLineCap,
LineBatchSegment, LineJoin as UrxLineJoin, Scene, Stroke as UrxStroke,
};
use uzor::fonts::{self, FontFamily};
use uzor::render::{
BatchPainter, BlendMode as UzorBlendMode, CircleBatch, Effects, GlyphMetric, GradientPainter,
LineSegment, Masking, Painter, RenderContext as UzorRenderContext, RenderContextExt,
ShapeHelpers, TextAlign, TextBaseline, TextBounds, TextMetrics, TextRenderer,
UiEffectHelpers,
};
#[derive(Clone, Debug)]
struct FontInfo {
size: f32,
bold: bool,
italic: bool,
family: FontFamily,
}
impl Default for FontInfo {
fn default() -> Self {
Self { size: 12.0, bold: false, italic: false, family: FontFamily::Roboto }
}
}
fn parse_css_font(s: &str) -> FontInfo {
let p = fonts::parse_css_font(s);
FontInfo { size: p.size, bold: p.bold, italic: p.italic, family: p.family }
}
fn parse_color(s: &str) -> Color {
let (r, g, b, a) = uzor::render::parse_color(s);
Color::from_rgba8(r, g, b, a)
}
fn apply_alpha(c: Color, alpha: f64) -> Color {
if alpha >= 1.0 { c } else { c.multiply_alpha(alpha as f32) }
}
#[derive(Clone)]
struct SavedState {
transform: KAffine,
fill_color: Color,
stroke_color: Color,
stroke_width: f64,
line_cap: Cap,
line_join: Join,
line_dash: Option<Vec<f64>>,
global_alpha: f64,
font_info: FontInfo,
text_align: TextAlign,
text_baseline: TextBaseline,
blend_mode: UzorBlendMode,
clip_pushes: u32,
}
#[derive(Clone)]
struct ShadowState {
dx: f64,
dy: f64,
color: Color,
}
#[derive(Clone, Copy, Debug)]
enum PathHint {
Empty,
FullCircle { cx: f64, cy: f64, radius: f64 },
Generic,
}
pub struct UrxRenderContext {
scene: Scene,
revision: u64,
width: u32,
height: u32,
dpr: f64,
transform: KAffine,
fill_color: Color,
stroke_color: Color,
stroke_width: f64,
line_cap: Cap,
line_join: Join,
line_dash: Option<Vec<f64>>,
global_alpha: f64,
font_info: FontInfo,
text_align: TextAlign,
text_baseline: TextBaseline,
blend_mode: UzorBlendMode,
path: BezPath,
path_hint: PathHint,
shadow: Option<ShadowState>,
clip_pushes: u32,
state_stack: Vec<SavedState>,
}
impl UrxRenderContext {
pub fn new(dpr: f64) -> Self {
Self {
scene: Scene::new(),
revision: 0,
width: 0,
height: 0,
dpr,
transform: KAffine::IDENTITY,
fill_color: Color::from_rgba8(0, 0, 0, 255),
stroke_color: Color::from_rgba8(0, 0, 0, 255),
stroke_width: 1.0,
line_cap: Cap::Butt,
line_join: Join::Miter,
line_dash: None,
global_alpha: 1.0,
font_info: FontInfo::default(),
text_align: TextAlign::Left,
text_baseline: TextBaseline::Middle,
blend_mode: UzorBlendMode::Normal,
path: BezPath::new(),
path_hint: PathHint::Empty,
shadow: None,
clip_pushes: 0,
state_stack: Vec::new(),
}
}
fn ensure_subpath_open(&mut self, fallback: KPoint) {
let needs_move = match self.path.elements().last() {
None => true,
Some(kurbo::PathEl::ClosePath) => true,
_ => false,
};
if needs_move {
self.path.move_to(fallback);
}
}
pub fn begin_frame(&mut self, width: u32, height: u32) {
self.revision = self.revision.saturating_add(1);
self.scene.reset();
self.width = width;
self.height = height;
self.transform = KAffine::IDENTITY;
self.path.truncate(0);
self.path_hint = PathHint::Empty;
self.shadow = None;
self.clip_pushes = 0;
self.state_stack.clear();
}
pub fn take_scene(&mut self) -> Scene {
std::mem::replace(&mut self.scene, Scene::new())
}
pub fn recycle_scene(&mut self) {
self.scene.reset();
}
pub fn scene(&self) -> &Scene { &self.scene }
pub fn revision(&self) -> u64 { self.revision }
pub fn size(&self) -> (u32, u32) { (self.width, self.height) }
fn effective_fill_brush(&self) -> PenikoBrush {
PenikoBrush::Solid(apply_alpha(self.fill_color, self.global_alpha))
}
fn effective_stroke_brush(&self) -> PenikoBrush {
PenikoBrush::Solid(apply_alpha(self.stroke_color, self.global_alpha))
}
fn gradient_brush(&self, g: Gradient) -> PenikoBrush {
if self.global_alpha < 1.0 {
let stops_vec: Vec<ColorStop> = g
.stops
.iter()
.map(|s| ColorStop {
offset: s.offset,
color: s.color.multiply_alpha(self.global_alpha as f32),
})
.collect();
let mut g2 = g.clone();
g2.stops = ColorStops::from(stops_vec.as_slice());
PenikoBrush::Gradient(g2)
} else {
PenikoBrush::Gradient(g)
}
}
fn emit_fill_path(&mut self, brush: PenikoBrush) {
if self.path.elements().is_empty() { return; }
if let Some(sh) = self.shadow.clone() {
self.scene.push(DrawCommand::FillPath {
path: self.path.clone(),
rule: FillRule::NonZero,
brush: PenikoBrush::Solid(apply_alpha(sh.color, self.global_alpha)),
transform: self.transform.then_translate(Vec2::new(sh.dx, sh.dy)),
});
}
self.scene.push(DrawCommand::FillPath {
path: self.path.clone(),
rule: FillRule::NonZero,
brush,
transform: self.transform,
});
}
fn current_stroke(&self) -> UrxStroke {
UrxStroke {
width: self.stroke_width as f32,
miter_limit: 4.0,
cap: to_urx_cap(self.line_cap),
join: to_urx_join(self.line_join),
dash: self.line_dash.as_ref().map(|pattern| UrxDash {
pattern: pattern.iter().map(|&v| v as f32).collect(),
phase: 0.0,
}),
}
}
fn emit_fill_rect(&mut self, x: f64, y: f64, w: f64, h: f64, radii: Option<[f32; 4]>) {
let rect = KRect::new(x, y, x + w, y + h);
if let Some(sh) = self.shadow.clone() {
self.scene.push(DrawCommand::FillRect {
rect,
radii,
brush: PenikoBrush::Solid(apply_alpha(sh.color, self.global_alpha)),
transform: self.transform.then_translate(Vec2::new(sh.dx, sh.dy)),
});
}
let brush = self.effective_fill_brush();
self.scene.push(DrawCommand::FillRect {
rect,
radii,
brush,
transform: self.transform,
});
}
fn emit_stroke_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
let rect = KRect::new(x, y, x + w, y + h);
let stroke = self.current_stroke();
let brush = self.effective_stroke_brush();
self.scene.push(DrawCommand::StrokeRect {
rect,
radii: None,
stroke,
brush,
transform: self.transform,
});
}
}
fn to_urx_cap(c: Cap) -> UrxLineCap {
match c {
Cap::Butt => UrxLineCap::Butt,
Cap::Round => UrxLineCap::Round,
Cap::Square => UrxLineCap::Square,
}
}
fn to_urx_join(j: Join) -> UrxLineJoin {
match j {
Join::Miter => UrxLineJoin::Miter,
Join::Round => UrxLineJoin::Round,
Join::Bevel => UrxLineJoin::Bevel,
}
}
impl Painter for UrxRenderContext {
fn save(&mut self) {
self.state_stack.push(SavedState {
transform: self.transform,
fill_color: self.fill_color,
stroke_color: self.stroke_color,
stroke_width: self.stroke_width,
line_cap: self.line_cap,
line_join: self.line_join,
line_dash: self.line_dash.clone(),
global_alpha: self.global_alpha,
font_info: self.font_info.clone(),
text_align: self.text_align,
text_baseline: self.text_baseline,
blend_mode: self.blend_mode,
clip_pushes: self.clip_pushes,
});
self.clip_pushes = 0;
}
fn restore(&mut self) {
for _ in 0..self.clip_pushes {
self.scene.push(DrawCommand::PopClip);
}
if let Some(s) = self.state_stack.pop() {
self.transform = s.transform;
self.fill_color = s.fill_color;
self.stroke_color = s.stroke_color;
self.stroke_width = s.stroke_width;
self.line_cap = s.line_cap;
self.line_join = s.line_join;
self.line_dash = s.line_dash;
self.global_alpha = s.global_alpha;
self.font_info = s.font_info;
self.text_align = s.text_align;
self.text_baseline = s.text_baseline;
self.blend_mode = s.blend_mode;
self.clip_pushes = s.clip_pushes;
}
}
fn translate(&mut self, x: f64, y: f64) {
self.transform = self.transform.pre_translate(Vec2::new(x, y));
}
fn rotate(&mut self, angle: f64) {
self.transform = self.transform.pre_rotate(angle);
}
fn scale(&mut self, x: f64, y: f64) {
self.transform = self.transform.pre_scale_non_uniform(x, y);
}
fn set_fill_color(&mut self, color: &str) { self.fill_color = parse_color(color); }
fn set_stroke_color(&mut self, color: &str) { self.stroke_color = parse_color(color); }
fn set_stroke_width(&mut self, width: f64) { self.stroke_width = width; }
fn set_global_alpha(&mut self, alpha: f64) { self.global_alpha = alpha.clamp(0.0, 1.0); }
fn set_line_dash(&mut self, pattern: &[f64]) {
self.line_dash = if pattern.is_empty() { None } else { Some(pattern.to_vec()) };
}
fn set_line_cap(&mut self, cap: &str) {
self.line_cap = match cap {
"round" => Cap::Round,
"square" => Cap::Square,
_ => Cap::Butt,
};
}
fn set_line_join(&mut self, join: &str) {
self.line_join = match join {
"round" => Join::Round,
"bevel" => Join::Bevel,
_ => Join::Miter,
};
}
fn begin_path(&mut self) {
self.path.truncate(0);
self.path_hint = PathHint::Empty;
}
fn move_to(&mut self, x: f64, y: f64) {
self.path_hint = PathHint::Generic;
self.path.move_to(KPoint::new(x, y));
}
fn line_to(&mut self, x: f64, y: f64) {
self.ensure_subpath_open(KPoint::new(x, y));
self.path.line_to(KPoint::new(x, y));
self.path_hint = PathHint::Generic;
}
fn close_path(&mut self) {
if !self.path.elements().is_empty()
&& !matches!(self.path.elements().last(), Some(kurbo::PathEl::ClosePath))
{
self.path.close_path();
self.path_hint = PathHint::Generic;
}
}
fn rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
self.path_hint = PathHint::Generic;
self.path.move_to(KPoint::new(x, y));
self.path.line_to(KPoint::new(x + w, y));
self.path.line_to(KPoint::new(x + w, y + h));
self.path.line_to(KPoint::new(x, y + h));
self.path.close_path();
}
fn arc(&mut self, cx: f64, cy: f64, radius: f64, start: f64, end: f64) {
let path_was_empty = self.path.elements().is_empty();
let sweep = end - start;
let arc = kurbo::Arc::new(
KPoint::new(cx, cy),
Vec2::new(radius, radius),
start,
sweep,
0.0,
);
self.ensure_subpath_open(KPoint::new(
cx + radius * start.cos(),
cy + radius * start.sin(),
));
for el in arc.append_iter(0.1) {
self.path.push(el);
}
self.path_hint = if path_was_empty
&& cx.is_finite()
&& cy.is_finite()
&& radius.is_finite()
&& radius >= 0.0
&& (sweep.abs() - std::f64::consts::TAU).abs() <= 1e-9
{
PathHint::FullCircle { cx, cy, radius }
} else {
PathHint::Generic
};
}
fn ellipse(&mut self, cx: f64, cy: f64, rx: f64, ry: f64, _rot: f64, start: f64, end: f64) {
self.path_hint = PathHint::Generic;
let arc = kurbo::Arc::new(
KPoint::new(cx, cy),
Vec2::new(rx, ry),
start,
end - start,
0.0,
);
self.ensure_subpath_open(KPoint::new(
cx + rx * start.cos(),
cy + ry * start.sin(),
));
for el in arc.append_iter(0.1) {
self.path.push(el);
}
}
fn quadratic_curve_to(&mut self, cpx: f64, cpy: f64, x: f64, y: f64) {
self.path_hint = PathHint::Generic;
self.ensure_subpath_open(KPoint::new(cpx, cpy));
self.path.quad_to(KPoint::new(cpx, cpy), KPoint::new(x, y));
}
fn bezier_curve_to(&mut self, cp1x: f64, cp1y: f64, cp2x: f64, cp2y: f64, x: f64, y: f64) {
self.path_hint = PathHint::Generic;
self.ensure_subpath_open(KPoint::new(cp1x, cp1y));
self.path.curve_to(
KPoint::new(cp1x, cp1y),
KPoint::new(cp2x, cp2y),
KPoint::new(x, y),
);
}
fn stroke(&mut self) {
if self.path.elements().is_empty() { return; }
let stroke = self.current_stroke();
let brush = self.effective_stroke_brush();
if stroke.dash.is_none() {
if let PathHint::FullCircle { cx, cy, radius } = self.path_hint {
self.scene.push(DrawCommand::StrokeRect {
rect: KRect::new(cx - radius, cy - radius, cx + radius, cy + radius),
radii: Some([radius as f32; 4]),
stroke,
brush,
transform: self.transform,
});
return;
}
}
self.scene.push(DrawCommand::StrokePath {
path: self.path.clone(),
stroke,
brush,
transform: self.transform,
});
}
fn fill(&mut self) {
let brush = self.effective_fill_brush();
self.emit_fill_path(brush);
}
}
impl ShapeHelpers for UrxRenderContext {
fn fill_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
self.emit_fill_rect(x, y, w, h, None);
}
fn stroke_rect(&mut self, x: f64, y: f64, w: f64, h: f64) {
self.emit_stroke_rect(x, y, w, h);
}
fn fill_rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, radius: f64) {
let r = radius.clamp(0.0, (w / 2.0).min(h / 2.0)) as f32;
self.emit_fill_rect(x, y, w, h, Some([r, r, r, r]));
}
fn stroke_rounded_rect(&mut self, x: f64, y: f64, w: f64, h: f64, radius: f64) {
let r = radius.clamp(0.0, (w / 2.0).min(h / 2.0)) as f32;
let rect = KRect::new(x, y, x + w, y + h);
let stroke = self.current_stroke();
let brush = self.effective_stroke_brush();
self.scene.push(DrawCommand::StrokeRect {
rect,
radii: Some([r, r, r, r]),
stroke,
brush,
transform: self.transform,
});
}
}
impl Masking for UrxRenderContext {
fn clip(&mut self) {
if self.path.elements().is_empty() { return; }
use kurbo::Shape;
let bbox = self.path.bounding_box();
self.scene.push(DrawCommand::PushClipRect {
rect: bbox,
transform: self.transform,
});
self.clip_pushes = self.clip_pushes.saturating_add(1);
}
fn clip_rect(&mut self, x: f64, y: f64, width: f64, height: f64) {
self.scene.push(DrawCommand::PushClipRect {
rect: KRect::new(x, y, x + width, y + height),
transform: self.transform,
});
self.clip_pushes = self.clip_pushes.saturating_add(1);
}
}
impl Effects for UrxRenderContext {
fn set_shadow(&mut self, dx: f64, dy: f64, _blur: f64, color: &str) {
self.shadow = Some(ShadowState { dx, dy, color: parse_color(color) });
}
fn clear_shadow(&mut self) { self.shadow = None; }
fn set_blend_mode(&mut self, mode: UzorBlendMode) { self.blend_mode = mode; }
}
fn build_gradient_stops(stops: &[(f32, &str)]) -> ColorStops {
let v: Vec<ColorStop> = stops
.iter()
.map(|(o, hex)| ColorStop { offset: *o, color: parse_color(hex).into() })
.collect();
ColorStops::from(v.as_slice())
}
impl GradientPainter for UrxRenderContext {
fn fill_linear_gradient(
&mut self,
stops: &[(f32, &str)],
x1: f64, y1: f64, x2: f64, y2: f64,
) {
if stops.is_empty() { return; }
let kind = LinearGradientPosition {
start: KPoint::new(x1, y1),
end: KPoint::new(x2, y2),
};
let g = Gradient {
kind: kind.into(),
stops: build_gradient_stops(stops),
extend: Extend::Pad,
..Gradient::default()
};
let brush = self.gradient_brush(g);
self.emit_fill_path(brush);
}
fn fill_radial_gradient(
&mut self,
cx: f64, cy: f64, r: f64,
stops: &[(f32, &str)],
_x: f64, _y: f64, _w: f64, _h: f64,
) {
if stops.is_empty() { return; }
let kind = RadialGradientPosition {
start_center: KPoint::new(cx, cy),
start_radius: 0.0,
end_center: KPoint::new(cx, cy),
end_radius: r as f32,
};
let g = Gradient {
kind: kind.into(),
stops: build_gradient_stops(stops),
extend: Extend::Pad,
..Gradient::default()
};
let brush = self.gradient_brush(g);
self.emit_fill_path(brush);
}
}
impl BatchPainter for UrxRenderContext {
fn draw_line_batch(&mut self, lines: &[LineSegment], color: &str, width: f64) {
if lines.is_empty() { return; }
self.set_stroke_color(color);
self.set_stroke_width(width);
let stroke = self.current_stroke();
let brush = self.effective_stroke_brush();
let segments = lines.iter().map(|line| LineBatchSegment {
from: Vec2::new(line.x1, line.y1),
to: Vec2::new(line.x2, line.y2),
}).collect();
self.scene.push(DrawCommand::LineBatch {
segments,
stroke,
brush,
transform: self.transform,
});
}
fn draw_circle_batch(&mut self, circles: &[CircleBatch], color: &str) {
if circles.is_empty() { return; }
self.set_fill_color(color);
let brush = self.effective_fill_brush();
for c in circles {
self.scene.push(DrawCommand::FillRect {
rect: KRect::new(c.cx - c.r, c.cy - c.r, c.cx + c.r, c.cy + c.r),
radii: Some([c.r as f32; 4]),
brush: brush.clone(),
transform: self.transform,
});
}
}
}
fn font_id_cache() -> &'static Mutex<HashMap<uzor::shaper::ShaperFontId, FontId>> {
static CACHE: OnceLock<Mutex<HashMap<uzor::shaper::ShaperFontId, FontId>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn resolve_font_id(shaper_font: uzor::shaper::ShaperFontId) -> Option<FontId> {
let mut cache = font_id_cache().lock().ok()?;
if let Some(&id) = cache.get(&shaper_font) {
return Some(id);
}
let bytes = uzor::shaper::font_bytes_for(shaper_font)?;
let id = uzor_urx_glyph::register_font(bytes).ok()?;
cache.insert(shaper_font, id);
Some(id)
}
const COLOR_GLYPH_FAMILIES: &[&str] = &["Noto Color Emoji"];
fn is_color_glyph_font(shaper_font: uzor::shaper::ShaperFontId) -> bool {
uzor::shaper::font_family_for(shaper_font)
.is_some_and(|family| COLOR_GLYPH_FAMILIES.contains(&family.as_str()))
}
fn is_translation_only(t: KAffine) -> bool {
let c = t.as_coeffs();
const EPS: f64 = 1e-6;
(c[0] - 1.0).abs() < EPS && c[1].abs() < EPS && c[2].abs() < EPS && (c[3] - 1.0).abs() < EPS
}
impl TextRenderer for UrxRenderContext {
fn set_font(&mut self, font: &str) { self.font_info = parse_css_font(font); }
fn set_text_align(&mut self, align: TextAlign) { self.text_align = align; }
fn set_text_baseline(&mut self, baseline: TextBaseline) { self.text_baseline = baseline; }
fn fill_text(&mut self, text: &str, x: f64, y: f64) {
if text.is_empty() { return; }
let font_str = font_string(&self.font_info);
let total_w = self.measure_text(text);
let x_off = match self.text_align {
TextAlign::Center => -total_w / 2.0,
TextAlign::Right => -total_w,
_ => 0.0,
};
let y_off = match self.text_baseline {
TextBaseline::Top => self.font_info.size as f64 * 0.8,
TextBaseline::Middle => self.font_info.size as f64 * 0.35,
TextBaseline::Bottom => 0.0,
TextBaseline::Alphabetic => 0.0,
};
let text_xform = KAffine::translate((x + x_off, y + y_off));
let combined = self.transform * text_xform;
if !is_translation_only(self.transform) {
metrics::counter!(
uzor_urx_core::metrics_keys::KEY_RENDER_PRIMITIVES,
"kind" => "urx_filltext_glyphrun_transform_fallback",
).increment(1);
self.fill_text_as_path(text, &font_str, combined);
return;
}
let segments = uzor::shaper::shape_glyph_runs(text, &font_str);
if segments.is_empty() {
return;
}
if let Some(sh) = self.shadow.clone() {
let shadow_xform = combined.then_translate(Vec2::new(sh.dx, sh.dy));
let shadow_brush = PenikoBrush::Solid(apply_alpha(sh.color, self.global_alpha));
self.emit_text_segments(&segments, &font_str, shadow_brush, shadow_xform);
}
let brush = self.effective_fill_brush();
self.emit_text_segments(&segments, &font_str, brush, combined);
}
}
impl UrxRenderContext {
fn emit_text_segments(
&mut self,
segments: &[uzor::shaper::GlyphSegment],
font_str: &str,
brush: PenikoBrush,
transform: KAffine,
) {
for seg in segments {
let seg_origin_x = seg.glyphs.first().map(|g| g.x as f64).unwrap_or(0.0);
if is_color_glyph_font(seg.font) {
metrics::counter!(
uzor_urx_core::metrics_keys::KEY_RENDER_PRIMITIVES,
"kind" => "urx_filltext_glyphrun_color_font_fallback",
).increment(1);
self.fill_path_segment(&seg.text, font_str, brush.clone(), transform.then_translate(Vec2::new(seg_origin_x, 0.0)));
continue;
}
let Some(font_id) = resolve_font_id(seg.font) else {
metrics::counter!(
uzor_urx_core::metrics_keys::KEY_RENDER_PRIMITIVES,
"kind" => "urx_filltext_glyphrun_font_bytes_unavailable",
).increment(1);
self.fill_path_segment(&seg.text, font_str, brush.clone(), transform.then_translate(Vec2::new(seg_origin_x, 0.0)));
continue;
};
let glyphs: Vec<Glyph> =
seg.glyphs.iter().map(|g| Glyph { glyph_id: g.glyph_id, x: g.x, y: g.y }).collect();
self.scene.push(DrawCommand::GlyphRun {
glyphs,
font: font_id,
font_size: seg.font_size,
brush: brush.clone(),
transform,
text: Some(seg.text.clone()),
});
}
}
fn fill_text_as_path(&mut self, text: &str, font_str: &str, transform: KAffine) {
if let Some(sh) = self.shadow.clone() {
let shadow_xform = transform.then_translate(Vec2::new(sh.dx, sh.dy));
self.fill_path_segment(text, font_str, PenikoBrush::Solid(apply_alpha(sh.color, self.global_alpha)), shadow_xform);
}
let brush = self.effective_fill_brush();
self.fill_path_segment(text, font_str, brush, transform);
}
fn fill_path_segment(&mut self, text: &str, font_str: &str, brush: PenikoBrush, transform: KAffine) {
let svg = uzor::shaper::text_to_path(text, font_str);
if svg.is_empty() { return; }
let Ok(path) = BezPath::from_svg(&svg) else { return; };
self.scene.push(DrawCommand::FillPath { path, rule: FillRule::NonZero, brush, transform });
}
}
fn font_string(info: &FontInfo) -> String {
let family = match info.family {
FontFamily::Roboto => "Roboto",
FontFamily::PtRootUi => "PT Root UI",
FontFamily::JetBrainsMono => "JetBrains Mono",
};
let mut parts: Vec<String> = Vec::with_capacity(4);
if info.italic { parts.push("italic".into()); }
if info.bold { parts.push("bold".into()); }
parts.push(format!("{}px", info.size));
parts.push(family.into());
parts.join(" ")
}
impl TextMetrics for UrxRenderContext {
fn measure_text(&self, text: &str) -> f64 {
let m = uzor::shaper::measure_glyphs(text, &font_string(&self.font_info));
m.last().map(|g| g.x_offset + g.advance).unwrap_or(0.0)
}
fn text_bounds(&self, text: &str, font: &str) -> TextBounds {
let info = parse_css_font(font);
let m = uzor::shaper::measure_glyphs(text, &font_string(&info));
let w = m.last().map(|g| g.x_offset + g.advance).unwrap_or(0.0);
let ascent = info.size as f64 * 0.9;
let descent = info.size as f64 * 0.3;
TextBounds {
x: 0.0, y: -ascent, w, h: ascent + descent, ascent, descent,
}
}
fn measure_text_glyphs(&self, text: &str, font: &str) -> Vec<GlyphMetric> {
uzor::shaper::measure_glyphs(text, font)
}
fn measure_text_wrapped(&self, text: &str, font: &str, max_width: f64) -> Vec<uzor::render::WrappedLine> {
uzor::shaper::measure_glyphs_wrapped(text, font, max_width)
}
fn text_to_path(&self, text: &str, font: &str) -> String {
uzor::shaper::text_to_path(text, font)
}
}
impl UiEffectHelpers for UrxRenderContext {}
impl UzorRenderContext for UrxRenderContext {
fn dpr(&self) -> f64 { self.dpr }
}
impl RenderContextExt for UrxRenderContext {
type BlurImage = ();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solid_line_batch_emits_one_line_batch() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
BatchPainter::draw_line_batch(
&mut ctx,
&[
LineSegment { x1: 1.0, y1: 2.0, x2: 3.0, y2: 4.0 },
LineSegment { x1: 5.0, y1: 6.0, x2: 7.0, y2: 8.0 },
],
"#ffffff",
1.5,
);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
assert!(matches!(
&scene.commands[0],
DrawCommand::LineBatch { segments, stroke, .. }
if segments.len() == 2
&& segments[0].from == Vec2::new(1.0, 2.0)
&& segments[0].to == Vec2::new(3.0, 4.0)
&& segments[1].from == Vec2::new(5.0, 6.0)
&& segments[1].to == Vec2::new(7.0, 8.0)
&& stroke.dash.is_none()
));
}
#[test]
fn dashed_line_batch_retains_batch_stroke_semantics() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::set_line_dash(&mut ctx, &[8.0, 5.0]);
BatchPainter::draw_line_batch(
&mut ctx,
&[
LineSegment { x1: 1.0, y1: 2.0, x2: 3.0, y2: 4.0 },
LineSegment { x1: 5.0, y1: 6.0, x2: 7.0, y2: 8.0 },
],
"#ffffff",
1.5,
);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
assert!(matches!(
&scene.commands[0],
DrawCommand::LineBatch { segments, stroke, .. }
if segments.len() == 2 && stroke.dash.is_some()
));
}
#[test]
fn circle_batch_emits_sdf_round_rects_with_original_geometry() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
BatchPainter::draw_circle_batch(
&mut ctx,
&[
CircleBatch { cx: 10.0, cy: 20.0, r: 3.0 },
CircleBatch { cx: 40.0, cy: 50.0, r: 7.5 },
],
"#ffffff",
);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 2);
let expected = [
(KRect::new(7.0, 17.0, 13.0, 23.0), [3.0; 4]),
(KRect::new(32.5, 42.5, 47.5, 57.5), [7.5; 4]),
];
for (command, (expected_rect, expected_radii)) in scene.commands.iter().zip(expected) {
match command {
DrawCommand::FillRect { rect, radii: Some(radii), .. } => {
assert_eq!(*rect, expected_rect);
assert_eq!(*radii, expected_radii);
}
other => panic!("expected SDF FillRect circle, got {:?}", other),
}
}
assert!(!scene.commands.iter().any(|command| matches!(command, DrawCommand::FillPath { .. })));
}
#[test]
fn recycle_scene_keeps_command_capacity() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
for x in 0..32 {
ShapeHelpers::fill_rect(&mut ctx, x as f64, 0.0, 1.0, 1.0);
}
let capacity = ctx.scene.commands.capacity();
ctx.recycle_scene();
assert!(ctx.scene.commands.is_empty());
assert_eq!(ctx.scene.commands.capacity(), capacity);
}
#[test]
fn begin_frame_advances_revision_and_reuses_scene_capacity() {
let mut ctx = UrxRenderContext::new(1.0);
assert_eq!(ctx.revision(), 0);
ctx.begin_frame(100, 100);
let first_revision = ctx.revision();
for x in 0..32 {
ShapeHelpers::fill_rect(&mut ctx, x as f64, 0.0, 1.0, 1.0);
}
let capacity = ctx.scene().commands.capacity();
ctx.begin_frame(200, 150);
assert_eq!(ctx.revision(), first_revision + 1);
assert!(ctx.scene().is_empty());
assert_eq!(ctx.scene().commands.capacity(), capacity);
assert_eq!(ctx.size(), (200, 150));
}
#[test]
fn retained_scene_and_revision_survive_read_only_access() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ShapeHelpers::fill_rect(&mut ctx, 1.0, 2.0, 3.0, 4.0);
let revision = ctx.revision();
assert_eq!(ctx.scene().len(), 1);
assert_eq!(ctx.scene().len(), 1);
assert_eq!(ctx.revision(), revision);
}
#[test]
fn full_circle_stroke_emits_native_rounded_stroke_rect() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::begin_path(&mut ctx);
Painter::arc(&mut ctx, 30.0, 40.0, 12.0, 0.0, std::f64::consts::TAU);
Painter::stroke(&mut ctx);
assert!(matches!(
&ctx.scene().commands[..],
[DrawCommand::StrokeRect {
rect,
radii: Some(radii),
..
}] if *rect == KRect::new(18.0, 28.0, 42.0, 52.0)
&& *radii == [12.0; 4]
));
}
#[test]
fn partial_or_extended_circle_path_keeps_generic_stroke_semantics() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::begin_path(&mut ctx);
Painter::arc(&mut ctx, 30.0, 40.0, 12.0, 0.0, std::f64::consts::PI);
Painter::line_to(&mut ctx, 30.0, 40.0);
Painter::stroke(&mut ctx);
assert!(matches!(
&ctx.scene().commands[..],
[DrawCommand::StrokePath { .. }]
));
}
#[test]
fn dashed_full_circle_keeps_generic_path_dash_phase() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::set_line_dash(&mut ctx, &[4.0, 3.0]);
Painter::begin_path(&mut ctx);
Painter::arc(&mut ctx, 30.0, 40.0, 12.0, 0.0, std::f64::consts::TAU);
Painter::stroke(&mut ctx);
assert!(matches!(
&ctx.scene().commands[..],
[DrawCommand::StrokePath { stroke, .. }] if stroke.dash.is_some()
));
}
use uzor::render::Painter;
#[test]
fn fill_rect_emits_one_fillrect() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.set_fill_color("#ff0000");
ShapeHelpers::fill_rect(&mut ctx, 10.0, 20.0, 30.0, 40.0);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
match &scene.commands[0] {
DrawCommand::FillRect { rect, .. } => {
assert_eq!(rect.x0, 10.0);
assert_eq!(rect.y0, 20.0);
assert_eq!(rect.x1, 40.0);
assert_eq!(rect.y1, 60.0);
}
other => panic!("expected FillRect, got {:?}", other),
}
}
#[test]
fn set_line_dash_carries_the_pattern_onto_the_emitted_stroke_path() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.set_stroke_color("#ffffff");
Painter::set_line_dash(&mut ctx, &[5.0, 3.0]);
ctx.begin_path();
ctx.move_to(0.0, 0.0);
ctx.line_to(10.0, 0.0);
Painter::stroke(&mut ctx);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
match &scene.commands[0] {
DrawCommand::StrokePath { stroke, .. } => {
let dash = stroke.dash.as_ref().expect("dashed stroke() must carry a Some(Dash)");
assert_eq!(dash.pattern, vec![5.0, 3.0]);
}
other => panic!("expected StrokePath, got {:?}", other),
}
}
#[test]
fn set_line_dash_empty_clears_a_previously_set_pattern() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.set_stroke_color("#ffffff");
Painter::set_line_dash(&mut ctx, &[5.0, 3.0]);
Painter::set_line_dash(&mut ctx, &[]);
ctx.begin_path();
ctx.move_to(0.0, 0.0);
ctx.line_to(10.0, 0.0);
Painter::stroke(&mut ctx);
let scene = ctx.take_scene();
match &scene.commands[0] {
DrawCommand::StrokePath { stroke, .. } => {
assert!(stroke.dash.is_none(), "an empty pattern must clear dashing back to solid");
}
other => panic!("expected StrokePath, got {:?}", other),
}
}
#[test]
fn save_restore_pops_pushed_clip() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.save();
ctx.clip_rect(0.0, 0.0, 50.0, 50.0);
ctx.restore();
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 2);
assert!(matches!(scene.commands[0], DrawCommand::PushClipRect { .. }));
assert!(matches!(scene.commands[1], DrawCommand::PopClip));
}
#[test]
fn path_fill_emits_fillpath() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.set_fill_color("#0000ff");
ctx.begin_path();
ctx.move_to(0.0, 0.0);
ctx.line_to(10.0, 0.0);
ctx.line_to(10.0, 10.0);
ctx.close_path();
Painter::fill(&mut ctx);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
assert!(matches!(scene.commands[0], DrawCommand::FillPath { .. }));
}
#[test]
fn fill_linear_gradient_emits_a_gradient_fillpath() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.begin_path();
ctx.rect(0.0, 0.0, 20.0, 10.0);
GradientPainter::fill_linear_gradient(
&mut ctx,
&[(0.0, "#ff0000"), (1.0, "#0000ff")],
0.0, 0.0, 20.0, 0.0,
);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1, "the gradient fill must emit its own draw command, not silently no-op");
match &scene.commands[0] {
DrawCommand::FillPath { brush, .. } => {
assert!(matches!(brush, PenikoBrush::Gradient(_)), "expected a Gradient brush, got {:?}", brush);
}
other => panic!("expected FillPath, got {:?}", other),
}
}
#[test]
fn fill_radial_gradient_emits_a_gradient_fillpath() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.begin_path();
ctx.rect(0.0, 0.0, 20.0, 20.0);
GradientPainter::fill_radial_gradient(
&mut ctx,
10.0, 10.0, 10.0,
&[(0.0, "#ff0000"), (1.0, "#0000ff")],
0.0, 0.0, 20.0, 20.0,
);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
match &scene.commands[0] {
DrawCommand::FillPath { brush, .. } => {
assert!(matches!(brush, PenikoBrush::Gradient(_)), "expected a Gradient brush, got {:?}", brush);
}
other => panic!("expected FillPath, got {:?}", other),
}
}
#[test]
fn gradient_fill_does_not_leak_into_the_next_solid_fill() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.begin_path();
ctx.rect(0.0, 0.0, 20.0, 10.0);
GradientPainter::fill_linear_gradient(
&mut ctx,
&[(0.0, "#ff8000"), (1.0, "#000000")],
0.0, 0.0, 20.0, 0.0,
);
ctx.set_fill_color("#ffffff");
ctx.begin_path();
ctx.rect(30.0, 0.0, 10.0, 10.0);
Painter::fill(&mut ctx);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 2);
assert!(matches!(scene.commands[0], DrawCommand::FillPath { brush: PenikoBrush::Gradient(_), .. }));
match &scene.commands[1] {
DrawCommand::FillPath { brush: PenikoBrush::Solid(c), .. } => {
assert_eq!(*c, Color::from_rgba8(255, 255, 255, 255), "the second fill must use ITS OWN white, not the gradient's orange leaking through");
}
other => panic!("expected a solid-white FillPath, got {:?}", other),
}
}
#[test]
fn transform_translate_propagates_to_emitted_op() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
ctx.translate(5.0, 7.0);
ctx.set_fill_color("#00ff00");
ShapeHelpers::fill_rect(&mut ctx, 0.0, 0.0, 10.0, 10.0);
let scene = ctx.take_scene();
match &scene.commands[0] {
DrawCommand::FillRect { transform, .. } => {
let coeffs = transform.as_coeffs();
assert_eq!(coeffs[4], 5.0);
assert_eq!(coeffs[5], 7.0);
}
_ => panic!("expected FillRect"),
}
}
#[test]
fn arc_on_empty_path_does_not_panic() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
let rc: &mut dyn uzor::render::RenderContext = &mut ctx;
rc.begin_path();
rc.arc(50.0, 50.0, 20.0, 0.0, std::f64::consts::PI);
rc.stroke();
let scene = ctx.take_scene();
assert!(scene.commands.iter().any(|c| matches!(c, DrawCommand::StrokePath { .. })));
}
#[test]
fn ellipse_on_empty_path_does_not_panic() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
let rc: &mut dyn uzor::render::RenderContext = &mut ctx;
rc.begin_path();
rc.ellipse(50.0, 50.0, 20.0, 30.0, 0.0, 0.0, std::f64::consts::TAU);
rc.fill();
let _ = ctx.take_scene();
}
#[test]
fn line_to_on_empty_path_does_not_panic() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
let rc: &mut dyn uzor::render::RenderContext = &mut ctx;
rc.begin_path();
rc.line_to(10.0, 20.0);
rc.line_to(30.0, 40.0);
rc.stroke();
let _ = ctx.take_scene();
}
#[test]
fn close_path_on_empty_is_silent_noop() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
let rc: &mut dyn uzor::render::RenderContext = &mut ctx;
rc.begin_path();
rc.close_path();
let _ = ctx.take_scene();
}
#[test]
fn bezier_after_close_does_not_panic() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
let rc: &mut dyn uzor::render::RenderContext = &mut ctx;
rc.begin_path();
rc.move_to(10.0, 10.0);
rc.line_to(20.0, 20.0);
rc.close_path();
rc.bezier_curve_to(30.0, 30.0, 40.0, 40.0, 50.0, 50.0);
rc.stroke();
let _ = ctx.take_scene();
}
#[test]
fn fill_text_emits_glyph_run_not_fillpath() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
ctx.set_fill_color("#ffffff");
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::fill_text(&mut ctx, "Hello", 10.0, 20.0);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1, "one segment (one font) -> exactly one GlyphRun");
match &scene.commands[0] {
DrawCommand::GlyphRun { glyphs, font_size, text, .. } => {
assert_eq!(glyphs.len(), 5, "one glyph per character, no ligatures in \"Hello\"");
assert_eq!(*font_size, 16.0);
assert_eq!(text.as_deref(), Some("Hello"));
}
other => panic!("expected GlyphRun, got {:?}", other),
}
}
#[test]
fn fill_text_glyph_run_transform_carries_the_origin() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
ctx.set_fill_color("#ffffff");
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::set_text_baseline(&mut ctx, TextBaseline::Top);
TextRenderer::fill_text(&mut ctx, "Hi", 30.0, 40.0);
let scene = ctx.take_scene();
match &scene.commands[0] {
DrawCommand::GlyphRun { transform, .. } => {
let c = transform.as_coeffs();
assert_eq!(c[4], 30.0);
assert!((c[5] - (40.0 + 16.0 * 0.8)).abs() < 1e-6, "Top baseline offset must be added: got {}", c[5]);
}
other => panic!("expected GlyphRun, got {:?}", other),
}
}
#[test]
fn fill_text_reuses_the_same_font_id_across_calls() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
ctx.set_fill_color("#ffffff");
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::fill_text(&mut ctx, "AB", 0.0, 20.0);
TextRenderer::fill_text(&mut ctx, "CD", 0.0, 40.0);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 2);
let font_id = |cmd: &DrawCommand| match cmd {
DrawCommand::GlyphRun { font, .. } => *font,
other => panic!("expected GlyphRun, got {:?}", other),
};
assert_eq!(font_id(&scene.commands[0]), font_id(&scene.commands[1]), "same font across two calls must reuse the SAME FontId");
}
#[test]
fn fill_text_falls_back_to_fillpath_under_a_scale_transform() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
ctx.set_fill_color("#ffffff");
Painter::scale(&mut ctx, 2.0, 2.0);
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::fill_text(&mut ctx, "Hi", 10.0, 20.0);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 1);
assert!(matches!(scene.commands[0], DrawCommand::FillPath { .. }), "scaled text must fall back to FillPath, not silently drop the scale");
}
#[test]
fn fill_text_with_shadow_emits_shadow_then_main_glyph_run() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
ctx.set_fill_color("#ffffff");
Effects::set_shadow(&mut ctx, 2.0, 2.0, 0.0, "#000000");
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::fill_text(&mut ctx, "Hi", 10.0, 20.0);
let scene = ctx.take_scene();
assert_eq!(scene.commands.len(), 2, "shadow pre-pass + main draw");
assert!(matches!(scene.commands[0], DrawCommand::GlyphRun { .. }));
assert!(matches!(scene.commands[1], DrawCommand::GlyphRun { .. }));
}
#[test]
fn empty_text_emits_nothing() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(200, 100);
TextRenderer::set_font(&mut ctx, "16px Roboto");
TextRenderer::fill_text(&mut ctx, "", 10.0, 20.0);
let scene = ctx.take_scene();
assert!(scene.commands.is_empty());
}
#[test]
fn translate_then_rotate_matches_local_frame_composition() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::translate(&mut ctx, 10.0, 0.0);
Painter::rotate(&mut ctx, std::f64::consts::FRAC_PI_2);
let p = ctx.transform * KPoint::new(5.0, 0.0);
assert!((p.x - 10.0).abs() < 1e-9, "x mismatch: got {p:?}");
assert!((p.y - 5.0).abs() < 1e-9, "y mismatch: got {p:?}");
assert!(
(p.x - 0.0).abs() > 1.0 || (p.y - 10.0).abs() > 1.0,
"result matches the WRONG (then_*, world-frame) composition"
);
}
#[test]
fn translate_then_scale_matches_local_frame_composition() {
let mut ctx = UrxRenderContext::new(1.0);
ctx.begin_frame(100, 100);
Painter::translate(&mut ctx, 10.0, 20.0);
Painter::scale(&mut ctx, 2.0, 3.0);
let origin = ctx.transform * KPoint::new(0.0, 0.0);
assert!((origin.x - 10.0).abs() < 1e-9 && (origin.y - 20.0).abs() < 1e-9);
let p = ctx.transform * KPoint::new(5.0, 5.0);
assert!((p.x - 20.0).abs() < 1e-9 && (p.y - 35.0).abs() < 1e-9, "got {p:?}");
}
}