Files
endorphin-tessera-gui/src/ui_bridge.rs
T
2026-10-21 02:20:00 +08:00

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