use std::time::Instant; use crate::ImageFit; use crate::scene::{ 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); 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, pub rect: Rect, pub focusable: bool, pub cursor: CursorIcon, } #[derive(Clone, Debug, PartialEq)] pub struct LayoutNode { pub path: LayoutPath, pub element_id: Option, pub rect: Rect, pub corner_radius: f32, pub pointer_events: bool, pub focusable: bool, pub cursor: CursorIcon, pub prepared_image: Option, pub prepared_text: Option, pub children: Vec, } #[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 { self.hit_path(point).into_iter().last() } pub fn hit_path(&self, point: crate::scene::Point) -> Vec { 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 { 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 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 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, ); 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, 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, ) -> LayoutNode { perf_stats.nodes += 1; 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), pointer_events: element.style.pointer_events, focusable: element.style.focusable, cursor, prepared_image: None, prepared_text: None, children: Vec::new(), }; if rect.size.width <= 0.0 || rect.size.height <= 0.0 { 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(); } 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(); } return interaction; } perf_stats.container_nodes += 1; if element.children.is_empty() { return interaction; } let content = inset_rect(rect, content_insets(&element.style)); if content.size.width <= 0.0 || content.size.height <= 0.0 { return interaction; } 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; let available_main = available_main.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, ); 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); 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, ); interaction.children.push(layout_element( child, child_rect, interaction.path.child(index), scene, text_system, perf_stats, )); cursor += child_main.max(0.0) + element.style.gap; } if pushed_clip { scene.pop_clip(); } interaction } fn hit_path_node(node: &LayoutNode, point: crate::scene::Point) -> Option> { if !point_hits_node_shape(node, point) { return None; } 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 { if !point_hits_node_shape(node, point) { return None; } 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 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 intrinsic_main_size( child: &Element, direction: FlexDirection, cross_size: f32, available_main: f32, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, ) -> 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), direction, ) } fn intrinsic_size( element: &Element, available_size: UiSize, text_system: &mut TextSystem, perf_stats: &mut LayoutPerfStats, ) -> UiSize { perf_stats.intrinsic_size_calls += 1; let insets = content_insets(&element.style); 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 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.max(0.0), content_height.max(0.0)); if element.children.is_empty() { return UiSize::new( explicit_width.unwrap_or(horizontal_insets(insets)), explicit_height.unwrap_or(vertical_insets(insets)), ); } let gap_total = element.style.gap * element.children.len().saturating_sub(1) as f32; let (intrinsic_content_width, intrinsic_content_height) = 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, ); width = width.max(child.style.width.unwrap_or(child_size.width)); if !skip_main { height += child.style.height.unwrap_or(child_size.height); } } (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, ); 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, ); if !skip_main { width += child_main; } height = height.max(child.style.height.unwrap_or(child_size.height)); } (width, height) } }; UiSize::new( explicit_width.unwrap_or(intrinsic_content_width + horizontal_insets(insets)), explicit_height.unwrap_or(intrinsic_content_height + vertical_insets(insets)), ) } #[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, } 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, } } } fn prepare_image(image: &ImageNode, rect: Rect, element_id: Option) -> 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 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 { match direction { FlexDirection::Row => child.style.width, FlexDirection::Column => child.style.height, } } fn child_cross_size(child: &Element, direction: FlexDirection) -> Option { 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::{layout_scene, layout_snapshot}; use crate::scene::{Color, DisplayItem, Point, Quad, Rect, UiSize}; use crate::text::{TextStyle, TextWrap}; use crate::tree::{Edges, Element, ElementId}; #[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 = 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 = 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 = 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 = 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 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 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> = 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() ); } }