2806 lines
101 KiB
Rust
2806 lines
101 KiB
Rust
//! Tessera's native visual protocols over Aster's managed object graph.
|
|
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",
|
|
];
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct UiNode {
|
|
pub block_id: String,
|
|
pub protocol_key: String,
|
|
pub slots: Vec<u64>,
|
|
pub interactive: bool,
|
|
pub kind: UiKind,
|
|
}
|
|
|
|
impl UiNode {
|
|
pub fn children(&self) -> &[Arc<UiNode>] {
|
|
match &self.kind {
|
|
UiKind::Column { children, .. }
|
|
| UiKind::Row { children, .. }
|
|
| UiKind::Button { children, .. }
|
|
| UiKind::Scroll { children, .. } => children,
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
pub fn shallow_eq(&self, other: &Self) -> bool {
|
|
if self.block_id != other.block_id
|
|
|| self.protocol_key != other.protocol_key
|
|
|| 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::Scroll {
|
|
width: a,
|
|
height: b,
|
|
padding: pa,
|
|
gap: ga,
|
|
children: _,
|
|
},
|
|
UiKind::Scroll {
|
|
width: c,
|
|
height: d,
|
|
padding: pb,
|
|
gap: gb,
|
|
children: _,
|
|
},
|
|
) => a == c && b == d && pa == pb && ga == gb && same_children(),
|
|
(
|
|
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<Arc<UiNode>>,
|
|
},
|
|
Text {
|
|
value: String,
|
|
size: u32,
|
|
color: String,
|
|
},
|
|
Rect {
|
|
width: f32,
|
|
height: f32,
|
|
radius: f32,
|
|
color: String,
|
|
},
|
|
Column {
|
|
padding: f32,
|
|
gap: f32,
|
|
children: Vec<Arc<UiNode>>,
|
|
},
|
|
Row {
|
|
padding: f32,
|
|
gap: f32,
|
|
children: Vec<Arc<UiNode>>,
|
|
},
|
|
Spacer(f32),
|
|
Scroll {
|
|
width: f32,
|
|
height: f32,
|
|
padding: f32,
|
|
gap: f32,
|
|
children: Vec<Arc<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>,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
fn method_signature(
|
|
store: &endor_core::ObjectStore,
|
|
desktop: ObjectRef,
|
|
receiver: ObjectRef,
|
|
operation: Operation,
|
|
) -> Result<CallSignature, VmError> {
|
|
use AnnotationType::{Any, Function, Nominal, Number, String as TextType, Unit};
|
|
use Operation::*;
|
|
let nominal = |name| desktop_type(store, name, desktop).map(Nominal);
|
|
let (arguments, returns, effect) = match operation {
|
|
Bind => (
|
|
vec![TextType, Function],
|
|
nominal("Subscription")?,
|
|
CallEffect::Mutating,
|
|
),
|
|
Mounts => (vec![], nominal("ObjectList")?, CallEffect::ReadOnly),
|
|
Children => (vec![], Any, CallEffect::ReadOnly),
|
|
Invalidate => (vec![TextType], Unit, CallEffect::Mutating),
|
|
SetText | SetColor => (vec![TextType], Unit, CallEffect::Mutating),
|
|
Append => (vec![nominal("Block")?], Number, CallEffect::Mutating),
|
|
Insert => (
|
|
vec![Number, nominal("Block")?],
|
|
Number,
|
|
CallEffect::Mutating,
|
|
),
|
|
Remove => (vec![Number], Unit, CallEffect::Mutating),
|
|
Replace => (vec![Number, nominal("Block")?], Unit, CallEffect::Mutating),
|
|
Move => (vec![Number, Number], Unit, CallEffect::Mutating),
|
|
Render | Navigate => (vec![nominal("Block")?], Unit, CallEffect::Mutating),
|
|
OnClick => (vec![Function], Nominal(receiver), CallEffect::Mutating),
|
|
OnEvent => (vec![nominal("Event")?], Unit, CallEffect::ReadOnly),
|
|
OnMount | OnUnmount => (vec![nominal("Mount")?], Unit, CallEffect::ReadOnly),
|
|
Click => (vec![nominal("Mount")?], Unit, CallEffect::Mutating),
|
|
Dispatch => (
|
|
vec![nominal("Event")?, nominal("Mount")?],
|
|
Unit,
|
|
CallEffect::Mutating,
|
|
),
|
|
MoveWindow => (
|
|
vec![nominal("Window")?],
|
|
Nominal(receiver),
|
|
CallEffect::Mutating,
|
|
),
|
|
MoveTab => (vec![Number], Nominal(receiver), CallEffect::Mutating),
|
|
Activate => (vec![], Nominal(receiver), CallEffect::Mutating),
|
|
Detach => (vec![], nominal("Window")?, CallEffect::Mutating),
|
|
Unmount | Close | CloseWindow | Back | Forward => (vec![], Unit, CallEffect::Mutating),
|
|
Open => (vec![Any], Nominal(receiver), CallEffect::Mutating),
|
|
PageWindow => (vec![], Any, CallEffect::ReadOnly),
|
|
On => (
|
|
vec![TextType, Function, Any],
|
|
nominal("Subscription")?,
|
|
CallEffect::Mutating,
|
|
),
|
|
_ => return Err(invalid("unsupported native method signature")),
|
|
};
|
|
let arity = operation.arity();
|
|
let mut parameters = vec![Nominal(receiver)];
|
|
parameters.extend(arguments);
|
|
let mut signature = CallSignature {
|
|
arity: endor_core::CallArity::range(arity.required + 1, arity.maximum + 1),
|
|
parameters: parameters.into_iter().map(CallType::from).collect(),
|
|
variadic: None,
|
|
returns: returns.into(),
|
|
effect,
|
|
};
|
|
match operation {
|
|
Open => signature.parameters[1] = CallType::nullable(nominal("Window")?),
|
|
PageWindow => signature.returns = CallType::nullable(nominal("Window")?),
|
|
On => signature.parameters[3] = CallType::Union(vec![AnnotationType::Bool, TextType]),
|
|
_ => {}
|
|
}
|
|
Ok(signature)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Function {
|
|
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,
|
|
}
|
|
|
|
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 {
|
|
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 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..])?;
|
|
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<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 {
|
|
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.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,
|
|
)
|
|
}
|
|
|
|
impl NativeCallable for Function {
|
|
fn arity(&self) -> Option<endor_core::CallArity> {
|
|
Some(self.operation.arity())
|
|
}
|
|
|
|
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
|
use Operation::*;
|
|
let arity = self.operation.arity();
|
|
let (minimum, maximum) = (arity.required, arity.maximum);
|
|
if !(minimum..=maximum).contains(&args.len()) {
|
|
return Err(invalid("incorrect UI method argument count"));
|
|
}
|
|
if matches!(self.operation, Implement | Implements | Supports) {
|
|
let capability = object(args.get(1))?;
|
|
let value = match self.operation {
|
|
Implement => {
|
|
cx.implement_trait(object(args.first())?, capability)?;
|
|
Value::Nil
|
|
}
|
|
Implements => Value::Bool(
|
|
cx.store()
|
|
.implements_trait(object(args.first())?, capability)?,
|
|
),
|
|
Supports => {
|
|
// Validate the contract even for immediate scalar values.
|
|
cx.store()
|
|
.payload::<endor_core::EndorTraitObject>(capability, "Trait")?;
|
|
Value::Bool(match args[0] {
|
|
Value::Object(value) => cx.store().supports(value, capability)?,
|
|
_ => false,
|
|
})
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
return Ok(NativeResult::Return(value));
|
|
}
|
|
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, Invalidate) {
|
|
if cx.store().is_read_only() {
|
|
return Err(invalid(
|
|
"invalidation is forbidden during read-only rendering",
|
|
));
|
|
}
|
|
let reason = args
|
|
.first()
|
|
.map(|value| text(Some(value)))
|
|
.transpose()?
|
|
.unwrap_or_else(|| "layout".into());
|
|
if !matches!(reason.as_str(), "paint" | "layout" | "content") {
|
|
return Err(invalid(
|
|
"invalidation reason must be paint, layout or content",
|
|
));
|
|
}
|
|
let owner = self.owner.ok_or(invalid("invalidation requires a Block"))?;
|
|
let (data, _) = block::resolve(cx.store(), owner)?;
|
|
let mut session = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
let mut affected = std::collections::HashSet::new();
|
|
for weak in &session.nodes {
|
|
if let Some(block) = cx.upgrade(*weak)?
|
|
&& block::resolve(cx.store(), block)?.0 == data
|
|
{
|
|
affected.insert(block);
|
|
}
|
|
}
|
|
for presentation in session.presentations.values_mut() {
|
|
presentation.invalidate(cx, &affected, &reason)?;
|
|
}
|
|
return Ok(NativeResult::Return(Value::Nil));
|
|
}
|
|
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 (window, presentation) in &session.presentations {
|
|
for (id, weak) in &presentation.mounts.active {
|
|
if let Some(object) = cx.upgrade(*weak)?
|
|
&& cx
|
|
.store()
|
|
.payload::<crate::mount::Mount>(object, "Mount")?
|
|
.block
|
|
== owner
|
|
{
|
|
positions.push((*window, *id, object));
|
|
}
|
|
}
|
|
}
|
|
positions.sort_unstable_by_key(|(window, id, _)| (*window, *id));
|
|
let set = crate::mount::collection(
|
|
cx,
|
|
positions.into_iter().map(|(_, _, object)| object).collect(),
|
|
session.desktop,
|
|
)?;
|
|
return Ok(NativeResult::Return(Value::Object(set)));
|
|
}
|
|
if matches!(self.operation, NewWindow) {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
let definition = self
|
|
.requested_type
|
|
.ok_or(invalid("Window constructor requires a type"))?;
|
|
let window = create_window(cx, &mut state, definition)?;
|
|
return Ok(NativeResult::Return(Value::Object(window)));
|
|
}
|
|
if matches!(self.operation, CloseWindow) {
|
|
let window = self
|
|
.owner
|
|
.ok_or(invalid("window close requires a receiver"))?;
|
|
let id = cx
|
|
.store()
|
|
.payload::<crate::window::ManagedWindow>(window, "Window")?
|
|
.id;
|
|
crate::window::close(cx, window)?;
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
if let Some(view) = state.presentations.get_mut(&id) {
|
|
cx.set_attr(view.desktop, "pending", Value::Nil)?;
|
|
view.pending = Some(None);
|
|
view.force_frame = true;
|
|
view.closing = true;
|
|
}
|
|
return Ok(NativeResult::Return(Value::Nil));
|
|
}
|
|
if matches!(self.operation, Window) {
|
|
let desktop = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?
|
|
.desktop;
|
|
return Ok(NativeResult::Return(
|
|
cx.store().get_attr(desktop, "window")?,
|
|
));
|
|
}
|
|
if matches!(self.operation, Viewport) {
|
|
let desktop = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?
|
|
.desktop;
|
|
let Value::Object(input) = cx.store().get_attr(desktop, "viewport")? else {
|
|
return Err(invalid("window viewport is unavailable"));
|
|
};
|
|
let width = cx.store().get_attr(input, "width")?;
|
|
let height = cx.store().get_attr(input, "height")?;
|
|
return Ok(NativeResult::Return(Value::Object(
|
|
crate::native_visual::record(
|
|
cx,
|
|
desktop,
|
|
"MeasureResult",
|
|
vec![("width", width), ("height", height)],
|
|
)?,
|
|
)));
|
|
}
|
|
if matches!(self.operation, Stats) {
|
|
if cx.store().is_read_only() {
|
|
return Err(invalid(
|
|
"runtime statistics are unavailable during read-only rendering",
|
|
));
|
|
}
|
|
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::<block::Block>(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),
|
|
("sourceMeasureHits", session.measure_cache.hits as i64),
|
|
("sourceMeasureMisses", session.measure_cache.misses as i64),
|
|
("arranged", session.layout.arranged as i64),
|
|
("layoutPasses", session.layout.settle_passes 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 {
|
|
Scroll => Some(Data::new(
|
|
Kind::Scroll {
|
|
width: dimension(args.first())?,
|
|
height: dimension(args.get(1))?,
|
|
},
|
|
vec![object(args.get(2))?],
|
|
0.0,
|
|
0.0,
|
|
)),
|
|
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::<Result<_, _>>()?,
|
|
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 | Present)
|
|
&& let Value::Object(value) = args[0]
|
|
{
|
|
let (desktop, current) = {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
(state.master, state.desktop)
|
|
};
|
|
let capability = desktop_type(cx.store(), "Presentable", desktop)?;
|
|
if !cx
|
|
.store()
|
|
.object(value)?
|
|
.payload()
|
|
.as_any()
|
|
.is::<block::Block>()
|
|
&& cx.store().supports(value, capability)?
|
|
{
|
|
let definition = cx
|
|
.store()
|
|
.type_of(value)?
|
|
.ok_or(invalid("untyped Presentable"))?;
|
|
let function = cx
|
|
.store()
|
|
.lookup_method(definition, "present")?
|
|
.ok_or(invalid("missing present method"))?;
|
|
let window = object(Some(&cx.store().get_attr(current, "window")?))?;
|
|
let complete = cx.allocate(ContextNativeFunction::new(
|
|
"validate presentation",
|
|
PresentResult {
|
|
open: matches!(self.operation, OpenValue),
|
|
session: self.session.clone(),
|
|
},
|
|
vec![desktop],
|
|
))?;
|
|
let instructions = [
|
|
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 0),
|
|
endor_core::Instruction::simple(endor_core::Opcode::PushNull),
|
|
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 1),
|
|
endor_core::Instruction::simple(endor_core::Opcode::PushNull),
|
|
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 2),
|
|
endor_core::Instruction::new(endor_core::Opcode::LoadConst, 3),
|
|
endor_core::Instruction::new(endor_core::Opcode::Call, 2),
|
|
endor_core::Instruction::new(endor_core::Opcode::Call, 1),
|
|
endor_core::Instruction::simple(endor_core::Opcode::ReturnValue),
|
|
];
|
|
let code = endor_core::EndorCode::from_instructions(
|
|
&instructions,
|
|
vec![
|
|
Value::Object(complete),
|
|
Value::Object(function),
|
|
Value::Object(value),
|
|
Value::Object(window),
|
|
],
|
|
endor_core::CodeLayout::default(),
|
|
)?
|
|
.with_location("Presentable.present", "<tessera>", 1);
|
|
let code = cx.allocate(endor_core::EndorCodeObject::new(code))?;
|
|
let globals = cx.new_dict()?;
|
|
let driver = cx.new_function("Presentable.present", code, globals)?;
|
|
attach_desktop_type(cx, driver, "Function", desktop)?;
|
|
return Ok(NativeResult::Invoke {
|
|
function: driver,
|
|
arguments: vec![],
|
|
});
|
|
}
|
|
}
|
|
if matches!(self.operation, OpenValue) {
|
|
let root = present_value(cx, &args[0], &self.session)?;
|
|
let page = if cx.store().payload::<block::Block>(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"))?;
|
|
if matches!(self.operation, Bind) {
|
|
let field = text(args.first())?;
|
|
let compute = object(args.get(1))?;
|
|
return Ok(NativeResult::Return(Value::Object(
|
|
crate::binding::connect(
|
|
cx,
|
|
owner,
|
|
&field,
|
|
compute,
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?
|
|
.master,
|
|
)?,
|
|
)));
|
|
}
|
|
if matches!(self.operation, MoveTab) {
|
|
let position = usize::try_from(integer(args.first())?)
|
|
.map_err(|_| invalid("tab position out of range"))?;
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
let id = page_window(cx.store(), &state, owner)?.ok_or(invalid("Page is not open"))?;
|
|
let view = state
|
|
.presentations
|
|
.get_mut(&id)
|
|
.ok_or(invalid("missing window"))?;
|
|
let window = object(Some(&cx.store().get_attr(view.desktop, "window")?))?;
|
|
crate::window::move_page(cx, window, owner, position)?;
|
|
view.force_frame = true;
|
|
return Ok(NativeResult::Return(Value::Object(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;
|
|
cx.set_attr(owner, "text", args[0].clone())?;
|
|
}
|
|
_ => 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")?;
|
|
cx.set_attr(owner, "color", Value::Text(value.clone()))?;
|
|
}
|
|
_ => return Err(invalid("this Block has no color")),
|
|
},
|
|
Append | Insert | Remove | Replace | Move => {
|
|
if !data.is_container() {
|
|
return Err(invalid("expected a container Block"));
|
|
}
|
|
if matches!(self.operation, Append | Insert) {
|
|
let (position, argument) = if matches!(self.operation, Insert) {
|
|
let position = usize::try_from(integer(args.first())?)
|
|
.map_err(|_| invalid("child position out of range"))?;
|
|
if position > data.children.len() {
|
|
return Err(invalid("child position out of range"));
|
|
}
|
|
(position, args.get(1))
|
|
} else {
|
|
(data.children.len(), args.first())
|
|
};
|
|
let child = object(argument)?;
|
|
block::validate(cx.store(), child, Some(data_ref))?;
|
|
let slot = data.next_slot;
|
|
data.next_slot = slot
|
|
.checked_add(1)
|
|
.filter(|next| *next <= i64::MAX as u64)
|
|
.ok_or(VmError::IdentityExhausted)?;
|
|
data.children.insert(position, 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 = usize::try_from(integer(args.get(1))?)
|
|
.map_err(|_| invalid("child position out of range"))?;
|
|
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)?;
|
|
}
|
|
Detach => {
|
|
let window = {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
let definition = desktop_type(cx.store(), "Window", state.master)?;
|
|
create_window(cx, &mut state, definition)?
|
|
};
|
|
show_in(cx, owner, &self.session, Some(window))?;
|
|
return Ok(NativeResult::Return(Value::Object(window)));
|
|
}
|
|
PageWindow => {
|
|
let state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
let value = if let Some(id) = page_window(cx.store(), &state, owner)? {
|
|
cx.store()
|
|
.get_attr(state.presentations[&id].desktop, "window")?
|
|
} else {
|
|
Value::Nil
|
|
};
|
|
return Ok(NativeResult::Return(value));
|
|
}
|
|
Open | Activate | MoveWindow => {
|
|
if matches!(self.operation, MoveWindow)
|
|
&& matches!(args.first(), None | Some(Value::Nil))
|
|
{
|
|
return Err(invalid("Page.moveToWindow requires a Window"));
|
|
}
|
|
let target = match args.first() {
|
|
None | Some(Value::Nil) => None,
|
|
Some(value) => Some(object(Some(value))?),
|
|
};
|
|
show_in(cx, owner, &self.session, target)?;
|
|
return Ok(NativeResult::Return(Value::Object(owner)));
|
|
}
|
|
Unmount => {
|
|
data.children.clear();
|
|
}
|
|
Close => {
|
|
crate::tasks::cancel_owner(cx, owner)?;
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| invalid("UI session poisoned"))?;
|
|
if let Some(id) = page_window(cx.store(), &state, owner)? {
|
|
// Closing the tab ends its presentation even if source
|
|
// reopens it before the next layout can detach the Mounts.
|
|
let page = cx.store().downgrade(owner)?;
|
|
for weak in state.presentations[&id].mounts.ordered() {
|
|
if let Some(mount) = cx.upgrade(weak)?
|
|
&& cx
|
|
.store()
|
|
.payload::<crate::mount::Mount>(mount, "Mount")?
|
|
.page
|
|
== Some(page)
|
|
{
|
|
crate::tasks::cancel_owner(cx, mount)?;
|
|
}
|
|
}
|
|
remove_page(
|
|
cx,
|
|
state
|
|
.presentations
|
|
.get_mut(&id)
|
|
.ok_or(invalid("missing window"))?,
|
|
owner,
|
|
)?;
|
|
}
|
|
return Ok(NativeResult::Return(Value::Nil));
|
|
}
|
|
OnClick => {
|
|
let handler = object(args.first())?;
|
|
crate::event_dispatch::validate_handler(cx.store(), handler)?;
|
|
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))?;
|
|
crate::event_dispatch::validate_handler(cx.store(), handler)?;
|
|
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::<crate::event_dispatch::Connection>(*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 | OnMount | OnUnmount => 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,
|
|
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,
|
|
active: active == Some(page),
|
|
});
|
|
}
|
|
state.tab_ids = live;
|
|
Ok(tabs)
|
|
}
|
|
|
|
impl UiBridge {
|
|
pub fn poll_tasks(&self, vm: &mut Vm) -> Result<usize> {
|
|
self.tasks.poll(vm)
|
|
}
|
|
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 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,
|
|
height: (size.height - 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")?))?;
|
|
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 = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.nodes
|
|
.clone();
|
|
crate::binding::refresh(vm, &nodes)
|
|
}
|
|
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 {
|
|
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 paint_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
root: Option<&UiNode>,
|
|
frame: &mut crate::document::Frame,
|
|
) -> Result<()> {
|
|
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 reads = crate::source_paint::render(vm, desktop, mounts, root, frame)?;
|
|
let reads = reads
|
|
.into_iter()
|
|
.map(|object| vm.change_version(object).map(|version| (object, version)))
|
|
.collect::<Result<std::collections::BTreeMap<_, _>, _>>()?;
|
|
self.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.paint_reads = reads;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn layout_frame(
|
|
&self,
|
|
vm: &mut Vm,
|
|
root: Option<Arc<UiNode>>,
|
|
) -> Result<crate::document::Frame> {
|
|
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();
|
|
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(frame);
|
|
};
|
|
let (mut layout, mut 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.height = (size.height - crate::tabs::height(&tabs)).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) = {
|
|
let mut state = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?;
|
|
let hooks = state
|
|
.mounts
|
|
.synchronize(vm, desktop, page, nodes, &frame, true)?;
|
|
(hooks, state.mounts.ordered())
|
|
};
|
|
let result = (|| {
|
|
let reads = crate::source_layout::render(
|
|
vm,
|
|
desktop,
|
|
mounts,
|
|
root.clone(),
|
|
&mut frame,
|
|
&mut layout,
|
|
&mut cache,
|
|
)?;
|
|
{
|
|
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 = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| anyhow::anyhow!("UI session poisoned"))?
|
|
.current;
|
|
// Capture versions before lifecycle hooks can change a dependency.
|
|
let reads = reads
|
|
.into_iter()
|
|
.map(|object| vm.change_version(object).map(|version| (object, version)))
|
|
.collect::<Result<std::collections::BTreeMap<_, _>, _>>()?;
|
|
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;
|
|
}
|
|
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)
|
|
})();
|
|
if let Some(hooks) = hooks {
|
|
vm.release(hooks)?;
|
|
}
|
|
result
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
|
let desktop = vm.new_object()?;
|
|
roots.push(desktop);
|
|
vm.set_attr(desktop, "viewport", Value::Nil)?;
|
|
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 {
|
|
nodes: Vec::new(),
|
|
presentations: std::collections::BTreeMap::from([(
|
|
crate::window::MAIN_WINDOW,
|
|
crate::presentation::Presentation::new(desktop, tabs),
|
|
)]),
|
|
current: crate::window::MAIN_WINDOW,
|
|
master: desktop,
|
|
next_window: crate::window::MAIN_WINDOW,
|
|
next_mount: 0,
|
|
retired_commands: Vec::new(),
|
|
}));
|
|
for (name, operation) in [
|
|
("implement", Operation::Implement),
|
|
("implements", Operation::Implements),
|
|
("supports", Operation::Supports),
|
|
("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),
|
|
("viewport", Operation::Viewport),
|
|
("window", Operation::Window),
|
|
] {
|
|
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 [
|
|
("Trait", "Object", None),
|
|
("Function", "Object", None),
|
|
("Block", "Object", None),
|
|
("ContainerBlock", "Block", None),
|
|
("TextBlock", "Block", Some("text")),
|
|
("RectBlock", "Block", Some("rect")),
|
|
("Spacer", "Block", Some("spacer")),
|
|
("ScrollBlock", "ContainerBlock", Some("scroll")),
|
|
("Row", "ContainerBlock", Some("row")),
|
|
("Column", "ContainerBlock", Some("column")),
|
|
("Button", "ContainerBlock", Some("button")),
|
|
("Page", "ContainerBlock", Some("page")),
|
|
("Window", "Object", Some("window-create")),
|
|
("Mount", "Object", None),
|
|
("Event", "Object", Some("event")),
|
|
("Subscription", "Object", None),
|
|
("Task", "Object", None),
|
|
("ObjectList", "Object", Some("object-list")),
|
|
] {
|
|
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
|
|
let constructor = match constructor_name {
|
|
Some("object-list") => {
|
|
let factory = vm.allocate(ContextNativeFunction::new(
|
|
"object-list",
|
|
crate::object_list::Constructor { desktop },
|
|
vec![desktop],
|
|
))?;
|
|
roots.push(factory);
|
|
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
|
vm.attach_type(factory, function_type)?;
|
|
TypeConstructor::NativeTyped(factory)
|
|
}
|
|
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,
|
|
"scroll" => Operation::Scroll,
|
|
"row" => Operation::Row,
|
|
"column" => Operation::Column,
|
|
"button" => Operation::Button,
|
|
"page" => Operation::Page,
|
|
"window-create" => Operation::NewWindow,
|
|
_ => 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));
|
|
}
|
|
for (name, fields) in [
|
|
(
|
|
"Constraints",
|
|
vec!["minWidth", "minHeight", "maxWidth", "maxHeight"],
|
|
),
|
|
("MeasureResult", vec!["width", "height"]),
|
|
("Bounds", vec!["x", "y", "width", "height"]),
|
|
("Point", vec!["x", "y"]),
|
|
("Arrangement", vec!["children"]),
|
|
("DisplayList", vec!["commands"]),
|
|
("PaintCommand", vec![]),
|
|
] {
|
|
let fields = fields
|
|
.into_iter()
|
|
.map(|name| endor_core::ClassField {
|
|
name: name.into(),
|
|
type_name: if matches!(name, "children" | "commands") {
|
|
"Object"
|
|
} else {
|
|
"Number"
|
|
}
|
|
.into(),
|
|
})
|
|
.collect();
|
|
let definition = vm.new_class(
|
|
name,
|
|
root_types.object,
|
|
fields,
|
|
std::collections::BTreeMap::new(),
|
|
)?;
|
|
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")?))?;
|
|
vm.set_function_type(function_type)?;
|
|
vm.set_source_type_checks(true)?;
|
|
for name in [
|
|
"open",
|
|
"text",
|
|
"rect",
|
|
"spacer",
|
|
"row",
|
|
"column",
|
|
"button",
|
|
"page",
|
|
"present",
|
|
"state",
|
|
"textOf",
|
|
"stats",
|
|
"viewport",
|
|
"window",
|
|
"isInstance",
|
|
"isSubtype",
|
|
"implement",
|
|
"implements",
|
|
"supports",
|
|
] {
|
|
let function = object(Some(&vm.get_attr(ui, name)?))?;
|
|
vm.attach_type(function, function_type)?;
|
|
}
|
|
for (type_name, name, operation) in [
|
|
("Block", "bind", Operation::Bind),
|
|
("Block", "mounts", Operation::Mounts),
|
|
("Block", "invalidate", Operation::Invalidate),
|
|
("Block", "onClick", Operation::OnClick),
|
|
("Block", "on", Operation::On),
|
|
("Block", "dispatch", Operation::Dispatch),
|
|
("Block", "onEvent", Operation::OnEvent),
|
|
("Block", "onMount", Operation::OnMount),
|
|
("Block", "onUnmount", Operation::OnUnmount),
|
|
("Block", "click", Operation::Click),
|
|
("TextBlock", "setText", Operation::SetText),
|
|
("TextBlock", "setColor", Operation::SetColor),
|
|
("RectBlock", "setColor", Operation::SetColor),
|
|
("ContainerBlock", "append", Operation::Append),
|
|
("ContainerBlock", "insert", Operation::Insert),
|
|
("ContainerBlock", "remove", Operation::Remove),
|
|
("ContainerBlock", "replace", Operation::Replace),
|
|
("ContainerBlock", "move", Operation::Move),
|
|
("ContainerBlock", "children", Operation::Children),
|
|
("Window", "close", Operation::CloseWindow),
|
|
("Page", "moveToWindow", Operation::MoveWindow),
|
|
("Page", "moveTab", Operation::MoveTab),
|
|
("Page", "detachToWindow", Operation::Detach),
|
|
("Page", "render", Operation::Render),
|
|
("Page", "unmount", Operation::Unmount),
|
|
("Page", "open", Operation::Open),
|
|
("Page", "window", Operation::PageWindow),
|
|
("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 {
|
|
signature: method_signature(vm.object_store(), desktop, expected, operation)?,
|
|
operation,
|
|
expected,
|
|
session: session.clone(),
|
|
},
|
|
vec![expected, desktop],
|
|
))?;
|
|
roots.push(method);
|
|
vm.attach_type(method, function_type)?;
|
|
vm.define_method(expected, name, method)?;
|
|
}
|
|
crate::window::install(vm, roots, desktop, tabs)?;
|
|
let block_type = object(Some(&vm.get_attr(ui, "Block")?))?;
|
|
crate::native_visual::install(vm, roots, desktop, block_type, function_type)?;
|
|
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)?;
|
|
crate::protocols::install(vm, roots, desktop, ui)?;
|
|
let tasks = crate::tasks::install(vm, roots, ui, desktop)?;
|
|
Ok(UiBridge { ui, session, tasks })
|
|
}
|
|
|
|
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)
|
|
}
|