475 lines
15 KiB
Rust
475 lines
15 KiB
Rust
//! Live visual objects. All semantic references are owned by Aster's heap;
|
|
//! native Arcs are reserved for immutable rendering snapshots.
|
|
use std::sync::Arc;
|
|
|
|
use endor_core::{NativeContext, ObjectPayload, ObjectRef, ObjectStore, Value, VmError};
|
|
|
|
use crate::ui_bridge::UiNode;
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Child {
|
|
pub slot: u64,
|
|
pub block: ObjectRef,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Kind {
|
|
Text {
|
|
value: String,
|
|
size: u32,
|
|
color: String,
|
|
},
|
|
Rect {
|
|
width: f32,
|
|
height: f32,
|
|
radius: f32,
|
|
color: String,
|
|
},
|
|
Spacer(f32),
|
|
Scroll {
|
|
width: f32,
|
|
height: f32,
|
|
},
|
|
Row,
|
|
Column,
|
|
Button,
|
|
Page,
|
|
}
|
|
|
|
/// The VM owns the Block -> Cell -> Data relationship. Shared assignment
|
|
/// changes the Cell to point at another Block, retaining the original identity.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Block {
|
|
pub state: ObjectRef,
|
|
pub page: bool,
|
|
pub tasks: Option<ObjectRef>,
|
|
}
|
|
|
|
impl ObjectPayload for Block {
|
|
fn reference_assignment_only(&self) -> bool {
|
|
true
|
|
}
|
|
fn attribute_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
|
|
let Value::Object(value) = store.cell_get(self.state)? else {
|
|
return Err(VmError::InvalidArgument("Block state is not an object"));
|
|
};
|
|
if store.object(value)?.payload().as_any().is::<Block>() {
|
|
Ok(Some(value))
|
|
} else {
|
|
store
|
|
.payload::<Data>(value, "Block data")?
|
|
.properties
|
|
.map(Some)
|
|
.ok_or(VmError::InvalidArgument(
|
|
"Block has no managed property storage",
|
|
))
|
|
}
|
|
}
|
|
|
|
fn type_target(&self, store: &ObjectStore) -> Result<Option<ObjectRef>, VmError> {
|
|
let Value::Object(value) = store.cell_get(self.state)? else {
|
|
return Err(VmError::InvalidArgument("Block state is not an object"));
|
|
};
|
|
if store.object(value)?.payload().as_any().is::<Block>() {
|
|
Ok(Some(value))
|
|
} else {
|
|
store.payload::<Data>(value, "Block data")?;
|
|
Ok(None)
|
|
}
|
|
}
|
|
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-block"
|
|
}
|
|
fn allows_attributes(&self) -> bool {
|
|
true
|
|
}
|
|
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
|
visit(self.state);
|
|
if let Some(tasks) = self.tasks {
|
|
visit(tasks);
|
|
}
|
|
}
|
|
fn assignment_cell(
|
|
&self,
|
|
source: ObjectRef,
|
|
store: &ObjectStore,
|
|
) -> Result<Option<ObjectRef>, VmError> {
|
|
if !store.object(source)?.payload().as_any().is::<Self>() {
|
|
return Ok(None);
|
|
}
|
|
if store.payload::<Self>(source, "Block")?.page != self.page {
|
|
return Err(VmError::InvalidArgument(
|
|
"Page assignment requires another Page",
|
|
));
|
|
}
|
|
validate(store, source, Some(self.state))?;
|
|
Ok(Some(self.state))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Data {
|
|
pub kind: Kind,
|
|
pub children: Vec<Child>,
|
|
pub padding: f32,
|
|
pub gap: f32,
|
|
pub flex: f32,
|
|
pub flex_policy: crate::layout::FlexPolicy,
|
|
pub main_align: crate::layout::MainAlign,
|
|
pub scroll_axis: crate::scroll::Axis,
|
|
pub next_slot: u64,
|
|
pub history: Vec<ObjectRef>,
|
|
pub history_index: usize,
|
|
pub bindings: Vec<ObjectRef>,
|
|
pub text_binding: Option<(ObjectRef, String)>,
|
|
pub click: Option<ObjectRef>,
|
|
pub subscriptions: Vec<ObjectRef>,
|
|
pub properties: Option<ObjectRef>,
|
|
}
|
|
|
|
impl Data {
|
|
pub fn new(kind: Kind, children: Vec<ObjectRef>, padding: f32, gap: f32) -> Self {
|
|
let next_slot = children.len() as u64;
|
|
Self {
|
|
kind,
|
|
children: children
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(slot, block)| Child {
|
|
slot: slot as u64,
|
|
block,
|
|
})
|
|
.collect(),
|
|
padding,
|
|
gap,
|
|
flex: 0.0,
|
|
flex_policy: crate::layout::FlexPolicy::default(),
|
|
main_align: crate::layout::MainAlign::Start,
|
|
scroll_axis: crate::scroll::Axis::Both,
|
|
next_slot,
|
|
history: vec![],
|
|
history_index: 0,
|
|
bindings: vec![],
|
|
text_binding: None,
|
|
click: None,
|
|
subscriptions: vec![],
|
|
properties: None,
|
|
}
|
|
}
|
|
pub fn is_container(&self) -> bool {
|
|
matches!(
|
|
self.kind,
|
|
Kind::Row | Kind::Column | Kind::Button | Kind::Page | Kind::Scroll { .. }
|
|
)
|
|
}
|
|
}
|
|
|
|
impl ObjectPayload for Data {
|
|
fn reference_assignment_only(&self) -> bool {
|
|
true
|
|
}
|
|
fn as_any(&self) -> &dyn std::any::Any {
|
|
self
|
|
}
|
|
fn kind_name(&self) -> &'static str {
|
|
"tessera-block-data"
|
|
}
|
|
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
|
if let Some(properties) = self.properties {
|
|
visit(properties);
|
|
}
|
|
for child in &self.children {
|
|
visit(child.block);
|
|
}
|
|
for item in &self.history {
|
|
visit(*item);
|
|
}
|
|
if let Some((source, _)) = &self.text_binding {
|
|
visit(*source);
|
|
}
|
|
for binding in &self.bindings {
|
|
visit(*binding);
|
|
}
|
|
for token in &self.subscriptions {
|
|
visit(*token);
|
|
}
|
|
if let Some(handler) = self.click {
|
|
visit(handler);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn allocate(cx: &mut NativeContext<'_>, mut data: Data) -> Result<ObjectRef, VmError> {
|
|
let page = matches!(data.kind, Kind::Page);
|
|
for child in &data.children {
|
|
validate(cx.store(), child.block, None)?;
|
|
}
|
|
let properties = cx.new_object()?;
|
|
for (name, value) in native_properties(&data) {
|
|
cx.set_attr(properties, name, value)?;
|
|
}
|
|
data.properties = Some(properties);
|
|
let data = cx.allocate(data)?;
|
|
let state = cx.new_cell(Some(Value::Object(data)))?;
|
|
cx.allocate(Block {
|
|
state,
|
|
page,
|
|
tasks: None,
|
|
})
|
|
}
|
|
|
|
/// Borrowed final data; forwarding is part of the managed graph, never an Arc
|
|
/// ownership graph hidden from the VM's cycle collector.
|
|
pub fn resolve(store: &ObjectStore, block: ObjectRef) -> Result<(ObjectRef, &Data), VmError> {
|
|
let (reference, data, _) = resolve_tracked(store, block)?;
|
|
Ok((reference, data))
|
|
}
|
|
|
|
pub fn resolve_tracked(
|
|
store: &ObjectStore,
|
|
block: ObjectRef,
|
|
) -> Result<(ObjectRef, &Data, Vec<ObjectRef>), VmError> {
|
|
let mut current = block;
|
|
let mut seen = std::collections::HashSet::new();
|
|
let mut dependencies = Vec::new();
|
|
loop {
|
|
if !seen.insert(current) {
|
|
return Err(VmError::InvalidArgument("cyclic Block reference"));
|
|
}
|
|
let state = store.payload::<Block>(current, "Block")?.state;
|
|
dependencies.extend([current, state]);
|
|
let Value::Object(value) = store.cell_get(state)? else {
|
|
return Err(VmError::InvalidArgument("Block state is not an object"));
|
|
};
|
|
if store.object(value)?.payload().as_any().is::<Data>() {
|
|
dependencies.push(value);
|
|
return Ok((
|
|
value,
|
|
store.payload::<Data>(value, "Block data")?,
|
|
dependencies,
|
|
));
|
|
}
|
|
current = value;
|
|
}
|
|
}
|
|
|
|
/// Validate the entire finite expansion before establishing a new composition
|
|
/// edge. The node count is an explicit rejected resource quota, not truncated UI.
|
|
pub fn validate(
|
|
store: &ObjectStore,
|
|
root: ObjectRef,
|
|
forbidden_state: Option<ObjectRef>,
|
|
) -> Result<(), VmError> {
|
|
enum Step {
|
|
Enter(ObjectRef),
|
|
Exit(ObjectRef),
|
|
}
|
|
let mut work = vec![Step::Enter(root)];
|
|
let mut path = std::collections::HashSet::new();
|
|
let mut visits = 0;
|
|
while let Some(step) = work.pop() {
|
|
match step {
|
|
Step::Exit(state) => {
|
|
path.remove(&state);
|
|
}
|
|
Step::Enter(block) => {
|
|
visits += 1;
|
|
if visits > 10_000 {
|
|
return Err(VmError::ExecutionLimit("expanded Block graph"));
|
|
}
|
|
let state = store.payload::<Block>(block, "Block")?.state;
|
|
if forbidden_state == Some(state) || !path.insert(state) {
|
|
return Err(VmError::InvalidArgument(
|
|
"Block composition would create a cycle",
|
|
));
|
|
}
|
|
work.push(Step::Exit(state));
|
|
let Value::Object(value) = store.cell_get(state)? else {
|
|
return Err(VmError::InvalidArgument("invalid Block state"));
|
|
};
|
|
if forbidden_state == Some(value) {
|
|
return Err(VmError::InvalidArgument(
|
|
"Block composition would create a cycle",
|
|
));
|
|
}
|
|
if let Ok(data) = store.payload::<Data>(value, "Block data") {
|
|
for child in data.children.iter().rev() {
|
|
work.push(Step::Enter(child.block));
|
|
}
|
|
} else {
|
|
work.push(Step::Enter(value));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn snapshot(store: &ObjectStore, root: ObjectRef) -> Result<Arc<UiNode>, VmError> {
|
|
crate::snapshot::RenderCache::default().snapshot(store, root)
|
|
}
|
|
|
|
/// Native visual parameters are ordinary managed attributes too. Composition,
|
|
/// histories and connections remain structural payload edges.
|
|
pub fn native_properties(data: &Data) -> Vec<(&'static str, Value)> {
|
|
fn number(value: f32) -> Value {
|
|
Value::Number((value as f64).into())
|
|
}
|
|
let mut fields = vec![
|
|
("flex", number(data.flex)),
|
|
(
|
|
"flexBasis",
|
|
data.flex_policy
|
|
.basis
|
|
.map_or_else(|| Value::Text("auto".into()), number),
|
|
),
|
|
("flexMin", number(data.flex_policy.min)),
|
|
("flexMax", number(data.flex_policy.max)),
|
|
("flexShrink", number(data.flex_policy.shrink)),
|
|
];
|
|
match &data.kind {
|
|
Kind::Text { value, size, color } => fields.extend([
|
|
("text", Value::Text(value.clone())),
|
|
("size", Value::Number((*size as i64).into())),
|
|
("color", Value::Text(color.clone())),
|
|
]),
|
|
Kind::Rect {
|
|
width,
|
|
height,
|
|
radius,
|
|
color,
|
|
} => fields.extend([
|
|
("width", number(*width)),
|
|
("height", number(*height)),
|
|
("radius", number(*radius)),
|
|
("color", Value::Text(color.clone())),
|
|
]),
|
|
Kind::Scroll { width, height } => fields.extend([
|
|
("width", number(*width)),
|
|
("height", number(*height)),
|
|
("axis", Value::Text(data.scroll_axis.name().into())),
|
|
]),
|
|
Kind::Spacer(size) => fields.push(("size", number(*size))),
|
|
_ => {}
|
|
}
|
|
if data.is_container() {
|
|
fields.extend([
|
|
("padding", number(data.padding)),
|
|
("gap", number(data.gap)),
|
|
("mainAlign", Value::Text(data.main_align.name().into())),
|
|
]);
|
|
}
|
|
fields
|
|
}
|
|
|
|
pub fn visual_data(store: &ObjectStore, data: &Data) -> Result<Data, VmError> {
|
|
let mut data = data.clone();
|
|
let properties = data
|
|
.properties
|
|
.ok_or(VmError::InvalidArgument("missing Block properties"))?;
|
|
for (name, _) in native_properties(&data) {
|
|
let value = store.get_attr(properties, name)?;
|
|
apply_visual_property(&mut data, name, &value)?;
|
|
}
|
|
if data.flex_policy.min > data.flex_policy.max {
|
|
return Err(VmError::InvalidArgument("flexMin exceeds flexMax"));
|
|
}
|
|
Ok(data)
|
|
}
|
|
|
|
pub fn apply_visual_property(data: &mut Data, name: &str, value: &Value) -> Result<(), VmError> {
|
|
let bad = || VmError::InvalidArgument("invalid native visual property");
|
|
let number = || -> Result<f32, VmError> {
|
|
let Value::Number(value) = value else {
|
|
return Err(bad());
|
|
};
|
|
let n = value.to_string().parse::<f32>().map_err(|_| bad())?;
|
|
if !n.is_finite() || !(0.0..=crate::ui_bridge::MAX_DIMENSION as f32).contains(&n) {
|
|
return Err(bad());
|
|
}
|
|
Ok(n)
|
|
};
|
|
match name {
|
|
"flexBasis" => {
|
|
data.flex_policy.basis = if matches!(value, Value::Nil)
|
|
|| matches!(value, Value::Text(text) if text == "auto")
|
|
{
|
|
None
|
|
} else {
|
|
Some(number()?)
|
|
};
|
|
return Ok(());
|
|
}
|
|
"flexMin" => {
|
|
data.flex_policy.min = number()?;
|
|
return Ok(());
|
|
}
|
|
"flexMax" => {
|
|
data.flex_policy.max = number()?;
|
|
return Ok(());
|
|
}
|
|
"flexShrink" => {
|
|
data.flex_policy.shrink = number()?;
|
|
return Ok(());
|
|
}
|
|
_ => {}
|
|
}
|
|
if name == "flex" {
|
|
data.flex = number()?;
|
|
return Ok(());
|
|
}
|
|
let container = data.is_container();
|
|
if container && name == "mainAlign" {
|
|
let Value::Text(value) = value else {
|
|
return Err(bad());
|
|
};
|
|
data.main_align = crate::layout::MainAlign::parse(value)?;
|
|
return Ok(());
|
|
}
|
|
match (&mut data.kind, name) {
|
|
(Kind::Text { value: text, .. }, "text") => {
|
|
let Value::Text(value) = value else {
|
|
return Err(bad());
|
|
};
|
|
if value.len() > 16_384 {
|
|
return Err(bad());
|
|
}
|
|
text.clone_from(value);
|
|
}
|
|
(Kind::Text { size, .. }, "size") => {
|
|
let n = number()?;
|
|
if !(1.0..=8.0).contains(&n) || n.fract() != 0.0 {
|
|
return Err(bad());
|
|
}
|
|
*size = n as u32;
|
|
}
|
|
(Kind::Text { color, .. } | Kind::Rect { color, .. }, "color") => {
|
|
let Value::Text(value) = value else {
|
|
return Err(bad());
|
|
};
|
|
crate::document::parse_color(value).map_err(|_| bad())?;
|
|
color.clone_from(value);
|
|
}
|
|
(Kind::Scroll { .. }, "axis") => {
|
|
let Value::Text(name) = value else {
|
|
return Err(bad());
|
|
};
|
|
data.scroll_axis = crate::scroll::Axis::parse(name)?;
|
|
}
|
|
(Kind::Scroll { width, .. }, "width") => *width = number()?,
|
|
(Kind::Scroll { height, .. }, "height") => *height = number()?,
|
|
(Kind::Rect { width, .. }, "width") => *width = number()?,
|
|
(Kind::Rect { height, .. }, "height") => *height = number()?,
|
|
(Kind::Rect { radius, .. }, "radius") => *radius = number()?,
|
|
(Kind::Spacer(size), "size") => *size = number()?,
|
|
(_, "padding") if container => data.padding = number()?,
|
|
(_, "gap") if container => data.gap = number()?,
|
|
_ => {}
|
|
}
|
|
Ok(())
|
|
}
|