use kurbo::{Point, Rect};
use serde::Serialize;
use taffy::prelude::{AvailableSpace, NodeId, Size, TaffyTree};
use vello::Scene;
use crate::element::{Element, Kind, Overlay, OverlayMode, OverlayPlacement, PathData, Semantics};
use crate::frame_state::FrameState;
use crate::id::WidgetId;
use crate::input::{EditorState, InputPaint};
use crate::layout;
use crate::painter;
use crate::style::{AlignItems, Direction, Display, Overflow, Paint, Position, Style};
use crate::text::{Fonts, ResolvedText, resolve_text};
use crate::theme::Theme;
use crate::tokens::{FOCUS_RING, R_FULL};
const SCROLLBAR_WIDTH: f64 = 6.0;
const SCROLLBAR_INSET: f64 = 2.0;
const MIN_SCROLL_RANGE: f32 = 0.5;
enum MeasureCtx {
Text {
text: String,
style: ResolvedText,
},
Rich {
spans: Vec<crate::element::Span>,
style: ResolvedText,
},
Input {
text: String,
style: ResolvedText,
multiline: bool,
},
}
fn measure_input_height(
fonts: &mut Fonts,
text: &str,
style: &ResolvedText,
multiline: bool,
wrap: Option<f32>,
) -> f32 {
let line = (style.px * style.line_height).ceil();
if !multiline || text.is_empty() {
return line;
}
let measured: std::borrow::Cow<'_, str> = if text.ends_with('\n') {
std::borrow::Cow::Owned(format!("{text} "))
} else {
std::borrow::Cow::Borrowed(text)
};
let (_, h) = fonts.measure(&measured, style, wrap);
h.max(line)
}
const INPUT_DEFAULT_WIDTH: f32 = 220.0;
enum PaintKind {
Box,
Text {
text: String,
style: ResolvedText,
},
Rich {
spans: Vec<crate::element::Span>,
style: ResolvedText,
},
Path(PathData),
Input(InputPaint),
Image(crate::element::ImageData),
}
#[derive(Debug, Clone, Copy, Default)]
struct NodeMeta {
focusable: bool,
focus_ring: bool,
invalid: bool,
}
struct ScrollInfo {
offset: f32,
thumb: Option<Rect>,
alpha: f32,
can_scroll: bool,
}
struct FrameNode {
id: WidgetId,
kind: PaintKind,
style: Style,
rect: Rect,
visible: Option<Rect>,
scroll: Option<ScrollInfo>,
meta: NodeMeta,
spin: Option<f32>,
access: (
Option<Semantics>,
Option<String>,
Option<String>,
Option<String>,
),
live: bool,
selection: Option<(usize, usize)>,
source: &'static std::panic::Location<'static>,
children: Vec<FrameNode>,
}
#[derive(Debug, Clone)]
pub struct AccessNode {
pub id: WidgetId,
pub semantics: Option<Semantics>,
pub label: Option<String>,
pub value: Option<String>,
pub rect: Rect,
pub focusable: bool,
pub key: Option<String>,
pub live: bool,
pub selection: Option<(usize, usize)>,
pub children: Vec<AccessNode>,
}
pub(crate) enum StaticText<'a> {
Plain(&'a str),
Rich(&'a [crate::element::Span]),
}
impl StaticText<'_> {
pub(crate) fn to_text(&self) -> std::borrow::Cow<'_, str> {
match self {
Self::Plain(s) => std::borrow::Cow::Borrowed(s),
Self::Rich(spans) => {
std::borrow::Cow::Owned(spans.iter().map(|s| s.text.as_str()).collect())
}
}
}
}
struct OverlayFrame {
id: WidgetId,
mode: OverlayMode,
node: FrameNode,
progress: f32,
backdrop: bool,
trap_focus: bool,
hittable: bool,
}
pub struct Frame {
root: FrameNode,
overlays: Vec<OverlayFrame>,
overlay_anchors: std::collections::HashMap<WidgetId, (WidgetId, OverlayMode)>,
canvas: Rect,
scale: f64,
thumb_color: crate::Color,
ring_color: crate::Color,
ring_color_invalid: crate::Color,
selection_color: crate::Color,
pub animating: bool,
}
struct BuiltNode {
taffy: NodeId,
id: WidgetId,
kind: PaintKind,
style: Style,
focusable: bool,
disabled: bool,
invalid: bool,
spin: Option<f32>,
stick_bottom: bool,
access: (
Option<Semantics>,
Option<String>,
Option<String>,
Option<String>,
),
live: bool,
selection: Option<(usize, usize)>,
source: &'static std::panic::Location<'static>,
children: Vec<BuiltNode>,
}
fn solid_fill(style: &Style) -> Option<crate::Color> {
match &style.fill {
Some(Paint::Solid(c)) => Some(*c),
_ => None,
}
}
fn state_layer_opacity(state: &FrameState, id: WidgetId, draggable: bool) -> Option<f32> {
let sl = crate::tokens::STATE_LAYER;
let mut op = 0.0_f32;
if state.is_hovered(id) {
op = op.max(sl.hover);
}
if state.focus_visible && state.focused() == Some(id) {
op = op.max(sl.focus);
}
if state.is_active(id) {
op = op.max(sl.press);
if draggable && state.dragging.is_some() {
op = op.max(sl.drag);
}
}
(op > 0.0).then_some(op)
}
fn resolve<Msg>(
el: &Element<Msg>,
theme: &Theme,
state: &mut FrameState,
id: WidgetId,
) -> (Style, bool) {
let mut style = el.style.clone();
if let Some(f) = &el.themed {
style = f(theme, style);
}
if !el.disabled {
if state.is_hovered(id)
&& let Some(f) = &el.hover_style
{
style = f(theme, style);
}
if state.is_active(id)
&& let Some(f) = &el.active_style
{
style = f(theme, style);
}
if state.focused() == Some(id)
&& let Some(f) = &el.focus_style
{
style = f(theme, style);
}
}
if let Some(content_fn) = &el.state_layer {
let content = content_fn(theme);
if el.disabled {
let base = solid_fill(&style).unwrap_or(theme.surface);
let veil = content.with_alpha(crate::tokens::STATE_LAYER.disabled_container);
style.fill = Some(Paint::Solid(crate::anim::over(veil, base)));
style.border = None;
style.shadows.clear();
style.shadow_token = None;
style.highlight_top = None;
} else if let Some(op) = state_layer_opacity(state, id, el.drag_source.is_some()) {
match &style.fill {
Some(Paint::Solid(c)) => {
style.fill = Some(Paint::Solid(crate::anim::over(content.with_alpha(op), *c)));
}
None => style.fill = Some(Paint::Solid(content.with_alpha(op))),
Some(_) => {}
}
}
}
if el.press_scale && !el.disabled && state.is_active(id) {
style.scale = crate::tokens::PRESS_SCALE;
}
if !el.disabled
&& state.focus_visible
&& state.focused() == Some(id)
&& let Some(border) = style.border.as_mut()
{
border.color = if el.invalid {
theme.danger.solid
} else {
theme.accent
};
}
if let Some(token) = style.shadow_token {
let mut layers = theme.shadow(token);
layers.append(&mut style.shadows);
style.shadows = layers;
}
if matches!(el.kind, Kind::Divider) && style.fill.is_none() {
style.fill = Some(Paint::Solid(theme.border_subtle));
}
if style.text.color.is_none() {
style.text.color = Some(theme.text);
}
let mut animating = false;
let transition = match (el.transition, el.enter) {
(Some(t), _) => Some(t),
(None, Some(enter)) => Some(enter),
(None, None) => None,
};
let keyboard_driven = state.focus_visible && state.focused() == Some(id);
if let Some(transition) = transition
&& !state.reduced_motion
&& !keyboard_driven
{
let now = state.now();
let seen = state.frame_no;
let anim = state.anims.entry(id).or_insert_with(|| {
let seed = if el.enter.is_some() {
let mut from = style.clone();
from.opacity = 0.0;
from
} else {
style.clone()
};
crate::anim::Anim::new(seed, now, seen)
});
let (animated, running) = anim.advance(&style, transition, now, seen);
style = animated;
animating = running;
}
if let Some(kf) = &el.keyframes {
style = crate::anim::sample_keyframes(kf, theme, &style, state.now(), state.reduced_motion);
animating |= !state.reduced_motion && !kf.stops.is_empty();
}
(style, animating)
}
struct PendingOverlay {
anchor: WidgetId,
id: WidgetId,
def: Overlay,
path: Vec<usize>,
}
#[expect(
clippy::too_many_arguments,
reason = "internal recursion carries build context"
)]
fn build<Msg>(
el: &Element<Msg>,
theme: &Theme,
fonts: &mut Fonts,
tree: &mut TaffyTree<MeasureCtx>,
state: &mut FrameState,
animating: &mut bool,
id: WidgetId,
in_stack: bool,
path: &mut Vec<usize>,
pending: &mut Vec<PendingOverlay>,
viewport: f32,
) -> BuiltNode {
if el.autofocus && !el.disabled {
let newly = match state.autofocus_last {
Some((prev, seen)) => prev != id || seen + 1 < state.frame_no,
None => true,
};
state.autofocus_last = Some((id, state.frame_no));
if newly {
state.focus = Some(id);
state.focus_visible = false;
}
}
let (mut style, anim) = resolve(el, theme, state, id);
*animating |= anim;
if style.has_ch() {
let ch = fonts.ch_width(&resolve_text(&style.text, theme));
style.resolve_ch(ch);
}
let generated: Vec<Element<Msg>>;
let child_slice: &[Element<Msg>] = match &el.virtual_rows {
Some(v) => {
generated = expand_virtual(v, id, state, viewport);
&generated
}
None => &el.children,
};
let children: Vec<BuiltNode> = child_slice
.iter()
.enumerate()
.filter_map(|(i, c)| {
let child_id = id.child(i, c.key.as_deref());
if let Some(def) = c.overlay {
let mut overlay_path = path.clone();
overlay_path.push(i);
pending.push(PendingOverlay {
anchor: id,
id: child_id,
def,
path: overlay_path,
});
return None;
}
path.push(i);
let node = build(
c, theme, fonts, tree, state, animating, child_id, el.stack, path, pending,
viewport,
);
path.pop();
Some(node)
})
.collect();
let taffy_style = layout::to_taffy(&style, in_stack);
let (taffy, kind) = match &el.kind {
Kind::Text(content) => {
let resolved = resolve_text(&style.text, theme);
let ctx = MeasureCtx::Text {
text: content.clone(),
style: resolved,
};
(
tree.new_leaf_with_context(taffy_style, ctx)
.expect("taffy new_leaf_with_context"),
PaintKind::Text {
text: content.clone(),
style: resolved,
},
)
}
Kind::Rich(spans) => {
let resolved = resolve_text(&style.text, theme);
let ctx = MeasureCtx::Rich {
spans: spans.clone(),
style: resolved,
};
(
tree.new_leaf_with_context(taffy_style, ctx)
.expect("taffy new_leaf_with_context"),
PaintKind::Rich {
spans: spans.clone(),
style: resolved,
},
)
}
Kind::Input(data) => {
let resolved = resolve_text(&style.text, theme);
let now = state.now();
let frame_no = state.frame_no;
let editor = state
.editors
.entry(id)
.or_insert_with(|| EditorState::new(&resolved, now, data.multiline));
editor.sync(&data.value, &resolved);
editor.multiline = data.multiline;
editor.seen = frame_no;
let focused = state.focused() == Some(id);
if focused && !state.reduced_motion {
*animating = true;
}
(
tree.new_leaf_with_context(
taffy_style,
MeasureCtx::Input {
text: data.value.clone(),
style: resolved,
multiline: data.multiline,
},
)
.expect("taffy new_leaf_with_context"),
PaintKind::Input(InputPaint {
placeholder: data.placeholder.clone(),
style: resolved,
placeholder_color: theme.text_subtle,
caret_color: theme.accent,
selection_color: theme.accent.with_alpha(0.25),
focused,
pad_x: f64::from(style.padding.left),
pad_y: f64::from(style.padding.top),
multiline: data.multiline,
}),
)
}
Kind::Path(data) => (
tree.new_leaf(taffy_style).expect("taffy new_leaf"),
PaintKind::Path(data.clone()),
),
Kind::Image(data) => (
tree.new_leaf(taffy_style).expect("taffy new_leaf"),
PaintKind::Image(data.clone()),
),
Kind::Box | Kind::Divider => {
let node = if children.is_empty() {
tree.new_leaf(taffy_style).expect("taffy new_leaf")
} else {
let ids: Vec<NodeId> = children.iter().map(|c| c.taffy).collect();
tree.new_with_children(taffy_style, &ids)
.expect("taffy new_with_children")
};
(node, PaintKind::Box)
}
};
if el.spin.is_some() && !state.reduced_motion {
*animating = true;
}
let semantics = el.semantics.or(match &el.kind {
Kind::Text(_) | Kind::Rich(_) => Some(Semantics::Label),
Kind::Image(_) => Some(Semantics::Image),
Kind::Input(data) => Some(Semantics::TextInput {
multiline: data.multiline,
}),
Kind::Box | Kind::Divider | Kind::Path(_) => None,
});
let label = el.label.clone().or(match &el.kind {
Kind::Text(content) => Some(content.clone()),
Kind::Rich(spans) => Some(spans.iter().map(|s| s.text.as_str()).collect()),
_ => None,
});
let value = match &el.kind {
Kind::Input(data) => Some(data.value.clone()),
_ => None,
};
BuiltNode {
taffy,
id,
kind,
style,
focusable: el.focusable,
disabled: el.disabled,
invalid: el.invalid,
spin: el.spin,
stick_bottom: el.stick_bottom,
access: (semantics, label, value, el.key.clone()),
live: el.live,
selection: match &el.kind {
Kind::Input(_) => state.editors.get(&id).map(|editor| {
let range = editor.editor.raw_selection().text_range();
(range.start, range.end)
}),
Kind::Text(_) | Kind::Rich(_) if el.selectable => state
.static_sel
.filter(|(sid, ..)| *sid == id)
.map(|(_, sel, _)| {
let range = sel.text_range();
(range.start, range.end)
}),
_ => None,
},
source: el.source,
children,
}
}
pub(crate) fn virtual_window(
count: usize,
row_height: f32,
offset: f32,
viewport: f32,
) -> std::ops::Range<usize> {
const OVERSCAN: usize = 8;
if count == 0 || row_height <= 0.0 || row_height.is_nan() || !viewport.is_finite() {
return 0..0;
}
#[expect(clippy::cast_precision_loss, reason = "row counts fit in f32")]
let max_offset = (count as f32 * row_height - viewport.max(0.0)).max(0.0);
let offset = offset.max(0.0).min(max_offset);
#[expect(clippy::cast_possible_truncation, reason = "row indices fit in usize")]
#[expect(clippy::cast_sign_loss, reason = "clamped non-negative above")]
let first = (offset / row_height).floor() as usize;
#[expect(clippy::cast_possible_truncation, reason = "row indices fit in usize")]
#[expect(clippy::cast_sign_loss, reason = "clamped non-negative above")]
let last = ((offset + viewport.max(0.0)) / row_height).ceil() as usize;
first.saturating_sub(OVERSCAN).min(count)..last.saturating_add(OVERSCAN).min(count)
}
pub(crate) fn materialize_virtual_row<Msg>(
v: &crate::element::VirtualData<Msg>,
i: usize,
) -> Element<Msg> {
let mut row = (v.builder)(i);
if row.key.is_none() {
row = row.id(&format!("v{i}"));
}
row.h(v.row_height).shrink0()
}
fn expand_virtual<Msg>(
v: &crate::element::VirtualData<Msg>,
id: WidgetId,
state: &mut FrameState,
viewport: f32,
) -> Vec<Element<Msg>> {
let offset = state.scroll_offset(id);
if v.variable {
const OVERSCAN: usize = 8;
let index = state
.virtual_heights
.entry(id)
.or_insert_with(|| crate::frame_state::HeightIndex::new_with(v.count, v.row_height));
index.ensure(v.count, v.row_height);
let first = index.index_at(offset).saturating_sub(OVERSCAN);
let last = (index.index_at(offset + viewport.max(0.0)) + 1 + OVERSCAN).min(v.count);
let window = first..last;
let top = index.offset_of(window.start);
let bottom = (index.total() - index.offset_of(window.end)).max(0.0);
state.virtual_windows.insert(id, window.clone());
let mut out = Vec::with_capacity(window.len() + 2);
out.push(crate::element::div().h(top).w_full().shrink0());
for i in window {
let mut row = (v.builder)(i);
if row.key.is_none() {
row = row.id(&format!("v{i}"));
}
out.push(row.shrink0());
}
out.push(crate::element::div().h(bottom).w_full().shrink0());
return out;
}
let window = virtual_window(v.count, v.row_height, offset, viewport);
let mut out = Vec::with_capacity(window.len() + 2);
#[expect(clippy::cast_precision_loss, reason = "row counts fit in f32")]
let top = window.start as f32 * v.row_height;
#[expect(clippy::cast_precision_loss, reason = "row counts fit in f32")]
let bottom = (v.count - window.end) as f32 * v.row_height;
out.push(crate::element::div().h(top).w_full().shrink0());
for i in window {
out.push(materialize_virtual_row(v, i));
}
out.push(crate::element::div().h(bottom).w_full().shrink0());
out
}
fn wrap_width(known: Option<f32>, available: AvailableSpace) -> Option<f32> {
known
.or(match available {
AvailableSpace::Definite(w) => Some(w),
AvailableSpace::MaxContent => None,
AvailableSpace::MinContent => Some(0.0),
})
.filter(|w| w.is_finite())
}
fn child_baseline(fonts: &mut Fonts, tree: &TaffyTree<MeasureCtx>, node: &BuiltNode) -> f64 {
let l = tree.layout(node.taffy).expect("taffy layout");
match &node.kind {
PaintKind::Text { text, style } => {
f64::from(fonts.first_baseline(text, style, Some(l.size.width)))
}
PaintKind::Rich { spans, style } => {
f64::from(fonts.first_baseline_rich(spans, style, Some(l.size.width)))
}
PaintKind::Box | PaintKind::Path(_) | PaintKind::Input(_) | PaintKind::Image(_) => {
f64::from(l.size.height)
}
}
}
struct Realize<'a> {
tree: &'a TaffyTree<MeasureCtx>,
fonts: &'a mut Fonts,
state: &'a mut FrameState,
animating: bool,
}
impl Realize<'_> {
fn realize(&mut self, node: BuiltNode, origin: Point, visible: Option<Rect>) -> FrameNode {
let l = self.tree.layout(node.taffy).expect("taffy layout");
let x = origin.x + f64::from(l.location.x);
let y = origin.y + f64::from(l.location.y);
let rect = Rect::new(
x,
y,
x + f64::from(l.size.width),
y + f64::from(l.size.height),
);
let scroll = (node.style.overflow_y == Overflow::Scroll).then(|| {
let max = (l.content_size.height - l.size.height).max(0.0);
let offset = if max >= MIN_SCROLL_RANGE {
self.state.clamp_scroll(node.id, max, node.stick_bottom)
} else {
self.state.clamp_scroll(node.id, 0.0, node.stick_bottom)
};
let alpha = if max >= MIN_SCROLL_RANGE {
self.state.scrollbar_alpha(node.id)
} else {
0.0
};
self.animating |= self.state.scrollbar_animating(node.id);
let thumb = (alpha > 0.0 && max >= MIN_SCROLL_RANGE).then(|| {
let track_h = rect.height() - 2.0 * SCROLLBAR_INSET;
let content_h = f64::from(l.content_size.height);
let thumb_h = (track_h * rect.height() / content_h).max(24.0).min(track_h);
let denom = f64::from(max);
let t = if denom > 0.0 {
f64::from(offset) / denom
} else {
0.0
};
let thumb_y = rect.y0 + SCROLLBAR_INSET + t * (track_h - thumb_h);
Rect::new(
rect.x1 - SCROLLBAR_INSET - SCROLLBAR_WIDTH,
thumb_y,
rect.x1 - SCROLLBAR_INSET,
thumb_y + thumb_h,
)
});
ScrollInfo {
offset,
thumb,
alpha,
can_scroll: max >= MIN_SCROLL_RANGE,
}
});
let child_visible = if node.style.clip {
Some(visible.map_or(rect, |v| v.intersect(rect)))
} else {
visible
};
let scroll_dy = scroll.as_ref().map_or(0.0, |s| f64::from(s.offset));
let child_origin = Point::new(x, y - scroll_dy);
let baseline_offsets: Option<Vec<f64>> = (node.style.display == Display::Flex
&& node.style.direction == Direction::Row
&& node.style.align_items == AlignItems::Baseline)
.then(|| {
let baselines: Vec<f64> = node
.children
.iter()
.map(|c| {
if c.style.position == Position::Absolute {
0.0
} else {
child_baseline(self.fonts, self.tree, c)
}
})
.collect();
let target = baselines.iter().copied().fold(0.0, f64::max);
baselines
.iter()
.zip(&node.children)
.map(|(b, c)| {
if c.style.position == Position::Absolute {
0.0
} else {
target - b
}
})
.collect()
});
let virtual_window = self.state.virtual_windows.get(&node.id).cloned();
let children: Vec<FrameNode> = node
.children
.into_iter()
.enumerate()
.map(|(i, child)| {
let dy = baseline_offsets.as_ref().map_or(0.0, |o| o[i]);
self.realize(
child,
Point::new(child_origin.x, child_origin.y + dy),
child_visible,
)
})
.collect();
if let Some(window) = virtual_window
&& let Some(index) = self.state.virtual_heights.get_mut(&node.id)
{
for (row, child) in window.zip(children.iter().skip(1)) {
#[expect(clippy::cast_possible_truncation, reason = "row heights fit in f32")]
index.record(row, child.rect.height() as f32);
}
}
let meta = NodeMeta {
focusable: node.focusable && !node.disabled,
focus_ring: node.focusable
&& !node.disabled
&& self.state.focused() == Some(node.id)
&& self.state.focus_visible,
invalid: node.invalid,
};
FrameNode {
id: node.id,
kind: node.kind,
style: node.style,
rect,
visible,
scroll,
meta,
spin: node.spin,
access: node.access,
live: node.live,
selection: node.selection,
source: node.source,
children,
}
}
}
pub fn build_frame<Msg>(
root: &Element<Msg>,
theme: &Theme,
fonts: &mut Fonts,
state: &mut FrameState,
size: (f32, f32),
scale: f64,
) -> Frame {
state.virtual_windows.clear();
let mut tree: TaffyTree<MeasureCtx> = TaffyTree::new();
state.frame_no += 1;
let mut transitions_running = false;
let mut path = Vec::new();
let mut pending = Vec::new();
let mut node = build(
root,
theme,
fonts,
&mut tree,
state,
&mut transitions_running,
WidgetId::ROOT,
false,
&mut path,
&mut pending,
size.1,
);
if root.style.width == crate::style::Length::Auto {
node.style.width = crate::style::Length::Px(size.0);
}
if root.style.height == crate::style::Length::Auto {
node.style.height = crate::style::Length::Px(size.1);
}
tree.set_style(node.taffy, layout::to_taffy(&node.style, false))
.expect("taffy set_style");
tree.compute_layout_with_measure(
node.taffy,
Size {
width: AvailableSpace::Definite(size.0),
height: AvailableSpace::Definite(size.1),
},
|known, available, _id, ctx, _style| match ctx {
Some(MeasureCtx::Text { text, style }) => {
let (w, h) = fonts.measure(text, style, wrap_width(known.width, available.width));
Size {
width: known.width.unwrap_or(w),
height: known.height.unwrap_or(h),
}
}
Some(MeasureCtx::Rich { spans, style }) => {
let (w, h) =
fonts.measure_rich(spans, style, wrap_width(known.width, available.width));
Size {
width: known.width.unwrap_or(w),
height: known.height.unwrap_or(h),
}
}
Some(MeasureCtx::Input {
text,
style,
multiline,
}) => Size {
width: known.width.unwrap_or(INPUT_DEFAULT_WIDTH),
height: known.height.unwrap_or_else(|| {
measure_input_height(
fonts,
text,
style,
*multiline,
wrap_width(known.width, available.width),
)
}),
},
None => Size::ZERO,
},
)
.expect("taffy compute_layout");
let mut realize = Realize {
tree: &tree,
fonts,
state,
animating: false,
};
let root_node = realize.realize(node, Point::ORIGIN, None);
let mut animating = realize.animating || transitions_running;
let canvas = Rect::new(0.0, 0.0, f64::from(size.0), f64::from(size.1));
let mut overlay_anchors = std::collections::HashMap::new();
let mut overlays: Vec<OverlayFrame> = Vec::new();
let mut queue = pending;
let mut present: Vec<WidgetId> = Vec::new();
while !queue.is_empty() {
let batch = std::mem::take(&mut queue);
for p in batch {
present.push(p.id);
overlay_anchors.insert(p.anchor, (p.id, p.def.mode));
let open = match p.def.mode {
OverlayMode::Open => {
state.open_overlay(p.id);
true
}
OverlayMode::Toggle => state.overlay_open(p.id),
OverlayMode::Hover { delay_ms } => {
match state.hovered_for(p.anchor) {
Some(t) if t >= f64::from(delay_ms) / 1000.0 => true,
Some(_) => {
animating = true;
false
}
None => false,
}
}
};
if !open {
continue;
}
let Some(el) = element_at(root, &p.path) else {
continue;
};
let anchor_rect = rect_in(&root_node, p.anchor)
.or_else(|| overlays.iter().find_map(|o| rect_in(&o.node, p.anchor)))
.unwrap_or(canvas);
let mut opath = Vec::new();
let mut nested = Vec::new();
let built = build(
el,
theme,
fonts,
&mut tree,
state,
&mut animating,
p.id,
false,
&mut opath,
&mut nested,
size.1,
);
for mut q in nested {
let mut full = p.path.clone();
full.extend(q.path.iter());
q.path = full;
queue.push(q);
}
tree.compute_layout_with_measure(
built.taffy,
Size {
width: AvailableSpace::Definite(size.0),
height: AvailableSpace::Definite(size.1),
},
|known, available, _id, ctx, _style| match ctx {
Some(MeasureCtx::Text { text, style }) => {
let (w, h) =
fonts.measure(text, style, wrap_width(known.width, available.width));
Size {
width: known.width.unwrap_or(w),
height: known.height.unwrap_or(h),
}
}
Some(MeasureCtx::Rich { spans, style }) => {
let (w, h) = fonts.measure_rich(
spans,
style,
wrap_width(known.width, available.width),
);
Size {
width: known.width.unwrap_or(w),
height: known.height.unwrap_or(h),
}
}
Some(MeasureCtx::Input {
text,
style,
multiline,
}) => Size {
width: known.width.unwrap_or(INPUT_DEFAULT_WIDTH),
height: known.height.unwrap_or_else(|| {
measure_input_height(
fonts,
text,
style,
*multiline,
wrap_width(known.width, available.width),
)
}),
},
None => Size::ZERO,
},
)
.expect("taffy compute_layout (overlay)");
let measured = tree.layout(built.taffy).expect("overlay layout").size;
let (w, h) = (f64::from(measured.width), f64::from(measured.height));
let progress = if state.reduced_motion {
1.0
} else {
let opened = state.overlay_opened.get(&p.id).copied().unwrap_or(0.0);
let t = ((state.now() - opened) / 0.2).clamp(0.0, 1.0);
#[expect(clippy::cast_possible_truncation, reason = "progress is 0..=1")]
{
crate::tokens::EASE_STANDARD.eval(t as f32)
}
};
if progress < 1.0 {
animating = true;
}
let state_pointer = state.pointer;
let origin = match p.def.placement {
OverlayPlacement::Below { gap } => {
let gap = f64::from(gap);
let y = if anchor_rect.y1 + gap + h <= canvas.y1
|| anchor_rect.y0 - gap - h < canvas.y0
{
anchor_rect.y1 + gap
} else {
anchor_rect.y0 - gap - h
};
Point::new(anchor_rect.x0.clamp(canvas.x0, (canvas.x1 - w).max(0.0)), y)
}
OverlayPlacement::BelowCenter { gap } => {
let gap = f64::from(gap);
let x = anchor_rect.x0 + (anchor_rect.width() - w) * 0.5;
let y = if anchor_rect.y1 + gap + h <= canvas.y1
|| anchor_rect.y0 - gap - h < canvas.y0
{
anchor_rect.y1 + gap
} else {
anchor_rect.y0 - gap - h
};
Point::new(x.clamp(canvas.x0, (canvas.x1 - w).max(0.0)), y)
}
OverlayPlacement::TopRight { margin } => {
let m = f64::from(margin);
Point::new((canvas.x1 - w - m).max(canvas.x0), canvas.y0 + m)
}
OverlayPlacement::Pointer { gap } => {
let fallback = (
#[expect(clippy::cast_possible_truncation, reason = "logical px")]
{
anchor_rect.x0 as f32
},
#[expect(clippy::cast_possible_truncation, reason = "logical px")]
{
anchor_rect.y1 as f32
},
);
let (px, py) = *state
.pointer_pins
.entry(p.id)
.or_insert_with(|| state_pointer.unwrap_or(fallback));
let g = f64::from(gap);
Point::new(
(f64::from(px) + g).clamp(canvas.x0, (canvas.x1 - w).max(canvas.x0)),
(f64::from(py) + g).clamp(canvas.y0, (canvas.y1 - h).max(canvas.y0)),
)
}
OverlayPlacement::Center => {
let dy = 8.0 * (1.0 - f64::from(progress));
Point::new(
canvas.x0 + (canvas.width() - w) * 0.5,
canvas.y0 + (canvas.height() - h) * 0.5 + dy,
)
}
};
let mut orealize = Realize {
tree: &tree,
fonts,
state,
animating: false,
};
let onode = orealize.realize(built, origin, None);
animating |= orealize.animating;
overlays.push(OverlayFrame {
id: p.id,
mode: p.def.mode,
node: onode,
progress,
backdrop: p.def.backdrop,
trap_focus: p.def.trap_focus,
hittable: !matches!(p.def.mode, OverlayMode::Hover { .. }),
});
}
}
let stale: Vec<WidgetId> = state
.overlays
.iter()
.copied()
.filter(|id| !present.contains(id))
.collect();
for id in stale {
state.close_overlay(id);
}
overlays.sort_by_key(|o| {
state
.overlays
.iter()
.position(|id| *id == o.id)
.unwrap_or(usize::MAX)
});
let frame_no = state.frame_no;
state.anims.retain(|_, a| a.seen == frame_no);
state.editors.retain(|_, e| e.seen == frame_no);
state.gc_scroll(frame_no);
Frame {
root: root_node,
overlays,
overlay_anchors,
canvas,
scale,
thumb_color: theme.text_subtle,
ring_color: theme.accent.with_alpha(FOCUS_RING.alpha),
ring_color_invalid: theme.danger.solid.with_alpha(FOCUS_RING.alpha),
selection_color: theme.accent.with_alpha(0.25),
animating,
}
}
fn element_at<'a, Msg>(root: &'a Element<Msg>, path: &[usize]) -> Option<&'a Element<Msg>> {
let mut el = root;
for &i in path {
el = el.children.get(i)?;
}
Some(el)
}
fn rect_in(node: &FrameNode, id: WidgetId) -> Option<Rect> {
if node.id == id {
return Some(node.rect);
}
node.children.iter().find_map(|c| rect_in(c, id))
}
pub fn build_scene<Msg>(
root: &Element<Msg>,
theme: &Theme,
fonts: &mut Fonts,
size: (f32, f32),
) -> Scene {
let mut state = FrameState::new();
state.reduced_motion = true;
let frame = build_frame(root, theme, fonts, &mut state, size, 1.0);
frame.paint(fonts, &mut state)
}
impl Frame {
pub fn paint(&self, fonts: &mut Fonts, state: &mut FrameState) -> Scene {
state.ime_caret = None;
let mut scene = Scene::new();
self.paint_node(&mut scene, fonts, state, &self.root);
for overlay in &self.overlays {
if overlay.backdrop {
let alpha = 0.4 * overlay.progress;
scene.fill(
peniko::Fill::NonZero,
kurbo::Affine::IDENTITY,
crate::Color::new([0.0, 0.0, 0.0, alpha]),
None,
&self.canvas,
);
}
let faded = overlay.progress < 1.0;
if faded {
scene.push_layer(
peniko::Fill::NonZero,
peniko::Mix::Normal,
overlay.progress,
kurbo::Affine::IDENTITY,
&self.canvas,
);
}
self.paint_node(&mut scene, fonts, state, &overlay.node);
if faded {
scene.pop_layer();
}
}
scene
}
fn paint_node(
&self,
scene: &mut Scene,
fonts: &mut Fonts,
state: &mut FrameState,
node: &FrameNode,
) {
if node.style.display == Display::None {
return;
}
if (node.style.scale - 1.0).abs() > 1e-4 {
let mut sub = Scene::new();
self.paint_node_unscaled(&mut sub, fonts, state, node);
let transform =
kurbo::Affine::scale_about(f64::from(node.style.scale), node.rect.center());
scene.append(&sub, Some(transform));
return;
}
self.paint_node_unscaled(scene, fonts, state, node);
}
fn paint_node_unscaled(
&self,
scene: &mut Scene,
fonts: &mut Fonts,
state: &mut FrameState,
node: &FrameNode,
) {
let layers = painter::push_box(scene, &node.style, node.rect, self.canvas, self.scale);
if node.meta.focus_ring {
let ring = if node.meta.invalid {
self.ring_color_invalid
} else {
self.ring_color
};
painter::focus_ring(scene, node.rect, node.style.corner_radius, ring);
}
match &node.kind {
PaintKind::Text { text, style } => {
let selection = state
.static_sel
.filter(|(sid, ..)| *sid == node.id)
.map(|(_, sel, _)| (sel, self.selection_color));
fonts.paint(scene, text, style, node.rect, selection);
}
PaintKind::Rich { spans, style } => {
let selection = state
.static_sel
.filter(|(sid, ..)| *sid == node.id)
.map(|(_, sel, _)| (sel, self.selection_color));
fonts.paint_rich(scene, spans, style, node.rect, selection);
}
PaintKind::Path(data) => {
let color = node.style.text.color.unwrap_or(self.thumb_color);
let rotation = node
.spin
.filter(|_| !state.reduced_motion)
.map_or(0.0, |p| {
let period = f64::from(p.max(1.0)) / 1000.0;
(state.now() % period) / period * std::f64::consts::TAU
});
painter::draw_path_rotated(
scene,
data,
node.style.path_trim,
color,
node.rect,
rotation,
);
}
PaintKind::Input(data) => {
let now = state.now();
let reduced = state.reduced_motion;
if let Some(editor) = state.editors.get_mut(&node.id) {
let caret =
crate::input::paint(scene, fonts, editor, data, node.rect, now, reduced);
if caret.is_some() {
state.ime_caret = caret;
}
}
}
PaintKind::Image(data) => {
painter::draw_image(scene, &data.image, node.rect, node.style.corner_radius);
}
PaintKind::Box => {}
}
for child in &node.children {
self.paint_node(scene, fonts, state, child);
}
if let Some(scroll) = &node.scroll
&& let Some(thumb) = scroll.thumb
{
let color = self.thumb_color.multiply_alpha(scroll.alpha * 0.6);
painter::fill_rounded(scene, thumb, R_FULL, color);
}
painter::pop_box(scene, layers);
}
pub(crate) fn canvas_height(&self) -> f32 {
#[expect(clippy::cast_possible_truncation, reason = "canvas sizes fit in f32")]
{
self.canvas.height() as f32
}
}
pub fn access_tree(&self) -> AccessNode {
fn project(node: &FrameNode) -> AccessNode {
let (semantics, label, value, key) = node.access.clone();
AccessNode {
id: node.id,
semantics,
label,
value,
rect: node.rect,
focusable: node.meta.focusable,
key,
live: node.live,
selection: node.selection,
children: node.children.iter().map(project).collect(),
}
}
let mut root = project(&self.root);
for overlay in &self.overlays {
root.children.push(project(&overlay.node));
}
root
}
pub fn debug_tree(&self) -> String {
fn fmt_rect(rect: Rect) -> String {
format!(
"({:.0},{:.0} {:.0}x{:.0})",
rect.x0,
rect.y0,
rect.width(),
rect.height()
)
}
fn emit(node: &FrameNode, depth: usize, tag: &str, out: &mut String) {
let kind = match &node.kind {
PaintKind::Box => "box",
PaintKind::Text { .. } => "text",
PaintKind::Rich { .. } => "richtext",
PaintKind::Path(_) => "path",
PaintKind::Input(_) => "input",
PaintKind::Image(_) => "image",
};
out.push_str(&" ".repeat(depth));
out.push_str(kind);
let (semantics, label, value, key) = &node.access;
if let Some(key) = key {
out.push_str(&format!(" #{key}"));
}
out.push(' ');
out.push_str(&fmt_rect(node.rect));
if !tag.is_empty() {
out.push_str(&format!(" {tag}"));
}
if node.scroll.is_some() {
out.push_str(" scroll");
}
if node.meta.focusable {
out.push_str(" focusable");
}
if let Some(semantics) = semantics {
out.push_str(&format!(" {}", crate::query::role_name(semantics)));
}
if let Some(label) = label {
out.push_str(&format!(" {label:?}"));
}
if let Some(value) = value {
out.push_str(&format!(" value={value:?}"));
}
out.push_str(&format!(
" src={}:{}",
node.source.file().replace('\\', "/"),
node.source.line()
));
out.push('\n');
for child in &node.children {
emit(child, depth + 1, "", out);
}
}
let mut out = String::new();
emit(&self.root, 0, "", &mut out);
for overlay in &self.overlays {
emit(&overlay.node, 0, "overlay", &mut out);
}
out
}
pub(crate) fn static_text_of(&self, id: WidgetId) -> Option<(StaticText<'_>, &ResolvedText)> {
fn find(node: &FrameNode, id: WidgetId) -> Option<(StaticText<'_>, &ResolvedText)> {
if node.id == id {
return match &node.kind {
PaintKind::Text { text, style } => Some((StaticText::Plain(text), style)),
PaintKind::Rich { spans, style } => Some((StaticText::Rich(spans), style)),
_ => None,
};
}
node.children.iter().find_map(|c| find(c, id))
}
find(&self.root, id).or_else(|| {
self.overlays
.iter()
.find_map(|overlay| find(&overlay.node, id))
})
}
pub(crate) fn scroll_target_for(&self, focus: Option<WidgetId>) -> Option<(WidgetId, Rect)> {
fn path_scrollables(
node: &FrameNode,
id: WidgetId,
out: &mut Vec<(WidgetId, Rect)>,
) -> bool {
let here = node
.scroll
.as_ref()
.is_some_and(|s| s.can_scroll)
.then_some((node.id, node.rect));
if let Some(h) = here {
out.push(h);
}
if node.id == id {
return true;
}
for child in &node.children {
if path_scrollables(child, id, out) {
return true;
}
}
if here.is_some() {
out.pop();
}
false
}
fn first_scrollable(node: &FrameNode) -> Option<(WidgetId, Rect)> {
if node.scroll.as_ref().is_some_and(|s| s.can_scroll) {
return Some((node.id, node.rect));
}
node.children.iter().find_map(first_scrollable)
}
if let Some(focus) = focus {
let mut path = Vec::new();
let found = path_scrollables(&self.root, focus, &mut path)
|| self.overlays.iter().any(|o| {
path.clear();
path_scrollables(&o.node, focus, &mut path)
});
if found && let Some(last) = path.last() {
return Some(*last);
}
}
first_scrollable(&self.root)
.or_else(|| self.overlays.iter().find_map(|o| first_scrollable(&o.node)))
}
pub fn scrollable_at(&self, point: Point) -> Option<WidgetId> {
fn walk(node: &FrameNode, point: Point) -> Option<WidgetId> {
if node.style.display == Display::None {
return None;
}
if let Some(v) = node.visible
&& !v.contains(point)
{
return None;
}
if !node.rect.contains(point) && node.style.clip {
return None;
}
for child in node.children.iter().rev() {
if let Some(id) = walk(child, point) {
return Some(id);
}
}
(node.scroll.as_ref().is_some_and(|s| s.can_scroll) && node.rect.contains(point))
.then_some(node.id)
}
walk(&self.root, point)
}
pub fn hit_chain(&self, point: Point) -> Vec<WidgetId> {
for overlay in self.overlays.iter().rev() {
if !overlay.hittable {
continue;
}
let mut chain = Vec::new();
if Self::walk_hit(&overlay.node, point, &mut chain) {
return chain;
}
if overlay.backdrop {
return Vec::new();
}
}
let mut chain = Vec::new();
Self::walk_hit(&self.root, point, &mut chain);
chain
}
fn walk_hit(node: &FrameNode, point: Point, out: &mut Vec<WidgetId>) -> bool {
if node.style.display == Display::None {
return false;
}
if let Some(v) = node.visible
&& !v.contains(point)
{
return false;
}
let inside = node.rect.contains(point);
if node.style.clip && !inside {
return false;
}
let mark = out.len();
if inside {
out.push(node.id);
}
for child in node.children.iter().rev() {
if Self::walk_hit(child, point, out) {
return true;
}
}
if inside {
true
} else {
out.truncate(mark);
false
}
}
pub fn rect_of(&self, id: WidgetId) -> Option<Rect> {
rect_in(&self.root, id).or_else(|| self.overlays.iter().find_map(|o| rect_in(&o.node, id)))
}
pub fn toggle_overlay_of(&self, anchor: WidgetId) -> Option<WidgetId> {
match self.overlay_anchors.get(&anchor) {
Some((id, OverlayMode::Toggle)) => Some(*id),
_ => None,
}
}
pub fn overlay_containing(&self, id: WidgetId) -> Option<WidgetId> {
fn contains(node: &FrameNode, id: WidgetId) -> bool {
node.id == id || node.children.iter().any(|c| contains(c, id))
}
self.overlays
.iter()
.find(|o| contains(&o.node, id))
.map(|o| o.id)
}
pub fn open_overlays_top_down(&self) -> Vec<(WidgetId, OverlayMode)> {
self.overlays.iter().rev().map(|o| (o.id, o.mode)).collect()
}
pub fn top_overlay_is_modal(&self) -> Option<WidgetId> {
self.overlays
.iter()
.next_back()
.filter(|o| o.backdrop)
.map(|o| o.id)
}
pub fn fraction_in(&self, id: WidgetId, point: Point) -> Option<(f32, f32)> {
let rect = self.rect_of(id)?;
if rect.width() <= 0.0 || rect.height() <= 0.0 {
return None;
}
#[expect(clippy::cast_possible_truncation, reason = "fractions are 0..=1")]
Some((
(((point.x - rect.x0) / rect.width()).clamp(0.0, 1.0)) as f32,
(((point.y - rect.y0) / rect.height()).clamp(0.0, 1.0)) as f32,
))
}
pub fn focusables(&self) -> Vec<WidgetId> {
fn walk(node: &FrameNode, out: &mut Vec<WidgetId>) {
if node.style.display == Display::None {
return;
}
if node.meta.focusable {
out.push(node.id);
}
for child in &node.children {
walk(child, out);
}
}
let mut out = Vec::new();
if let Some(trap) = self.overlays.iter().rev().find(|o| o.trap_focus) {
walk(&trap.node, &mut out);
return out;
}
walk(&self.root, &mut out);
for overlay in &self.overlays {
if overlay.hittable {
walk(&overlay.node, &mut out);
}
}
out
}
pub fn is_scrollable(&self, id: WidgetId) -> bool {
fn walk(node: &FrameNode, id: WidgetId) -> bool {
(node.id == id && node.scroll.as_ref().is_some_and(|s| s.can_scroll))
|| node.children.iter().any(|c| walk(c, id))
}
walk(&self.root, id)
}
pub fn dump(&self) -> String {
let dump = NodeDump::from_node(&self.root);
serde_json::to_string_pretty(&dump).expect("layout dump serializes")
}
}
#[derive(Serialize)]
struct NodeDump {
id: u64,
kind: &'static str,
rect: [f32; 4],
#[serde(skip_serializing_if = "Option::is_none")]
text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
fill: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
scroll_offset: Option<f32>,
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<NodeDump>,
}
impl NodeDump {
fn from_node(node: &FrameNode) -> Self {
#[expect(clippy::cast_possible_truncation, reason = "logical px fit in f32")]
let rect = [
node.rect.x0 as f32,
node.rect.y0 as f32,
node.rect.width() as f32,
node.rect.height() as f32,
];
Self {
id: node.id.0,
kind: match &node.kind {
PaintKind::Box => "box",
PaintKind::Text { .. } => "text",
PaintKind::Rich { .. } => "richtext",
PaintKind::Path(_) => "path",
PaintKind::Input(_) => "input",
PaintKind::Image(_) => "image",
},
rect,
text: match &node.kind {
PaintKind::Text { text, .. } => Some(text.clone()),
PaintKind::Rich { spans, .. } => {
Some(spans.iter().map(|s| s.text.as_str()).collect())
}
PaintKind::Box | PaintKind::Path(_) | PaintKind::Input(_) | PaintKind::Image(_) => {
None
}
},
fill: node.style.fill.as_ref().map(|f| match f {
Paint::Solid(c) => {
let c = c.to_rgba8();
format!("#{:02x}{:02x}{:02x}", c.r, c.g, c.b)
}
Paint::LinearGradient { .. } => "linear-gradient".to_owned(),
Paint::RadialGradient { .. } => "radial-gradient".to_owned(),
}),
scroll_offset: node.scroll.as_ref().map(|s| s.offset),
children: node.children.iter().map(Self::from_node).collect(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tokens::STATE_LAYER;
#[test]
fn state_layer_opacity_picks_the_strongest_state() {
let id = WidgetId::ROOT;
let mut s = FrameState::new();
assert_eq!(state_layer_opacity(&s, id, false), None);
s.hovered.insert(id, 0.0);
assert_eq!(state_layer_opacity(&s, id, false), Some(STATE_LAYER.hover));
s.focus = Some(id);
s.focus_visible = true;
assert_eq!(state_layer_opacity(&s, id, false), Some(STATE_LAYER.focus));
s.active = Some(id);
assert_eq!(state_layer_opacity(&s, id, false), Some(STATE_LAYER.press));
s.dragging = Some("payload".to_owned());
assert_eq!(state_layer_opacity(&s, id, true), Some(STATE_LAYER.drag));
}
#[test]
fn pointer_focus_raises_no_focus_veil() {
let id = WidgetId::ROOT;
let mut s = FrameState::new();
s.focus = Some(id);
s.focus_visible = false; assert_eq!(state_layer_opacity(&s, id, false), None);
}
}