304 lines
9.3 KiB
Rust
304 lines
9.3 KiB
Rust
//! Tessera-owned UI payloads using Aster's existing embedding interface.
|
|
use std::{
|
|
any::Any,
|
|
collections::HashMap,
|
|
sync::{Arc, Mutex, OnceLock},
|
|
};
|
|
|
|
use anyhow::{Result, bail};
|
|
use endor_core::{EndorReal, ObjectPayload, ObjectRef, Value, Vm, VmError};
|
|
|
|
use crate::document::parse_color;
|
|
|
|
pub const MAX_UI_NODES: usize = 4096;
|
|
pub const MAX_UI_DEPTH: usize = 64;
|
|
pub const MAX_DIMENSION: f64 = 1_000_000.0;
|
|
|
|
#[derive(Debug)]
|
|
pub struct UiNode {
|
|
pub kind: UiKind,
|
|
depth: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum UiKind {
|
|
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 NodePayload(UiSlot);
|
|
|
|
type UiSlot = Arc<OnceLock<Arc<UiNode>>>;
|
|
|
|
impl ObjectPayload for NodePayload {
|
|
fn as_any(&self) -> &dyn Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-ui-node"
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct BuildSession {
|
|
// Borrowed handles only. Candidate owns every pool root until extraction
|
|
// and execution have finished. Native child edges contain Arc<UiNode>,
|
|
// never VM handles, so payload-owned GC edges remain empty and immutable.
|
|
pool: Vec<(ObjectRef, UiSlot)>,
|
|
indexes: HashMap<ObjectRef, usize>,
|
|
used: usize,
|
|
}
|
|
|
|
impl BuildSession {
|
|
fn child(&self, value: &Value) -> Result<Arc<UiNode>, VmError> {
|
|
let Value::Object(reference) = value else {
|
|
return Err(VmError::InvalidArgument("UI children must be UI nodes"));
|
|
};
|
|
self.indexes
|
|
.get(reference)
|
|
.and_then(|index| self.pool[*index].1.get())
|
|
.cloned()
|
|
.ok_or(VmError::InvalidArgument(
|
|
"UI children must be initialized nodes from this build",
|
|
))
|
|
}
|
|
|
|
fn emit(&mut self, kind: UiKind) -> Result<Value, VmError> {
|
|
let depth = match &kind {
|
|
UiKind::Column { children, .. } | UiKind::Row { children, .. } => {
|
|
children.iter().map(|node| node.depth).max().unwrap_or(0) + 1
|
|
}
|
|
_ => 1,
|
|
};
|
|
if depth > MAX_UI_DEPTH {
|
|
return Err(VmError::InvalidArgument("UI tree exceeds 64 levels"));
|
|
}
|
|
let Some((reference, slot)) = self.pool.get(self.used) else {
|
|
return Err(VmError::InvalidArgument(
|
|
"UI build exceeds the 4096 node budget",
|
|
));
|
|
};
|
|
slot.set(Arc::new(UiNode { kind, depth }))
|
|
.map_err(|_| VmError::InvalidArgument("UI node initialized twice"))?;
|
|
self.used += 1;
|
|
Ok(Value::Object(*reference))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
enum Constructor {
|
|
Text,
|
|
Rect,
|
|
Column,
|
|
Row,
|
|
Spacer,
|
|
}
|
|
|
|
impl Constructor {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Text => "text",
|
|
Self::Rect => "rect",
|
|
Self::Column => "column",
|
|
Self::Row => "row",
|
|
Self::Spacer => "spacer",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct UiConstructor {
|
|
constructor: Constructor,
|
|
session: Arc<Mutex<BuildSession>>,
|
|
}
|
|
|
|
impl ObjectPayload for UiConstructor {
|
|
fn as_any(&self) -> &dyn Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-ui-constructor"
|
|
}
|
|
|
|
fn call(&self, arguments: &[Value]) -> Result<Value, VmError> {
|
|
let constructor = self.constructor;
|
|
let (required, maximum) = match constructor {
|
|
Constructor::Text => (1, 3),
|
|
Constructor::Rect => (2, 4),
|
|
Constructor::Column | Constructor::Row => (2, MAX_UI_NODES + 2),
|
|
Constructor::Spacer => (1, 1),
|
|
};
|
|
if !(required..=maximum).contains(&arguments.len()) {
|
|
return Err(VmError::ArgumentCount {
|
|
name: format!("ui.{}", constructor.name()),
|
|
required,
|
|
maximum,
|
|
provided: arguments.len(),
|
|
});
|
|
}
|
|
let mut session = self
|
|
.session
|
|
.lock()
|
|
.map_err(|_| VmError::InvalidArgument("UI build session is poisoned"))?;
|
|
let kind = match constructor {
|
|
Constructor::Text => {
|
|
let Value::Text(value) = &arguments[0] else {
|
|
return Err(VmError::InvalidArgument("ui.text expects a string"));
|
|
};
|
|
if value.len() > 16_384 {
|
|
return Err(VmError::InvalidArgument("UI text exceeds 16 KiB"));
|
|
}
|
|
let size = if let Some(value) = arguments.get(1) {
|
|
let Value::Number(number) = value else {
|
|
return Err(VmError::InvalidArgument(
|
|
"text size must be an integer from 1 to 8",
|
|
));
|
|
};
|
|
number
|
|
.as_i64()
|
|
.filter(|size| (1..=8).contains(size))
|
|
.ok_or(VmError::InvalidArgument(
|
|
"text size must be an integer from 1 to 8",
|
|
))? as u32
|
|
} else {
|
|
1
|
|
};
|
|
UiKind::Text {
|
|
value: value.clone(),
|
|
size,
|
|
color: color(arguments.get(2), "#242422")?,
|
|
}
|
|
}
|
|
Constructor::Rect => UiKind::Rect {
|
|
width: dimension(&arguments[0])?,
|
|
height: dimension(&arguments[1])?,
|
|
radius: arguments.get(2).map(dimension).transpose()?.unwrap_or(0.0),
|
|
color: color(arguments.get(3), "#DCE8FF")?,
|
|
},
|
|
Constructor::Column | Constructor::Row => {
|
|
let padding = dimension(&arguments[0])?;
|
|
let gap = dimension(&arguments[1])?;
|
|
let children = arguments[2..]
|
|
.iter()
|
|
.map(|value| session.child(value))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
if matches!(constructor, Constructor::Column) {
|
|
UiKind::Column {
|
|
padding,
|
|
gap,
|
|
children,
|
|
}
|
|
} else {
|
|
UiKind::Row {
|
|
padding,
|
|
gap,
|
|
children,
|
|
}
|
|
}
|
|
}
|
|
Constructor::Spacer => UiKind::Spacer(dimension(&arguments[0])?),
|
|
};
|
|
session.emit(kind)
|
|
}
|
|
}
|
|
|
|
fn color(value: Option<&Value>, default: &str) -> Result<String, VmError> {
|
|
let value = match value {
|
|
Some(Value::Text(value)) => value.as_str(),
|
|
None => default,
|
|
_ => return Err(VmError::InvalidArgument("UI color must be a string")),
|
|
};
|
|
parse_color(value)
|
|
.map_err(|_| VmError::InvalidArgument("UI color must use #RRGGBB or #RRGGBBAA"))?;
|
|
Ok(value.to_owned())
|
|
}
|
|
|
|
fn dimension(value: &Value) -> Result<f32, VmError> {
|
|
let invalid =
|
|
|| VmError::InvalidArgument("UI dimensions must be finite numbers between 0 and 1000000");
|
|
let Value::Number(number) = value else {
|
|
return Err(invalid());
|
|
};
|
|
// Conversion belongs to the UI boundary. Aster's exact Number is unchanged.
|
|
let value = match number.as_real() {
|
|
Some(EndorReal::Integer(value)) => {
|
|
value.to_string().parse::<f64>().map_err(|_| invalid())?
|
|
}
|
|
Some(EndorReal::Decimal(value)) => value.literal().parse::<f64>().map_err(|_| invalid())?,
|
|
Some(EndorReal::BinaryFloat(bits)) => f64::from_bits(*bits),
|
|
_ => return Err(invalid()),
|
|
};
|
|
if !value.is_finite() || !(0.0..=MAX_DIMENSION).contains(&value) {
|
|
return Err(invalid());
|
|
}
|
|
Ok(value as f32)
|
|
}
|
|
|
|
/// All allocation roots go to the candidate, including partial installation.
|
|
pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<ObjectRef> {
|
|
let mut pool = Vec::with_capacity(MAX_UI_NODES);
|
|
let mut indexes = HashMap::with_capacity(MAX_UI_NODES);
|
|
for index in 0..MAX_UI_NODES {
|
|
let slot = Arc::new(OnceLock::new());
|
|
let reference = vm.allocate(NodePayload(slot.clone()))?;
|
|
roots.push(reference);
|
|
indexes.insert(reference, index);
|
|
pool.push((reference, slot));
|
|
}
|
|
let session = Arc::new(Mutex::new(BuildSession {
|
|
pool,
|
|
indexes,
|
|
used: 0,
|
|
}));
|
|
let ui = vm.new_object()?;
|
|
roots.push(ui);
|
|
for constructor in [
|
|
Constructor::Text,
|
|
Constructor::Rect,
|
|
Constructor::Column,
|
|
Constructor::Row,
|
|
Constructor::Spacer,
|
|
] {
|
|
let function = vm.allocate(UiConstructor {
|
|
constructor,
|
|
session: session.clone(),
|
|
})?;
|
|
roots.push(function);
|
|
vm.set_attr(ui, constructor.name(), Value::Object(function))?;
|
|
}
|
|
Ok(ui)
|
|
}
|
|
|
|
pub fn snapshot(vm: &Vm, result: &Value) -> Result<Arc<UiNode>> {
|
|
let Value::Object(reference) = result else {
|
|
bail!("main must return a UI node");
|
|
};
|
|
Ok(vm
|
|
.payload::<NodePayload>(*reference, "Tessera UI node")?
|
|
.0
|
|
.get()
|
|
.ok_or_else(|| anyhow::anyhow!("main returned an uninitialized UI node"))?
|
|
.clone())
|
|
}
|