//! Tessera's native visual protocols over Aster's managed object graph. use std::sync::{Arc, Mutex}; use anyhow::{Result, bail}; use endor_core::{ ContextNativeFunction, EndorReal, EndorTypeObject, NativeCallable, NativeContext, NativeResult, ObjectRef, TypeConstructor, Value, Vm, VmError, WeakObjectRef, }; use crate::{ block::{self, Child, Data, Kind}, document::parse_color, }; pub const MAX_UI_NODES: usize = 4096; pub const MAX_PAGES: usize = 64; pub const MAX_DIMENSION: f64 = 1_000_000.0; pub const TYPE_NAMES: &[&str] = &[ "Object", "Type", "Function", "Block", "ContainerBlock", "TextBlock", "RectBlock", "Spacer", "Row", "Column", "Button", "Page", "Mount", "Event", "Subscription", ]; pub const QUERY_NAMES: &[&str] = &["isInstance", "isSubtype"]; #[derive(Debug, Clone, PartialEq)] pub struct UiNode { pub block_id: String, pub slots: Vec, pub interactive: bool, pub kind: UiKind, } impl UiNode { pub fn children(&self) -> &[Arc] { match &self.kind { UiKind::Column { children, .. } | UiKind::Row { children, .. } | UiKind::Button { children, .. } => children, _ => &[], } } pub fn shallow_eq(&self, other: &Self) -> bool { if self.block_id != other.block_id || self.slots != other.slots || self.interactive != other.interactive { return false; } let same_children = || { self.children().len() == other.children().len() && self .children() .iter() .zip(other.children()) .all(|(a, b)| Arc::ptr_eq(a, b)) }; match (&self.kind, &other.kind) { ( UiKind::Text { value: a, size: sa, color: ca, }, UiKind::Text { value: b, size: sb, color: cb, }, ) => a == b && sa == sb && ca == cb, ( UiKind::Rect { width: a, height: b, radius: c, color: d, }, UiKind::Rect { width: e, height: f, radius: g, color: h, }, ) => (a, b, c, d) == (e, f, g, h), (UiKind::Spacer(a), UiKind::Spacer(b)) => a == b, ( UiKind::Column { padding: a, gap: b, .. }, UiKind::Column { padding: c, gap: d, .. }, ) | ( UiKind::Row { padding: a, gap: b, .. }, UiKind::Row { padding: c, gap: d, .. }, ) | ( UiKind::Button { padding: a, gap: b, .. }, UiKind::Button { padding: c, gap: d, .. }, ) => a == c && b == d && same_children(), _ => false, } } } #[derive(Debug, Clone, PartialEq)] pub enum UiKind { Button { padding: f32, gap: f32, children: Vec>, }, Text { value: String, size: u32, color: String, }, Rect { width: f32, height: f32, radius: f32, color: String, }, Column { padding: f32, gap: f32, children: Vec>, }, Row { padding: f32, gap: f32, children: Vec>, }, Spacer(f32), } #[derive(Debug)] struct Session { desktop: ObjectRef, tabs: ObjectRef, active: Option, pending: Option>, nodes: Vec, last_snapshot: Option>, render_cache: crate::snapshot::RenderCache, layout: crate::layout::LayoutRuntime, mounts: crate::mount::MountTable, published_version: u64, snapshot_visited: usize, snapshot_reused: usize, } type Shared = Arc>; #[derive(Debug, Clone, Copy)] enum Operation { IsInstance, IsSubtype, OpenValue, Mounts, Stats, State, StateSet, Increment, TextOf, Text, Rect, Spacer, Row, Column, Button, Page, SetText, SetColor, Append, Remove, Replace, Move, Children, Render, Unmount, Open, Close, Activate, Navigate, Back, Forward, OnClick, On, Dispatch, OnEvent, Click, Present, } #[derive(Debug)] struct Function { operation: Operation, owner: Option, session: Shared, requested_type: Option, } /// One shared implementation on the nominal type. The VM binds receivers; /// individual Blocks no longer allocate owner-capturing method closures. #[derive(Debug)] struct TypeMethod { operation: Operation, expected: ObjectRef, session: Shared, } impl NativeCallable for TypeMethod { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let owner = object(args.first())?; if !cx.store().is_instance(owner, self.expected)? { return Err(invalid("incorrect receiver type for visual method")); } Function { operation: self.operation, owner: Some(owner), session: self.session.clone(), requested_type: None, } .invoke(cx, &args[1..]) } } /// The Type dispatcher passes the requested subclass explicitly. Native /// constructor options precede the subclass's inherited/own field arguments. #[derive(Debug)] struct NativeFactory { operation: Operation, session: Shared, } impl NativeCallable for NativeFactory { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let requested = object(args.first())?; let fields = cx .store() .payload::(requested, "Type")? .fields() .to_vec(); let values = &args[1..]; let native_count = values .len() .checked_sub(fields.len()) .ok_or(invalid("missing subclass field arguments"))?; let result = Function { operation: self.operation, owner: None, session: self.session.clone(), requested_type: Some(requested), } .invoke(cx, &values[..native_count])?; let NativeResult::Return(Value::Object(instance)) = result else { return Err(invalid("native constructor did not return an instance")); }; if cx.store().type_of(instance)? != Some(requested) { return Err(invalid( "native constructor returned an incorrect nominal type", )); } for (field, value) in fields.iter().zip(&values[native_count..]) { cx.set_attr(instance, &field.name, value.clone())?; } Ok(NativeResult::Return(Value::Object(instance))) } } fn invalid(message: &'static str) -> VmError { VmError::InvalidArgument(message) } fn object(value: Option<&Value>) -> Result { match value { Some(Value::Object(value)) => Ok(*value), _ => Err(invalid("expected an object")), } } fn text(value: Option<&Value>) -> Result { match value { Some(Value::Text(value)) => Ok(value.clone()), _ => Err(invalid("expected text")), } } fn integer(value: Option<&Value>) -> Result { match value { Some(Value::Number(value)) => value .as_i64() .and_then(|v| u64::try_from(v).ok()) .ok_or(invalid("expected a nonnegative integer")), _ => Err(invalid("expected a nonnegative integer")), } } fn color(value: Option<&Value>, default: &str) -> Result { let value = value .map(|v| text(Some(v))) .transpose()? .unwrap_or_else(|| default.into()); parse_color(&value).map_err(|_| invalid("color must use #RRGGBB or #RRGGBBAA"))?; Ok(value) } fn dimension(value: Option<&Value>) -> Result { let Some(Value::Number(number)) = value else { return Err(invalid("expected a finite nonnegative dimension")); }; let value = match number.as_real() { Some(EndorReal::Integer(v)) => v.to_string().parse::().ok(), Some(EndorReal::Decimal(v)) => v.literal().parse::().ok(), Some(EndorReal::BinaryFloat(v)) => Some(f64::from_bits(*v)), _ => None, } .filter(|v| v.is_finite() && (0.0..=MAX_DIMENSION).contains(v)) .ok_or(invalid("dimension exceeds limits"))?; Ok(value as f32) } fn bind( cx: &mut NativeContext<'_>, owner: ObjectRef, name: &'static str, operation: Operation, session: &Shared, ) -> Result<(), VmError> { let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; let function = cx.allocate(ContextNativeFunction::new( name, Function { operation, owner: Some(owner), session: session.clone(), requested_type: None, }, vec![owner, desktop], ))?; attach_desktop_type(cx, function, "Function", desktop)?; cx.set_attr(owner, name, Value::Object(function)) } fn new_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, ) -> Result { new_typed_block(cx, data, session, None) } fn new_typed_block( cx: &mut NativeContext<'_>, data: Data, session: &Shared, requested: Option, ) -> Result { let type_name = match data.kind { Kind::Text { .. } => "TextBlock", Kind::Rect { .. } => "RectBlock", Kind::Spacer(_) => "Spacer", Kind::Row => "Row", Kind::Column => "Column", Kind::Button => "Button", Kind::Page => "Page", }; let page = matches!(data.kind, Kind::Page); let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; let expected = desktop_type(cx.store(), type_name, desktop)?; let type_object = requested.unwrap_or(expected); if !cx.store().is_subtype(type_object, expected)? { return Err(invalid("native constructor cannot allocate this subclass")); } let block = block::allocate(cx, data)?; cx.attach_type(block, type_object)?; { let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; if state.nodes.len() >= MAX_UI_NODES { return Err(VmError::ExecutionLimit("4096 node budget")); } if page { let mut pages = 0; for weak in &state.nodes { if let Some(node) = cx.upgrade(*weak)? && cx.store().payload::(node, "Block")?.page { pages += 1; } } if pages >= MAX_PAGES { return Err(VmError::ExecutionLimit("64 page budget")); } } state.nodes.push(cx.store().downgrade(block)?); } Ok(block) } fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> { let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?; cx.dict_set( state.tabs, Value::Text(page.to_string()), Value::Object(page), )?; cx.set_attr(state.desktop, "pending", Value::Object(page))?; state.pending = Some(Some(page)); Ok(()) } fn present_value( cx: &mut NativeContext<'_>, value: &Value, session: &Shared, ) -> Result { if let Value::Object(block) = value && cx .store() .object(*block)? .payload() .as_any() .is::() { return Ok(*block); } let value = match value { Value::Nil => "nil".into(), Value::Bool(v) => v.to_string(), Value::Number(v) => v.to_string(), Value::Text(v) => v.clone(), Value::Object(v) => cx.store().repr(*v)?, }; if value.len() > 16_384 { return Err(invalid("presentation exceeds 16 KiB")); } new_block( cx, Data::new( Kind::Text { value, size: 1, color: "#242422".into(), }, vec![], 0.0, 0.0, ), session, ) } impl NativeCallable for Function { fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { use Operation::*; let (minimum, maximum) = match self.operation { IsInstance | IsSubtype => (2, 2), Stats | Mounts => (0, 0), State | StateSet => (1, 1), Increment => (0, 0), TextOf => (2, 4), Text => (1, 3), Rect => (2, 4), OpenValue | Spacer | SetText | SetColor | Append | Remove | Render | Navigate | OnClick | Present => (1, 1), Row | Column => (2, MAX_UI_NODES + 2), Button => (1, 1), Replace | Move => (2, 2), Page | Unmount | Open | Close | Activate | Back | Forward | Children => (0, 0), Click => (0, 1), On => (2, 3), Dispatch => (1, 2), OnEvent => (1, 1), }; if !(minimum..=maximum).contains(&args.len()) { return Err(invalid("incorrect UI method argument count")); } if matches!(self.operation, IsInstance | IsSubtype) { let candidate = object(args.first())?; let expected = object(args.get(1))?; let value = if matches!(self.operation, IsInstance) { cx.store().is_instance(candidate, expected)? } else { cx.store().is_subtype(candidate, expected)? }; return Ok(NativeResult::Return(Value::Bool(value))); } if matches!(self.operation, Mounts) { let owner = self.owner.ok_or(invalid("mount query requires a Block"))?; let session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; let mut positions = Vec::new(); for (id, weak) in &session.mounts.active { if let Some(object) = cx.upgrade(*weak)? && cx .store() .payload::(object, "Mount")? .block == owner { positions.push((*id, object)); } } positions.sort_unstable_by_key(|(id, _)| *id); let set = crate::mount::collection( cx, positions.into_iter().map(|(_, object)| object).collect(), )?; return Ok(NativeResult::Return(Value::Object(set))); } if matches!(self.operation, Stats) { let session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; let mut blocks = 0_i64; let mut pages = 0_i64; for weak in &session.nodes { if let Some(object) = cx.upgrade(*weak)? { blocks += 1; if cx.store().payload::(object, "Block")?.page { pages += 1; } } } let objects = cx.store().object_count() as i64; let tabs = cx.store().dict_items(session.tabs)?.len() as i64; let stats = cx.new_object()?; for (name, value) in [ ("objects", objects), ("blocks", blocks), ("pages", pages), ("tabs", tabs), ("mounts", session.mounts.active.len() as i64), ("snapshotVisited", session.snapshot_visited as i64), ("snapshotReused", session.snapshot_reused as i64), ("measured", session.layout.measured as i64), ("arranged", session.layout.arranged as i64), ] { cx.set_attr(stats, name, Value::Number(value.into()))?; } return Ok(NativeResult::Return(Value::Object(stats))); } if matches!(self.operation, State) { let state = cx.new_object()?; attach_named_type(cx, state, "Object", &self.session)?; cx.set_attr(state, "value", args[0].clone())?; bind(cx, state, "set", StateSet, &self.session)?; bind(cx, state, "increment", Increment, &self.session)?; return Ok(NativeResult::Return(Value::Object(state))); } if matches!(self.operation, StateSet | Increment) { let owner = self.owner.ok_or(invalid("state method has no owner"))?; let value = if matches!(self.operation, StateSet) { args[0].clone() } else { let Value::Number(value) = cx.store().get_attr(owner, "value")? else { return Err(invalid("increment requires an integer state")); }; let next = value .as_i64() .and_then(|n| n.checked_add(1)) .ok_or(invalid("integer state increment exceeds its range"))?; Value::Number(next.into()) }; cx.set_attr(owner, "value", value)?; return Ok(NativeResult::Return(Value::Nil)); } if matches!(self.operation, TextOf) { let source = object(args.first())?; let field = text(args.get(1))?; cx.store().get_attr(source, &field)?; let size = args .get(2) .map(|v| integer(Some(v))) .transpose()? .unwrap_or(1); if !(1..=8).contains(&size) { return Err(invalid("text size must be 1 through 8")); } let mut data = Data::new( Kind::Text { value: String::new(), size: size as u32, color: color(args.get(3), "#242422")?, }, vec![], 0.0, 0.0, ); data.text_binding = Some((source, field)); return Ok(NativeResult::Return(Value::Object(new_block( cx, data, &self.session, )?))); } let constructor = match self.operation { Text => Some(Data::new( Kind::Text { value: text(args.first())?, size: args .get(1) .map(|v| integer(Some(v))) .transpose()? .unwrap_or(1) .try_into() .map_err(|_| invalid("text size exceeds limits"))?, color: color(args.get(2), "#242422")?, }, vec![], 0.0, 0.0, )), Rect => Some(Data::new( Kind::Rect { width: dimension(args.first())?, height: dimension(args.get(1))?, radius: args .get(2) .map(|v| dimension(Some(v))) .transpose()? .unwrap_or(0.0), color: color(args.get(3), "#DCE8FF")?, }, vec![], 0.0, 0.0, )), Spacer => Some(Data::new( Kind::Spacer(dimension(args.first())?), vec![], 0.0, 0.0, )), Row | Column => Some(Data::new( if matches!(self.operation, Row) { Kind::Row } else { Kind::Column }, args[2..] .iter() .map(|v| object(Some(v))) .collect::>()?, dimension(args.first())?, dimension(args.get(1))?, )), Page => Some(Data::new(Kind::Page, vec![], 0.0, 0.0)), Button => { let label = new_block( cx, Data::new( Kind::Text { value: text(args.first())?, size: 1, color: "#243047".into(), }, vec![], 0.0, 0.0, ), &self.session, )?; Some(Data::new(Kind::Button, vec![label], 10.0, 0.0)) } _ => None, }; if let Some(data) = constructor { if let Kind::Text { size, value, .. } = &data.kind && (!(1..=8).contains(size) || value.len() > 16_384) { return Err(invalid("text exceeds size limits")); } return Ok(NativeResult::Return(Value::Object(new_typed_block( cx, data, &self.session, self.requested_type, )?))); } if matches!(self.operation, OpenValue) { let root = present_value(cx, &args[0], &self.session)?; let page = if cx.store().payload::(root, "Block")?.page { root } else { new_block( cx, Data::new(Kind::Page, vec![root], 0.0, 0.0), &self.session, )? }; show(cx, page, &self.session)?; return Ok(NativeResult::Return(Value::Object(page))); } if matches!(self.operation, Present) { return Ok(NativeResult::Return(Value::Object(present_value( cx, &args[0], &self.session, )?))); } let owner = self.owner.ok_or(invalid("method has no owner"))?; let (data_ref, data) = block::resolve(cx.store(), owner)?; let mut data = data.clone(); let mut result = Value::Nil; match self.operation { SetText => match &mut data.kind { Kind::Text { value, .. } => { let next = text(args.first())?; if next.len() > 16_384 { return Err(invalid("text exceeds 16 KiB")); } *value = next; data.text_binding = None; } _ => return Err(invalid("setText requires a text Block")), }, SetColor => match &mut data.kind { Kind::Text { color: value, .. } | Kind::Rect { color: value, .. } => { *value = color(args.first(), "#242422")? } _ => return Err(invalid("this Block has no color")), }, Append | Remove | Replace | Move => { if !data.is_container() { return Err(invalid("expected a container Block")); } if matches!(self.operation, Append) { let child = object(args.first())?; block::validate(cx.store(), child, Some(data_ref))?; let slot = data.next_slot; data.next_slot = slot.checked_add(1).ok_or(VmError::IdentityExhausted)?; data.children.push(Child { slot, block: child }); result = Value::Number((slot as i64).into()); } else { let slot = integer(args.first())?; let index = data .children .iter() .position(|v| v.slot == slot) .ok_or(invalid("child slot does not exist"))?; match self.operation { Remove => { data.children.remove(index); } Replace => { let child = object(args.get(1))?; block::validate(cx.store(), child, Some(data_ref))?; data.children[index].block = child; } Move => { let to = integer(args.get(1))? as usize; if to >= data.children.len() { return Err(invalid("child position out of range")); } let child = data.children.remove(index); data.children.insert(to, child); } _ => unreachable!(), } } } Children => { let result = cx.new_dict()?; for child in data.children { cx.dict_set( result, Value::Text(child.slot.to_string()), Value::Object(child.block), )?; } return Ok(NativeResult::Return(Value::Object(result))); } Render | Navigate => { if !matches!(data.kind, Kind::Page) { return Err(invalid("expected a Page")); } let root = object(args.first())?; block::validate(cx.store(), root, Some(data_ref))?; if matches!(self.operation, Navigate) { if data.history.is_empty() { if let Some(old) = data.children.first() { data.history.push(old.block); } } else { data.history.truncate(data.history_index + 1); } data.history.push(root); data.history_index = data.history.len() - 1; } data.children = vec![Child { slot: 0, block: root, }]; data.next_slot = data.next_slot.max(1); show(cx, owner, &self.session)?; } Back | Forward => { if data.history.is_empty() { return Ok(NativeResult::Return(Value::Nil)); } let next = if matches!(self.operation, Back) { data.history_index.saturating_sub(1) } else { (data.history_index + 1).min(data.history.len() - 1) }; data.history_index = next; data.children = vec![Child { slot: 0, block: data.history[next], }]; show(cx, owner, &self.session)?; } Open | Activate => { show(cx, owner, &self.session)?; return Ok(NativeResult::Return(Value::Object(owner))); } Unmount => { data.children.clear(); } Close => { let mut session = self .session .lock() .map_err(|_| invalid("UI session poisoned"))?; cx.dict_delete(session.tabs, &Value::Text(owner.to_string()))?; if session.active == Some(owner) || session.pending == Some(Some(owner)) { let next = cx.store() .dict_items(session.tabs)? .first() .and_then(|(_, value)| { if let Value::Object(page) = value { Some(*page) } else { None } }); cx.set_attr( session.desktop, "pending", next.map(Value::Object).unwrap_or(Value::Nil), )?; session.pending = Some(next); } return Ok(NativeResult::Return(Value::Nil)); } OnClick => { let handler = object(args.first())?; data.click = Some(handler); result = Value::Object(owner); } On => { let kind = text(args.first())?; if kind.is_empty() || kind.len() > 128 { return Err(invalid("invalid event kind")); } let handler = object(args.get(1))?; let capture = match args.get(2) { None | Some(Value::Bool(false)) => false, Some(Value::Bool(true)) => true, Some(Value::Text(phase)) if phase == "capture" => true, Some(Value::Text(phase)) if phase == "bubble" => false, _ => return Err(invalid("phase must be capture or bubble")), }; let mut active = Vec::new(); for token in &data.subscriptions { if cx .store() .payload::(*token, "Connection")? .handler .is_some() { active.push(*token); } } if active.len() >= MAX_UI_NODES { return Err(invalid("too many event subscriptions")); } let connection = cx.allocate(crate::event_dispatch::Connection { kind, handler: Some(handler), capture, })?; let token = cx.allocate(crate::event_dispatch::Subscription { connection: cx.store().downgrade(connection)?, })?; attach_named_type(cx, token, "Subscription", &self.session)?; active.push(connection); data.subscriptions = active; result = Value::Object(token); } OnEvent => return Ok(NativeResult::Return(Value::Nil)), Click | Dispatch => { let desktop = self .session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; let (event, mount) = if matches!(self.operation, Click) { let definition = desktop_type(cx.store(), "Event", desktop)?; ( crate::event_dispatch::new_event(cx, definition, "click")?, args.first(), ) } else { (object(args.first())?, args.get(1)) }; let mount = mount.map(|value| object(Some(value))).transpose()?; return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop); } _ => return Err(invalid("unsupported Block operation")), } cx.replace_payload(data_ref, data)?; Ok(NativeResult::Return(result)) } } pub struct UiBridge { pub ui: ObjectRef, session: Shared, } impl UiBridge { pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> { vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?; for name in TYPE_NAMES.iter().chain(QUERY_NAMES) { let value = vm.get_attr(self.ui, name)?; vm.dict_set(globals, Value::Text((*name).into()), value)?; } Ok(()) } pub fn has_page(&self) -> Result { Ok(self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))? .pending .flatten() .is_some()) } pub fn open_value(&self, vm: &mut Vm, value: &Value) -> Result<()> { let function = object(Some(&vm.get_attr(self.ui, "open")?))?; let page = vm.call(function, std::slice::from_ref(value))?; vm.release_value(page)?; Ok(()) } pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> { let (weak, desktop) = { let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; if state.published_version != input.layout_version { bail!("event targets an obsolete presentation"); } ( *state .mounts .active .get(&input.mount) .ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?, state.desktop, ) }; let mount = vm .upgrade(weak)? .ok_or_else(|| anyhow::anyhow!("Mount has expired"))?; let result = (|| { let data = vm.payload::(mount, "Mount")?.clone(); if !data.mounted { bail!("event targets an obsolete presentation"); } if !data.geometry.interactive { bail!("Mount does not accept input"); } vm.set_attr(desktop, "focus", Value::Number((input.mount as i64).into()))?; vm.set_attr(desktop, "focusRequested", Value::Bool(true))?; let (owner, name, args) = match input.kind.as_str() { "click" => (data.block, "click", vec![Value::Object(mount)]), "focus" => (mount, "requestFocus", vec![]), _ => bail!("unsupported native event"), }; let bound = vm.bind_attr(owner, name)?; let result = match &bound { Value::Object(function) => vm.call_with_limits( *function, &args, endor_core::ExecutionLimits { max_frames: 128, max_instructions: 1_000_000, }, ), _ => Err(invalid("event method is not callable")), }; vm.release_value(bound)?; vm.release_value(result?)?; Ok(()) })(); vm.release(mount)?; result } pub fn reclaim(&self, vm: &mut Vm, _roots: &mut Vec) -> Result<()> { vm.collect_cycles(); let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let mut live = Vec::new(); for weak in &state.nodes { if let Some(object) = vm.upgrade(*weak)? { vm.release(object)?; live.push(*weak); } } state.nodes = live; state.render_cache.prune(vm.object_store()); Ok(()) } pub fn discard_pending(&self, vm: &mut Vm) -> Result<()> { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; state.pending = None; vm.set_attr(state.desktop, "pending", Value::Nil)?; if state.active.is_some_and(|v| vm.object(v).is_err()) { state.active = None; } Ok(()) } pub fn prepare_frame(&self, vm: &mut Vm) -> Result>>> { let changes = vm.take_changes(); let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let active = state.pending.unwrap_or(state.active); state.render_cache.invalidate(changes); let root = active .map(|v| state.render_cache.snapshot(vm.object_store(), v)) .transpose()?; let same = match (&root, &state.last_snapshot) { (Some(a), Some(b)) => Arc::ptr_eq(a, b), (None, None) => true, _ => false, }; let focus_changed = matches!( vm.get_attr(state.desktop, "focusRequested"), Ok(Value::Bool(true)) ); let changed = state.pending.is_some() || !same || focus_changed; if changed { state.snapshot_visited = state.render_cache.visited; state.snapshot_reused = state.render_cache.reused; } Ok(changed.then_some(root)) } pub fn commit_frame( &self, vm: &mut Vm, snapshot: Option>, frame: &crate::document::Frame, ) -> Result<()> { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let page = state.pending.unwrap_or(state.active); let desktop = state.desktop; let nodes = state.nodes.clone(); state.mounts.synchronize(vm, desktop, page, nodes, frame)?; if snapshot.is_none() { state.layout.clear_occurrences(); } if let Some(page) = state.pending.take() { state.active = page; } vm.set_attr(state.desktop, "pending", Value::Nil)?; vm.set_attr(state.desktop, "focusRequested", Value::Bool(false))?; state.last_snapshot = snapshot; state.published_version = frame.layout_version; Ok(()) } pub fn layout_frame(&self, vm: &Vm, root: &UiNode) -> Result { let mut state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; let mut frame = state.layout.frame(root)?; frame.focus = match vm.get_attr(state.desktop, "focus") { Ok(Value::Number(id)) => id.as_i64().and_then(|n| u64::try_from(n).ok()), _ => None, }; frame.focus = frame .focus .filter(|id| frame.mounts.iter().any(|m| m.id == *id && m.interactive)); Ok(frame) } pub fn rendered(&self, vm: &Vm) -> Result>> { let state = self .session .lock() .map_err(|_| anyhow::anyhow!("UI session poisoned"))?; state .pending .flatten() .map(|v| block::snapshot(vm.object_store(), v)) .transpose() .map_err(Into::into) } } pub fn install(vm: &mut Vm, roots: &mut Vec) -> Result { let desktop = vm.new_object()?; roots.push(desktop); let tabs = vm.new_dict()?; roots.push(tabs); vm.set_attr(desktop, "tabs", Value::Object(tabs))?; let ui = vm.new_object()?; roots.push(ui); let session = Arc::new(Mutex::new(Session { desktop, tabs, active: None, pending: None, nodes: vec![], last_snapshot: None, render_cache: crate::snapshot::RenderCache::default(), layout: crate::layout::LayoutRuntime::default(), mounts: crate::mount::MountTable::default(), published_version: 0, snapshot_visited: 0, snapshot_reused: 0, })); for (name, operation) in [ ("isInstance", Operation::IsInstance), ("isSubtype", Operation::IsSubtype), ("open", Operation::OpenValue), ("text", Operation::Text), ("rect", Operation::Rect), ("spacer", Operation::Spacer), ("row", Operation::Row), ("column", Operation::Column), ("button", Operation::Button), ("page", Operation::Page), ("present", Operation::Present), ("state", Operation::State), ("textOf", Operation::TextOf), ("stats", Operation::Stats), ] { let function = vm.allocate(ContextNativeFunction::new( name, Function { operation, owner: None, session: session.clone(), requested_type: None, }, vec![desktop], ))?; roots.push(function); vm.set_attr(ui, name, Value::Object(function))?; } let root_types = vm.bootstrap_types()?; roots.extend([root_types.object, root_types.type_type]); vm.set_attr(ui, "Object", Value::Object(root_types.object))?; vm.set_attr(ui, "Type", Value::Object(root_types.type_type))?; let mut definitions = vec![ ("Object", root_types.object), ("Type", root_types.type_type), ]; for (name, base_name, constructor_name) in [ ("Function", "Object", None), ("Block", "Object", None), ("ContainerBlock", "Block", None), ("TextBlock", "Block", Some("text")), ("RectBlock", "Block", Some("rect")), ("Spacer", "Block", Some("spacer")), ("Row", "ContainerBlock", Some("row")), ("Column", "ContainerBlock", Some("column")), ("Button", "ContainerBlock", Some("button")), ("Page", "ContainerBlock", Some("page")), ("Mount", "Object", None), ("Event", "Object", Some("event")), ("Subscription", "Object", None), ] { let base = object(Some(&vm.get_attr(ui, base_name)?))?; let constructor = match constructor_name { Some("event") => { let factory = vm.allocate(ContextNativeFunction::new( "event", crate::event_dispatch::Constructor, vec![], ))?; roots.push(factory); let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; vm.attach_type(factory, function_type)?; TypeConstructor::NativeTyped(factory) } Some(constructor_name) => { let operation = match constructor_name { "text" => Operation::Text, "rect" => Operation::Rect, "spacer" => Operation::Spacer, "row" => Operation::Row, "column" => Operation::Column, "button" => Operation::Button, "page" => Operation::Page, _ => return Err(invalid("unknown native type allocator").into()), }; let factory = vm.allocate(ContextNativeFunction::new( constructor_name, NativeFactory { operation, session: session.clone(), }, vec![desktop], ))?; roots.push(factory); let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; vm.attach_type(factory, function_type)?; TypeConstructor::NativeTyped(factory) } None => TypeConstructor::Abstract, }; let definition = vm.new_type(name, base, constructor)?; roots.push(definition); vm.set_attr(ui, name, Value::Object(definition))?; definitions.push((name, definition)); } // The desktop owns the registry through ordinary traced attributes. The // Rust session only borrows handles and adds no hidden lifetime roots. let types = vm.new_dict()?; roots.push(types); for (name, definition) in definitions { vm.dict_set(types, Value::Text(name.into()), Value::Object(definition))?; } vm.set_attr(desktop, "types", Value::Object(types))?; vm.attach_type(desktop, root_types.object)?; vm.attach_type(ui, root_types.object)?; let function_type = object(Some(&vm.get_attr(ui, "Function")?))?; for name in [ "open", "text", "rect", "spacer", "row", "column", "button", "page", "present", "state", "textOf", "stats", "isInstance", "isSubtype", ] { let function = object(Some(&vm.get_attr(ui, name)?))?; vm.attach_type(function, function_type)?; } for (type_name, name, operation) in [ ("Block", "mounts", Operation::Mounts), ("Block", "onClick", Operation::OnClick), ("Block", "on", Operation::On), ("Block", "dispatch", Operation::Dispatch), ("Block", "onEvent", Operation::OnEvent), ("Block", "click", Operation::Click), ("TextBlock", "setText", Operation::SetText), ("TextBlock", "setColor", Operation::SetColor), ("RectBlock", "setColor", Operation::SetColor), ("ContainerBlock", "append", Operation::Append), ("ContainerBlock", "remove", Operation::Remove), ("ContainerBlock", "replace", Operation::Replace), ("ContainerBlock", "move", Operation::Move), ("ContainerBlock", "children", Operation::Children), ("Page", "render", Operation::Render), ("Page", "unmount", Operation::Unmount), ("Page", "open", Operation::Open), ("Page", "close", Operation::Close), ("Page", "activate", Operation::Activate), ("Page", "navigate", Operation::Navigate), ("Page", "back", Operation::Back), ("Page", "forward", Operation::Forward), ] { let expected = object(Some(&vm.get_attr(ui, type_name)?))?; let method = vm.allocate(ContextNativeFunction::new( name, TypeMethod { operation, expected, session: session.clone(), }, vec![expected, desktop], ))?; roots.push(method); vm.attach_type(method, function_type)?; vm.define_method(expected, name, method)?; } let event_type = object(Some(&vm.get_attr(ui, "Event")?))?; let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?; crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?; Ok(UiBridge { ui, session }) } fn attach_named_type( cx: &mut NativeContext<'_>, object: ObjectRef, name: &str, session: &Shared, ) -> Result<(), VmError> { let desktop = session .lock() .map_err(|_| invalid("UI session poisoned"))? .desktop; attach_desktop_type(cx, object, name, desktop) } pub fn attach_desktop_type( cx: &mut NativeContext<'_>, instance: ObjectRef, name: &str, desktop: ObjectRef, ) -> Result<(), VmError> { let definition = desktop_type(cx.store(), name, desktop)?; cx.attach_type(instance, definition) } fn desktop_type( store: &endor_core::ObjectStore, name: &str, desktop: ObjectRef, ) -> Result { let types = object(Some(&store.get_attr(desktop, "types")?))?; object(store.dict_get(types, &Value::Text(name.into()))?.as_ref()) } pub fn snapshot(vm: &Vm, value: &Value) -> Result> { let Value::Object(root) = value else { bail!("main must return a Block"); }; block::snapshot(vm.object_store(), *root).map_err(Into::into) }