Files
ruin/lib/ui/src/layout.rs

2304 lines
77 KiB
Rust

use std::collections::HashMap;
use std::time::Instant;
use crate::ImageFit;
use crate::scene::{
DisplayItem, Point, PreparedImage, PreparedText, Rect, RoundedRect, SceneSnapshot, ShadowRect,
UiSize,
};
use crate::text::TextSystem;
use crate::tree::{CursorIcon, Edges, Element, ElementId, FlexDirection, ImageNode, Style};
pub fn layout_scene(version: u64, logical_size: UiSize, root: &Element) -> SceneSnapshot {
let mut text_system = TextSystem::new();
layout_scene_with_text_system(version, logical_size, root, &mut text_system)
}
pub fn layout_scene_with_text_system(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
) -> SceneSnapshot {
layout_snapshot_with_text_system(version, logical_size, root, text_system).scene
}
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutSnapshot {
pub scene: SceneSnapshot,
pub interaction_tree: InteractionTree,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LayoutPath(Vec<u32>);
impl LayoutPath {
pub fn root() -> Self {
Self(Vec::new())
}
pub fn segments(&self) -> &[u32] {
&self.0
}
pub(crate) fn child(&self, index: usize) -> Self {
let mut segments = self.0.clone();
segments.push(index as u32);
Self(segments)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct HitTarget {
pub path: LayoutPath,
pub element_id: Option<ElementId>,
pub rect: Rect,
pub focusable: bool,
pub cursor: CursorIcon,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutNode {
pub path: LayoutPath,
pub element_id: Option<ElementId>,
pub rect: Rect,
pub corner_radius: f32,
pub clip_rect: Option<Rect>,
pub pointer_events: bool,
pub focusable: bool,
pub cursor: CursorIcon,
pub scroll_metrics: Option<ScrollMetrics>,
pub prepared_image: Option<PreparedImage>,
pub prepared_text: Option<PreparedText>,
pub children: Vec<LayoutNode>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScrollMetrics {
pub viewport_rect: Rect,
pub content_height: f32,
pub offset_y: f32,
pub max_offset_y: f32,
pub scrollbar_track: Option<Rect>,
pub scrollbar_thumb: Option<Rect>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct TextHitTarget {
pub target: HitTarget,
pub byte_offset: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub struct InteractionTree {
pub root: LayoutNode,
}
impl InteractionTree {
pub fn hit_test(&self, point: crate::scene::Point) -> Option<HitTarget> {
self.hit_path(point).into_iter().last()
}
pub fn hit_path(&self, point: crate::scene::Point) -> Vec<HitTarget> {
let Some(mut path) = hit_path_node(&self.root, point) else {
return Vec::new();
};
path.reverse();
path
}
pub fn text_hit_test(&self, point: crate::scene::Point) -> Option<TextHitTarget> {
text_hit_test_node(&self.root, point)
}
pub fn text_for_element(&self, element_id: ElementId) -> Option<&PreparedText> {
text_for_element_node(&self.root, element_id)
}
pub fn scroll_metrics_for_element(&self, element_id: ElementId) -> Option<&ScrollMetrics> {
scroll_metrics_for_element_node(&self.root, element_id)
}
pub fn rect_for_element(&self, element_id: ElementId) -> Option<Rect> {
rect_for_element_node(&self.root, element_id)
}
}
impl LayoutNode {
/// Return a copy of this node (and all descendants) with all positions shifted by `offset`.
pub fn translated(self, offset: Point) -> Self {
fn translate_rect(r: Rect, o: Point) -> Rect {
Rect::new(
r.origin.x + o.x,
r.origin.y + o.y,
r.size.width,
r.size.height,
)
}
let rect = translate_rect(self.rect, offset);
let clip_rect = self.clip_rect.map(|r| translate_rect(r, offset));
let scroll_metrics = self.scroll_metrics.map(|m| ScrollMetrics {
viewport_rect: translate_rect(m.viewport_rect, offset),
scrollbar_track: m.scrollbar_track.map(|r| translate_rect(r, offset)),
scrollbar_thumb: m.scrollbar_thumb.map(|r| translate_rect(r, offset)),
..m
});
let prepared_text = self.prepared_text.map(|t| t.translated(offset));
let prepared_image = self.prepared_image.map(|img| PreparedImage {
rect: translate_rect(img.rect, offset),
..img
});
let children = self
.children
.into_iter()
.map(|c| c.translated(offset))
.collect();
LayoutNode {
rect,
clip_rect,
scroll_metrics,
prepared_text,
prepared_image,
children,
..self
}
}
}
pub fn layout_snapshot(version: u64, logical_size: UiSize, root: &Element) -> LayoutSnapshot {
let mut text_system = TextSystem::new();
layout_snapshot_with_text_system(version, logical_size, root, &mut text_system)
}
pub fn layout_snapshot_with_text_system(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
) -> LayoutSnapshot {
let mut layout_cache = LayoutCache::new();
layout_snapshot_with_cache(version, logical_size, root, text_system, &mut layout_cache)
}
pub fn layout_snapshot_with_cache(
version: u64,
logical_size: UiSize,
root: &Element,
text_system: &mut TextSystem,
layout_cache: &mut LayoutCache,
) -> LayoutSnapshot {
let layout_started = Instant::now();
let perf_enabled = tracing::enabled!(target: "ruin_ui::layout_perf", tracing::Level::DEBUG);
let mut perf_stats = LayoutPerfStats::new(perf_enabled);
text_system.reset_frame_stats();
let mut scene = SceneSnapshot::new(version, logical_size);
let interaction_root = layout_element(
root,
Rect::new(
0.0,
0.0,
logical_size.width.max(0.0),
logical_size.height.max(0.0),
),
LayoutPath::root(),
&mut scene,
text_system,
&mut perf_stats,
layout_cache,
None,
);
let text_stats = text_system.take_frame_stats();
if perf_stats.enabled {
tracing::debug!(
target: "ruin_ui::layout_perf",
scene_version = version,
width = logical_size.width,
height = logical_size.height,
total_ms = layout_started.elapsed().as_secs_f64() * 1_000.0,
nodes = perf_stats.nodes,
text_nodes = perf_stats.text_nodes,
container_nodes = perf_stats.container_nodes,
background_quads = perf_stats.background_quads,
intrinsic_calls = perf_stats.intrinsic_calls,
intrinsic_size_calls = perf_stats.intrinsic_size_calls,
intrinsic_text_calls = perf_stats.intrinsic_text_calls,
intrinsic_container_calls = perf_stats.intrinsic_container_calls,
intrinsic_ms = perf_stats.intrinsic_ms,
text_prepare_calls = perf_stats.text_prepare_calls,
text_prepare_ms = perf_stats.text_prepare_ms,
viewport_culled = perf_stats.viewport_culled,
layout_cache_hits = perf_stats.layout_cache_hits,
layout_cache_misses = perf_stats.layout_cache_misses,
intrinsic_cache_hits = perf_stats.intrinsic_cache_hits,
text_requests = text_stats.requests,
text_cache_hits = text_stats.cache_hits,
text_cache_misses = text_stats.cache_misses,
text_output_glyphs = text_stats.output_glyphs,
text_family_resolve_ms = text_stats.family_resolve_ms,
text_buffer_build_ms = text_stats.buffer_build_ms,
text_glyph_collect_ms = text_stats.glyph_collect_ms,
text_miss_ms = text_stats.miss_ms,
scene_items = scene.items.len(),
"layout snapshot perf"
);
}
LayoutSnapshot {
scene,
interaction_tree: InteractionTree {
root: interaction_root,
},
}
}
fn layout_element(
element: &Element,
rect: Rect,
path: LayoutPath,
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
// Scissor rect from the nearest enclosing scroll box (window coords).
// Elements that don't overlap this rect are skipped entirely.
clip_rect: Option<Rect>,
) -> LayoutNode {
perf_stats.nodes += 1;
// Viewport culling: skip fully off-screen elements inside a scroll box.
if let Some(clip) = clip_rect
&& !rects_overlap(rect, clip)
{
perf_stats.viewport_culled += 1;
return LayoutNode {
path,
element_id: element.id,
rect,
corner_radius: 0.0,
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor: element.style.cursor.unwrap_or(CursorIcon::Default),
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
}
// Incremental layout cache check.
// Round to nearest pixel to eliminate floating-point representation
// artifacts from arithmetic paths (e.g. total - padding - gap) that
// produce logically identical sizes with different bit patterns.
let subtree_hash = element.subtree_hash();
let cache_key = LayoutCacheKey {
subtree_hash,
avail_width_bits: rect.size.width.round() as u32,
avail_height_bits: rect.size.height.round() as u32,
};
if let Some(cached) = layout_cache.results.get(&cache_key) {
let offset = rect.origin;
for item in &cached.scene_items {
scene.items.push(item.translated(offset));
}
perf_stats.layout_cache_hits += 1;
return cached.interaction_node.clone().translated(offset);
}
perf_stats.layout_cache_misses += 1;
let scene_start = scene.items.len();
let cursor = element.style.cursor.unwrap_or_else(|| {
if element.text_node().is_some() {
CursorIcon::Text
} else {
CursorIcon::Default
}
});
let mut interaction = LayoutNode {
path,
element_id: element.id,
rect,
corner_radius: uniform_corner_radius(&element.style, rect),
clip_rect: None,
pointer_events: element.style.pointer_events,
focusable: element.style.focusable,
cursor,
scroll_metrics: None,
prepared_image: None,
prepared_text: None,
children: Vec::new(),
};
if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
cache_layout(layout_cache, cache_key, &interaction, &[], rect.origin);
return interaction;
}
push_box_shadows(
scene,
rect,
&element.style,
crate::BoxShadowKind::Outer,
perf_stats,
);
push_container_fill_and_border(scene, rect, &element.style, perf_stats);
push_box_shadows(
scene,
rect,
&element.style,
crate::BoxShadowKind::Inner,
perf_stats,
);
let clip_radius = uniform_corner_radius(&element.style, rect);
let pushed_clip = if clip_radius > 0.0 {
scene.push_clip(rect, clip_radius);
true
} else {
false
};
if let Some(text) = element.text_node() {
perf_stats.text_nodes += 1;
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width > 0.0 && content.size.height > 0.0 {
perf_stats.text_prepare_calls += 1;
let prepare_started = perf_stats.enabled.then(Instant::now);
let mut prepared = text_system.prepare_spans(
&text.spans,
content.origin,
&text.style,
Some(content.size),
);
prepared.element_id = element.id;
scene.push_text(prepared.clone());
interaction.prepared_text = Some(prepared);
if let Some(prepare_started) = prepare_started {
perf_stats.text_prepare_ms += prepare_started.elapsed().as_secs_f64() * 1_000.0;
}
}
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
if let Some(image) = element.image_node() {
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width > 0.0 && content.size.height > 0.0 {
let prepared = prepare_image(image, content, element.id);
scene.push_image(prepared.clone());
interaction.prepared_image = Some(prepared);
}
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
if let Some(scroll_box) = element.scroll_box_node() {
perf_stats.container_nodes += 1;
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width > 0.0 && content.size.height > 0.0 {
let gutter_width = scroll_box
.scrollbar
.gutter_width
.max(0.0)
.min(content.size.width);
let viewport_rect = Rect::new(
content.origin.x,
content.origin.y,
(content.size.width - gutter_width).max(0.0),
content.size.height,
);
interaction.clip_rect = Some(viewport_rect);
let content_size = intrinsic_container_content_size(
element,
UiSize::new(
viewport_rect.size.width.max(0.0),
viewport_rect.size.height.max(0.0),
),
text_system,
perf_stats,
layout_cache,
);
let provisional_content_height = content_size.height.max(viewport_rect.size.height);
let requested_offset_y = scroll_box.offset_y.max(0.0);
let provisional_max_offset_y =
(provisional_content_height - viewport_rect.size.height).max(0.0);
let mut offset_y = requested_offset_y.min(provisional_max_offset_y);
let child_scene_start = scene.items.len();
if viewport_rect.size.width > 0.0 && viewport_rect.size.height > 0.0 {
scene.push_clip(viewport_rect, 0.0);
interaction.children = layout_container_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
provisional_content_height,
),
&interaction.path,
scene,
text_system,
perf_stats,
layout_cache,
Some(viewport_rect),
);
scene.pop_clip();
}
let actual_content_height = interaction
.children
.iter()
.filter_map(max_layout_node_bottom)
.fold(viewport_rect.origin.y - offset_y, f32::max)
- (viewport_rect.origin.y - offset_y);
let content_height = provisional_content_height
.max(actual_content_height.ceil())
.max(viewport_rect.size.height);
let max_offset_y = (content_height - viewport_rect.size.height).max(0.0);
let corrected_offset_y = requested_offset_y.min(max_offset_y);
if (corrected_offset_y - offset_y).abs() > f32::EPSILON
&& viewport_rect.size.width > 0.0
&& viewport_rect.size.height > 0.0
{
offset_y = corrected_offset_y;
scene.items.truncate(child_scene_start);
scene.push_clip(viewport_rect, 0.0);
interaction.children = layout_container_children(
element,
Rect::new(
viewport_rect.origin.x,
viewport_rect.origin.y - offset_y,
viewport_rect.size.width,
content_height,
),
&interaction.path,
scene,
text_system,
perf_stats,
layout_cache,
Some(viewport_rect),
);
scene.pop_clip();
} else {
offset_y = corrected_offset_y;
}
interaction.scroll_metrics = Some(ScrollMetrics {
viewport_rect,
content_height,
offset_y,
max_offset_y,
scrollbar_track: None,
scrollbar_thumb: None,
});
let (scrollbar_track, scrollbar_thumb) = scrollbar_geometry(
content,
scroll_box.scrollbar,
content_height,
viewport_rect.size.height,
offset_y,
);
if let Some(scroll_metrics) = interaction.scroll_metrics.as_mut() {
scroll_metrics.scrollbar_track = scrollbar_track;
scroll_metrics.scrollbar_thumb = scrollbar_thumb;
}
paint_scrollbar(
scene,
scroll_box.scrollbar,
scrollbar_track,
scrollbar_thumb,
perf_stats,
);
}
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
perf_stats.container_nodes += 1;
if element.children.is_empty() {
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
let content = inset_rect(rect, content_insets(&element.style));
if content.size.width <= 0.0 || content.size.height <= 0.0 {
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
return interaction;
}
interaction.children = layout_container_children(
element,
content,
&interaction.path,
scene,
text_system,
perf_stats,
layout_cache,
// Do NOT propagate clip_rect into children: the scroll-box PushClip already clips
// them visually, and propagating the clip causes the layout cache to bake in
// culling results for a specific scroll position. When the scroll changes, the
// same cache entry would replay with stale "empty" children that were culled at
// the previous offset. Culling applies only at the direct-child level of the
// scroll box (which passes Some(viewport_rect) to layout_container_children).
None,
);
if pushed_clip {
scene.pop_clip();
}
cache_layout(
layout_cache,
cache_key,
&interaction,
&scene.items[scene_start..],
rect.origin,
);
interaction
}
/// Store a layout result in the cache in origin-relative (local) coordinates.
fn cache_layout(
cache: &mut LayoutCache,
key: LayoutCacheKey,
interaction: &LayoutNode,
scene_items: &[DisplayItem],
origin: Point,
) {
let neg = Point::new(-origin.x, -origin.y);
cache.results.insert(
key,
CachedLayout {
interaction_node: interaction.clone().translated(neg),
scene_items: scene_items.iter().map(|i| i.translated(neg)).collect(),
},
);
}
fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option<Vec<HitTarget>> {
if !point_hits_node_shape(node, point) {
return None;
}
if node
.clip_rect
.is_none_or(|clip_rect| clip_rect.contains(point))
{
for child in node.children.iter().rev() {
if let Some(mut hits) = hit_path_node(child, point) {
if node.pointer_events {
hits.push(HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
});
}
return Some(hits);
}
}
}
if node.pointer_events {
return Some(vec![HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
}]);
}
None
}
fn text_hit_test_node(node: &LayoutNode, point: crate::scene::Point) -> Option<TextHitTarget> {
if !point_hits_node_shape(node, point) {
return None;
}
if node
.clip_rect
.is_none_or(|clip_rect| clip_rect.contains(point))
{
for child in node.children.iter().rev() {
if let Some(hit) = text_hit_test_node(child, point) {
return Some(hit);
}
}
}
if !node.pointer_events {
return None;
}
let prepared_text = node.prepared_text.as_ref()?;
if !prepared_text.selectable {
return None;
}
Some(TextHitTarget {
target: HitTarget {
path: node.path.clone(),
element_id: node.element_id,
rect: node.rect,
focusable: node.focusable,
cursor: node.cursor,
},
byte_offset: prepared_text.byte_offset_for_position(point),
})
}
fn text_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<&PreparedText> {
if node.element_id == Some(element_id)
&& let Some(prepared_text) = node.prepared_text.as_ref()
{
return Some(prepared_text);
}
for child in &node.children {
if let Some(prepared_text) = text_for_element_node(child, element_id) {
return Some(prepared_text);
}
}
None
}
fn scroll_metrics_for_element_node(
node: &LayoutNode,
element_id: ElementId,
) -> Option<&ScrollMetrics> {
if node.element_id == Some(element_id)
&& let Some(scroll_metrics) = node.scroll_metrics.as_ref()
{
return Some(scroll_metrics);
}
for child in &node.children {
if let Some(scroll_metrics) = scroll_metrics_for_element_node(child, element_id) {
return Some(scroll_metrics);
}
}
None
}
fn rect_for_element_node(node: &LayoutNode, element_id: ElementId) -> Option<Rect> {
if node.element_id == Some(element_id) {
return Some(node.rect);
}
for child in &node.children {
if let Some(rect) = rect_for_element_node(child, element_id) {
return Some(rect);
}
}
None
}
fn point_hits_node_shape(node: &LayoutNode, point: crate::scene::Point) -> bool {
node.rect.contains(point)
&& (node.corner_radius <= 0.0
|| point_in_rounded_rect(point, node.rect, node.corner_radius))
}
fn point_in_rounded_rect(point: crate::scene::Point, rect: Rect, radius: f32) -> bool {
let radius = radius
.max(0.0)
.min(rect.size.width * 0.5)
.min(rect.size.height * 0.5);
if radius <= 0.0 {
return true;
}
let left = rect.origin.x;
let top = rect.origin.y;
let right = rect.origin.x + rect.size.width;
let bottom = rect.origin.y + rect.size.height;
if point.x >= left + radius && point.x < right - radius
|| point.y >= top + radius && point.y < bottom - radius
{
return true;
}
let corner_center = if point.x < left + radius {
if point.y < top + radius {
crate::scene::Point::new(left + radius, top + radius)
} else {
crate::scene::Point::new(left + radius, bottom - radius)
}
} else if point.y < top + radius {
crate::scene::Point::new(right - radius, top + radius)
} else {
crate::scene::Point::new(right - radius, bottom - radius)
};
let dx = point.x - corner_center.x;
let dy = point.y - corner_center.y;
dx * dx + dy * dy <= radius * radius
}
#[derive(Clone, Copy, Debug)]
struct MeasuredChild {
cross: f32,
main: f32,
is_flex: bool,
}
fn layout_container_children(
element: &Element,
content: Rect,
path: &LayoutPath,
scene: &mut SceneSnapshot,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
clip_rect: Option<Rect>,
) -> Vec<LayoutNode> {
if element.children.is_empty() || content.size.width <= 0.0 || content.size.height <= 0.0 {
return Vec::new();
}
let gap_count = element.children.len().saturating_sub(1) as f32;
let total_gap = element.style.gap * gap_count;
let available_main =
(main_axis_size(content.size, element.style.direction).max(0.0) - total_gap).max(0.0);
let available_cross = cross_axis_size(content.size, element.style.direction).max(0.0);
let mut measured_children = Vec::with_capacity(element.children.len());
let mut fixed_total = 0.0;
let mut flex_total = 0.0;
for child in &element.children {
let cross = child_cross_size(child, element.style.direction)
.unwrap_or(available_cross)
.clamp(0.0, available_cross);
let explicit_main =
child_main_size(child, element.style.direction).map(|main| main.max(0.0));
let is_flex = explicit_main.is_none() && child.style.flex_grow > 0.0;
let measured_main = explicit_main.unwrap_or_else(|| {
if is_flex {
0.0
} else {
perf_stats.intrinsic_calls += 1;
if child.text_node().is_some() {
perf_stats.intrinsic_text_calls += 1;
} else {
perf_stats.intrinsic_container_calls += 1;
}
let intrinsic_started = perf_stats.enabled.then(Instant::now);
let intrinsic = intrinsic_main_size(
child,
element.style.direction,
cross,
available_main,
text_system,
perf_stats,
layout_cache,
);
if let Some(intrinsic_started) = intrinsic_started {
perf_stats.intrinsic_ms += intrinsic_started.elapsed().as_secs_f64() * 1_000.0;
}
intrinsic
}
});
if is_flex {
flex_total += child_flex_weight(child);
} else {
fixed_total += measured_main;
}
measured_children.push(MeasuredChild {
cross,
main: measured_main,
is_flex,
});
}
let remaining_main = (available_main - fixed_total).max(0.0);
let mut cursor = main_axis_origin(content, element.style.direction);
let mut children = Vec::with_capacity(element.children.len());
for (index, (child, measured)) in element.children.iter().zip(measured_children).enumerate() {
let child_main = if measured.is_flex {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
} else {
measured.main
};
let child_rect = child_rect(
content,
element.style.direction,
cursor,
child_main.max(0.0),
measured.cross,
);
children.push(layout_element(
child,
child_rect,
path.child(index),
scene,
text_system,
perf_stats,
layout_cache,
clip_rect,
));
cursor += child_main.max(0.0) + element.style.gap;
}
children
}
fn intrinsic_main_size(
child: &Element,
direction: FlexDirection,
cross_size: f32,
available_main: f32,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> f32 {
if let Some(text) = child.text_node() {
let constraints = match direction {
FlexDirection::Row => (Some(available_main.max(0.0)), Some(cross_size.max(0.0))),
FlexDirection::Column => (Some(cross_size.max(0.0)), None),
};
let content =
text_system.measure_spans(&text.spans, &text.style, constraints.0, constraints.1);
let padding = main_axis_padding(content_insets(&child.style), direction);
return main_axis_size(content, direction) + padding;
}
if let Some(image) = child.image_node() {
let resolved = resolve_image_element_size(child, image.resource.size());
return main_axis_size(resolved, direction);
}
let available_size = match direction {
FlexDirection::Row => UiSize::new(available_main.max(0.0), cross_size.max(0.0)),
FlexDirection::Column => UiSize::new(cross_size.max(0.0), available_main.max(0.0)),
};
main_axis_size(
intrinsic_size(child, available_size, text_system, perf_stats, layout_cache),
direction,
)
}
fn intrinsic_container_content_size(
element: &Element,
content_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
if element.children.is_empty() {
return UiSize::new(0.0, 0.0);
}
let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32;
match element.style.direction {
FlexDirection::Column => {
let mut width: f32 = 0.0;
let mut height = gap_total;
for child in &element.children {
let skip_main = child.style.flex_grow > 0.0 && child.style.height.is_none();
let child_size = intrinsic_size(
child,
UiSize::new(
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system,
perf_stats,
layout_cache,
);
width = width.max(child.style.width.unwrap_or(child_size.width));
if !skip_main {
height += child.style.height.unwrap_or(child_size.height);
}
}
UiSize::new(width, height)
}
FlexDirection::Row => {
let mut width = gap_total;
let mut height: f32 = 0.0;
let mut fixed_main = 0.0;
let mut flex_total = 0.0;
let mut child_main_sizes = Vec::with_capacity(element.children.len());
for child in &element.children {
let is_flex = child.style.flex_grow > 0.0 && child.style.width.is_none();
if is_flex {
flex_total += child_flex_weight(child);
child_main_sizes.push(None);
continue;
}
let child_size = intrinsic_size(
child,
UiSize::new(
child.style.width.unwrap_or(content_size.width),
child.style.height.unwrap_or(content_size.height),
),
text_system,
perf_stats,
layout_cache,
);
let child_main = child.style.width.unwrap_or(child_size.width);
fixed_main += child_main;
child_main_sizes.push(Some(child_main));
}
let remaining_main = (content_size.width - gap_total - fixed_main).max(0.0);
for (child, measured_main) in element.children.iter().zip(child_main_sizes.iter()) {
let skip_main = child.style.flex_grow > 0.0 && child.style.width.is_none();
let child_main = measured_main.unwrap_or_else(|| {
if flex_total <= 0.0 {
0.0
} else {
remaining_main * (child_flex_weight(child) / flex_total)
}
});
let child_size = intrinsic_size(
child,
UiSize::new(
child_main,
child.style.height.unwrap_or(content_size.height),
),
text_system,
perf_stats,
layout_cache,
);
if !skip_main {
width += child_main;
}
height = height.max(child.style.height.unwrap_or(child_size.height));
}
UiSize::new(width, height)
}
}
}
fn intrinsic_size(
element: &Element,
available_size: UiSize,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
perf_stats.intrinsic_size_calls += 1;
// Intrinsic size cache check.
// Round to nearest pixel — same rationale as the layout cache key.
let cache_key = (
element.subtree_hash(),
available_size.width.round() as u32,
available_size.height.round() as u32,
);
if let Some(&cached) = layout_cache.intrinsic_cache.get(&cache_key) {
perf_stats.intrinsic_cache_hits += 1;
return cached;
}
let insets = content_insets(&element.style);
let result = intrinsic_size_inner(
element,
available_size,
insets,
text_system,
perf_stats,
layout_cache,
);
layout_cache.intrinsic_cache.insert(cache_key, result);
result
}
fn intrinsic_size_inner(
element: &Element,
available_size: UiSize,
insets: Edges,
text_system: &mut TextSystem,
perf_stats: &mut LayoutPerfStats,
layout_cache: &mut LayoutCache,
) -> UiSize {
if let Some(text) = element.text_node() {
let measured = text_system.measure_spans(
&text.spans,
&text.style,
Some(available_size.width.max(0.0)),
Some(available_size.height.max(0.0)),
);
return UiSize::new(
element
.style
.width
.unwrap_or(measured.width + horizontal_insets(insets)),
element
.style
.height
.unwrap_or(measured.height + vertical_insets(insets)),
);
}
if let Some(image) = element.image_node() {
let resolved = resolve_image_element_size(element, image.resource.size());
return UiSize::new(
resolved.width + horizontal_insets(insets),
resolved.height + vertical_insets(insets),
);
}
let explicit_width = element.style.width;
let explicit_height = element.style.height;
let scroll_gutter = element
.scroll_box_node()
.map_or(0.0, |scroll_box| scroll_box.scrollbar.gutter_width.max(0.0));
let content_width =
explicit_width.unwrap_or(available_size.width).max(0.0) - horizontal_insets(insets);
let content_height =
explicit_height.unwrap_or(available_size.height).max(0.0) - vertical_insets(insets);
let content_size = UiSize::new(
(content_width - scroll_gutter).max(0.0),
content_height.max(0.0),
);
if element.children.is_empty() {
return UiSize::new(
explicit_width.unwrap_or(horizontal_insets(insets) + scroll_gutter),
explicit_height.unwrap_or(vertical_insets(insets)),
);
}
let intrinsic_content = intrinsic_container_content_size(
element,
content_size,
text_system,
perf_stats,
layout_cache,
);
UiSize::new(
explicit_width
.unwrap_or(intrinsic_content.width + horizontal_insets(insets) + scroll_gutter),
explicit_height.unwrap_or(intrinsic_content.height + vertical_insets(insets)),
)
}
/// Cache for incremental layout. Holds both full-subtree layout results and
/// intrinsic-size results keyed by subtree hash + available size.
///
/// Cleared by calling [`LayoutCache::clear`] or dropped between runs if desired.
/// In practice, the cache is held across frames so unchanged subtrees pay no layout cost.
#[derive(Default)]
pub struct LayoutCache {
results: HashMap<LayoutCacheKey, CachedLayout>,
/// Intrinsic-size cache: keyed by (subtree_hash, avail_w.to_bits(), avail_h.to_bits()).
pub intrinsic_cache: HashMap<(u64, u32, u32), UiSize>,
}
impl LayoutCache {
pub fn new() -> Self {
Self::default()
}
pub fn clear(&mut self) {
self.results.clear();
self.intrinsic_cache.clear();
}
}
#[derive(Eq, Hash, PartialEq)]
struct LayoutCacheKey {
subtree_hash: u64,
avail_width_bits: u32,
avail_height_bits: u32,
}
struct CachedLayout {
/// Layout node in origin-relative (local) coordinates.
interaction_node: LayoutNode,
/// Scene items in origin-relative (local) coordinates.
scene_items: Vec<DisplayItem>,
}
#[derive(Debug, Default)]
struct LayoutPerfStats {
enabled: bool,
nodes: usize,
text_nodes: usize,
container_nodes: usize,
background_quads: usize,
intrinsic_calls: usize,
intrinsic_size_calls: usize,
intrinsic_text_calls: usize,
intrinsic_container_calls: usize,
intrinsic_ms: f64,
text_prepare_calls: usize,
text_prepare_ms: f64,
viewport_culled: usize,
layout_cache_hits: usize,
layout_cache_misses: usize,
intrinsic_cache_hits: usize,
}
impl LayoutPerfStats {
const fn new(enabled: bool) -> Self {
Self {
enabled,
nodes: 0,
text_nodes: 0,
container_nodes: 0,
background_quads: 0,
intrinsic_calls: 0,
intrinsic_size_calls: 0,
intrinsic_text_calls: 0,
intrinsic_container_calls: 0,
intrinsic_ms: 0.0,
text_prepare_calls: 0,
text_prepare_ms: 0.0,
viewport_culled: 0,
layout_cache_hits: 0,
layout_cache_misses: 0,
intrinsic_cache_hits: 0,
}
}
}
/// Returns true if two rects have any overlap (including touching edges).
#[inline]
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.origin.x < b.origin.x + b.size.width
&& a.origin.x + a.size.width > b.origin.x
&& a.origin.y < b.origin.y + b.size.height
&& a.origin.y + a.size.height > b.origin.y
}
fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option<ElementId>) -> PreparedImage {
let source_size = image.resource.size();
let source_aspect = if source_size.height > 0.0 {
source_size.width / source_size.height
} else {
1.0
};
let rect_aspect = if rect.size.height > 0.0 {
rect.size.width / rect.size.height
} else {
source_aspect
};
let (draw_rect, uv_rect) = match image.fit {
ImageFit::Fill => (rect, (0.0, 0.0, 1.0, 1.0)),
ImageFit::Contain => {
let scale = (rect.size.width / source_size.width)
.min(rect.size.height / source_size.height)
.max(0.0);
let width = source_size.width * scale;
let height = source_size.height * scale;
let x = rect.origin.x + (rect.size.width - width) * 0.5;
let y = rect.origin.y + (rect.size.height - height) * 0.5;
(Rect::new(x, y, width, height), (0.0, 0.0, 1.0, 1.0))
}
ImageFit::Cover => {
if rect_aspect > source_aspect {
let visible_height = (source_aspect / rect_aspect).clamp(0.0, 1.0);
let inset = (1.0 - visible_height) * 0.5;
(rect, (0.0, inset, 1.0, 1.0 - inset))
} else {
let visible_width = (rect_aspect / source_aspect).clamp(0.0, 1.0);
let inset = (1.0 - visible_width) * 0.5;
(rect, (inset, 0.0, 1.0 - inset, 1.0))
}
}
};
PreparedImage {
element_id,
resource: image.resource.clone(),
rect: draw_rect,
uv_rect,
}
}
fn resolve_image_element_size(element: &Element, intrinsic: UiSize) -> UiSize {
match (element.style.width, element.style.height) {
(Some(width), Some(height)) => UiSize::new(width.max(0.0), height.max(0.0)),
(Some(width), None) if intrinsic.width > 0.0 => UiSize::new(
width.max(0.0),
(width * intrinsic.height / intrinsic.width).max(0.0),
),
(None, Some(height)) if intrinsic.height > 0.0 => UiSize::new(
(height * intrinsic.width / intrinsic.height).max(0.0),
height.max(0.0),
),
_ => intrinsic,
}
}
fn scrollbar_geometry(
content_rect: Rect,
scrollbar: crate::ScrollbarStyle,
content_height: f32,
viewport_height: f32,
offset_y: f32,
) -> (Option<Rect>, Option<Rect>) {
let gutter_width = scrollbar.gutter_width.max(0.0).min(content_rect.size.width);
if gutter_width <= 0.0 || content_rect.size.height <= 0.0 {
return (None, None);
}
let gutter_rect = Rect::new(
content_rect.origin.x + content_rect.size.width - gutter_width,
content_rect.origin.y,
gutter_width,
content_rect.size.height,
);
let track_rect = inset_rect(
gutter_rect,
Edges {
top: 0.0,
right: 0.0,
bottom: 0.0,
left: (gutter_width * 0.16).clamp(1.0, gutter_width * 0.5),
},
);
if track_rect.size.width <= 0.0 || track_rect.size.height <= 0.0 {
return (None, None);
}
let max_offset_y = (content_height - viewport_height).max(0.0);
if max_offset_y <= 0.0 {
return (Some(track_rect), None);
}
let thumb_height = ((viewport_height / content_height.max(viewport_height))
* track_rect.size.height)
.max(scrollbar.min_thumb_size.max(0.0))
.min(track_rect.size.height);
let thumb_range = (track_rect.size.height - thumb_height).max(0.0);
let thumb_origin_y = track_rect.origin.y + thumb_range * (offset_y / max_offset_y.max(1.0));
let thumb_rect = Rect::new(
track_rect.origin.x,
thumb_origin_y,
track_rect.size.width,
thumb_height,
);
(Some(track_rect), Some(thumb_rect))
}
fn paint_scrollbar(
scene: &mut SceneSnapshot,
scrollbar: crate::ScrollbarStyle,
track_rect: Option<Rect>,
thumb_rect: Option<Rect>,
perf_stats: &mut LayoutPerfStats,
) {
let radius_for = |rect: Rect| {
scrollbar
.corner_radius
.max(0.0)
.min(rect.size.width * 0.5)
.min(rect.size.height * 0.5)
};
if let Some(track_rect) = track_rect {
perf_stats.background_quads += 1;
scene.push_rounded_rect(RoundedRect {
rect: track_rect,
fill: Some(scrollbar.track_color),
border_color: None,
border_width: 0.0,
radius: radius_for(track_rect),
});
}
if let Some(thumb_rect) = thumb_rect {
perf_stats.background_quads += 1;
scene.push_rounded_rect(RoundedRect {
rect: thumb_rect,
fill: Some(scrollbar.thumb_color),
border_color: None,
border_width: 0.0,
radius: radius_for(thumb_rect),
});
}
}
fn max_layout_node_bottom(node: &LayoutNode) -> Option<f32> {
let mut max_bottom =
(node.rect.size.height > 0.0).then_some(node.rect.origin.y + node.rect.size.height);
if let Some(prepared_text) = node.prepared_text.as_ref()
&& let Some(bounds) = prepared_text.bounds
{
let text_bottom = prepared_text.origin.y + bounds.height;
max_bottom = Some(max_bottom.map_or(text_bottom, |current| current.max(text_bottom)));
}
if let Some(prepared_image) = node.prepared_image.as_ref() {
let image_bottom = prepared_image.rect.origin.y + prepared_image.rect.size.height;
max_bottom = Some(max_bottom.map_or(image_bottom, |current| current.max(image_bottom)));
}
for child in &node.children {
if let Some(child_bottom) = max_layout_node_bottom(child) {
max_bottom = Some(max_bottom.map_or(child_bottom, |current| current.max(child_bottom)));
}
}
max_bottom
}
fn push_box_shadows(
scene: &mut SceneSnapshot,
rect: Rect,
style: &Style,
kind: crate::BoxShadowKind,
perf_stats: &mut LayoutPerfStats,
) {
let source_radius = uniform_corner_radius(style, rect);
for shadow in &style.box_shadows {
let shadow_rect = Rect::new(
rect.origin.x + shadow.offset.x - shadow.spread,
rect.origin.y + shadow.offset.y - shadow.spread,
rect.size.width + shadow.spread * 2.0,
rect.size.height + shadow.spread * 2.0,
);
if shadow.kind != kind || shadow_rect.size.width <= 0.0 || shadow_rect.size.height <= 0.0 {
continue;
}
perf_stats.background_quads += 1;
scene.push_shadow_rect(ShadowRect {
rect: shadow_rect,
source_rect: rect,
color: shadow.color,
blur: shadow.blur.max(0.0),
radius: clamp_corner_radius(source_radius + shadow.spread, shadow_rect),
source_radius,
kind,
});
}
}
fn push_container_fill_and_border(
scene: &mut SceneSnapshot,
rect: Rect,
style: &Style,
perf_stats: &mut LayoutPerfStats,
) {
let radius = uniform_corner_radius(style, rect);
let border_width = style
.border
.map(|border| {
border
.width
.clamp(0.0, rect.size.width * 0.5)
.min(rect.size.height * 0.5)
})
.unwrap_or(0.0);
let border_color = style.border.map(|border| border.color);
if radius > 0.0 && (style.background.is_some() || border_width > 0.0) {
perf_stats.background_quads += 1;
scene.push_rounded_rect(RoundedRect {
rect,
fill: style.background,
border_color,
border_width,
radius,
});
return;
}
if let Some(color) = style.background {
perf_stats.background_quads += 1;
scene.push_quad(rect, color);
}
let Some(border) = style.border else {
return;
};
if border_width <= 0.0 {
return;
}
let top = Rect::new(rect.origin.x, rect.origin.y, rect.size.width, border_width);
let bottom = Rect::new(
rect.origin.x,
rect.origin.y + rect.size.height - border_width,
rect.size.width,
border_width,
);
let middle_height = (rect.size.height - border_width * 2.0).max(0.0);
let left = Rect::new(
rect.origin.x,
rect.origin.y + border_width,
border_width,
middle_height,
);
let right = Rect::new(
rect.origin.x + rect.size.width - border_width,
rect.origin.y + border_width,
border_width,
middle_height,
);
for border_rect in [top, bottom, left, right] {
if border_rect.size.width <= 0.0 || border_rect.size.height <= 0.0 {
continue;
}
perf_stats.background_quads += 1;
scene.push_quad(border_rect, border.color);
}
}
fn content_insets(style: &Style) -> Edges {
let border = style
.border
.map(|border| border.width.max(0.0))
.unwrap_or(0.0);
Edges {
top: style.padding.top + border,
right: style.padding.right + border,
bottom: style.padding.bottom + border,
left: style.padding.left + border,
}
}
fn uniform_corner_radius(style: &Style, rect: Rect) -> f32 {
clamp_corner_radius(
style
.corner_radius
.top_left
.max(style.corner_radius.top_right)
.max(style.corner_radius.bottom_right)
.max(style.corner_radius.bottom_left),
rect,
)
}
fn clamp_corner_radius(radius: f32, rect: Rect) -> f32 {
radius
.max(0.0)
.min(rect.size.width * 0.5)
.min(rect.size.height * 0.5)
}
fn horizontal_insets(edges: Edges) -> f32 {
edges.left + edges.right
}
fn vertical_insets(edges: Edges) -> f32 {
edges.top + edges.bottom
}
fn inset_rect(rect: Rect, edges: Edges) -> Rect {
let width = (rect.size.width - edges.left - edges.right).max(0.0);
let height = (rect.size.height - edges.top - edges.bottom).max(0.0);
Rect::new(
rect.origin.x + edges.left,
rect.origin.y + edges.top,
width,
height,
)
}
fn child_main_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.width,
FlexDirection::Column => child.style.height,
}
}
fn child_cross_size(child: &Element, direction: FlexDirection) -> Option<f32> {
match direction {
FlexDirection::Row => child.style.height,
FlexDirection::Column => child.style.width,
}
}
fn child_flex_weight(child: &Element) -> f32 {
if child.style.flex_grow > 0.0 {
child.style.flex_grow
} else {
1.0
}
}
fn main_axis_padding(edges: Edges, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => edges.left + edges.right,
FlexDirection::Column => edges.top + edges.bottom,
}
}
fn main_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => size.width,
FlexDirection::Column => size.height,
}
}
fn cross_axis_size(size: UiSize, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => size.height,
FlexDirection::Column => size.width,
}
}
fn main_axis_origin(rect: Rect, direction: FlexDirection) -> f32 {
match direction {
FlexDirection::Row => rect.origin.x,
FlexDirection::Column => rect.origin.y,
}
}
fn child_rect(
content: Rect,
direction: FlexDirection,
main_origin: f32,
main_size: f32,
cross_size: f32,
) -> Rect {
match direction {
FlexDirection::Row => Rect::new(main_origin, content.origin.y, main_size, cross_size),
FlexDirection::Column => Rect::new(content.origin.x, main_origin, cross_size, main_size),
}
}
#[cfg(test)]
mod tests {
use super::{LayoutCache, layout_scene, layout_snapshot, layout_snapshot_with_cache};
use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize};
use crate::text::TextSystem;
use crate::text::{TextStyle, TextWrap};
use crate::tree::{Edges, Element, ElementId, FlexDirection};
#[test]
fn row_layout_apportions_fixed_and_flex_children() {
let root = Element::row()
.padding(Edges::all(10.0))
.gap(10.0)
.children([
Element::new()
.width(50.0)
.background(Color::rgb(0xAA, 0x11, 0x11)),
Element::new()
.flex(1.0)
.background(Color::rgb(0x11, 0xAA, 0x11)),
Element::new()
.flex(2.0)
.background(Color::rgb(0x11, 0x11, 0xAA)),
]);
let scene = layout_scene(1, UiSize::new(300.0, 100.0), &root);
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
assert_eq!(
quads,
vec![
Quad::new(
Rect::new(10.0, 10.0, 50.0, 80.0),
Color::rgb(0xAA, 0x11, 0x11)
),
Quad::new(
Rect::new(70.0, 10.0, 70.0, 80.0),
Color::rgb(0x11, 0xAA, 0x11)
),
Quad::new(
Rect::new(150.0, 10.0, 140.0, 80.0),
Color::rgb(0x11, 0x11, 0xAA)
),
]
);
}
#[test]
fn column_layout_reflows_text_and_moves_following_children() {
let root = Element::column()
.padding(Edges::all(10.0))
.gap(10.0)
.children([
Element::text(
"RUIN text nodes should reflow inside narrow layout columns instead of acting like overlays.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(20.0)
.with_wrap(TextWrap::Word),
)
.padding(Edges::all(8.0))
.background(Color::rgb(0x22, 0x2C, 0x46)),
Element::new()
.height(40.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
]);
let scene = layout_scene(1, UiSize::new(160.0, 220.0), &root);
let text = scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("layout should emit a text display item");
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
let text_bounds = text.bounds.expect("text layout should provide bounds");
assert_eq!(text.origin.x, 18.0);
assert_eq!(text.origin.y, 18.0);
assert_eq!(text_bounds.width, 124.0);
assert!(text_bounds.height > 20.0);
assert_eq!(quads.len(), 2);
assert!(quads[1].rect.origin.y > text.origin.y + text_bounds.height);
}
#[test]
fn column_container_with_text_children_gets_intrinsic_height() {
let root = Element::column().child(
Element::column()
.padding(Edges::all(12.0))
.background(Color::rgb(0x22, 0x33, 0x44))
.child(Element::paragraph(
"Paragraph containers should not collapse to zero height anymore.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)),
);
let scene = layout_scene(1, UiSize::new(420.0, 240.0), &root);
let quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) => Some(*quad),
_ => None,
})
.collect();
assert!(quads.iter().any(|quad| quad.rect.size.height > 24.0));
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::Text(_)))
);
}
#[test]
fn bordered_container_insets_text_and_emits_border_quads() {
let border_color = Color::rgb(0xF5, 0xD0, 0x74);
let root = Element::column().child(
Element::column()
.background(Color::rgb(0x22, 0x33, 0x44))
.border(4.0, border_color)
.padding(Edges::all(10.0))
.child(Element::paragraph(
"Bordered content should be inset past the stroke.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)),
);
let scene = layout_scene(1, UiSize::new(320.0, 160.0), &root);
let text = scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("bordered container should emit text");
let border_quads: Vec<Quad> = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) if quad.color == border_color => Some(*quad),
_ => None,
})
.collect();
assert_eq!(text.origin.x, 14.0);
assert_eq!(text.origin.y, 14.0);
assert_eq!(border_quads.len(), 4);
}
#[test]
fn row_container_counts_flex_child_height_in_intrinsic_size() {
let row_color = Color::rgb(0x12, 0x24, 0x36);
let root = Element::column().child(
Element::row()
.padding(Edges::all(12.0))
.gap(16.0)
.background(row_color)
.children([
Element::new()
.width(120.0)
.height(60.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
Element::column().flex(1.0).child(Element::paragraph(
"A flex child with wrapped text should make the row grow tall enough to contain it.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(24.0)
.with_wrap(TextWrap::Word),
)),
]),
);
let scene = layout_scene(1, UiSize::new(360.0, 320.0), &root);
let row_quad = scene
.items
.iter()
.filter_map(|item| match item {
DisplayItem::Quad(quad) if quad.color == row_color => Some(*quad),
_ => None,
})
.next()
.expect("row container should emit a background quad");
let text = scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("row should emit a text display item");
let text_bounds = text.bounds.expect("text layout should provide bounds");
let row_bottom = row_quad.rect.origin.y + row_quad.rect.size.height;
let text_bottom = text.origin.y + text_bounds.height;
assert!(row_quad.rect.size.height > 84.0);
assert!(row_bottom >= text_bottom);
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::Text(_)))
);
}
#[test]
fn rounded_container_emits_rounded_rect_and_clip_items() {
let root = Element::column().child(
Element::column()
.background(Color::rgb(0x18, 0x20, 0x2F))
.border(2.0, Color::rgb(0xF5, 0xD0, 0x74))
.corner_radius(18.0)
.padding(Edges::all(12.0))
.child(Element::paragraph(
"Rounded containers should emit a rounded paint item and clip their children.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)),
);
let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::RoundedRect(_)))
);
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::PushClip(_)))
);
assert!(
scene
.items
.iter()
.any(|item| matches!(item, DisplayItem::PopClip))
);
}
#[test]
fn empty_rounded_container_pops_clip_before_following_sibling() {
let root = Element::column().children([
Element::column()
.height(56.0)
.corner_radius(12.0)
.background(Color::rgb(0x22, 0x33, 0x44)),
Element::paragraph(
"Sibling text should not inherit a stale clip from an empty rounded container.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
),
]);
let scene = layout_scene(1, UiSize::new(480.0, 240.0), &root);
let rounded_push = scene
.items
.iter()
.position(|item| matches!(item, DisplayItem::PushClip(_)))
.expect("rounded empty container should push a clip");
let rounded_pop = scene
.items
.iter()
.position(|item| matches!(item, DisplayItem::PopClip))
.expect("rounded empty container should pop its clip");
let sibling_text = scene
.items
.iter()
.position(|item| matches!(item, DisplayItem::Text(text) if text.text.contains("Sibling text")))
.expect("following sibling text should be emitted");
assert!(rounded_push < rounded_pop);
assert!(rounded_pop < sibling_text);
}
#[test]
fn shadowed_container_emits_shadow_rect_items() {
let root = Element::column().child(
Element::column()
.background(Color::rgb(0x18, 0x20, 0x2F))
.corner_radius(18.0)
.shadow(crate::BoxShadow::new(
crate::Point::new(0.0, 8.0),
18.0,
-2.0,
Color::rgba(0x05, 0x08, 0x0F, 0x90),
crate::BoxShadowKind::Outer,
))
.shadow(crate::BoxShadow::new(
crate::Point::new(0.0, 4.0),
8.0,
0.0,
Color::rgba(0x00, 0x00, 0x00, 0x70),
crate::BoxShadowKind::Inner,
))
.child(Element::paragraph(
"Shadowed containers should emit dedicated shadow paint items.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)),
);
let scene = layout_scene(1, UiSize::new(320.0, 180.0), &root);
assert!(
scene
.items
.iter()
.filter(|item| matches!(item, DisplayItem::ShadowRect(_)))
.count()
>= 2
);
}
#[test]
fn scroll_box_exposes_scroll_metrics_and_reserves_scrollbar_gutter() {
let scrollbox_id = ElementId::new(9);
let root = Element::column().child(
Element::scroll_box(48.0)
.id(scrollbox_id)
.width(180.0)
.height(96.0)
.padding(Edges::all(8.0))
.children([
Element::paragraph(
"Scroll boxes should clip oversized content and reserve space for a scrollbar.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
Element::paragraph(
"This extra paragraph forces overflow so the layout exposes max offset and scrollbar geometry.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
Element::paragraph(
"Keyboard and wheel handling use these metrics to clamp scrolling in the demo.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(22.0),
),
]),
);
let snapshot = layout_snapshot(1, UiSize::new(240.0, 220.0), &root);
let scroll_metrics = snapshot
.interaction_tree
.scroll_metrics_for_element(scrollbox_id)
.expect("scroll box should expose scroll metrics");
assert!(scroll_metrics.max_offset_y > 0.0);
assert!(scroll_metrics.viewport_rect.size.width < 180.0);
assert!(scroll_metrics.scrollbar_track.is_some());
assert!(scroll_metrics.scrollbar_thumb.is_some());
}
#[test]
fn scroll_box_clamps_stale_offset_before_laying_out_reflowed_content() {
let scrollbox_id = ElementId::new(19);
let root = Element::column().child(
Element::scroll_box(240.0)
.id(scrollbox_id)
.width(320.0)
.height(120.0)
.padding(Edges::all(8.0))
.child(Element::paragraph(
"When the viewport becomes wider, wrapped scroll-box content can get much \
shorter. Layout should clamp any now-invalid stale offset before positioning \
the children so the viewport does not open up an empty gap at the bottom.",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(22.0)
.with_wrap(TextWrap::Word),
)),
);
let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root);
let scroll_metrics = snapshot
.interaction_tree
.scroll_metrics_for_element(scrollbox_id)
.expect("scroll box should expose scroll metrics");
let visible_text = snapshot
.scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("scroll box should still emit text");
let text_bounds = visible_text
.bounds
.expect("prepared text should expose bounds for wrapped content");
let text_bottom = visible_text.origin.y + text_bounds.height;
let viewport_bottom =
scroll_metrics.viewport_rect.origin.y + scroll_metrics.viewport_rect.size.height;
assert!(scroll_metrics.offset_y <= scroll_metrics.max_offset_y);
assert!(text_bottom >= viewport_bottom - 1.0);
}
#[test]
fn scroll_box_preserves_requested_offset_for_direct_text_children() {
let scrollbox_id = ElementId::new(23);
let root = Element::column().child(
Element::scroll_box(240.0)
.id(scrollbox_id)
.width(320.0)
.height(120.0)
.padding(Edges::all(8.0))
.child(Element::text(
"line 01\nline 02\nline 03\nline 04\nline 05\nline 06\nline 07\nline 08\nline 09\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15\nline 16\nline 17\nline 18\nline 19\nline 20\nline 21\nline 22\nline 23\nline 24\nline 25\nline 26",
TextStyle::new(16.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(20.0),
)),
);
let snapshot = layout_snapshot(1, UiSize::new(360.0, 220.0), &root);
let scroll_metrics = snapshot
.interaction_tree
.scroll_metrics_for_element(scrollbox_id)
.expect("scroll box should expose scroll metrics");
let visible_text = snapshot
.scene
.items
.iter()
.find_map(|item| match item {
DisplayItem::Text(text) => Some(text),
_ => None,
})
.expect("scroll box should emit direct text children");
assert!(scroll_metrics.max_offset_y > 240.0);
assert_eq!(scroll_metrics.offset_y, 240.0);
assert!(visible_text.origin.y < scroll_metrics.viewport_rect.origin.y);
}
#[test]
fn interaction_tree_hit_test_returns_deepest_pointer_target() {
let root = Element::column()
.id(ElementId::new(1))
.children([Element::new()
.id(ElementId::new(2))
.height(80.0)
.background(Color::rgb(0x22, 0x33, 0x44))
.child(
Element::new()
.id(ElementId::new(3))
.width(120.0)
.height(40.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
)]);
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
let hit = snapshot
.interaction_tree
.hit_test(Point::new(20.0, 20.0))
.expect("point should hit nested child");
assert_eq!(hit.element_id, Some(ElementId::new(3)));
assert_eq!(hit.path.segments(), &[0, 0]);
}
#[test]
fn interaction_tree_skips_pointer_disabled_node_and_falls_back_to_parent() {
let root = Element::column().id(ElementId::new(1)).child(
Element::new()
.id(ElementId::new(2))
.height(80.0)
.background(Color::rgb(0x22, 0x33, 0x44))
.child(
Element::new()
.id(ElementId::new(3))
.width(120.0)
.height(40.0)
.pointer_events(false)
.background(Color::rgb(0x44, 0x55, 0x66)),
),
);
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
let hit = snapshot
.interaction_tree
.hit_test(Point::new(20.0, 20.0))
.expect("point should still hit parent");
assert_eq!(hit.element_id, Some(ElementId::new(2)));
}
#[test]
fn rounded_corner_hit_test_excludes_clipped_corner_region() {
let root = Element::column().pointer_events(false).child(
Element::column()
.id(ElementId::new(2))
.width(120.0)
.height(80.0)
.corner_radius(20.0)
.background(Color::rgb(0x22, 0x33, 0x44)),
);
let snapshot = layout_snapshot(1, UiSize::new(160.0, 120.0), &root);
assert!(
snapshot
.interaction_tree
.hit_test(Point::new(4.0, 4.0))
.is_none()
);
let hit = snapshot
.interaction_tree
.hit_test(Point::new(24.0, 24.0))
.expect("point inside rounded body should hit child");
assert_eq!(hit.element_id, Some(ElementId::new(2)));
}
#[test]
fn interaction_tree_hit_path_includes_pointer_ancestors() {
let root = Element::column().pointer_events(false).child(
Element::new()
.id(ElementId::new(2))
.height(80.0)
.background(Color::rgb(0x22, 0x33, 0x44))
.child(
Element::new()
.id(ElementId::new(3))
.width(120.0)
.height(40.0)
.background(Color::rgb(0x44, 0x55, 0x66)),
),
);
let snapshot = layout_snapshot(1, UiSize::new(320.0, 200.0), &root);
let hit_path = snapshot.interaction_tree.hit_path(Point::new(20.0, 20.0));
let hit_ids: Vec<Option<ElementId>> =
hit_path.iter().map(|target| target.element_id).collect();
assert_eq!(
hit_ids,
vec![Some(ElementId::new(2)), Some(ElementId::new(3))]
);
}
#[test]
fn interaction_tree_exposes_prepared_text_for_text_nodes() {
let text_id = ElementId::new(9);
let root = Element::column().child(
Element::paragraph(
"Selection should be able to map pointer positions back into prepared text.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF)).with_line_height(24.0),
)
.id(text_id),
);
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
let prepared_text = snapshot
.interaction_tree
.text_for_element(text_id)
.expect("text node should expose prepared text");
let text_hit = snapshot
.interaction_tree
.text_hit_test(Point::new(12.0, 12.0))
.expect("point should hit prepared text");
assert_eq!(prepared_text.element_id, Some(text_id));
assert_eq!(text_hit.target.element_id, Some(text_id));
assert!(text_hit.byte_offset <= prepared_text.text.len());
}
#[test]
fn interaction_tree_skips_unselectable_text_nodes() {
let text_id = ElementId::new(10);
let root = Element::column().child(
Element::paragraph(
"Titles and labels can opt out of text selection.",
TextStyle::new(18.0, Color::rgb(0xFF, 0xFF, 0xFF))
.with_line_height(24.0)
.with_selectable(false),
)
.id(text_id),
);
let snapshot = layout_snapshot(1, UiSize::new(420.0, 240.0), &root);
assert!(
snapshot
.interaction_tree
.text_for_element(text_id)
.is_some()
);
assert!(
snapshot
.interaction_tree
.text_hit_test(Point::new(12.0, 12.0))
.is_none()
);
}
// --- incremental layout cache tests ---
fn text_style() -> TextStyle {
TextStyle::new(14.0, Color::rgb(0xFF, 0xFF, 0xFF))
}
fn make_tree(n: usize) -> Element {
let mut root = Element::new().direction(FlexDirection::Column);
for i in 0..n {
root = root.child(Element::text(format!("item {i}"), text_style()));
}
root
}
#[test]
fn stable_layout_produces_equal_snapshots() {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
let root = make_tree(10);
let size = UiSize::new(400.0, 600.0);
let snap1 = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
let snap2 = layout_snapshot_with_cache(2, size, &root, &mut text_system, &mut layout_cache);
assert_eq!(
snap1.scene.items, snap2.scene.items,
"scene items should be identical"
);
assert_eq!(
snap1.interaction_tree.root, snap2.interaction_tree.root,
"interaction trees should be identical"
);
}
#[test]
fn cache_effectiveness_single_change() {
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
// Build a 200-node tree; warm up the cache.
let root = make_tree(200);
let size = UiSize::new(400.0, 4000.0);
layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
// Change one node and re-layout.
let mut root2 = root;
root2.children[100] = Element::text("CHANGED", text_style());
let _ = layout_snapshot_with_cache(2, size, &root2, &mut text_system, &mut layout_cache);
let hits = layout_cache.results.len();
// At least 199 of 200 children should be cached (one changed).
// The root (container) also misses since its hash changed.
// Total cache entries: 200 children (199 hit on second pass + 1 new) + root.
// We verify that we have substantially more hits than misses on the second pass
// by checking that the cache has entries for at least 199 children.
assert!(hits >= 199, "expected >= 199 cache entries, got {hits}");
}
#[test]
fn scroll_culling_skips_off_screen_children() {
use crate::tree::ScrollbarStyle;
let mut text_system = TextSystem::new();
let mut layout_cache = LayoutCache::new();
// Scroll box showing roughly 5 items (each 40px tall), 100 total.
let item_height = 40.0;
let viewport_height = 200.0;
let n = 100;
let mut scroll_box = Element::scroll_box(0.0)
.width(400.0)
.height(viewport_height);
for i in 0..n {
scroll_box = scroll_box.child(
Element::new()
.height(item_height)
.child(Element::text(format!("item {i}"), text_style())),
);
}
let root = Element::new().child(scroll_box);
let size = UiSize::new(400.0, viewport_height);
// Hack: we need LayoutPerfStats to check viewport_culled.
// Instead, test indirectly: scene items for off-screen text should be absent.
let snap = layout_snapshot_with_cache(1, size, &root, &mut text_system, &mut layout_cache);
// Only text items within the 200px viewport should appear in the scene.
let text_items = snap
.scene
.items
.iter()
.filter(|item| matches!(item, DisplayItem::Text(_)))
.count();
// At most 6 text items should be visible (5 fit + 1 partial).
assert!(
text_items <= 6,
"expected <= 6 text items in scene, got {text_items} (culling not working)"
);
assert!(
text_items >= 4,
"expected >= 4 text items visible, got {text_items}"
);
}
}