Files
endorphin-tessera-gui/src/model/block.rs
T
2026-10-25 21:25:43 +08:00

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(())
}