//! Live visual objects. All semantic references are owned by Aster's heap; //! native Arcs are reserved for immutable rendering snapshots. use std::sync::Arc; use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError}; use crate::ui_bridge::UiNode; #[derive(Debug, Clone)] pub struct Child { pub slot: u64, pub block: ObjectRef, } #[derive(Debug, Clone)] pub enum Kind { Text { value: String, size: u32, color: String, }, Rect { width: f32, height: f32, radius: f32, color: String, }, Spacer(f32), Scroll { width: f32, height: f32, }, Row, Column, Button, Page, } /// The VM owns the Block -> Cell -> Data relationship. Shared assignment /// changes the Cell to point at another Block, retaining the original identity. #[derive(Debug, Clone)] pub struct Block { pub state: ObjectRef, pub page: bool, pub tasks: Option, } impl ObjectPayload for Block { fn reference_assignment_only(&self) -> bool { true } fn attribute_target(&self, store: &ObjectStore) -> Result, VmError> { let Value::Object(value) = store.cell_get(self.state)? else { return Err(VmError::InvalidArgument("Block state is not an object")); }; if store.object(value)?.payload().as_any().is::() { Ok(Some(value)) } else { store .payload::(value, "Block data")? .properties .map(Some) .ok_or(VmError::InvalidArgument( "Block has no managed property storage", )) } } fn type_target(&self, store: &ObjectStore) -> Result, VmError> { let Value::Object(value) = store.cell_get(self.state)? else { return Err(VmError::InvalidArgument("Block state is not an object")); }; if store.object(value)?.payload().as_any().is::() { Ok(Some(value)) } else { store.payload::(value, "Block data")?; Ok(None) } } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-block" } fn allows_attributes(&self) -> bool { true } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { visit(self.state); if let Some(tasks) = self.tasks { visit(tasks); } } fn assignment_cell( &self, source: ObjectRef, store: &ObjectStore, ) -> Result, VmError> { if !store.object(source)?.payload().as_any().is::() { return Ok(None); } if store.payload::(source, "Block")?.page != self.page { return Err(VmError::InvalidArgument( "Page assignment requires another Page", )); } validate(store, source, Some(self.state))?; Ok(Some(self.state)) } } #[derive(Debug, Clone)] pub struct Data { pub kind: Kind, pub children: Vec, pub padding: f32, pub gap: f32, pub flex: f32, pub flex_policy: crate::layout::FlexPolicy, pub main_align: crate::layout::MainAlign, pub scroll_axis: crate::scroll::Axis, pub next_slot: u64, pub history: Vec, pub history_index: usize, pub bindings: Vec, pub text_binding: Option<(ObjectRef, String)>, pub click: Option, pub subscriptions: Vec, pub properties: Option, } impl Data { pub fn new(kind: Kind, children: Vec, padding: f32, gap: f32) -> Self { let next_slot = children.len() as u64; Self { kind, children: children .into_iter() .enumerate() .map(|(slot, block)| Child { slot: slot as u64, block, }) .collect(), padding, gap, flex: 0.0, flex_policy: crate::layout::FlexPolicy::default(), main_align: crate::layout::MainAlign::Start, scroll_axis: crate::scroll::Axis::Both, next_slot, history: vec![], history_index: 0, bindings: vec![], text_binding: None, click: None, subscriptions: vec![], properties: None, } } pub fn is_container(&self) -> bool { matches!( self.kind, Kind::Row | Kind::Column | Kind::Button | Kind::Page | Kind::Scroll { .. } ) } } impl ObjectPayload for Data { fn reference_assignment_only(&self) -> bool { true } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-block-data" } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { if let Some(properties) = self.properties { visit(properties); } for child in &self.children { visit(child.block); } for item in &self.history { visit(*item); } if let Some((source, _)) = &self.text_binding { visit(*source); } for binding in &self.bindings { visit(*binding); } for token in &self.subscriptions { visit(*token); } if let Some(handler) = self.click { visit(handler); } } } pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result { let page = matches!(data.kind, Kind::Page); for child in &data.children { validate(cx.store(), child.block, None)?; } let properties = cx.new_object()?; for (name, value) in native_properties(&data) { cx.set_attr(properties, name, value)?; } data.properties = Some(properties); let data = cx.allocate(data)?; let state = cx.new_cell(Some(Value::Object(data)))?; cx.allocate(Block { state, page, tasks: None, }) } /// Borrowed final data; forwarding is part of the managed graph, never an Arc /// ownership graph hidden from the VM's cycle collector. pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> { let (reference, data, _) = resolve_tracked(store, block)?; Ok((reference, data)) } pub fn resolve_tracked( store: &ObjectStore, block: ObjectRef, ) -> Result<(ObjectRef, &Data, Vec), VmError> { let mut current = block; let mut seen = std::collections::HashSet::new(); let mut dependencies = Vec::new(); loop { if !seen.insert(current) { return Err(VmError::InvalidArgument("cyclic Block reference")); } let state = store.payload::(current, "Block")?.state; dependencies.extend([current, state]); let Value::Object(value) = store.cell_get(state)? else { return Err(VmError::InvalidArgument("Block state is not an object")); }; if store.object(value)?.payload().as_any().is::() { dependencies.push(value); return Ok(( value, store.payload::(value, "Block data")?, dependencies, )); } current = value; } } /// Validate the entire finite expansion before establishing a new composition /// edge. The node count is an explicit rejected resource quota, not truncated UI. pub fn validate( store: &ObjectStore, root: ObjectRef, forbidden_state: Option, ) -> Result<(), VmError> { enum Step { Enter(ObjectRef), Exit(ObjectRef), } let mut work = vec![Step::Enter(root)]; let mut path = std::collections::HashSet::new(); let mut visits = 0; while let Some(step) = work.pop() { match step { Step::Exit(state) => { path.remove(&state); } Step::Enter(block) => { visits += 1; if visits > 10_000 { return Err(VmError::ExecutionLimit("expanded Block graph")); } let state = store.payload::(block, "Block")?.state; if forbidden_state == Some(state) || !path.insert(state) { return Err(VmError::InvalidArgument( "Block composition would create a cycle", )); } work.push(Step::Exit(state)); let Value::Object(value) = store.cell_get(state)? else { return Err(VmError::InvalidArgument("invalid Block state")); }; if forbidden_state == Some(value) { return Err(VmError::InvalidArgument( "Block composition would create a cycle", )); } if let Ok(data) = store.payload::(value, "Block data") { for child in data.children.iter().rev() { work.push(Step::Enter(child.block)); } } else { work.push(Step::Enter(value)); } } } } Ok(()) } pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result, VmError> { crate::snapshot::RenderCache::default().snapshot(store, root) } /// Native visual parameters are ordinary managed attributes too. Composition, /// histories and connections remain structural payload edges. pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> { fn number(value: f32) -> Value { Value::Number((value as f64).into()) } let mut fields = vec![ ("flex", number(data.flex)), ( "flexBasis", data.flex_policy .basis .map_or_else(|| Value::Text("auto".into()), number), ), ("flexMin", number(data.flex_policy.min)), ("flexMax", number(data.flex_policy.max)), ("flexShrink", number(data.flex_policy.shrink)), ]; match &data.kind { Kind::Text { value, size, color } => fields.extend([ ("text", Value::Text(value.clone())), ("size", Value::Number((*size as i64).into())), ("color", Value::Text(color.clone())), ]), Kind::Rect { width, height, radius, color, } => fields.extend([ ("width", number(*width)), ("height", number(*height)), ("radius", number(*radius)), ("color", Value::Text(color.clone())), ]), Kind::Scroll { width, height } => fields.extend([ ("width", number(*width)), ("height", number(*height)), ("axis", Value::Text(data.scroll_axis.name().into())), ]), Kind::Spacer(size) => fields.push(("size", number(*size))), _ => {} } if data.is_container() { fields.extend([ ("padding", number(data.padding)), ("gap", number(data.gap)), ("mainAlign", Value::Text(data.main_align.name().into())), ]); } fields } pub fn visual_data(store: &ObjectStore, data: &Data) -> Result { let mut data = data.clone(); let properties = data .properties .ok_or(VmError::InvalidArgument("missing Block properties"))?; for (name, _) in native_properties(&data) { let value = store.get_attr(properties, name)?; apply_visual_property(&mut data, name, &value)?; } if data.flex_policy.min > data.flex_policy.max { return Err(VmError::InvalidArgument("flexMin exceeds flexMax")); } Ok(data) } pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> { let bad = || VmError::InvalidArgument("invalid native visual property"); let number = || -> Result { let Value::Number(value) = value else { return Err(bad()); }; let n = value.to_string().parse::().map_err(|_| bad())?; if !n.is_finite() || !(0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(&n) { return Err(bad()); } Ok(n) }; match name { "flexBasis" => { data.flex_policy.basis = if matches!(value, Value::Nil) || matches!(value, Value::Text(text) if text == "auto") { None } else { Some(number()?) }; return Ok(()); } "flexMin" => { data.flex_policy.min = number()?; return Ok(()); } "flexMax" => { data.flex_policy.max = number()?; return Ok(()); } "flexShrink" => { data.flex_policy.shrink = number()?; return Ok(()); } _ => {} } if name == "flex" { data.flex = number()?; return Ok(()); } let container = data.is_container(); if container && name == "mainAlign" { let Value::Text(value) = value else { return Err(bad()); }; data.main_align = crate::layout::MainAlign::parse(value)?; return Ok(()); } match (&mut data.kind, name) { (Kind::Text { value: text, .. }, "text") => { let Value::Text(value) = value else { return Err(bad()); }; if value.len() > 16_384 { return Err(bad()); } text.clone_from(value); } (Kind::Text { size, .. }, "size") => { let n = number()?; if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 { return Err(bad()); } *size = n as u32; } (Kind::Text { color, .. } | Kind::Rect { color, .. }, "color") => { let Value::Text(value) = value else { return Err(bad()); }; crate::document::parse_color(value).map_err(|_| bad())?; color.clone_from(value); } (Kind::Scroll { .. }, "axis") => { let Value::Text(name) = value else { return Err(bad()); }; data.scroll_axis = crate::scroll::Axis::parse(name)?; } (Kind::Scroll { width, .. }, "width") => *width = number()?, (Kind::Scroll { height, .. }, "height") => *height = number()?, (Kind::Rect { width, .. }, "width") => *width = number()?, (Kind::Rect { height, .. }, "height") => *height = number()?, (Kind::Rect { radius, .. }, "radius") => *radius = number()?, (Kind::Spacer(size), "size") => *size = number()?, (_, "padding") if container => data.padding = number()?, (_, "gap") if container => data.gap = number()?, _ => {} } Ok(()) }