use crate::ir::*;
use crate::shader::*;
use crate::state::{EnvelopeKind, UiState};
use crate::text::atlas::RunStyle;
use crate::text::metrics as text_metrics;
use crate::theme::Theme;
use crate::tokens;
use crate::tree::*;
pub fn draw_ops(root: &El, ui_state: &UiState) -> Vec<DrawOp> {
draw_ops_with_theme(root, ui_state, &Theme::default())
}
pub fn draw_ops_with_theme(root: &El, ui_state: &UiState, theme: &Theme) -> Vec<DrawOp> {
let mut out = Vec::new();
push_node(root, ui_state, theme, &mut out, None, (0.0, 0.0), 1.0, 1.0);
out
}
#[allow(clippy::too_many_arguments)]
fn push_node(
n: &El,
ui_state: &UiState,
theme: &Theme,
out: &mut Vec<DrawOp>,
inherited_scissor: Option<Rect>,
inherited_translate: (f32, f32),
inherited_opacity: f32,
inherited_focus_envelope: f32,
) {
let computed = ui_state.rect(&n.computed_id);
let state = ui_state.node_state(&n.computed_id);
let hover_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Hover);
let press_amount = ui_state.envelope(&n.computed_id, EnvelopeKind::Press);
let focus_ring_alpha = ui_state.envelope(&n.computed_id, EnvelopeKind::FocusRing);
let (fill, stroke, text_color, weight, suffix) =
apply_state(n, state, hover_amount, press_amount);
let total_translate = (
inherited_translate.0 + n.translate.0,
inherited_translate.1 + n.translate.1,
);
let focus_alpha_mul = if n.alpha_follows_focused_ancestor {
inherited_focus_envelope
} else {
1.0
};
let opacity = inherited_opacity * n.opacity * focus_alpha_mul;
let child_focus_envelope = if n.focusable {
focus_ring_alpha
} else {
inherited_focus_envelope
};
let translated_rect = translated(computed, total_translate);
let inner_painted_rect = scaled_around_center(translated_rect, n.scale);
let painted_font_size = n.font_size * n.scale;
let own_scissor = if n.clip {
intersect_scissor(inherited_scissor, inner_painted_rect)
} else {
inherited_scissor
};
if let Some(custom) = &n.shader_override {
let painted_rect = inner_painted_rect.outset(n.paint_overflow);
let mut uniforms = custom.uniforms.clone();
uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
out.push(DrawOp::Quad {
id: n.computed_id.clone(),
rect: painted_rect,
scissor: own_scissor,
shader: custom.handle,
uniforms,
});
} else if fill.is_some() || stroke.is_some() || focus_ring_alpha > 0.0 {
let mut uniforms = UniformBlock::new();
if let Some(c) = fill {
uniforms.insert("fill", UniformValue::Color(opaque(c, opacity)));
}
if let Some(c) = stroke {
uniforms.insert("stroke", UniformValue::Color(opaque(c, opacity)));
uniforms.insert("stroke_width", UniformValue::F32(n.stroke_width));
}
uniforms.insert("radius", UniformValue::F32(n.radius));
if n.shadow > 0.0 {
uniforms.insert("shadow", UniformValue::F32(n.shadow));
}
uniforms.insert("inner_rect", inner_rect_uniform(inner_painted_rect));
if n.focusable && focus_ring_alpha > 0.0 {
let base = tokens::FOCUS_RING;
let eased_alpha = (base.a as f32 * focus_ring_alpha * opacity)
.round()
.clamp(0.0, 255.0) as u8;
uniforms.insert(
"focus_color",
UniformValue::Color(base.with_alpha(eased_alpha)),
);
uniforms.insert("focus_width", UniformValue::F32(tokens::FOCUS_RING_WIDTH));
}
theme.apply_surface_uniforms(n.surface_role, &mut uniforms);
let effective_shadow = match uniforms.get("shadow") {
Some(UniformValue::F32(s)) => *s,
_ => 0.0,
};
let painted_rect =
inner_painted_rect.outset(combined_overflow(n.paint_overflow, effective_shadow));
out.push(DrawOp::Quad {
id: n.computed_id.clone(),
rect: painted_rect,
scissor: own_scissor,
shader: theme.surface_handle(n.surface_role),
uniforms,
});
}
if let Some(text) = &n.text {
let display = match suffix {
Some(s) => format!("{text}{s}"),
None => text.clone(),
};
let display = match (n.text_wrap, n.text_max_lines) {
(TextWrap::Wrap, Some(max_lines)) => text_metrics::clamp_text_to_lines(
&display,
painted_font_size,
weight,
n.font_mono,
inner_painted_rect.w,
max_lines,
),
_ => display,
};
let display = match (n.text_wrap, n.text_overflow) {
(TextWrap::NoWrap, TextOverflow::Ellipsis) => text_metrics::ellipsize_text(
&display,
painted_font_size,
weight,
n.font_mono,
inner_painted_rect.w,
),
_ => display,
};
let anchor = match n.text_align {
TextAlign::Start => TextAnchor::Start,
TextAlign::Center => TextAnchor::Middle,
TextAlign::End => TextAnchor::End,
};
let text_color = opaque(text_color.unwrap_or(tokens::TEXT_FOREGROUND), opacity);
let layout = text_metrics::layout_text(
&display,
painted_font_size,
weight,
n.font_mono,
n.text_wrap,
match n.text_wrap {
TextWrap::NoWrap => None,
TextWrap::Wrap => Some(inner_painted_rect.w),
},
);
out.push(DrawOp::GlyphRun {
id: n.computed_id.clone(),
rect: inner_painted_rect,
scissor: own_scissor,
shader: ShaderHandle::Stock(StockShader::Text),
color: text_color,
text: display,
size: painted_font_size,
weight,
mono: n.font_mono,
wrap: n.text_wrap,
anchor,
layout,
});
}
if let Some(name) = n.icon {
let color = opaque(text_color.unwrap_or(tokens::TEXT_FOREGROUND), opacity);
let icon_size = painted_font_size
.min(inner_painted_rect.w)
.min(inner_painted_rect.h)
.max(1.0);
let icon_rect = Rect::new(
inner_painted_rect.center_x() - icon_size * 0.5,
inner_painted_rect.center_y() - icon_size * 0.5,
icon_size,
icon_size,
);
out.push(DrawOp::Icon {
id: n.computed_id.clone(),
rect: icon_rect,
scissor: own_scissor,
name,
color,
size: icon_size,
stroke_width: n.icon_stroke_width * n.scale,
});
}
if matches!(n.kind, Kind::Inlines) {
let runs = collect_inline_runs(n, opacity);
let concat: String = runs.iter().map(|(t, _)| t.as_str()).collect();
let inline_size = inline_paragraph_font_size(n) * n.scale;
let anchor = match n.text_align {
TextAlign::Start => TextAnchor::Start,
TextAlign::Center => TextAnchor::Middle,
TextAlign::End => TextAnchor::End,
};
let layout = text_metrics::layout_text(
&concat,
inline_size,
FontWeight::Regular,
false,
n.text_wrap,
match n.text_wrap {
TextWrap::NoWrap => None,
TextWrap::Wrap => Some(inner_painted_rect.w),
},
);
out.push(DrawOp::AttributedText {
id: n.computed_id.clone(),
rect: inner_painted_rect,
scissor: own_scissor,
shader: ShaderHandle::Stock(StockShader::Text),
runs,
size: inline_size,
wrap: n.text_wrap,
anchor,
layout,
});
return;
}
for c in &n.children {
push_node(
c,
ui_state,
theme,
out,
own_scissor,
total_translate,
opacity,
child_focus_envelope,
);
}
}
fn collect_inline_runs(node: &El, opacity: f32) -> Vec<(String, RunStyle)> {
let mut runs: Vec<(String, RunStyle)> = Vec::with_capacity(node.children.len());
for c in &node.children {
match c.kind {
Kind::Text => {
if let Some(text) = &c.text {
let color = opaque(c.text_color.unwrap_or(tokens::TEXT_FOREGROUND), opacity);
let mut style = RunStyle::new(c.font_weight, color);
if c.text_italic {
style = style.italic();
}
if c.font_mono {
style = style.mono();
}
runs.push((text.clone(), style));
}
}
Kind::HardBreak => {
runs.push((
"\n".to_string(),
RunStyle::new(FontWeight::Regular, tokens::TEXT_FOREGROUND),
));
}
_ => {}
}
}
runs
}
fn inline_paragraph_font_size(node: &El) -> f32 {
let mut size: f32 = node.font_size;
for c in &node.children {
if matches!(c.kind, Kind::Text) {
size = size.max(c.font_size);
}
}
size
}
fn translated(r: Rect, offset: (f32, f32)) -> Rect {
if offset.0 == 0.0 && offset.1 == 0.0 {
return r;
}
Rect::new(r.x + offset.0, r.y + offset.1, r.w, r.h)
}
fn combined_overflow(paint_overflow: Sides, shadow: f32) -> Sides {
if shadow <= 0.0 {
return paint_overflow;
}
Sides {
left: paint_overflow.left.max(shadow),
right: paint_overflow.right.max(shadow),
top: paint_overflow.top.max(shadow * 0.5),
bottom: paint_overflow.bottom.max(shadow * 1.5),
}
}
fn scaled_around_center(r: Rect, s: f32) -> Rect {
if (s - 1.0).abs() < f32::EPSILON {
return r;
}
let cx = r.center_x();
let cy = r.center_y();
let w = r.w * s;
let h = r.h * s;
Rect::new(cx - w * 0.5, cy - h * 0.5, w, h)
}
fn opaque(c: Color, opacity: f32) -> Color {
if (opacity - 1.0).abs() < f32::EPSILON {
return c;
}
let a = (c.a as f32 * opacity.clamp(0.0, 1.0)).round() as u8;
c.with_alpha(a)
}
fn apply_state(
n: &El,
state: InteractionState,
hover: f32,
press: f32,
) -> (
Option<Color>,
Option<Color>,
Option<Color>,
FontWeight,
Option<&'static str>,
) {
let mut fill = n.fill;
let mut stroke = n.stroke;
let mut text_color = n.text_color;
let weight = n.font_weight;
let mut suffix = None;
if hover > 0.0 {
fill = fill.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
stroke = stroke.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN), hover));
text_color = text_color.map(|c| c.mix(c.lighten(tokens::HOVER_LIGHTEN * 0.5), hover));
}
if press > 0.0 {
fill = fill.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
stroke = stroke.map(|c| c.mix(c.darken(tokens::PRESS_DARKEN), press));
}
match state {
InteractionState::Default
| InteractionState::Focus
| InteractionState::Hover
| InteractionState::Press => {}
InteractionState::Disabled => {
let alpha = (255.0 * tokens::DISABLED_ALPHA) as u8;
fill = fill.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
stroke = stroke.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
text_color =
text_color.map(|c| c.with_alpha(((c.a as u32 * alpha as u32) / 255) as u8));
}
InteractionState::Loading => {
text_color = text_color.map(|c| c.with_alpha(((c.a as u32 * 200) / 255) as u8));
suffix = Some(" ⋯");
}
}
(fill, stroke, text_color, weight, suffix)
}
fn inner_rect_uniform(r: Rect) -> UniformValue {
UniformValue::Vec4([r.x, r.y, r.w, r.h])
}
fn intersect_scissor(current: Option<Rect>, next: Rect) -> Option<Rect> {
match current {
Some(r) => Some(r.intersect(next).unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0))),
None => Some(next),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::UiState;
use crate::{button, column, row};
#[test]
fn clip_sets_scissor_on_descendant_ops() {
let mut root = column([row([
button("Inside").key("inside"),
button("Too wide").key("outside").width(Size::Fixed(300.0)),
])
.clip()
.width(Size::Fixed(120.0))]);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 100.0));
let ops = draw_ops(&root, &state);
let clipped = ops
.iter()
.find(|op| op.id().contains("outside"))
.expect("outside button op");
let DrawOp::Quad { scissor, .. } = clipped else {
panic!("expected button surface quad");
};
assert_eq!(*scissor, Some(Rect::new(0.0, 0.0, 120.0, 36.0)));
}
#[test]
fn text_align_center_emits_middle_anchor() {
let mut root = crate::text("Centered").center_text();
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 80.0));
let ops = draw_ops(&root, &state);
let DrawOp::GlyphRun { anchor, .. } = &ops[0] else {
panic!("expected glyph run");
};
assert_eq!(*anchor, TextAnchor::Middle);
}
#[test]
fn paragraph_emits_wrapped_glyph_run() {
let mut root = crate::paragraph("This sentence should wrap in a narrow box.")
.width(Size::Fixed(120.0));
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 120.0, 120.0));
let ops = draw_ops(&root, &state);
let DrawOp::GlyphRun { wrap, .. } = &ops[0] else {
panic!("expected glyph run");
};
assert_eq!(*wrap, TextWrap::Wrap);
}
#[test]
fn opacity_multiplies_alpha_on_quad_uniforms() {
let mut root = button("X")
.fill(Color::rgba(200, 100, 50, 200))
.opacity(0.5);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let ops = draw_ops(&root, &state);
let DrawOp::Quad { uniforms, .. } = &ops[0] else {
panic!("expected quad op");
};
let UniformValue::Color(c) = uniforms.get("fill").expect("fill") else {
panic!("fill should be a colour");
};
assert_eq!(c.a, 100, "alpha should be halved by opacity 0.5");
}
#[test]
fn theme_can_route_implicit_surfaces_to_custom_shader() {
let mut root = button("X").primary();
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let theme = Theme::default()
.with_surface_shader("xp_surface")
.with_surface_uniform("theme_strength", UniformValue::F32(0.75));
let ops = draw_ops_with_theme(&root, &state, &theme);
let DrawOp::Quad {
shader, uniforms, ..
} = &ops[0]
else {
panic!("expected themed surface quad");
};
assert_eq!(*shader, ShaderHandle::Custom("xp_surface"));
assert_eq!(
uniforms.get("theme_strength"),
Some(&UniformValue::F32(0.75))
);
assert!(
matches!(uniforms.get("fill"), Some(UniformValue::Color(_))),
"familiar rounded-rect uniforms should stay available for manifests"
);
assert!(
matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
"custom surface shaders should also receive packed instance slots"
);
assert_eq!(
uniforms.get("vec_c"),
Some(&UniformValue::Vec4([
1.0,
tokens::RADIUS_MD,
tokens::SHADOW_SM * 0.5,
0.0
]))
);
}
#[test]
fn theme_can_route_surface_role_to_custom_shader() {
let mut root = crate::card("Panel", [crate::text("Body")])
.surface_role(SurfaceRole::Popover)
.key("panel");
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 240.0, 120.0));
let theme = Theme::default()
.with_role_shader(SurfaceRole::Popover, "popover_surface")
.with_role_uniform(SurfaceRole::Popover, "elevation", UniformValue::F32(2.0));
let ops = draw_ops_with_theme(&root, &state, &theme);
let DrawOp::Quad {
shader, uniforms, ..
} = &ops[0]
else {
panic!("expected themed surface quad");
};
assert_eq!(*shader, ShaderHandle::Custom("popover_surface"));
assert_eq!(uniforms.get("elevation"), Some(&UniformValue::F32(2.0)));
assert_eq!(
uniforms.get("surface_role"),
Some(&UniformValue::F32(SurfaceRole::Popover.uniform_id()))
);
assert!(
matches!(uniforms.get("vec_a"), Some(UniformValue::Color(_))),
"role-routed custom shaders should receive packed rect slots"
);
assert_eq!(
uniforms.get("vec_c"),
Some(&UniformValue::Vec4([
1.0,
tokens::RADIUS_LG,
tokens::SHADOW_LG,
0.0
]))
);
}
#[test]
fn translate_offsets_paint_rect_and_inherits_to_children() {
let mut root = column([button("X").key("x")]).translate(50.0, 30.0);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 400.0, 200.0));
let inner = inner_rect_quad_for(&root, &state, "x").expect("x quad inner_rect");
let untranslated = find_computed(&root, &state, "x").expect("x computed");
assert!((inner.x - (untranslated.x + 50.0)).abs() < 0.5);
assert!((inner.y - (untranslated.y + 30.0)).abs() < 0.5);
}
#[test]
fn scale_scales_rect_around_center() {
let mut root = column([button("X").key("x").scale(2.0).width(Size::Fixed(40.0))]);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 100.0));
let pre = find_computed(&root, &state, "x").expect("computed");
let post = inner_rect_quad_for(&root, &state, "x").expect("painted inner_rect");
assert!((post.w - pre.w * 2.0).abs() < 0.5);
assert!((post.h - pre.h * 2.0).abs() < 0.5);
let pre_cx = pre.center_x();
let post_cx = post.center_x();
assert!(
(pre_cx - post_cx).abs() < 0.5,
"centre should be preserved by scale-around-centre",
);
}
#[test]
fn shadow_auto_expands_painted_rect_around_inner_rect() {
let mut root = column([El::new(Kind::Group)
.key("c")
.fill(tokens::BG_CARD)
.radius(tokens::RADIUS_LG)
.shadow(tokens::SHADOW_MD)
.width(Size::Fixed(80.0))
.height(Size::Fixed(40.0))]);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
let ops = draw_ops(&root, &state);
let (painted, inner) = ops
.iter()
.find_map(|op| match op {
DrawOp::Quad {
id, rect, uniforms, ..
} if id.contains("c") => {
let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
return None;
};
Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
}
_ => None,
})
.expect("shadowed quad with inner_rect");
let blur = tokens::SHADOW_MD;
assert!(
(inner.x - painted.x - blur).abs() < 0.5,
"left halo == blur, painted.x={}, inner.x={}",
painted.x,
inner.x,
);
assert!(
(painted.right() - inner.right() - blur).abs() < 0.5,
"right halo == blur",
);
assert!(
(inner.y - painted.y - blur * 0.5).abs() < 0.5,
"top halo == blur * 0.5",
);
assert!(
(painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
"bottom halo == blur * 1.5",
);
}
#[test]
fn shadow_overflow_takes_per_side_max_with_explicit_paint_overflow() {
let combined = super::combined_overflow(crate::tree::Sides::all(8.0), tokens::SHADOW_MD);
assert!((combined.left - 12.0).abs() < f32::EPSILON);
assert!((combined.right - 12.0).abs() < f32::EPSILON);
assert!((combined.top - 8.0).abs() < f32::EPSILON);
assert!((combined.bottom - 18.0).abs() < f32::EPSILON);
}
#[test]
fn shadow_overflow_is_zero_when_shadow_is_zero() {
let combined = super::combined_overflow(crate::tree::Sides::zero(), 0.0);
assert_eq!(combined, crate::tree::Sides::zero());
}
#[test]
fn shadow_uniform_is_set_when_n_shadow_is_nonzero() {
let mut root = column([El::new(Kind::Group)
.key("c")
.fill(tokens::BG_CARD)
.radius(tokens::RADIUS_LG)
.shadow(tokens::SHADOW_MD)
.width(Size::Fixed(80.0))
.height(Size::Fixed(40.0))]);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
let ops = draw_ops(&root, &state);
let uniforms = ops
.iter()
.find_map(|op| match op {
DrawOp::Quad { id, uniforms, .. } if id.contains("c") => Some(uniforms.clone()),
_ => None,
})
.expect("shadowed quad");
assert_eq!(
uniforms.get("shadow"),
Some(&UniformValue::F32(tokens::SHADOW_MD)),
".shadow(SHADOW_MD) on a node without surface_role must reach the shader unchanged",
);
}
#[test]
fn theme_role_override_propagates_to_painted_rect() {
let mut root = column([crate::card("Card", [crate::text("Body")]).key("c")]);
let mut state = UiState::new();
crate::layout::layout(&mut root, &mut state, Rect::new(0.0, 0.0, 200.0, 200.0));
let ops = draw_ops(&root, &state);
let (painted, inner) = ops
.iter()
.find_map(|op| match op {
DrawOp::Quad {
id, rect, uniforms, ..
} if id.contains("c") => {
let UniformValue::Vec4(v) = uniforms.get("inner_rect")? else {
return None;
};
Some((*rect, Rect::new(v[0], v[1], v[2], v[3])))
}
_ => None,
})
.expect("card quad with inner_rect");
let blur = tokens::SHADOW_SM;
assert!(
(inner.x - painted.x - blur).abs() < 0.5,
"left halo == effective (theme-resolved) shadow, painted.x={}, inner.x={}",
painted.x,
inner.x,
);
assert!(
(painted.bottom() - inner.bottom() - blur * 1.5).abs() < 0.5,
"bottom halo == effective shadow * 1.5",
);
}
fn inner_rect_quad_for(root: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
use crate::shader::UniformValue;
let ops = draw_ops(root, ui_state);
for op in ops {
if let DrawOp::Quad {
id, rect, uniforms, ..
} = op
&& id.contains(key)
{
if let Some(UniformValue::Vec4(v)) = uniforms.get("inner_rect") {
return Some(Rect::new(v[0], v[1], v[2], v[3]));
}
return Some(rect);
}
}
None
}
fn find_computed(node: &El, ui_state: &UiState, key: &str) -> Option<Rect> {
if node.key.as_deref() == Some(key) {
return Some(ui_state.rect(&node.computed_id));
}
node.children
.iter()
.find_map(|c| find_computed(c, ui_state, key))
}
}