//! Managed presentation contexts. The active desktop owns the mount dictionary; //! reverse links and the host registry are weak, so shared content cannot retain a Page. use crate::document::{Frame, MountGeometry}; use endor_core::{ ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectPayload, ObjectRef, Value, Vm, VmError, WeakObjectRef, }; use std::{ collections::HashMap, sync::{Arc, Mutex}, }; #[derive(Debug, Clone)] pub struct Mount { pub block: ObjectRef, pub page: Option, pub parent: Option, pub first_child: Option, pub next_sibling: Option, pub geometry: MountGeometry, pub scroll_offset: [f32; 2], pub version: u64, pub mounted: bool, pub tasks: Option, // Private derived scratch, installed before the read-only layout call and // cleared by geometry commit. It contains no managed references or roots. pub layout: Option>>, } impl ObjectPayload for Mount { fn reference_assignment_only(&self) -> bool { true } fn as_any(&self) -> &dyn std::any::Any { self } fn kind_name(&self) -> &'static str { "tessera-mount" } fn allows_attributes(&self) -> bool { true } fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) { visit(self.block); if let Some(tasks) = self.tasks { visit(tasks); } } } #[derive(Debug, Clone, Copy)] enum Query { Bounds, ScrollTo, ScrollBy, ScrollPosition, ScrollIntoView, Parent, FirstChild, NextSibling, Page, Focus, HitTest, } #[derive(Debug)] struct Method { signature: endor_core::CallSignature, owner: ObjectRef, desktop: WeakObjectRef, query: Query, } impl NativeCallable for Method { fn signature(&self) -> Option { Some(self.signature.clone()) } fn arity(&self) -> Option { Some(endor_core::CallArity::exact( if matches!( self.query, Query::HitTest | Query::ScrollTo | Query::ScrollBy ) { 2 } else { 0 }, )) } fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result { let mount = cx.store().payload::(self.owner, "Mount")?.clone(); if !mount.mounted && matches!( self.query, Query::Bounds | Query::Focus | Query::HitTest | Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::ScrollPosition ) { return Err(VmError::InvalidArgument("Mount is no longer attached")); } let arity = if matches!( self.query, Query::HitTest | Query::ScrollTo | Query::ScrollBy ) { 2 } else { 0 }; if args.len() != arity { return Err(VmError::InvalidArgument("incorrect Mount method arity")); } let value = match self.query { Query::ScrollTo | Query::ScrollBy => { crate::scroll::set( cx, self.owner, self.desktop, [ crate::scroll::coordinate(&args[0])?, crate::scroll::coordinate(&args[1])?, ], matches!(self.query, Query::ScrollBy), )?; Value::Nil } Query::ScrollIntoView => { crate::scroll::into_view(cx, self.owner, self.desktop)?; Value::Nil } Query::ScrollPosition => { let p = mount .geometry .scroll .ok_or(VmError::InvalidArgument("Mount is not scrollable"))?; let result = cx.new_object()?; for (name, value) in [("x", p[0]), ("y", p[1]), ("maxX", p[2]), ("maxY", p[3])] { cx.set_attr(result, name, Value::Number((value as f64).into()))?; } Value::Object(result) } Query::Bounds => { let geometry = if let Some(layout) = &mount.layout { layout .lock() .map_err(|_| VmError::InvalidArgument("layout context poisoned"))? .bounds .ok_or(VmError::InvalidArgument( "Mount geometry is unavailable during measurement", ))? } else { [ mount.geometry.x, mount.geometry.y, mount.geometry.width, mount.geometry.height, ] }; let bounds = cx.new_object()?; let endor_core::CallType::Single(endor_core::AnnotationType::Nominal(definition)) = &self.signature.returns else { return Err(VmError::InvalidArgument("invalid Mount bounds signature")); }; cx.attach_type(bounds, *definition)?; for (name, value) in [ ("x", geometry[0]), ("y", geometry[1]), ("width", geometry[2]), ("height", geometry[3]), ] { cx.set_attr(bounds, name, Value::Number((value as f64).into()))?; } cx.set_attr( bounds, "layoutVersion", Value::Number((mount.version as i64).into()), )?; Value::Object(bounds) } Query::Focus => { crate::scroll::into_view(cx, self.owner, self.desktop)?; if !mount.geometry.interactive { return Err(VmError::InvalidArgument("Mount is not focusable")); } let desktop = cx .upgrade(self.desktop)? .ok_or(VmError::InvalidArgument("Mount window has expired"))?; cx.set_attr( desktop, "focus", Value::Number((mount.geometry.id as i64).into()), )?; cx.set_attr(desktop, "focusRequested", Value::Bool(true))?; Value::Nil } Query::HitTest => { if mount.layout.is_some() { return Err(VmError::InvalidArgument( "Mount hitTest is unavailable during layout", )); } let number = |value: &Value| -> Result { let Value::Number(number) = value else { return Err(VmError::InvalidArgument( "point coordinates must be numbers", )); }; let number = number .to_string() .parse::() .map_err(|_| VmError::InvalidArgument("invalid point coordinate"))?; if !number.is_finite() { return Err(VmError::InvalidArgument("invalid point coordinate")); } Ok(number) }; Value::Bool(mount.geometry.contains(crate::geometry::Point::new( number(&args[0])?, number(&args[1])?, ))) } query => { let weak = match query { Query::Parent => mount.parent, Query::FirstChild => mount.first_child, Query::NextSibling => mount.next_sibling, Query::Page => mount.page, _ => unreachable!(), }; weak.map(|weak| cx.upgrade(weak)) .transpose()? .flatten() .map(Value::Object) .unwrap_or(Value::Nil) } }; Ok(NativeResult::Return(value)) } } fn mount_signature( store: &endor_core::ObjectStore, desktop: ObjectRef, query: Query, ) -> Result { use endor_core::{AnnotationType as T, CallEffect, CallSignature, CallType}; let nominal = |name| crate::ui_bridge::desktop_type(store, name, desktop).map(T::Nominal); let returns = match query { Query::Bounds => nominal("Bounds")?.into(), Query::Parent | Query::FirstChild | Query::NextSibling => { CallType::nullable(nominal("Mount")?) } Query::Page => CallType::nullable(nominal("Page")?), Query::HitTest => T::Bool.into(), Query::ScrollPosition => T::Any.into(), _ => T::Unit.into(), }; let effect = if matches!( query, Query::ScrollTo | Query::ScrollBy | Query::ScrollIntoView | Query::Focus ) { CallEffect::Mutating } else { CallEffect::ReadOnly }; let parameters = if matches!(query, Query::HitTest | Query::ScrollTo | Query::ScrollBy) { vec![T::Number, T::Number] } else { vec![] }; let mut signature = CallSignature::exact(parameters, T::Any, effect); signature.returns = returns; Ok(signature) } #[derive(Debug, Default, Clone)] pub struct MountTable { pub active: HashMap, order: Vec, } #[derive(Debug)] struct Sync { desktop: ObjectRef, page: Option, nodes: Vec, old: HashMap, old_order: Vec, frame: Frame, preparing: bool, output: Arc>>, } #[derive(Debug)] struct LifecycleCall { function: ObjectRef, block: ObjectRef, mount: ObjectRef, unmount: bool, } impl NativeCallable for LifecycleCall { fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { if self.unmount { crate::tasks::cancel_owner(cx, self.mount)?; } Ok(NativeResult::Invoke { function: self.function, arguments: vec![Value::Object(self.block), Value::Object(self.mount)], }) } } fn lifecycle_call( cx: &mut NativeContext<'_>, mount: ObjectRef, name: &'static str, ) -> Result { let block = cx.store().payload::(mount, "Mount")?.block; let definition = cx .store() .type_of(block)? .ok_or(VmError::InvalidArgument("untyped mounted Block"))?; let function = cx .store() .lookup_method(definition, name)? .ok_or(VmError::InvalidArgument("Block has no lifecycle method"))?; cx.allocate(ContextNativeFunction::new( name, LifecycleCall { function, block, mount, unmount: name == "onUnmount", }, vec![function, block, mount], )) } impl NativeCallable for Sync { fn arity(&self) -> Option { Some(endor_core::CallArity::exact(0)) } fn invoke(&self, cx: &mut NativeContext<'_>, _args: &[Value]) -> Result { let mut blocks = HashMap::new(); for weak in &self.nodes { if let Some(block) = cx.upgrade(*weak)? { blocks.insert(block.to_string(), block); } } let page = self .page .map(|page| cx.store().downgrade(page)) .transpose()?; let mut objects = HashMap::new(); let mut created = Vec::new(); for geometry in &self.frame.mounts { let block = *blocks .get(&geometry.block_id) .ok_or(VmError::InvalidArgument( "frame references an unavailable Block", ))?; let old = self .old .get(&geometry.id) .map(|weak| cx.upgrade(*weak)) .transpose()? .flatten(); let reusable = if let Some(old) = old { let mount = cx.store().payload::(old, "Mount")?; (mount.mounted && mount.page == page && mount.block == block).then_some(old) } else { None }; let object = if let Some(old) = reusable { old } else { let object = cx.allocate(Mount { block, page, parent: None, first_child: None, next_sibling: None, geometry: geometry.clone(), scroll_offset: [0.0, 0.0], version: self.frame.layout_version, mounted: true, tasks: None, layout: None, })?; crate::ui_bridge::attach_desktop_type(cx, object, "Mount", self.desktop)?; for (name, query) in [ ("bounds", Query::Bounds), ("scrollTo", Query::ScrollTo), ("scrollBy", Query::ScrollBy), ("scrollPosition", Query::ScrollPosition), ("scrollIntoView", Query::ScrollIntoView), ("parent", Query::Parent), ("firstChild", Query::FirstChild), ("nextSibling", Query::NextSibling), ("page", Query::Page), ("requestFocus", Query::Focus), ("hitTest", Query::HitTest), ] { let function = cx.allocate(ContextNativeFunction::new( name, Method { signature: mount_signature(cx.store(), self.desktop, query)?, owner: object, desktop: cx.store().downgrade(self.desktop)?, query, }, vec![object], ))?; cx.set_attr(object, name, Value::Object(function))?; } cx.set_attr(object, "id", Value::Number((geometry.id as i64).into()))?; cx.set_attr(object, "block", Value::Object(block))?; created.push(object); object }; objects.insert(geometry.id, object); } let root = cx.new_dict()?; for geometry in &self.frame.mounts { let object = objects[&geometry.id]; let link = |id: Option| -> Result, VmError> { id.map(|id| cx.store().downgrade(objects[&id])).transpose() }; let mount = Mount { tasks: cx.store().payload::(object, "Mount")?.tasks, block: blocks[&geometry.block_id], page, parent: link(geometry.parent)?, first_child: link(geometry.first_child)?, next_sibling: link(geometry.next_sibling)?, geometry: geometry.clone(), scroll_offset: if self.preparing { cx.store().payload::(object, "Mount")?.scroll_offset } else { geometry.scroll.map(|s| [s[0], s[1]]).unwrap_or([0.0, 0.0]) }, version: self.frame.layout_version, mounted: true, layout: if self.preparing { Some(Arc::new(Mutex::new(crate::layout::Context::default()))) } else { None }, }; let old = cx.store().payload::(object, "Mount")?; if self.preparing || old.layout.is_some() || !old.mounted || old.version != mount.version || old.geometry != mount.geometry || old.page != mount.page || old.parent != mount.parent || old.first_child != mount.first_child || old.next_sibling != mount.next_sibling || matches!( cx.store().get_attr(object, "mounted"), Err(VmError::AttributeNotFound(_)) ) { cx.replace_payload(object, mount)?; cx.set_attr(object, "mounted", Value::Bool(true))?; cx.set_attr( object, "layoutVersion", Value::Number((self.frame.layout_version as i64).into()), )?; } cx.dict_set( root, Value::Text(geometry.id.to_string()), Value::Object(object), )?; } let mut hooks = Vec::new(); // Layout records are preorder; reversing guarantees children unmount // before their parents. A Page change creates fresh Mount contexts. for id in self.old_order.iter().rev() { let weak = self.old[id]; if let Some(object) = cx.upgrade(weak)? && objects.get(id) != Some(&object) { let mut mount = cx.store().payload::(object, "Mount")?.clone(); mount.mounted = false; mount.layout = None; mount.parent = None; mount.first_child = None; mount.next_sibling = None; cx.replace_payload(object, mount)?; cx.set_attr(object, "mounted", Value::Bool(false))?; hooks.push(lifecycle_call(cx, object, "onUnmount")?); } } for object in created { hooks.push(lifecycle_call(cx, object, "onMount")?); } let driver = if hooks.is_empty() { None } else { Some(crate::vm_sequence::build( cx, hooks, "", self.desktop, )?) }; let weak = objects .into_iter() .map(|(id, object)| Ok((id, cx.store().downgrade(object)?))) .collect::, VmError>>()?; cx.set_attr(self.desktop, "mounts", Value::Object(root))?; *self .output .lock() .map_err(|_| VmError::InvalidArgument("Mount registry poisoned"))? = weak; Ok(NativeResult::Return( driver.map(Value::Object).unwrap_or(Value::Nil), )) } } /// Explicitly owned roots protect the old presentation while preparation /// temporarily replaces its desktop dictionary and Mount payloads. pub struct Checkpoint { table: MountTable, dictionary: Option, states: Vec<(ObjectRef, Mount, Option, Option)>, roots: Vec, } impl Checkpoint { pub fn release(self, vm: &mut Vm) -> Result<(), VmError> { let mut result = Ok(()); for root in self.roots.into_iter().rev() { if let Err(error) = vm.release(root) { result = Err(error); } } result } pub fn restore( self, vm: &mut Vm, desktop: ObjectRef, table: &mut MountTable, ) -> Result<(), VmError> { let result = (|| { let old: std::collections::HashSet<_> = self.states.iter().map(|state| state.0).collect(); for weak in table.ordered() { if let Some(object) = vm.upgrade(weak)? { let result = (|| { if !old.contains(&object) { let mut mount = vm.payload::(object, "Mount")?.clone(); mount.mounted = false; mount.layout = None; mount.parent = None; mount.first_child = None; mount.next_sibling = None; vm.replace_payload(object, mount)?; vm.set_attr(object, "mounted", Value::Bool(false))?; } Ok::<_, VmError>(()) })(); vm.release(object)?; result?; } } for (object, mount, mounted, version) in &self.states { vm.replace_payload(*object, mount.clone())?; for (name, value) in [("mounted", mounted), ("layoutVersion", version)] { if let Some(value) = value { vm.set_attr(*object, name, value.clone())?; } else { match vm.del_attr(*object, name) { Ok(()) | Err(VmError::AttributeNotFound(_)) => {} Err(error) => return Err(error), } } } } if let Some(dictionary) = self.dictionary { vm.set_attr(desktop, "mounts", Value::Object(dictionary))?; } else { match vm.del_attr(desktop, "mounts") { Ok(()) | Err(VmError::AttributeNotFound(_)) => {} Err(error) => return Err(error), } } *table = self.table.clone(); Ok(()) })(); let released = self.release(vm); result?; released } } impl MountTable { pub fn checkpoint(&self, vm: &mut Vm, desktop: ObjectRef) -> Result { let dictionary = match vm.get_attr(desktop, "mounts") { Ok(Value::Object(dictionary)) => Some(dictionary), Err(VmError::AttributeNotFound(_)) => None, Ok(_) => return Err(VmError::InvalidArgument("invalid mount dictionary")), Err(error) => return Err(error), }; let mut roots = Vec::new(); if let Some(dictionary) = dictionary { vm.retain(dictionary)?; roots.push(dictionary); } let mut checkpoint = Checkpoint { table: self.clone(), dictionary, states: Vec::new(), roots, }; let result = (|| { for weak in self.ordered() { if let Some(object) = vm.upgrade(weak)? { checkpoint.roots.push(object); let mount = vm.payload::(object, "Mount")?.clone(); let mut attributes = Vec::new(); for name in ["mounted", "layoutVersion"] { let value = match vm.get_attr(object, name) { Ok(value) => Some(value), Err(VmError::AttributeNotFound(_)) => None, Err(error) => return Err(error), }; if let Some(Value::Object(object)) = &value { vm.retain(*object)?; checkpoint.roots.push(*object); } attributes.push(value); } checkpoint.states.push(( object, mount, attributes.remove(0), attributes.remove(0), )); } } Ok::<_, VmError>(()) })(); if let Err(error) = result { checkpoint.release(vm)?; return Err(error); } Ok(checkpoint) } /// A faulted domain cannot run user onUnmount callbacks. Still detach every /// context and release its owning dictionary through the normal heap barrier. pub fn retire(&mut self, vm: &mut Vm, desktop: ObjectRef) -> Result<(), VmError> { for weak in self.ordered().into_iter().rev() { if let Some(object) = vm.upgrade(weak)? { let result = (|| { let mut mount = vm.payload::(object, "Mount")?.clone(); mount.mounted = false; mount.layout = None; mount.parent = None; mount.first_child = None; mount.next_sibling = None; vm.replace_payload(object, mount)?; vm.set_attr(object, "mounted", Value::Bool(false)) })(); vm.release(object)?; result?; } } let dictionary = vm.new_dict()?; let result = vm.set_attr(desktop, "mounts", Value::Object(dictionary)); vm.release(dictionary)?; result?; self.active.clear(); self.order.clear(); Ok(()) } pub fn ordered(&self) -> Vec { self.order .iter() .filter_map(|id| self.active.get(id).copied()) .collect() } pub fn synchronize( &mut self, vm: &mut Vm, desktop: ObjectRef, page: Option, nodes: Vec, frame: &Frame, preparing: bool, ) -> Result, VmError> { let output = Arc::new(Mutex::new(HashMap::new())); let mut captures = vec![desktop]; captures.extend(page); let function = vm.allocate(ContextNativeFunction::new( "mount-sync", Sync { desktop, page, nodes, old: self.active.clone(), old_order: self.order.clone(), frame: frame.clone(), preparing, output: output.clone(), }, captures, ))?; let result = vm.call(function, &[]); vm.release(function)?; let result = result?; self.active = std::mem::take( &mut *output .lock() .map_err(|_| VmError::InvalidArgument("Mount registry poisoned"))?, ); self.order = frame.mounts.iter().map(|mount| mount.id).collect(); match result { Value::Object(hooks) => Ok(Some(hooks)), Value::Nil => Ok(None), value => { vm.release_value(value)?; Err(VmError::InvalidArgument("invalid lifecycle batch")) } } } } pub use crate::object_list::collection;