1960 lines
66 KiB
Rust
1960 lines
66 KiB
Rust
//! Tessera's native visual protocols over Aster's managed object graph.
|
|
mod bootstrap;
|
|
mod calls;
|
|
mod developer_pages;
|
|
mod signatures;
|
|
mod tree;
|
|
|
|
pub use bootstrap::install;
|
|
use signatures::{method_signature, operation_signature};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use anyhow::{Result, bail};
|
|
use endor_core::{
|
|
AnnotationType, CallEffect, CallSignature, CallType, 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",
|
|
"Trait",
|
|
"Visual",
|
|
"EventTarget",
|
|
"Children",
|
|
"Presentable",
|
|
"Block",
|
|
"ContainerBlock",
|
|
"ScrollBlock",
|
|
"TextBlock",
|
|
"RectBlock",
|
|
"Spacer",
|
|
"Row",
|
|
"Column",
|
|
"Button",
|
|
"Page",
|
|
"Window",
|
|
"Mount",
|
|
"Event",
|
|
"Subscription",
|
|
"Task",
|
|
"ObjectList",
|
|
"Constraints",
|
|
"MeasureResult",
|
|
"Bounds",
|
|
"Point",
|
|
"Arrangement",
|
|
"DisplayList",
|
|
"PaintCommand",
|
|
];
|
|
pub const QUERY_NAMES: &[&str] = &[
|
|
"isInstance",
|
|
"isSubtype",
|
|
"implement",
|
|
"implements",
|
|
"supports",
|
|
];
|
|
|
|
pub use tree::{UiKind, UiNode};
|
|
|
|
#[derive(Debug)]
|
|
struct Session {
|
|
// Weak visual-object registry belongs to the VM, not a particular window.
|
|
nodes: Vec<WeakObjectRef>,
|
|
presentations: std::collections::BTreeMap<u64, crate::presentation::Presentation>,
|
|
current: u64,
|
|
master: ObjectRef,
|
|
next_window: u64,
|
|
next_mount: u64,
|
|
retired_commands: Vec<crate::window::Command>,
|
|
refresh_yields: usize,
|
|
refresh_restarts: usize,
|
|
layout_cancels: usize,
|
|
paint_cancels: usize,
|
|
paint_slices: usize,
|
|
binding_evaluations: usize,
|
|
exiting: bool,
|
|
}
|
|
|
|
impl Session {
|
|
fn host_desktops(&self) -> std::collections::HashSet<ObjectRef> {
|
|
self.presentations
|
|
.values()
|
|
.map(|view| view.desktop)
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for Session {
|
|
type Target = crate::presentation::Presentation;
|
|
fn deref(&self) -> &Self::Target {
|
|
self.presentations
|
|
.get(&self.current)
|
|
.expect("selected presentation")
|
|
}
|
|
}
|
|
impl std::ops::DerefMut for Session {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
self.presentations
|
|
.get_mut(&self.current)
|
|
.expect("selected presentation")
|
|
}
|
|
}
|
|
type Shared = Arc<Mutex<Session>>;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Operation {
|
|
IsInstance,
|
|
Implement,
|
|
Implements,
|
|
Supports,
|
|
IsSubtype,
|
|
OpenValue,
|
|
Mounts,
|
|
Stats,
|
|
Viewport,
|
|
Window,
|
|
PageWindow,
|
|
NewWindow,
|
|
CloseWindow,
|
|
Detach,
|
|
MoveWindow,
|
|
MoveTab,
|
|
State,
|
|
StateSet,
|
|
Increment,
|
|
TextOf,
|
|
Bind,
|
|
Text,
|
|
Rect,
|
|
Spacer,
|
|
Scroll,
|
|
Row,
|
|
Column,
|
|
Button,
|
|
Page,
|
|
SetText,
|
|
SetColor,
|
|
Append,
|
|
Insert,
|
|
Remove,
|
|
Replace,
|
|
Move,
|
|
Children,
|
|
Render,
|
|
Unmount,
|
|
Open,
|
|
Close,
|
|
Activate,
|
|
Navigate,
|
|
Back,
|
|
Forward,
|
|
OnClick,
|
|
On,
|
|
Dispatch,
|
|
OnEvent,
|
|
Invalidate,
|
|
OnMount,
|
|
OnUnmount,
|
|
Click,
|
|
Present,
|
|
}
|
|
|
|
impl Operation {
|
|
fn arity(self) -> endor_core::CallArity {
|
|
use Operation::*;
|
|
let (minimum, maximum) = match self {
|
|
IsInstance | IsSubtype | Bind | Implement | Implements | Supports => (2, 2),
|
|
Stats | Viewport | Window | PageWindow | Mounts => (0, 0),
|
|
State | StateSet => (1, 1),
|
|
Increment => (0, 0),
|
|
TextOf => (2, 4),
|
|
Scroll => (3, 3),
|
|
Text => (1, 3),
|
|
Rect => (2, 4),
|
|
MoveTab | MoveWindow | OpenValue | Spacer | SetText | SetColor | Append | Remove
|
|
| Render | Navigate | OnClick | Present => (1, 1),
|
|
Row | Column => (2, MAX_UI_NODES + 2),
|
|
Button => (1, 1),
|
|
Insert | Replace | Move => (2, 2),
|
|
Page | Unmount | Close | Activate | Back | Forward | Children | NewWindow
|
|
| CloseWindow | Detach => (0, 0),
|
|
Open => (0, 1),
|
|
Click | Invalidate => (0, 1),
|
|
On => (2, 3),
|
|
Dispatch => (1, 2),
|
|
OnEvent | OnMount | OnUnmount => (1, 1),
|
|
};
|
|
endor_core::CallArity::range(minimum, maximum)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Function {
|
|
signature: Option<CallSignature>,
|
|
operation: Operation,
|
|
owner: Option<ObjectRef>,
|
|
session: Shared,
|
|
requested_type: Option<ObjectRef>,
|
|
}
|
|
|
|
/// One shared implementation on the nominal type. The VM binds receivers;
|
|
/// individual Blocks no longer allocate owner-capturing method closures.
|
|
#[derive(Debug)]
|
|
struct TypeMethod {
|
|
signature: CallSignature,
|
|
operation: Operation,
|
|
expected: ObjectRef,
|
|
session: Shared,
|
|
}
|
|
|
|
pub(crate) fn is_intrinsic_event(
|
|
store: &endor_core::ObjectStore,
|
|
function: ObjectRef,
|
|
) -> Result<bool, VmError> {
|
|
let Some(function) = store
|
|
.object(function)?
|
|
.payload()
|
|
.as_any()
|
|
.downcast_ref::<ContextNativeFunction>()
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
Ok(function
|
|
.implementation::<TypeMethod>()
|
|
.is_some_and(|method| matches!(method.operation, Operation::OnEvent)))
|
|
}
|
|
|
|
impl NativeCallable for TypeMethod {
|
|
fn signature(&self) -> Option<CallSignature> {
|
|
Some(self.signature.clone())
|
|
}
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
let arity = self.operation.arity();
|
|
Some(endor_core::CallArity::range(
|
|
arity.required + 1,
|
|
arity.maximum + 1,
|
|
))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
let owner = object(args.first())?;
|
|
if !cx.store().is_instance(owner, self.expected)? {
|
|
return Err(invalid("incorrect receiver type for visual method"));
|
|
}
|
|
Function {
|
|
signature: None,
|
|
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 {
|
|
signature: Option<CallSignature>,
|
|
options: Option<CallSignature>,
|
|
operation: Operation,
|
|
session: Shared,
|
|
}
|
|
|
|
impl NativeCallable for NativeFactory {
|
|
fn signature(&self) -> Option<CallSignature> {
|
|
self.signature.clone()
|
|
}
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::range(
|
|
self.operation.arity().required + 1,
|
|
usize::MAX,
|
|
))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
let requested = object(args.first())?;
|
|
let fields = cx
|
|
.store()
|
|
.payload::<EndorTypeObject>(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"))?;
|
|
cx.store()
|
|
.check_class_field_arguments(requested, &values[native_count..])?;
|
|
self.options
|
|
.as_ref()
|
|
.ok_or(invalid("native factory is not initialized"))?
|
|
.check_arguments(
|
|
cx.store(),
|
|
"native constructor options",
|
|
&values[..native_count],
|
|
)?;
|
|
let result = Function {
|
|
signature: None,
|
|
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<ObjectRef, VmError> {
|
|
match value {
|
|
Some(Value::Object(value)) => Ok(*value),
|
|
_ => Err(invalid("expected an object")),
|
|
}
|
|
}
|
|
fn text(value: Option<&Value>) -> Result<String, VmError> {
|
|
match value {
|
|
Some(Value::Text(value)) => Ok(value.clone()),
|
|
_ => Err(invalid("expected text")),
|
|
}
|
|
}
|
|
fn integer(value: Option<&Value>) -> Result<u64, VmError> {
|
|
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<String, VmError> {
|
|
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<f32, VmError> {
|
|
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::<f64>().ok(),
|
|
Some(EndorReal::Decimal(v)) => v.literal().parse::<f64>().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 {
|
|
signature: Some(operation_signature(cx.store(), desktop, operation)?),
|
|
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<ObjectRef, VmError> {
|
|
new_typed_block(cx, data, session, None)
|
|
}
|
|
|
|
fn new_typed_block(
|
|
cx: &mut NativeContext<'_>,
|
|
data: Data,
|
|
session: &Shared,
|
|
requested: Option<ObjectRef>,
|
|
) -> Result<ObjectRef, VmError> {
|
|
let type_name = match data.kind {
|
|
Kind::Text { .. } => "TextBlock",
|
|
Kind::Rect { .. } => "RectBlock",
|
|
Kind::Spacer(_) => "Spacer",
|
|
Kind::Scroll { .. } => "ScrollBlock",
|
|
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::<block::Block>(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 create_window(
|
|
cx: &mut NativeContext<'_>,
|
|
state: &mut Session,
|
|
definition: ObjectRef,
|
|
) -> Result<ObjectRef, VmError> {
|
|
if cx.store().is_read_only() {
|
|
return Err(invalid(
|
|
"window creation is forbidden during read-only rendering",
|
|
));
|
|
}
|
|
if state.exiting {
|
|
return Err(invalid("runtime is exiting; window creation is forbidden"));
|
|
}
|
|
if state.presentations.len() >= 16 {
|
|
return Err(VmError::ExecutionLimit("16 window budget"));
|
|
}
|
|
let id = state
|
|
.next_window
|
|
.checked_add(1)
|
|
.filter(|id| *id <= i64::MAX as u64)
|
|
.ok_or(invalid("window identities exhausted"))?;
|
|
let (window, desktop, tabs) = crate::window::create(cx, state.master, id, definition)?;
|
|
let mut presentation = crate::presentation::Presentation::new(desktop, tabs);
|
|
presentation.force_frame = true;
|
|
state.presentations.insert(id, presentation);
|
|
state.next_window = id;
|
|
Ok(window)
|
|
}
|
|
|
|
fn page_window(
|
|
store: &endor_core::ObjectStore,
|
|
state: &Session,
|
|
page: ObjectRef,
|
|
) -> Result<Option<u64>, VmError> {
|
|
for (id, view) in &state.presentations {
|
|
let window = object(Some(&store.get_attr(view.desktop, "window")?))?;
|
|
if crate::window::pages(store, window)?.contains(&page) {
|
|
return Ok(Some(*id));
|
|
}
|
|
}
|
|
Ok(None)
|
|
}
|
|
fn remove_page(
|
|
cx: &mut NativeContext<'_>,
|
|
view: &mut crate::presentation::Presentation,
|
|
page: ObjectRef,
|
|
) -> Result<(), VmError> {
|
|
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
|
|
crate::window::unregister_page(cx, window, page)?;
|
|
if view.pending.unwrap_or(view.active) == Some(page) {
|
|
let next = crate::window::pages(cx.store(), window)?.first().copied();
|
|
cx.set_attr(
|
|
view.desktop,
|
|
"pending",
|
|
next.map(Value::Object).unwrap_or(Value::Nil),
|
|
)?;
|
|
view.pending = Some(next);
|
|
}
|
|
Ok(())
|
|
}
|
|
fn show_in(
|
|
cx: &mut NativeContext<'_>,
|
|
page: ObjectRef,
|
|
session: &Shared,
|
|
target: Option<ObjectRef>,
|
|
) -> Result<(), VmError> {
|
|
let mut state = session.lock().map_err(|_| invalid("UI session poisoned"))?;
|
|
let old = page_window(cx.store(), &state, page)?;
|
|
let id = match target {
|
|
Some(window) => {
|
|
let data = cx
|
|
.store()
|
|
.payload::<crate::window::ManagedWindow>(window, "Window")?;
|
|
if data.closed {
|
|
return Err(invalid("native window is closed"));
|
|
}
|
|
let id = data.id;
|
|
if state
|
|
.presentations
|
|
.get(&id)
|
|
.is_none_or(|view| view.desktop != data.desktop)
|
|
{
|
|
return Err(invalid("window belongs to another runtime"));
|
|
}
|
|
id
|
|
}
|
|
None => old.unwrap_or(state.current),
|
|
};
|
|
if let Some(old) = old
|
|
&& old != id
|
|
{
|
|
remove_page(
|
|
cx,
|
|
state
|
|
.presentations
|
|
.get_mut(&old)
|
|
.ok_or(invalid("missing source window"))?,
|
|
page,
|
|
)?;
|
|
}
|
|
let view = state
|
|
.presentations
|
|
.get_mut(&id)
|
|
.ok_or(invalid("missing target window"))?;
|
|
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
|
|
crate::window::register_page(cx, window, page)?;
|
|
cx.set_attr(view.desktop, "pending", Value::Object(page))?;
|
|
view.pending = Some(Some(page));
|
|
Ok(())
|
|
}
|
|
fn show(cx: &mut NativeContext<'_>, page: ObjectRef, session: &Shared) -> Result<(), VmError> {
|
|
show_in(cx, page, session, None)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct PresentResult {
|
|
open: bool,
|
|
session: Shared,
|
|
}
|
|
impl NativeCallable for PresentResult {
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(endor_core::CallArity::exact(1))
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
let [Value::Object(root)] = args else {
|
|
return Err(invalid("Presentable.present must return a Block"));
|
|
};
|
|
let is_page = cx.store().payload::<block::Block>(*root, "Block")?.page;
|
|
let result = if self.open {
|
|
let page = if is_page {
|
|
*root
|
|
} else {
|
|
new_block(
|
|
cx,
|
|
Data::new(Kind::Page, vec![*root], 0.0, 0.0),
|
|
&self.session,
|
|
)?
|
|
};
|
|
show(cx, page, &self.session)?;
|
|
page
|
|
} else {
|
|
*root
|
|
};
|
|
Ok(NativeResult::Return(Value::Object(result)))
|
|
}
|
|
}
|
|
|
|
fn present_value(
|
|
cx: &mut NativeContext<'_>,
|
|
value: &Value,
|
|
session: &Shared,
|
|
) -> Result<ObjectRef, VmError> {
|
|
if let Value::Object(block) = value
|
|
&& cx
|
|
.store()
|
|
.object(*block)?
|
|
.payload()
|
|
.as_any()
|
|
.is::<block::Block>()
|
|
{
|
|
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,
|
|
)
|
|
}
|
|
|
|
#[must_use = "pending layout must be resumed on its owning VM"]
|
|
pub enum LayoutProgress {
|
|
Pending(Box<PendingLayout>),
|
|
Complete(Box<crate::document::Frame>),
|
|
}
|
|
|
|
struct LayoutContext {
|
|
root: Arc<UiNode>,
|
|
tabs: Vec<crate::tabs::Tab>,
|
|
full_viewport: Option<crate::layout::Size>,
|
|
desktop: ObjectRef,
|
|
window: u64,
|
|
hooks: Option<ObjectRef>,
|
|
checkpoint: crate::mount::Checkpoint,
|
|
}
|
|
|
|
pub struct PendingLayout {
|
|
work: crate::source_layout::Work,
|
|
context: LayoutContext,
|
|
}
|
|
|
|
pub struct UiBridge {
|
|
pub ui: ObjectRef,
|
|
session: Shared,
|
|
tasks: crate::tasks::Scheduler,
|
|
}
|
|
|
|
fn invoke_member(
|
|
vm: &mut Vm,
|
|
owner: ObjectRef,
|
|
method: &str,
|
|
arguments: &[Value],
|
|
instruction_budget: usize,
|
|
) -> Result<Value> {
|
|
let bound = vm.bind_attr(owner, method)?;
|
|
let result = match &bound {
|
|
Value::Object(function) => vm
|
|
.call_with_limits(
|
|
*function,
|
|
arguments,
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: instruction_budget,
|
|
},
|
|
)
|
|
.map_err(|error| crate::endor_host::runtime_error(vm, error)),
|
|
_ => Err(anyhow::anyhow!("object method is not callable")),
|
|
};
|
|
vm.release_value(bound)?;
|
|
result
|
|
}
|
|
|
|
fn project_tabs(
|
|
vm: &Vm,
|
|
state: &mut crate::presentation::Presentation,
|
|
) -> Result<Vec<crate::tabs::Tab>> {
|
|
let window = object(Some(&vm.get_attr(state.desktop, "window")?))?;
|
|
let pages = crate::window::pages(vm.object_store(), window)?;
|
|
let active = state.pending.unwrap_or(state.active);
|
|
let mut live = Vec::new();
|
|
let mut tabs = Vec::new();
|
|
for (index, page) in pages.iter().copied().enumerate() {
|
|
let weak = vm.object_store().downgrade(page)?;
|
|
let id = if let Some((_, id)) = state.tab_ids.iter().find(|(w, _)| *w == weak) {
|
|
*id
|
|
} else {
|
|
state.next_tab = state
|
|
.next_tab
|
|
.checked_add(1)
|
|
.ok_or_else(|| anyhow::anyhow!("tab identities exhausted"))?;
|
|
state.next_tab
|
|
};
|
|
live.push((weak, id));
|
|
let title = match vm.get_attr(page, "title") {
|
|
Ok(Value::Text(title)) if !title.is_empty() => title
|
|
.chars()
|
|
.filter(|c| !c.is_control())
|
|
.take(120)
|
|
.collect(),
|
|
_ => format!("Page {}", index + 1),
|
|
};
|
|
tabs.push(crate::tabs::Tab {
|
|
id,
|
|
title,
|
|
developer_tool: match vm.get_attr(page, "_tesseraTool") {
|
|
Ok(Value::Text(name)) => Some(name),
|
|
_ => None,
|
|
},
|
|
active: active == Some(page),
|
|
});
|
|
}
|
|
state.tab_ids = live;
|
|
Ok(tabs)
|
|
}
|
|
|
|
impl UiBridge {
|
|
/// Detach all occurrences without running layout/paint. All windows are
|
|
/// already closed before source unmount hooks run, so they cannot remount.
|
|
pub fn shutdown(&self, vm: &mut Vm, callbacks: bool) -> Result<(usize, usize, usize)> {
|
|
let tasks = self.tasks.quiesce(vm)?;
|
|
let mut drivers = Vec::new();
|
|
let mut mounts = 0;
|
|
let prepared = (|| -> Result<usize> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.exiting = true;
|
|
let ids: Vec<_> = state.presentations.keys().copied().collect();
|
|
for id in &ids {
|
|
let view = state.presentations.get_mut(id).expect("known window");
|
|
let desktop = view.desktop;
|
|
mounts += view.mounts.active.len();
|
|
if callbacks {
|
|
if let Some(hooks) = view.mounts.synchronize(
|
|
vm,
|
|
desktop,
|
|
None,
|
|
Vec::new(),
|
|
&crate::document::Frame::default(),
|
|
false,
|
|
)? {
|
|
drivers.push((*id, hooks));
|
|
}
|
|
} else {
|
|
view.mounts.retire(vm, desktop)?;
|
|
}
|
|
let window = object(Some(&vm.get_attr(desktop, "window")?))?;
|
|
crate::window::retire(vm, window)?;
|
|
vm.set_attr(desktop, "pending", Value::Nil)?;
|
|
vm.set_attr(desktop, "focus", Value::Nil)?;
|
|
view.pending = Some(None);
|
|
view.active = None;
|
|
view.closing = true;
|
|
}
|
|
Ok(ids.len())
|
|
})();
|
|
let mut result = prepared.map(|windows| (windows, mounts, tasks));
|
|
for (id, hooks) in drivers {
|
|
if result.is_ok() {
|
|
let call = (|| -> Result<()> {
|
|
self.select_window(id)?;
|
|
let value = vm
|
|
.call_with_limits(
|
|
hooks,
|
|
&[],
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 1_000_000,
|
|
},
|
|
)
|
|
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
|
|
vm.release_value(value)?;
|
|
Ok(())
|
|
})();
|
|
if let Err(error) = call {
|
|
result = Err(error);
|
|
}
|
|
}
|
|
if let Err(error) = vm.release(hooks) {
|
|
result = Err(error.into());
|
|
}
|
|
}
|
|
self.select_window(crate::window::MAIN_WINDOW)?;
|
|
result
|
|
}
|
|
|
|
pub fn has_cooperative_tasks(&self) -> bool {
|
|
self.tasks.has_cooperative_work()
|
|
}
|
|
|
|
pub fn poll_tasks(&self, vm: &mut Vm, limit: usize) -> Result<usize> {
|
|
self.tasks.poll(vm, limit)
|
|
}
|
|
pub fn cancel_tasks(&self, vm: &mut Vm) -> Result<usize> {
|
|
self.tasks.cancel_all(vm)
|
|
}
|
|
pub fn window_ids(&self) -> Result<Vec<u64>> {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let mut ids: Vec<_> = state.presentations.keys().copied().collect();
|
|
ids.sort_by_key(|id| {
|
|
let view = &state.presentations[id];
|
|
(
|
|
if view.active.is_some()
|
|
&& view.pending.is_some()
|
|
&& view.pending.flatten() != view.active
|
|
{
|
|
0
|
|
} else {
|
|
1
|
|
},
|
|
*id,
|
|
)
|
|
});
|
|
Ok(ids)
|
|
}
|
|
pub fn accepts_window(&self, id: u64) -> bool {
|
|
self.session.lock().is_ok_and(|state| {
|
|
state
|
|
.presentations
|
|
.get(&id)
|
|
.is_some_and(|view| !view.closing)
|
|
})
|
|
}
|
|
|
|
pub fn select_window(&self, id: u64) -> Result<()> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
if !state.presentations.contains_key(&id) {
|
|
bail!("window is unavailable");
|
|
}
|
|
state.current = id;
|
|
Ok(())
|
|
}
|
|
pub fn retire_window(&self, vm: &mut Vm, id: u64) -> Result<()> {
|
|
if id == crate::window::MAIN_WINDOW {
|
|
bail!("the primary shell is closed by the OS window control");
|
|
}
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let Some(view) = state.presentations.get_mut(&id) else {
|
|
return Ok(());
|
|
};
|
|
let desktop = view.desktop;
|
|
let window = object(Some(&vm.get_attr(desktop, "window")?))?;
|
|
view.mounts.retire(vm, desktop)?;
|
|
crate::window::retire(vm, window)?;
|
|
vm.set_attr(desktop, "pending", Value::Nil)?;
|
|
vm.set_attr(desktop, "focus", Value::Nil)?;
|
|
vm.set_attr(desktop, "focusRequested", Value::Bool(false))?;
|
|
// Draining is not publication: failed-domain effects are discarded.
|
|
let _ = crate::window::take_commands(vm, window)?;
|
|
let windows = object(Some(&vm.get_attr(state.master, "windows")?))?;
|
|
vm.dict_delete(windows, &Value::Text(id.to_string()))?;
|
|
state.presentations.remove(&id);
|
|
state.current = crate::window::MAIN_WINDOW;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn finish_closed(&self, vm: &mut Vm) -> Result<()> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let ids: Vec<_> = state
|
|
.presentations
|
|
.iter()
|
|
.filter(|(id, view)| **id != crate::window::MAIN_WINDOW && view.closing)
|
|
.map(|(id, _)| *id)
|
|
.collect();
|
|
let windows = object(Some(&vm.get_attr(state.master, "windows")?))?;
|
|
for id in ids {
|
|
let desktop = state.presentations[&id].desktop;
|
|
let window = object(Some(&vm.get_attr(desktop, "window")?))?;
|
|
state
|
|
.retired_commands
|
|
.extend(crate::window::take_commands(vm, window)?);
|
|
vm.dict_delete(windows, &Value::Text(id.to_string()))?;
|
|
state.presentations.remove(&id);
|
|
}
|
|
state.current = crate::window::MAIN_WINDOW;
|
|
Ok(())
|
|
}
|
|
pub fn take_window_commands(&self, vm: &Vm) -> Result<Vec<crate::window::Command>> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let mut commands = std::mem::take(&mut state.retired_commands);
|
|
for view in state.presentations.values() {
|
|
let window = object(Some(&vm.get_attr(view.desktop, "window")?))?;
|
|
commands.extend(crate::window::take_commands(vm, window)?);
|
|
}
|
|
commands.sort_by_key(|command| (command.window, command.sequence));
|
|
Ok(commands)
|
|
}
|
|
|
|
pub fn set_viewport(&self, vm: &mut Vm, viewport: Option<crate::layout::Size>) -> Result<bool> {
|
|
if let Some(size) = viewport
|
|
&& (!size.width.is_finite()
|
|
|| !size.height.is_finite()
|
|
|| !(0.0..=MAX_DIMENSION as f32).contains(&size.width)
|
|
|| !(0.0..=MAX_DIMENSION as f32).contains(&size.height))
|
|
{
|
|
bail!("invalid window viewport");
|
|
}
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
if state.layout.viewport == viewport {
|
|
return Ok(false);
|
|
}
|
|
if let Some(size) = viewport {
|
|
let input = vm.new_object()?;
|
|
let result = (|| {
|
|
vm.set_attr(input, "width", Value::Number((size.width as f64).into()))?;
|
|
vm.set_attr(input, "height", Value::Number((size.height as f64).into()))?;
|
|
vm.set_attr(state.desktop, "viewport", Value::Object(input))
|
|
})();
|
|
vm.release(input)?;
|
|
result?;
|
|
} else {
|
|
vm.set_attr(state.desktop, "viewport", Value::Nil)?;
|
|
}
|
|
state.layout.viewport = viewport;
|
|
// A fresh Console domain can own window inputs without replacing the
|
|
// preserved displayed page until it actually opens/renders something.
|
|
state.force_frame |= state.active.is_some() || state.pending.flatten().is_some();
|
|
Ok(true)
|
|
}
|
|
|
|
pub fn install_globals(&self, vm: &mut Vm, globals: ObjectRef) -> Result<()> {
|
|
vm.dict_set(globals, Value::Text("ui".into()), Value::Object(self.ui))?;
|
|
// Compatibility spelling used by the original module examples.
|
|
vm.dict_set(
|
|
globals,
|
|
Value::Text("View".into()),
|
|
vm.get_attr(self.ui, "Block")?,
|
|
)?;
|
|
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<bool> {
|
|
Ok(self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.presentations
|
|
.values()
|
|
.any(|view| view.pending.flatten().is_some() || view.active.is_some()))
|
|
}
|
|
pub fn accepts_tab_drop(&self, vm: &Vm, input: &crate::events::Input) -> bool {
|
|
let Some(target) = &input.tab_drop else {
|
|
return true;
|
|
};
|
|
let Ok(state) = self.session.lock() else {
|
|
return false;
|
|
};
|
|
for (id, version) in [
|
|
(input.window, input.layout_version),
|
|
(target.window, target.layout_version),
|
|
] {
|
|
let Some(view) = state.presentations.get(&id) else {
|
|
return false;
|
|
};
|
|
if view.closing || view.published_version != version {
|
|
return false;
|
|
}
|
|
let Ok(Value::Object(window)) = vm.get_attr(view.desktop, "window") else {
|
|
return false;
|
|
};
|
|
let Ok(pages) = crate::window::pages(vm.object_store(), window) else {
|
|
return false;
|
|
};
|
|
if view.tab_ids.len() != pages.len()
|
|
|| !pages
|
|
.iter()
|
|
.zip(&view.tab_ids)
|
|
.all(|(page, (weak, _))| vm.object_store().downgrade(*page).ok() == Some(*weak))
|
|
{
|
|
return false;
|
|
}
|
|
if id == target.window && target.index > pages.len() {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
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(())
|
|
}
|
|
fn dispatch_tab(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
|
let page = {
|
|
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");
|
|
}
|
|
let weak = state
|
|
.tab_ids
|
|
.iter()
|
|
.find(|(_, id)| *id == input.mount)
|
|
.map(|(weak, _)| *weak)
|
|
.ok_or_else(|| anyhow::anyhow!("tab has expired"))?;
|
|
let window = object(Some(&vm.get_attr(state.desktop, "window")?))?;
|
|
crate::window::pages(vm.object_store(), window)?
|
|
.iter()
|
|
.copied()
|
|
.find(|page| vm.object_store().downgrade(*page).ok() == Some(weak))
|
|
.ok_or_else(|| anyhow::anyhow!("Page is no longer open"))?
|
|
};
|
|
if let Some(target) = &input.tab_drop {
|
|
let window = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let view = state
|
|
.presentations
|
|
.get(&target.window)
|
|
.ok_or_else(|| anyhow::anyhow!("drop window has closed"))?;
|
|
if view.published_version != target.layout_version {
|
|
bail!("drop targets an obsolete presentation");
|
|
}
|
|
object(Some(&vm.get_attr(view.desktop, "window")?))?
|
|
};
|
|
if target.window != input.window {
|
|
let value =
|
|
invoke_member(vm, page, "moveToWindow", &[Value::Object(window)], 300_000)?;
|
|
vm.release_value(value)?;
|
|
}
|
|
// An override may deliberately decline or redirect the move. Do
|
|
// not reorder an unrelated window after that source decision.
|
|
let destination = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
page_window(vm.object_store(), &state, page)?
|
|
};
|
|
if destination == Some(target.window) {
|
|
let pages = crate::window::pages(vm.object_store(), window)?;
|
|
let from = pages
|
|
.iter()
|
|
.position(|candidate| *candidate == page)
|
|
.ok_or_else(|| anyhow::anyhow!("Page is no longer open"))?;
|
|
let position = if input.window == target.window && from < target.index {
|
|
target.index - 1
|
|
} else {
|
|
target.index
|
|
};
|
|
if position >= pages.len() {
|
|
bail!("drop destination changed during Page method");
|
|
}
|
|
let value = invoke_member(
|
|
vm,
|
|
page,
|
|
"moveTab",
|
|
&[Value::Number((position as i64).into())],
|
|
300_000,
|
|
)?;
|
|
vm.release_value(value)?;
|
|
let value = invoke_member(vm, page, "activate", &[], 300_000)?;
|
|
vm.release_value(value)?;
|
|
}
|
|
return Ok(());
|
|
}
|
|
let method = match input.kind.as_str() {
|
|
"tab-close" => "close",
|
|
"tab-detach" => "detachToWindow",
|
|
_ => "activate",
|
|
};
|
|
let value = invoke_member(vm, page, method, &[], 1_000_000)?;
|
|
let placed = (|| -> Result<()> {
|
|
if input.kind == "tab-detach"
|
|
&& let Some([x, y]) = input.screen_position
|
|
&& let Value::Object(window) = &value
|
|
&& vm
|
|
.payload::<crate::window::ManagedWindow>(*window, "Window")
|
|
.is_ok()
|
|
{
|
|
let result = invoke_member(
|
|
vm,
|
|
*window,
|
|
"moveTo",
|
|
&[
|
|
Value::Number((x as i64).into()),
|
|
Value::Number((y as i64).into()),
|
|
],
|
|
100_000,
|
|
)?;
|
|
vm.release_value(result)?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
vm.release_value(value)?;
|
|
placed?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
|
|
input
|
|
.validate()
|
|
.map_err(|message| anyhow::anyhow!(message))?;
|
|
if self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.current
|
|
!= input.window
|
|
{
|
|
bail!("event belongs to another window");
|
|
}
|
|
if input.kind == "window-close" {
|
|
let desktop = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.desktop;
|
|
let window = object(Some(&vm.get_attr(desktop, "window")?))?;
|
|
let bound = vm.bind_attr(window, "close")?;
|
|
let result = match &bound {
|
|
Value::Object(function) => vm.call_with_limits(
|
|
*function,
|
|
&[],
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 1_000_000,
|
|
},
|
|
),
|
|
_ => Err(invalid("Window.close is not callable")),
|
|
};
|
|
vm.release_value(bound)?;
|
|
vm.release_value(result?)?;
|
|
return Ok(());
|
|
}
|
|
if let Some(name) = input.kind.strip_prefix("developer-open:") {
|
|
let function = object(Some(&vm.get_attr(self.ui, "developerTools")?))?;
|
|
let page = vm.call(function, &[Value::Text(name.into())])?;
|
|
vm.release_value(page)?;
|
|
return Ok(());
|
|
}
|
|
if matches!(
|
|
input.kind.as_str(),
|
|
"tab-activate" | "tab-close" | "tab-detach" | "tab-drop"
|
|
) {
|
|
return self.dispatch_tab(vm, input);
|
|
}
|
|
let (weak, desktop, candidates, viewport) = {
|
|
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,
|
|
state.mounts.ordered(),
|
|
state.layout.viewport.map(|size| crate::layout::Size {
|
|
width: (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0),
|
|
height: (size.height
|
|
- crate::tabs::INSET
|
|
- crate::tabs::height(&state.last_tabs))
|
|
.max(0.0),
|
|
}),
|
|
)
|
|
};
|
|
if input.kind == "scroll" {
|
|
let mount = vm
|
|
.upgrade(weak)?
|
|
.ok_or_else(|| anyhow::anyhow!("scroll Mount expired"))?;
|
|
let result = (|| {
|
|
let data = vm.payload::<crate::mount::Mount>(mount, "Mount")?;
|
|
let point = input
|
|
.point
|
|
.ok_or_else(|| anyhow::anyhow!("scroll has no pointer"))?;
|
|
if !data.mounted
|
|
|| data.version != input.layout_version
|
|
|| viewport
|
|
.is_some_and(|size| point[0] >= size.width || point[1] >= size.height)
|
|
|| !data
|
|
.geometry
|
|
.contains(crate::geometry::Point::new(point[0], point[1]))
|
|
{
|
|
bail!("scroll targets an obsolete or clipped Mount");
|
|
}
|
|
let delta = input
|
|
.delta
|
|
.ok_or_else(|| anyhow::anyhow!("scroll has no delta"))?;
|
|
let method = vm.bind_attr(mount, "scrollBy")?;
|
|
let result = match method {
|
|
Value::Object(function) => vm.call_with_limits(
|
|
function,
|
|
&[
|
|
Value::Number((delta[0] as f64).into()),
|
|
Value::Number((delta[1] as f64).into()),
|
|
],
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 100_000,
|
|
},
|
|
),
|
|
_ => Err(invalid("scrollBy is not callable")),
|
|
};
|
|
vm.release_value(method)?;
|
|
vm.release_value(result?)?;
|
|
Ok(())
|
|
})();
|
|
vm.release(mount)?;
|
|
return result;
|
|
}
|
|
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
|
|
if viewport.is_some_and(|size| {
|
|
[press, release]
|
|
.iter()
|
|
.any(|point| point[0] >= size.width || point[1] >= size.height)
|
|
}) {
|
|
return Ok(());
|
|
}
|
|
let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
mount
|
|
} else {
|
|
vm.upgrade(weak)?
|
|
.ok_or_else(|| anyhow::anyhow!("Mount has expired"))?
|
|
};
|
|
let result = (|| {
|
|
let data = vm.payload::<crate::mount::Mount>(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((data.geometry.id 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<ObjectRef>) -> 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;
|
|
for presentation in state.presentations.values_mut() {
|
|
presentation.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"))?;
|
|
for presentation in state.presentations.values_mut() {
|
|
let window = object(Some(&vm.get_attr(presentation.desktop, "window")?))?;
|
|
// A failed first render must not become visible on the next input
|
|
// merely because show() already registered its Page with the Window.
|
|
if presentation.active.is_none() && presentation.last_tabs.is_empty() {
|
|
let unpublished = crate::window::pages(vm.object_store(), window)?.to_vec();
|
|
for page in unpublished {
|
|
crate::window::unregister_page_host(vm, window, page)?;
|
|
}
|
|
}
|
|
let pages = crate::window::pages(vm.object_store(), window)?;
|
|
let next = if presentation.closing {
|
|
None
|
|
} else {
|
|
presentation
|
|
.active
|
|
.filter(|page| pages.contains(page))
|
|
.or_else(|| pages.first().copied())
|
|
};
|
|
// Earlier successful statements in a failed REPL input remain live.
|
|
// Repair ownership changes before the next publication so a closed or
|
|
// moved Page cannot survive in an old context's mount dictionary.
|
|
presentation.pending = if next != presentation.active || presentation.closing {
|
|
Some(next)
|
|
} else {
|
|
None
|
|
};
|
|
vm.set_attr(
|
|
presentation.desktop,
|
|
"pending",
|
|
presentation
|
|
.pending
|
|
.flatten()
|
|
.map(Value::Object)
|
|
.unwrap_or(Value::Nil),
|
|
)?;
|
|
if presentation
|
|
.active
|
|
.is_some_and(|page| vm.object(page).is_err())
|
|
{
|
|
presentation.active = None;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
pub fn refresh_bindings(&self, vm: &mut Vm) -> Result<()> {
|
|
let (nodes, desktops) = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
(state.nodes.clone(), state.host_desktops())
|
|
};
|
|
let evaluated = crate::binding::refresh(vm, &nodes, &desktops)?;
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.binding_evaluations = state.binding_evaluations.saturating_add(evaluated);
|
|
Ok(())
|
|
}
|
|
pub fn prepare_frame(&self, vm: &mut Vm) -> Result<Option<Option<Arc<UiNode>>>> {
|
|
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);
|
|
// One VM change drain feeds every presentation before selecting a root.
|
|
// Otherwise a window refreshed first could consume another window's work.
|
|
for presentation in state.presentations.values_mut() {
|
|
presentation.observe_changes(vm, &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 tabs = project_tabs(vm, &mut state)?;
|
|
let changed = state.pending.is_some()
|
|
|| !same
|
|
|| focus_changed
|
|
|| matches!(
|
|
vm.get_attr(state.desktop, "scrollRequested"),
|
|
Ok(Value::Bool(true))
|
|
)
|
|
|| state.force_frame
|
|
|| tabs != state.last_tabs;
|
|
if changed {
|
|
if !same {
|
|
state.last_invalid_dependency =
|
|
format!("snapshot {}", state.render_cache.last_invalidation);
|
|
}
|
|
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<Arc<UiNode>>,
|
|
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();
|
|
let hooks = state
|
|
.mounts
|
|
.synchronize(vm, desktop, page, nodes, frame, false)?;
|
|
let result = (|| {
|
|
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))?;
|
|
vm.set_attr(state.desktop, "scrollRequested", Value::Bool(false))?;
|
|
state.force_frame = false;
|
|
state.last_snapshot = snapshot;
|
|
state.last_tabs = frame.tabs.clone();
|
|
state.published_version = frame.layout_version;
|
|
// Hooks may call Page/container APIs; never hold the session mutex
|
|
// across user code. Publish only after the refresh loop settles.
|
|
drop(state);
|
|
if let Some(hooks) = hooks {
|
|
let value = vm
|
|
.call_with_limits(
|
|
hooks,
|
|
&[],
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 1_000_000,
|
|
},
|
|
)
|
|
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
|
|
vm.release_value(value)?;
|
|
}
|
|
Ok(())
|
|
})();
|
|
if let Some(hooks) = hooks {
|
|
vm.release(hooks)?;
|
|
}
|
|
result
|
|
}
|
|
|
|
pub fn begin_paint_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
root: Option<Arc<UiNode>>,
|
|
frame: crate::document::Frame,
|
|
) -> Result<crate::source_paint::Progress> {
|
|
let (desktop, mounts) = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let mounts = frame
|
|
.mounts
|
|
.iter()
|
|
.map(|mount| {
|
|
state
|
|
.mounts
|
|
.active
|
|
.get(&mount.id)
|
|
.copied()
|
|
.map(|weak| (mount.id, weak))
|
|
.ok_or_else(|| anyhow::anyhow!("paint Mount is unavailable"))
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
(state.desktop, mounts)
|
|
};
|
|
let progress = crate::source_paint::start(vm, desktop, mounts, root, frame, 256)?;
|
|
self.accept_paint_progress(progress)
|
|
}
|
|
|
|
pub fn resume_paint_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
work: crate::source_paint::Work,
|
|
) -> Result<crate::source_paint::Progress> {
|
|
let progress = work.resume(vm, 256)?;
|
|
self.accept_paint_progress(progress)
|
|
}
|
|
|
|
fn accept_paint_progress(
|
|
&self,
|
|
progress: crate::source_paint::Progress,
|
|
) -> Result<crate::source_paint::Progress> {
|
|
if let crate::source_paint::Progress::Complete(draft) = &progress {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let desktops = state.host_desktops();
|
|
let reads = draft
|
|
.reads
|
|
.iter()
|
|
.filter(|read| !desktops.contains(&read.object()))
|
|
.cloned()
|
|
.collect();
|
|
let (reads, names) = crate::presentation::split_reads(reads);
|
|
state.paint_reads = reads;
|
|
state.paint_names = names;
|
|
state.paint_slices = draft.slices;
|
|
}
|
|
Ok(progress)
|
|
}
|
|
|
|
pub fn cancel_paint_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
window: u64,
|
|
work: crate::source_paint::Work,
|
|
) -> Result<()> {
|
|
let cancelled = work.cancel(vm);
|
|
let invalidated = self.select_window(window).and_then(|()| {
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.force_frame = true;
|
|
Ok(())
|
|
});
|
|
cancelled?;
|
|
invalidated
|
|
}
|
|
|
|
pub fn begin_layout_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
root: Option<Arc<UiNode>>,
|
|
) -> Result<LayoutProgress> {
|
|
anyhow::ensure!(
|
|
!vm.has_suspended_call() && !vm.object_store().is_read_only(),
|
|
"cannot prepare another frame during suspended layout"
|
|
);
|
|
let Some(root) = root else {
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.measure_cache
|
|
.clear();
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.layout_reads
|
|
.clear();
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.layout_names
|
|
.clear();
|
|
let frame = {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.layout.version = state
|
|
.layout
|
|
.version
|
|
.checked_add(1)
|
|
.ok_or_else(|| anyhow::anyhow!("layout version exhausted"))?;
|
|
crate::document::Frame {
|
|
window: state.current,
|
|
layout_version: state.layout.version,
|
|
tabs: project_tabs(vm, &mut state)?,
|
|
..crate::document::Frame::default()
|
|
}
|
|
};
|
|
self.commit_frame(vm, None, &frame)?;
|
|
return Ok(LayoutProgress::Complete(Box::new(frame)));
|
|
};
|
|
let (mut layout, cache, desktop, page, nodes) = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
(
|
|
state.layout.clone(),
|
|
state.measure_cache.clone(),
|
|
state.desktop,
|
|
state.pending.unwrap_or(state.active),
|
|
state.nodes.clone(),
|
|
)
|
|
};
|
|
let tabs = {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
project_tabs(vm, &mut state)?
|
|
};
|
|
let full_viewport = layout.viewport;
|
|
if let Some(size) = &mut layout.viewport {
|
|
size.width = (size.width - crate::tabs::RAIL - crate::tabs::INSET).max(0.0);
|
|
size.height = (size.height - crate::tabs::height(&tabs) - crate::tabs::INSET).max(0.0);
|
|
}
|
|
{
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
layout.reserve_identity_floor(state.next_mount);
|
|
}
|
|
let mut frame = layout.structure(&root)?;
|
|
{
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.next_mount = state.next_mount.max(layout.identity_high_watermark());
|
|
frame.window = state.current;
|
|
}
|
|
let (hooks, mounts, checkpoint) = {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let checkpoint = state.mounts.checkpoint(vm, desktop)?;
|
|
let hooks = match state
|
|
.mounts
|
|
.synchronize(vm, desktop, page, nodes, &frame, true)
|
|
{
|
|
Ok(hooks) => hooks,
|
|
Err(error) => {
|
|
checkpoint.restore(vm, desktop, &mut state.mounts)?;
|
|
return Err(error.into());
|
|
}
|
|
};
|
|
(hooks, state.mounts.ordered(), checkpoint)
|
|
};
|
|
let context = LayoutContext {
|
|
root: root.clone(),
|
|
tabs,
|
|
full_viewport,
|
|
desktop,
|
|
window: frame.window,
|
|
hooks,
|
|
checkpoint,
|
|
};
|
|
let draft = crate::source_layout::Draft {
|
|
frame,
|
|
runtime: layout,
|
|
cache,
|
|
reads: Vec::new(),
|
|
};
|
|
let progress = crate::source_layout::start(vm, desktop, mounts, root, draft, 256);
|
|
self.accept_layout_progress(vm, context, progress)
|
|
}
|
|
|
|
pub fn note_refresh_yield(&self) -> Result<()> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.refresh_yields = state.refresh_yields.saturating_add(1);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn note_refresh_restart(
|
|
&self,
|
|
cancelled_layout: bool,
|
|
cancelled_paint: bool,
|
|
) -> Result<()> {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
state.refresh_restarts = state.refresh_restarts.saturating_add(1);
|
|
state.layout_cancels = state
|
|
.layout_cancels
|
|
.saturating_add(usize::from(cancelled_layout));
|
|
state.paint_cancels = state
|
|
.paint_cancels
|
|
.saturating_add(usize::from(cancelled_paint));
|
|
Ok(())
|
|
}
|
|
|
|
fn discard_layout_context(&self, vm: &mut Vm, context: LayoutContext) -> Result<()> {
|
|
let LayoutContext {
|
|
desktop,
|
|
window,
|
|
hooks,
|
|
checkpoint,
|
|
..
|
|
} = context;
|
|
let selected = self.select_window(window);
|
|
let restored = if selected.is_ok() {
|
|
match self.session.lock() {
|
|
Ok(mut state) => {
|
|
let result = checkpoint.restore(vm, desktop, &mut state.mounts);
|
|
state.force_frame = true;
|
|
result.map_err(anyhow::Error::from)
|
|
}
|
|
Err(_) => checkpoint
|
|
.release(vm)
|
|
.map_err(anyhow::Error::from)
|
|
.and(Err(anyhow::anyhow!("UI session poisoned"))),
|
|
}
|
|
} else {
|
|
checkpoint.release(vm).map_err(anyhow::Error::from)
|
|
};
|
|
let released = hooks.map(|hooks| vm.release(hooks)).transpose();
|
|
selected?;
|
|
restored?;
|
|
released?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn cancel_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
|
let PendingLayout { work, context } = pending;
|
|
let cancelled = work.cancel(vm);
|
|
let restored = self.discard_layout_context(vm, context);
|
|
cancelled?;
|
|
restored
|
|
}
|
|
|
|
pub fn abort_layout_frame(&self, vm: &mut Vm, pending: PendingLayout) -> Result<()> {
|
|
self.cancel_layout_frame(vm, pending)
|
|
}
|
|
|
|
pub fn resume_layout_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
pending: PendingLayout,
|
|
) -> Result<LayoutProgress> {
|
|
let PendingLayout { work, context } = pending;
|
|
if let Err(error) = self.select_window(context.window) {
|
|
let cancelled = work.cancel(vm);
|
|
let restored = self.discard_layout_context(vm, context);
|
|
cancelled?;
|
|
restored?;
|
|
return Err(error);
|
|
}
|
|
let progress = work.resume(vm, 256);
|
|
self.accept_layout_progress(vm, context, progress)
|
|
}
|
|
|
|
fn accept_layout_progress(
|
|
&self,
|
|
vm: &mut Vm,
|
|
context: LayoutContext,
|
|
progress: Result<crate::source_layout::Progress>,
|
|
) -> Result<LayoutProgress> {
|
|
match progress {
|
|
Ok(crate::source_layout::Progress::Pending(work)) => {
|
|
Ok(LayoutProgress::Pending(Box::new(PendingLayout {
|
|
work,
|
|
context,
|
|
})))
|
|
}
|
|
Ok(crate::source_layout::Progress::Complete(draft)) => self
|
|
.finish_layout_frame(vm, context, draft)
|
|
.map(|frame| LayoutProgress::Complete(Box::new(frame))),
|
|
Err(error) => {
|
|
self.discard_layout_context(vm, context)?;
|
|
Err(error)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn finish_layout_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
context: LayoutContext,
|
|
draft: crate::source_layout::Draft,
|
|
) -> Result<crate::document::Frame> {
|
|
let LayoutContext {
|
|
root,
|
|
tabs,
|
|
full_viewport,
|
|
desktop,
|
|
window,
|
|
hooks,
|
|
checkpoint,
|
|
} = context;
|
|
let crate::source_layout::Draft {
|
|
mut frame,
|
|
runtime: mut layout,
|
|
mut cache,
|
|
mut reads,
|
|
} = draft;
|
|
let result: Result<crate::document::Frame> = (|| {
|
|
self.select_window(window)?;
|
|
{
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
crate::scroll::project(vm, &state.mounts, &root, &mut frame)?;
|
|
}
|
|
// Native layout may replace the structural Frame; attach chrome only
|
|
// after either layout path has produced the final page geometry.
|
|
frame.tabs = tabs;
|
|
frame.window = window;
|
|
// Capture versions before lifecycle hooks can change a dependency.
|
|
let desktops = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.host_desktops();
|
|
// Shared intrinsic functions capture the primary desktop even when
|
|
// invoked from another window. Desktop bookkeeping is private host
|
|
// state; Window, viewport input and business dependencies remain.
|
|
reads.retain(|read| !desktops.contains(&read.object()));
|
|
cache.exclude_host_desktops(&desktops);
|
|
let (reads, names) = crate::presentation::split_reads(reads);
|
|
frame.focus = match vm.get_attr(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));
|
|
{
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
layout.viewport = full_viewport;
|
|
state.layout = layout;
|
|
state.measure_cache = cache;
|
|
state.layout_reads = reads;
|
|
state.layout_names = names;
|
|
}
|
|
self.commit_frame(vm, Some(root), &frame)?;
|
|
if let Some(hooks) = hooks {
|
|
let value = vm
|
|
.call_with_limits(
|
|
hooks,
|
|
&[],
|
|
endor_core::ExecutionLimits {
|
|
max_frames: 128,
|
|
max_instructions: 1_000_000,
|
|
},
|
|
)
|
|
.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
|
|
vm.release_value(value)?;
|
|
}
|
|
Ok(frame)
|
|
})();
|
|
let released_hooks = hooks.map(|hooks| vm.release(hooks)).transpose();
|
|
let released_checkpoint = checkpoint.release(vm);
|
|
let frame = result?;
|
|
released_hooks?;
|
|
released_checkpoint?;
|
|
Ok(frame)
|
|
}
|
|
|
|
pub fn rendered(&self, vm: &Vm) -> Result<Option<Arc<UiNode>>> {
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
pub(crate) fn desktop_type(
|
|
store: &endor_core::ObjectStore,
|
|
name: &str,
|
|
desktop: ObjectRef,
|
|
) -> Result<ObjectRef, VmError> {
|
|
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<Arc<UiNode>> {
|
|
let Value::Object(root) = value else {
|
|
bail!("main must return a Block");
|
|
};
|
|
block::snapshot(vm.object_store(), *root).map_err(Into::into)
|
|
}
|