Files
endorphin-tessera-gui/src/native_visual.rs
T
codex-bot 6e69c5e5ce
Verify Tessera GUI / verify (push) Failing after 7m33s
Add per-Mount scrolling with shared paint and input clipping
2026-10-21 22:05:08 +08:00

352 lines
13 KiB
Rust

//! Native visual protocol entry points. Values are ordinary managed Endor
//! objects; geometry is scoped to a live occurrence, never stored on a Block.
use crate::{
block,
document::RenderCommand,
layout::{self, Size},
mount::Mount,
ui_bridge::{MAX_DIMENSION, attach_desktop_type},
};
use endor_core::{
ContextNativeFunction, NativeCallable, NativeContext, NativeResult, ObjectRef, Value, Vm,
VmError,
};
fn invalid(message: &'static str) -> VmError {
VmError::InvalidArgument(message)
}
fn object(value: &Value) -> Result<ObjectRef, VmError> {
if let Value::Object(object) = value {
Ok(*object)
} else {
Err(invalid("visual protocol requires objects"))
}
}
pub(crate) fn number(
cx: &NativeContext<'_>,
object: ObjectRef,
name: &str,
signed: bool,
) -> Result<f32, VmError> {
let Value::Number(value) = cx.store().get_attr(object, name)? else {
return Err(invalid("visual field must be a Number"));
};
let value = value
.to_string()
.parse::<f32>()
.map_err(|_| invalid("invalid visual number"))?;
if !value.is_finite() || value.abs() > MAX_DIMENSION as f32 || (!signed && value < 0.0) {
return Err(invalid("visual number exceeds bounds"));
}
Ok(value)
}
pub(crate) fn value(value: f32) -> Value {
Value::Number((value as f64).into())
}
pub(crate) fn record(
cx: &mut NativeContext<'_>,
desktop: ObjectRef,
kind: &str,
fields: Vec<(&str, Value)>,
) -> Result<ObjectRef, VmError> {
let object = cx.new_object()?;
attach_desktop_type(cx, object, kind, desktop)?;
for (key, value) in fields {
cx.set_attr(object, key, value)?;
}
Ok(object)
}
fn size(node: &crate::ui_bridge::UiNode) -> Result<Size, VmError> {
let frame =
layout::frame(node).map_err(|_| invalid("native measurement exceeds layout limits"))?;
let root = frame
.mounts
.first()
.ok_or(invalid("empty native measurement"))?;
Ok(Size {
width: root.width,
height: root.height,
})
}
#[derive(Debug, Clone, Copy)]
enum Query {
Measure,
Arrange,
Paint,
HitTest,
}
#[derive(Debug)]
struct Method {
query: Query,
desktop: ObjectRef,
}
impl NativeCallable for Method {
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [owner, context, input] = args else {
return Err(invalid("visual method expects a Mount and an input value"));
};
let owner = object(owner)?;
let mount = cx.store().payload::<Mount>(object(context)?, "Mount")?;
if !mount.mounted || mount.block != owner {
return Err(invalid(
"visual context is detached or belongs to another Block",
));
}
let geometry = mount.geometry.clone();
let staged = mount
.layout
.as_ref()
.map(|layout| {
let layout = layout
.lock()
.map_err(|_| invalid("layout context poisoned"))?;
Ok::<_, VmError>((layout.node.clone(), layout.children.clone()))
})
.transpose()?;
let input = object(input)?;
if matches!(self.query, Query::HitTest) {
if staged.is_some() {
return Err(invalid("hitTest is unavailable during layout"));
}
let x = number(cx, input, "x", true)?;
let y = number(cx, input, "y", true)?;
return Ok(NativeResult::Return(Value::Bool(
x >= 0.0 && y >= 0.0 && x < geometry.width && y < geometry.height,
)));
}
if matches!(&staged, Some((None, _))) {
return Err(invalid(
"layout context is not prepared for this occurrence",
));
}
let node = if let Some((Some(node), _)) = &staged {
node.clone()
} else {
block::snapshot(cx.store(), owner)?
};
let result = match self.query {
Query::Measure => {
let min_w = number(cx, input, "minWidth", false)?;
let min_h = number(cx, input, "minHeight", false)?;
let max_w = number(cx, input, "maxWidth", false)?;
let max_h = number(cx, input, "maxHeight", false)?;
if min_w > max_w || min_h > max_h {
return Err(invalid("minimum exceeds maximum constraint"));
}
let size = if let Some((_, children)) = &staged {
layout::measure_native(&node, children.clone())
.map_err(|_| invalid("native measurement exceeds layout limits"))?
} else {
size(&node)?
};
Value::Object(record(
cx,
self.desktop,
"MeasureResult",
vec![
("width", value(size.width.clamp(min_w, max_w))),
("height", value(size.height.clamp(min_h, max_h))),
],
)?)
}
Query::Arrange => {
let x = number(cx, input, "x", false)?;
let y = number(cx, input, "y", false)?;
let width = number(cx, input, "width", false)?;
let height = number(cx, input, "height", false)?;
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
let children = if let Some((_, children)) = &staged {
children.clone()
} else {
node.children()
.iter()
.map(|child| size(child))
.collect::<Result<Vec<_>, _>>()?
};
let points = layout::arrange_native(&node, x, y, &children)
.map_err(|_| invalid("native arrangement exceeds limits"))?;
let mut result = Vec::new();
for ((slot, size), (x, y)) in node.slots.iter().zip(children).zip(points) {
let bounds = record(
cx,
self.desktop,
"Bounds",
vec![
("x", value(x)),
("y", value(y)),
("width", value(size.width)),
("height", value(size.height)),
],
)?;
cx.set_attr(bounds, "slot", Value::Number((*slot as i64).into()))?;
result.push(bounds);
}
let result = crate::object_list::collection(cx, result, self.desktop)?;
Value::Object(record(
cx,
self.desktop,
"Arrangement",
vec![("children", Value::Object(result))],
)?)
}
Query::Paint => {
let x = number(cx, input, "x", true)?;
let y = number(cx, input, "y", true)?;
let width = number(cx, input, "width", false)?;
let height = number(cx, input, "height", false)?;
if x + width > MAX_DIMENSION as f32 || y + height > MAX_DIMENSION as f32 {
return Err(invalid("visual bounds exceed limits"));
}
let mut commands = Vec::new();
if let Some(command) = layout::paint_native(&node, x, y, Size { width, height }) {
let fields = match command {
RenderCommand::Text {
x,
y,
value: text,
size,
color,
} => vec![
("kind", Value::Text("text".into())),
("x", value(x)),
("y", value(y)),
("text", Value::Text(text)),
("size", Value::Number((size as i64).into())),
("color", Value::Text(color)),
],
RenderCommand::RoundedRect {
x,
y,
width,
height,
radius,
color,
} => vec![
("kind", Value::Text("roundedRect".into())),
("x", value(x)),
("y", value(y)),
("width", value(width)),
("height", value(height)),
("radius", value(radius)),
("color", Value::Text(color)),
],
_ => return Err(invalid("unsupported native paint command")),
};
let command = record(cx, self.desktop, "PaintCommand", fields)?;
commands.push(command);
}
let commands = crate::object_list::collection(cx, commands, self.desktop)?;
Value::Object(record(
cx,
self.desktop,
"DisplayList",
vec![("commands", Value::Object(commands))],
)?)
}
Query::HitTest => unreachable!("handled above"),
};
Ok(NativeResult::Return(result))
}
}
#[derive(Debug)]
struct PointMethod {
expected: ObjectRef,
circle: bool,
}
impl NativeCallable for PointMethod {
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let Some(Value::Object(point)) = args.first() else {
return Err(invalid("Point method requires a Point"));
};
if !cx.store().is_instance(*point, self.expected)? {
return Err(invalid("Point method requires a Point"));
}
let x = number(cx, *point, "x", true)?;
let y = number(cx, *point, "y", true)?;
let inside = if self.circle {
let [
_,
Value::Number(cx_value),
Value::Number(cy_value),
Value::Number(radius),
] = args
else {
return Err(invalid("inCircle requires center x/y and radius"));
};
let parse = |value: &endor_core::EndorNumber| -> Result<f64, VmError> {
let value = value
.to_string()
.parse::<f64>()
.map_err(|_| invalid("invalid circle number"))?;
if !value.is_finite() || value.abs() > MAX_DIMENSION {
return Err(invalid("circle number exceeds bounds"));
}
Ok(value)
};
let dx = x as f64 - parse(cx_value)?;
let dy = y as f64 - parse(cy_value)?;
let radius = parse(radius)?;
if radius < 0.0 {
return Err(invalid("circle radius must be nonnegative"));
}
dx * dx + dy * dy <= radius * radius
} else {
let [_, Value::Object(bounds)] = args else {
return Err(invalid("inside requires Bounds"));
};
let left = number(cx, *bounds, "x", true)?;
let top = number(cx, *bounds, "y", true)?;
let width = number(cx, *bounds, "width", false)?;
let height = number(cx, *bounds, "height", false)?;
x >= left && y >= top && x < left + width && y < top + height
};
Ok(NativeResult::Return(Value::Bool(inside)))
}
}
pub fn install(
vm: &mut Vm,
roots: &mut Vec<ObjectRef>,
desktop: ObjectRef,
block_type: ObjectRef,
function_type: ObjectRef,
) -> Result<(), VmError> {
for (name, query) in [
("measure", Query::Measure),
("arrange", Query::Arrange),
("paint", Query::Paint),
("hitTest", Query::HitTest),
] {
let function = vm.allocate(ContextNativeFunction::new(
name,
Method { query, desktop },
vec![desktop],
))?;
roots.push(function);
vm.attach_type(function, function_type)?;
vm.define_method(block_type, name, function)?;
}
let registry = object(&vm.get_attr(desktop, "types")?)?;
let point_type = object(
&vm.dict_get(registry, &Value::Text("Point".into()))?
.ok_or(invalid("missing Point type"))?,
)?;
for (name, circle) in [("inside", false), ("inCircle", true)] {
let function = vm.allocate(ContextNativeFunction::new(
name,
PointMethod {
expected: point_type,
circle,
},
vec![point_type],
))?;
roots.push(function);
vm.attach_type(function, function_type)?;
vm.define_method(point_type, name, function)?;
}
Ok(())
}