//! Iterative measure/arrange/paint over derived visual data. Every occurrence //! has its own geometry; measurement caches never become semantic object state. use crate::{ document::{Frame, MountGeometry, RenderCommand}, text::{self, TextStyle}, ui_bridge::{MAX_DIMENSION, UiKind, UiNode}, }; use anyhow::{Context as _, Result, bail}; use std::collections::{HashMap, HashSet}; const MAX_LAYOUT_VISITS: usize = 10_000; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum MainAlign { #[default] Start, Center, End, SpaceBetween, } impl MainAlign { pub fn name(self) -> &'static str { match self { Self::Start => "start", Self::Center => "center", Self::End => "end", Self::SpaceBetween => "space-between", } } pub fn parse(value: &str) -> Result { match value { "start" => Ok(Self::Start), "center" => Ok(Self::Center), "end" => Ok(Self::End), "space-between" => Ok(Self::SpaceBetween), _ => Err(endor_core::VmError::InvalidArgument( "mainAlign expects start, center, end or space-between", )), } } } /// Main-axis constraints interpreted independently by each parent occurrence. #[derive(Debug, Clone, Copy, PartialEq)] pub struct FlexPolicy { pub basis: Option, pub min: f32, pub max: f32, pub shrink: f32, } impl Default for FlexPolicy { fn default() -> Self { Self { basis: None, min: 0.0, max: MAX_DIMENSION as f32, shrink: 0.0, } } } #[derive(Default, Debug, Clone, Copy, PartialEq)] pub struct Size { pub width: f32, pub height: f32, } #[derive(Debug, Clone, PartialEq)] enum Shape { Text(String, u32, f32), Rect(f32, f32), Container(bool, f32, f32, Vec), } #[derive(Debug, Clone, Hash, PartialEq, Eq)] struct Key { parent: Option, slot: u64, block: String, } #[derive(Debug, Clone, Default)] pub struct LayoutRuntime { sizes: HashMap, ids: HashMap, next_id: u64, pub version: u64, pub measured: usize, pub arranged: usize, pub settle_passes: usize, pub slices: usize, pub viewport: Option, } struct Item<'a> { node: &'a UiNode, parent: Option, children: Vec, id: u64, constraints: [f32; 4], } fn bounded(value: f32) -> Result { if !value.is_finite() || !(0.0..=MAX_DIMENSION as f32).contains(&value) { bail!("UI layout exceeds the 1000000 coordinate/size limit"); } Ok(value) } pub fn root_constraints(viewport: Option) -> [f32; 4] { let size = viewport.unwrap_or(Size { width: MAX_DIMENSION as f32, height: MAX_DIMENSION as f32, }); [0.0, 0.0, size.width, size.height] } pub fn child_constraints(node: &UiNode, parent: [f32; 4]) -> [f32; 4] { if let UiKind::Scroll { width, height, .. } = node.kind { return arranged_child_constraints( node, parent, constrained(Size { width, height }, parent), ); } match container(node) { Some((true, padding, _)) => [ 0.0, 0.0, MAX_DIMENSION as f32, (parent[3] - padding * 2.0).max(0.0), ], Some((false, padding, _)) => [ 0.0, 0.0, (parent[2] - padding * 2.0).max(0.0), MAX_DIMENSION as f32, ], None => parent, } } /// Scroll cross-axis constraints come from this occurrence's actual viewport, /// including a source parent's allocation rather than only constructor size. pub fn arranged_child_constraints(node: &UiNode, available: [f32; 4], bounds: Size) -> [f32; 4] { if let UiKind::Scroll { padding, axis, .. } = node.kind { return [ 0.0, 0.0, if axis.horizontal() { MAX_DIMENSION as f32 } else { (bounds.width - padding * 2.0).max(0.0) }, if axis.vertical() { MAX_DIMENSION as f32 } else { (bounds.height - padding * 2.0).max(0.0) }, ]; } child_constraints(node, available) } pub fn constrained(size: Size, constraints: [f32; 4]) -> Size { Size { width: size.width.clamp(constraints[0], constraints[2]), height: size.height.clamp(constraints[1], constraints[3]), } } fn container(node: &UiNode) -> Option<(bool, f32, f32)> { match &node.kind { UiKind::Row { padding, gap, .. } => Some((true, *padding, *gap)), UiKind::Scroll { padding, gap, .. } => Some((false, *padding, *gap)), UiKind::Column { padding, gap, .. } | UiKind::Button { padding, gap, .. } => { Some((false, *padding, *gap)) } _ => None, } } pub fn main_axis(node: &UiNode) -> Option { container(node).map(|(row, _, _)| usize::from(!row)) } fn measure_shape(shape: &Shape) -> Result { let size = match shape { Shape::Text(value, size, width) => { let style = TextStyle::ui(*size); let intrinsic = text::measure(value, style); let wrapped = text::measure_wrapped(value, style, *width); Size { width: intrinsic.width.min(*width), height: wrapped.height, } } Shape::Rect(width, height) => Size { width: *width, height: *height, }, Shape::Container(row, padding, gap, children) => { let along = children .iter() .map(|s| if *row { s.width } else { s.height }) .sum::() + padding * 2.0 + gap * children.len().saturating_sub(1) as f32; let cross = children .iter() .map(|s| if *row { s.height } else { s.width }) .fold(0.0_f32, f32::max) + padding * 2.0; if *row { Size { width: along, height: cross, } } else { Size { width: cross, height: along, } } } }; bounded(size.width)?; bounded(size.height)?; Ok(size) } fn shape(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { Ok(match &node.kind { UiKind::Text { value, size, .. } => Shape::Text(value.clone(), *size, constraints[2]), UiKind::Rect { width, height, .. } => Shape::Rect(*width, *height), UiKind::Spacer(size) => Shape::Rect(*size, *size), UiKind::Scroll { width, height, .. } => Shape::Rect(*width, *height), _ => { let (row, padding, gap) = container(node).context("unknown native visual protocol")?; Shape::Container(row, padding, gap, children) } }) } pub fn measure_native(node: &UiNode, children: Vec, constraints: [f32; 4]) -> Result { measure_shape(&shape(node, children, constraints)?) } /// Private derived scratch space for one occurrence during layout. Contains no /// managed references; source code cannot mutate it through heap attributes. #[derive(Debug, Default)] pub struct Context { pub node: Option>, pub children: Vec, pub preferred: Vec, pub bounds: Option<[f32; 4]>, } pub fn paint_native(node: &UiNode, x: f32, y: f32, size: Size) -> Option { let size_for_text = size; match &node.kind { UiKind::Text { value, size, color } => Some(RenderCommand::Text { x, y, value: value.clone(), size: *size, color: color.clone(), wrap_bounds: Some([size_for_text.width, size_for_text.height]), }), UiKind::Rect { radius, color, .. } => Some(RenderCommand::RoundedRect { x, y, width: size.width, height: size.height, radius: *radius, color: color.clone(), }), UiKind::Button { .. } => Some(RenderCommand::RoundedRect { x, y, width: size.width, height: size.height, radius: 7.0, color: "#E3EAF7".into(), }), _ => None, } } /// Allocate main-axis space with bounded growth and basis-weighted shrinking. /// Preferred sizes come from this occurrence's first measurement in the solve; /// remeasurement must not turn a prior allocation into a new intrinsic basis. pub fn flex_sizes(node: &UiNode, bounds: Size, children: &[Size], preferred: &[Size]) -> Vec { let Some((row, padding, gap)) = container(node) else { return children.to_vec(); }; let nodes = node.children(); if !nodes.iter().any(|child| child.has_flex()) { return children.to_vec(); } let space = ((if row { bounds.width } else { bounds.height }) as f64 - padding as f64 * 2.0 - gap as f64 * children.len().saturating_sub(1) as f64) .max(0.0); let bases: Vec = nodes .iter() .zip(preferred) .map(|(child, size)| { child.flex_policy.basis.unwrap_or_else(|| { if child.flex > 0.0 { 0.0 } else if row { size.width } else { size.height } }) as f64 }) .collect(); let mut sizes: Vec = nodes .iter() .zip(&bases) .map(|(child, basis)| { basis.clamp(child.flex_policy.min as f64, child.flex_policy.max as f64) }) .collect(); let free = space - sizes.iter().sum::(); let growing = free > 0.0; let weights: Vec = nodes .iter() .zip(&bases) .map(|(child, basis)| { if growing { child.flex as f64 } else { child.flex_policy.shrink as f64 * basis } }) .collect(); let limits: Vec = nodes .iter() .map(|child| { if growing { child.flex_policy.max as f64 } else { child.flex_policy.min as f64 } }) .collect(); let mut active: Vec = (0..nodes.len()) .filter(|&i| { weights[i] > 0.0 && if growing { sizes[i] < limits[i] } else { sizes[i] > limits[i] } }) .collect(); let mut remaining = free.abs(); while remaining > 0.0 && !active.is_empty() { let total = active.iter().map(|&i| weights[i]).sum::(); let mut spent = 0.0; let mut saturated = false; for &i in &active { let capacity = (limits[i] - sizes[i]).abs(); let amount = (remaining * weights[i] / total).min(capacity); sizes[i] += if growing { amount } else { -amount }; spent += amount; saturated |= amount >= capacity; } remaining = (remaining - spent).max(0.0); // Without saturation this round distributed all available space. Do not // spin on floating point residue; a saturated round freezes a child. if !saturated { break; } active.retain(|&i| { if growing { sizes[i] < limits[i] } else { sizes[i] > limits[i] } }); } let mut output = children.to_vec(); for (size, along) in output.iter_mut().zip(sizes) { if row { size.width = along as f32; } else { size.height = along as f32; } } output } pub fn arrange_native( node: &UiNode, x: f32, y: f32, bounds: Size, children: &[Size], ) -> Result> { let Some((row, padding, gap)) = container(node) else { return Ok(vec![]); }; let occupied = children .iter() .map(|child| if row { child.width } else { child.height }) .sum::() + gap * children.len().saturating_sub(1) as f32; let available = if row { bounds.width } else { bounds.height }; let spare = (available - padding * 2.0 - occupied).max(0.0); let offset = match node.main_align { MainAlign::Center => spare / 2.0, MainAlign::End => spare, _ => 0.0, }; let distributed_gap = if node.main_align == MainAlign::SpaceBetween && children.len() > 1 { spare / (children.len() - 1) as f32 } else { 0.0 }; let mut cursor = padding + offset; let mut points = Vec::with_capacity(children.len()); for child in children { let point = if row { (x + cursor, y + padding) } else { (x + padding, y + cursor) }; bounded(point.0)?; bounded(point.1)?; bounded(point.0 + child.width)?; bounded(point.1 + child.height)?; points.push(point); cursor += if row { child.width } else { child.height }; cursor += gap + distributed_gap; } Ok(points) } impl LayoutRuntime { pub fn invalidate(&mut self, block: &str) { self.sizes.remove(block); } pub fn reserve_identity_floor(&mut self, floor: u64) { self.next_id = self.next_id.max(floor); } pub fn identity_high_watermark(&self) -> u64 { self.next_id } pub fn clear_occurrences(&mut self) { self.ids.clear(); } pub fn frame(&mut self, root: &UiNode) -> Result { // Stage all cache/identity changes; a rejected frame leaves the last // valid occurrence registry intact and never publishes partial geometry. let mut next = self.clone(); let frame = next.build(root, false)?; *self = next; Ok(frame) } /// Allocate stable occurrence identities without measuring or publishing geometry. pub fn structure(&mut self, root: &UiNode) -> Result { let mut next = self.clone(); let frame = next.build(root, true)?; *self = next; Ok(frame) } pub fn measure_node( &mut self, node: &UiNode, children: Vec, constraints: [f32; 4], ) -> Result { let shape = shape(node, children, constraints)?; if let Some((_, size)) = self .sizes .get(&node.block_id) .filter(|(old, _)| *old == shape) { return Ok(*size); } self.measured += 1; let size = measure_shape(&shape)?; self.sizes.insert(node.block_id.clone(), (shape, size)); Ok(size) } fn build(&mut self, root: &UiNode, structural: bool) -> Result { self.measured = 0; self.arranged = 0; self.settle_passes = usize::from(!structural); self.slices = usize::from(!structural); let mut items = Vec::>::new(); let mut work = vec![(root, None::, 0, root_constraints(self.viewport))]; let mut used = HashSet::new(); while let Some((node, parent, slot, constraints)) = work.pop() { if items.len() >= MAX_LAYOUT_VISITS { bail!("expanded UI layout exceeds 10000 nodes"); } if node.slots.len() != node.children().len() { bail!("invalid child slot table"); } let key = Key { parent: parent.map(|i| items[i].id), slot, block: node.block_id.clone(), }; let id = if let Some(id) = self.ids.get(&key) { *id } else { self.next_id = self .next_id .checked_add(1) .context("Mount identity exhausted")?; self.ids.insert(key.clone(), self.next_id); self.next_id }; if !used.insert(key) { bail!("duplicate child slot in one presentation"); } let index = items.len(); if let Some(parent) = parent { items[parent].children.push(index); } items.push(Item { node, parent, children: vec![], id, constraints, }); for (child, slot) in node.children().iter().zip(&node.slots).rev() { work.push(( child.as_ref(), Some(index), *slot, child_constraints(node, constraints), )); } } let mut sizes = vec![Size::default(); items.len()]; if !structural { for (index, item) in items.iter().enumerate().rev() { sizes[index] = constrained( self.measure_node( item.node, item.children.iter().map(|i| sizes[*i]).collect(), item.constraints, )?, item.constraints, ); } } let mut points = vec![(0.0_f32, 0.0_f32); items.len()]; let mut mounts = Vec::with_capacity(items.len()); let mut commands = Vec::new(); let mut siblings = vec![None; items.len()]; for item in &items { for pair in item.children.windows(2) { siblings[pair[0]] = Some(items[pair[1]].id); } } for (index, item) in items.iter().enumerate() { self.arranged += usize::from(!structural); let (x, y) = points[index]; let size = sizes[index]; bounded(x)?; bounded(y)?; bounded(x + size.width)?; bounded(y + size.height)?; mounts.push(MountGeometry { id: item.id, clip: None, scroll: None, block_id: item.node.block_id.clone(), parent: item.parent.map(|i| items[i].id), first_child: item.children.first().map(|i| items[*i].id), next_sibling: siblings[index], x, y, width: size.width, height: size.height, interactive: item.node.interactive, }); if !structural && let Some(command) = paint_native(item.node, x, y, size) { commands.push(command); } if structural { continue; } let child_sizes: Vec<_> = item.children.iter().map(|child| sizes[*child]).collect(); let child_sizes = flex_sizes(item.node, size, &child_sizes, &child_sizes); for (child, allocated) in item.children.iter().zip(&child_sizes) { sizes[*child] = *allocated; } for (child, point) in item.children .iter() .zip(arrange_native(item.node, x, y, size, &child_sizes)?) { points[*child] = point; } } self.ids.retain(|key, _| used.contains(key)); let blocks: HashSet<_> = items .iter() .map(|item| item.node.block_id.as_str()) .collect(); self.sizes .retain(|block, _| blocks.contains(block.as_str())); self.version = self .version .checked_add(1) .context("layout version exhausted")?; Ok(Frame { commands, mounts, layout_version: self.version, ..Frame::default() }) } } pub fn frame(root: &UiNode) -> Result { LayoutRuntime::default().frame(root) } pub fn frame_with_viewport(root: &UiNode, viewport: Size) -> Result { LayoutRuntime { viewport: Some(viewport), ..Default::default() } .frame(root) }