Dispatch source hit testing for native pointer clicks
Verify Tessera GUI / verify (push) Failing after 7m31s

This commit is contained in:
2026-10-21 15:37:26 +08:00
parent d19fab55b9
commit 7b11e32eb6
14 changed files with 560 additions and 30 deletions
+6 -2
View File
@@ -166,7 +166,7 @@ candidate and returns 500, retaining the previous document.
This stage supports live mounted UI for both complete programs and Console inputs, with immutable window frames on the existing worker.
Native mouse/keyboard activation and code-driven callbacks share onClick/click. Managed Mount objects expose each occurrence, and property dependencies and measurement caches avoid recomputing clean visual subtrees. Arrangement still traverses occurrences.
Tab chrome/multiple windows, trait definitions and custom visual measurement/painting, window-size language inputs and process isolation are still being implemented. Worker threads,
Tab chrome/multiple windows, trait definitions and custom visual measurement/arrangement, window-size language inputs and process isolation are still being implemented. Worker threads,
source budgets and VM instruction limits are not a complete untrusted-code sandbox.
## Developer tools
@@ -196,7 +196,11 @@ Native visual methods are now available on Block: `measure(mount, Constraints(mi
Native measurement finds intrinsic subtree size and clamps the returned size to the given constraints. Native Row/Column arrangement uses intrinsic child sizes, padding and gaps; it does not stretch children to fill the supplied bounds. Hit testing uses local coordinates against the current Mount dimensions. Coordinates/dimensions must be finite and remain within the native layout limit. Width/height and constraint ranges cannot be negative or inverted. The value constructors preserve fields as ordinary objects; methods validate their contents when used, and global annotation type checking remains pending.
`invalidate("paint")` requests a frame while retaining measurement caches; `invalidate("layout")` also clears affected intrinsic measurements; `invalidate("content")` also invalidates derived content and ancestor dependencies. The default reason is layout. Requests coalesce at the refresh boundary, cover all aliases/occurrences of the same current content and preserve Mount identities. Hidden objects do not force visible frames. See [visual-protocols.endor](examples/visual-protocols.endor). Source paint overrides now run during actual frame publication; measure/arrange/hitTest overrides during layout/input and contexts before first measurement remain future work, so this is not the complete custom visual protocol.
`invalidate("paint")` requests a frame while retaining measurement caches; `invalidate("layout")` also clears affected intrinsic measurements; `invalidate("content")` also invalidates derived content and ancestor dependencies. The default reason is layout. Requests coalesce at the refresh boundary, cover all aliases/occurrences of the same current content and preserve Mount identities. Hidden objects do not force visible frames. See [visual-protocols.endor](examples/visual-protocols.endor). Source paint overrides now run during actual frame publication; measure/arrange overrides during layout and contexts before first measurement remain future work, so this is not the complete custom visual protocol.
Native pointer clicks now resolve source `hitTest(self, mount, point): Bool` overrides in reverse paint order, then dispatch through the existing capture/target/bubble route. The Point is local to that occurrence; `point.inside(bounds)` uses a half-open rectangle and `point.inCircle(cx, cy, radius)` includes the circle boundary. Each candidate is first restricted to its Mount rectangle, so hit shapes can refine that area, but cannot expand it. A rejected target falls through to another interactive occurrence, including a parent. Ordinary Blocks become interactive through a live click subscription or a source onEvent override; disconnecting the final click subscription updates that status. See [source-hit.endor](examples/source-hit.endor).
The native app sends both press and release positions with the click. Both targets are resolved against the current published frame and heap when the release is processed; only matching Mount identities dispatch a click. These selectors share one read-only VM call and budget, and must return Bool. Callback execution starts after the read-only scope has ended. This is semantic click handling, not a raw pointer-down/up event stream or pointer capture. Keyboard/programmatic activation retains its semantic target and bypasses spatial filtering. API pointer events carry `point: [x, y]` and `press_point: [x, y]` together; GET code exposes X-Tessera-Session and X-Tessera-Layout-Version headers for clients to obtain current metadata without evaluating more source.
A source subclass can override `paint(self, mount, geometry): DisplayList`. The renderer invokes that method for each occurrence, after Mount synchronization and hooks, using current logical Bounds. Its commands replace that occurrence's native paint in preorder. Calling `RectBlock.paint(self, mount, geometry)` or another native base method obtains a fresh result that can be modified before returning. `DisplayList(ObjectList(command, ...))` builds multiple commands; ObjectList accepts objects and has a 10000-member bound. PaintCommand fields use kind (`roundedRect`, `strokeRoundedRect` or `text`), x/y/color and the corresponding width/height/radius, strokeWidth, or text/size fields. Commands must pass the same native validation and a total 10000-command frame limit. See [source-paint.endor](examples/source-paint.endor), including a Button source method that updates the same tint field used by two occurrences.
+1
View File
@@ -2,6 +2,7 @@
| File | Purpose |
| --- | --- |
| [source-hit.endor](source-hit.endor) | Source circle hit testing, ordinary Block click subscriptions and fallback to the parent through transparent space. |
| [source-paint.endor](source-paint.endor) | Source paint overrides render checked commands in the window; a source Button method updates the shared tint model. |
| [visual-protocols.endor](visual-protocols.endor) | Real source calls for constrained native measurement, arrangement and managed paint-command results from live Mounts. |
| [mount-lifecycle.endor](mount-lifecycle.endor) | Source onMount/onUnmount methods, two occurrences of one object, ordered notifications and retained Mount lifetime. |
+23
View File
@@ -0,0 +1,23 @@
class CircleTarget: RectBlock {
hits: Object
function hitTest(self: CircleTarget, mount: Mount, point: Point): Bool {
return point.inCircle(40, 40, 40)
}
function paint(self: CircleTarget, mount: Mount, geometry: Bounds): DisplayList {
drawing = RectBlock.paint(self, mount, geometry)
drawing.commands.at(0).width = 80
drawing.commands.at(0).radius = 40
return drawing
}
function onEvent(self: CircleTarget, event: Event): Unit {
self.hits.increment()
event.stopPropagation()
}
}
hits = ui.state(0)
background = ui.state(0)
circle = CircleTarget(160, 80, 0, "#237A45", hits)
root = Column(24, 16, circle, TextBlock("点击圆内计入圆;透明右半区域交给父容器"), TextBlock("圆内点击"), ui.textOf(hits, "value"), TextBlock("父容器点击"), ui.textOf(background, "value"))
connection = root.on("click", background.increment)
page = Page()
page.render(root)
+1
View File
@@ -14,6 +14,7 @@
| events.rs | Bounded native input transport to the owning VM |
| event_dispatch.rs | Managed Event, weak Subscription tokens, Block-owned connections and occurrence-specific capture/target/bubble through a budgeted VM driver |
| vm_sequence.rs | Shared validated call-sequence builder for event and lifecycle batches in the existing VM frame loop |
| source_hit.rs | Read-only source/native pointer target selectors, reverse paint order, strict Bool results and matching press/release targets |
| source_paint.rs | Read-only source paint dispatch, shared VM budget, checked command conversion and versioned object dependencies |
| native_visual.rs | Managed native visual protocol queries with occurrence validation, value conversion and bounded results |
| object_list.rs | Traced indexed results shared by Mount queries and visual protocol results |
+17 -4
View File
@@ -287,6 +287,16 @@ fn code_request(
"X-Tessera-Revision",
&current.revision.to_string(),
);
add_header(
&mut response,
"X-Tessera-Session",
&current.document.frame.session.to_string(),
);
add_header(
&mut response,
"X-Tessera-Layout-Version",
&current.document.frame.layout_version.to_string(),
);
add_header(
&mut response,
"X-Tessera-Source-Kind",
@@ -441,6 +451,9 @@ impl Worker {
if !matches!(input.kind.as_str(), "click" | "focus") {
return error_response(StatusCode(400), "unsupported event kind");
}
if let Err(message) = input.validate() {
return error_response(StatusCode(400), message);
}
let current = match namespace.read() {
Ok(current) => current,
Err(_) => return error_response(StatusCode(500), "namespace lock poisoned"),
@@ -755,12 +768,12 @@ expose actual type relations. Types, bases, instances and native constructors li
in the same GC graph. Ordinary source classes support `class Child: Base`, typed field
declarations, explicit first `self` parameters, positional field construction and
member assignment. Inherited methods bind the actual receiver; overridden methods
are dynamically selected through self. Native allocator subclasses and traits remain
pending. Native Block/Page methods use the same type method tables and binding path.
are dynamically selected through self. Concrete native visual subclasses reuse real native allocators; abstract bases remain unconstructible. Traits and annotation checking remain pending. Native Block/Page methods use the same type method tables and binding path.
Source paint overrides execute in the actual frame pipeline under a read-only heap policy. Source hitTest overrides similarly refine native pointer targets in VM paint order; Point.inside(bounds) and Point.inCircle(cx, cy, radius) provide primitive shape queries. Managed DisplayList/ObjectList/PaintCommand and Constraints/Bounds/Point results are ordinary runtime objects.
Native input POST /events is a transport envelope, not Endor source. The window
sends current session, layout_version and Mount identity; stale positions fail
without calling a callback. Native mouse, Tab, Enter and Space delegate to the same
sends current session, layout_version and Mount identity; stale positions fail without calling a callback. Pointer clicks also include point and press_point [x,y] in logical page coordinates. Both targets are resolved through read-only hitTest, with fallback behind a rejected candidate; a click is dispatched only when both resolve to the same Mount. Keyboard/code clicks omit coordinates and remain semantic activation. GET /code also exposes X-Tessera-Session and X-Tessera-Layout-Version headers for transport clients. Native mouse, Tab, Enter and Space delegate to the same
managed click/focus methods used by code. Events appear in Network, not Console source history.
## UI host object
+11 -7
View File
@@ -102,6 +102,8 @@ impl TesseraApp {
layout_version: frame.layout_version,
mount,
kind: "focus".into(),
point: None,
press_point: None,
});
}
self.request_redraw();
@@ -205,6 +207,8 @@ impl TesseraApp {
layout_version: frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: Some([self.pointer.x, self.pointer.y]),
press_point: Some([self.pointer.x, self.pointer.y]),
})
}
@@ -250,6 +254,8 @@ impl TesseraApp {
layout_version: frame.layout_version,
mount: targets[index].id,
kind: "focus".into(),
point: None,
press_point: None,
})
});
if let Some(next) = next {
@@ -442,14 +448,12 @@ impl ApplicationHandler<AppEvent> for TesseraApp {
);
} else {
if let Some(pressed) = self.guest_pressed.take()
&& self.guest_hit().is_some_and(|hit| {
hit.session == pressed.session
&& hit.layout_version == pressed.layout_version
&& hit.mount == pressed.mount
})
&& let Some(mut released) = self.guest_hit()
&& released.session == pressed.session
&& released.layout_version == pressed.layout_version
{
self.guest_focus = Some(pressed.clone());
self.send_guest(pressed);
released.press_point = pressed.point;
self.send_guest(released);
}
self.developer_tools.release(
&self.layout.developer_tools,
+129 -1
View File
@@ -801,6 +801,120 @@ mod tests {
const EXAMPLE: &str = include_str!("../examples/hello.endor");
const PAGE: &str = include_str!("../examples/page.endor");
#[test]
fn source_hit_filters_shape_falls_back_and_requires_matching_press_release() {
for (press, release, expected_hits, expected_background) in [
([64.0, 64.0], [64.0, 64.0], "1", "0"),
([144.0, 64.0], [144.0, 64.0], "0", "1"),
([64.0, 64.0], [144.0, 64.0], "0", "0"),
([9999.0, 9999.0], [9999.0, 9999.0], "0", "0"),
] {
let (mut session, initial) = ReplSession::load(
include_str!("../examples/source-hit.endor"),
"app.main",
81,
1,
)
.unwrap();
let circle = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.width == 160.0)
.unwrap();
session
.dispatch(&crate::events::Input {
session: 81,
layout_version: initial.frame.layout_version,
mount: circle.id,
kind: "click".into(),
point: Some(release),
press_point: Some(press),
})
.unwrap();
assert_eq!(
session.execute("hits.value", "app.main").unwrap().value,
expected_hits
);
assert_eq!(
session
.execute("background.value", "app.main")
.unwrap()
.value,
expected_background
);
}
}
#[test]
fn source_hit_rejects_wrong_result_and_business_writes_before_callbacks() {
for (body, message) in [
("return 1", "hitTest must return Bool"),
(
"self.hits.increment()\nreturn point.inCircle(40, 40, 40)",
"read-only",
),
] {
let source = include_str!("../examples/source-hit.endor")
.replace("return point.inCircle(40, 40, 40)", body);
let (mut session, initial) = ReplSession::load(&source, "app.main", 82, 1).unwrap();
let circle = initial
.frame
.mounts
.iter()
.find(|mount| mount.interactive && mount.width == 160.0)
.unwrap();
let error = session
.dispatch(&crate::events::Input {
session: 82,
layout_version: initial.frame.layout_version,
mount: circle.id,
kind: "click".into(),
point: Some([64.0, 64.0]),
press_point: Some([64.0, 64.0]),
})
.err()
.unwrap();
assert!(error.to_string().contains(message), "{error}");
assert!(session.is_faulted());
let globals = session.candidate.globals.unwrap();
let Value::Object(hits) = session
.candidate
.vm
.dict_get(globals, &Value::Text("hits".into()))
.unwrap()
.unwrap()
else {
panic!("state")
};
assert_eq!(
session.candidate.vm.get_attr(hits, "value").unwrap(),
Value::Number(0.into())
);
}
}
#[test]
fn ordinary_block_subscription_updates_interactivity_after_disconnect() {
let source = r##"hits = ui.state(0)
rect = RectBlock(80, 40, 0, "#DDEEFF")
token = rect.on("click", hits.increment)
page = Page()
page.render(rect)"##;
let (mut session, initial) = ReplSession::load(source, "app.main", 83, 1).unwrap();
assert!(initial.frame.mounts.iter().any(|mount| mount.interactive));
let result = session.execute("token.disconnect()", "app.main").unwrap();
assert!(
result
.document
.unwrap()
.frame
.mounts
.iter()
.all(|mount| !mount.interactive)
);
}
#[test]
fn source_paint_can_rebind_old_plain_reference_to_new_result_without_mutating_it() {
let source = r##"class ReboundRect: RectBlock {
@@ -1198,11 +1312,21 @@ mod tests {
}
fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument {
let globals = session.candidate.globals.unwrap();
let Value::Object(button) = session
.candidate
.vm
.dict_get(globals, &Value::Text("button".into()))
.unwrap()
.unwrap()
else {
panic!("Button")
};
let mount = document
.frame
.mounts
.iter()
.find(|mount| mount.interactive)
.find(|mount| mount.interactive && mount.block_id == button.to_string())
.unwrap();
session
.dispatch(&crate::events::Input {
@@ -1210,6 +1334,8 @@ mod tests {
layout_version: document.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
press_point: None,
})
.unwrap()
.document
@@ -1486,6 +1612,8 @@ mod tests {
layout_version: initial.frame.layout_version,
mount: mount.id,
kind: "click".into(),
point: None,
press_point: None,
};
let updated = session.dispatch(&event).unwrap().document.unwrap();
updated.validate().unwrap();
+23 -1
View File
@@ -1,4 +1,4 @@
//! Native input transport. Only opaque frame/mount identities cross to the VM worker.
//! Native input transport. Frame/mount identities and optional logical pointer positions cross to the VM worker.
use serde::{Deserialize, Serialize};
use std::{
io::{Read, Write},
@@ -16,6 +16,28 @@ pub struct Input {
pub layout_version: u64,
pub mount: u64,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub point: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub press_point: Option<[f32; 2]>,
}
impl Input {
pub fn validate(&self) -> Result<(), &'static str> {
match (self.point, self.press_point) {
(None, None) => Ok(()),
(Some(point), Some(press))
if self.kind == "click"
&& point
.into_iter()
.chain(press)
.all(|value| value.is_finite() && (0.0..=1_000_000.0).contains(&value)) =>
{
Ok(())
}
_ => Err("pointer click requires finite press/release coordinates"),
}
}
}
pub struct Client(SyncSender<Input>);
+1
View File
@@ -18,6 +18,7 @@ mod object_list;
mod scene;
mod snapshot;
mod software_renderer;
mod source_hit;
mod source_paint;
mod sources;
mod system_ui;
+7
View File
@@ -370,6 +370,13 @@ impl NativeCallable for Sync {
}
impl MountTable {
pub fn ordered(&self) -> Vec<WeakObjectRef> {
self.order
.iter()
.filter_map(|id| self.active.get(id).copied())
.collect()
}
pub fn synchronize(
&mut self,
vm: &mut Vm,
+82 -5
View File
@@ -94,6 +94,13 @@ impl NativeCallable for Method {
}
let geometry = mount.geometry.clone();
let input = object(input)?;
if matches!(self.query, Query::HitTest) {
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,
)));
}
let node = block::snapshot(cx.store(), owner)?;
let result = match self.query {
Query::Measure => {
@@ -208,16 +215,68 @@ impl NativeCallable for Method {
vec![("commands", Value::Object(commands))],
)?)
}
Query::HitTest => {
let x = number(cx, input, "x", true)?;
let y = number(cx, input, "y", true)?;
Value::Bool(x >= 0.0 && y >= 0.0 && x < geometry.width && y < geometry.height)
}
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>,
@@ -240,5 +299,23 @@ pub fn install(
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(())
}
+30 -5
View File
@@ -143,23 +143,45 @@ impl RenderCache {
}
path.remove(&data_ref);
let mut protocol_key = String::new();
let mut custom_event = false;
if let Some(definition) = store.type_of(block)? {
protocol_key.push_str(&definition.to_string());
for name in ["measure", "arrange", "paint", "hitTest"] {
for name in ["measure", "arrange", "paint", "hitTest", "onEvent"] {
if let Some(method) = store.lookup_method(definition, name)? {
protocol_key.push_str(&format!("/{method}"));
}
}
let event = store.lookup_method(definition, "onEvent")?;
let mut native_event = None;
let mut current = Some(definition);
while let Some(definition) = current {
dependencies.push(Dependency {
object: definition,
property: None,
});
current = store
.payload::<endor_core::EndorTypeObject>(definition, "Type")?
.base();
let item = store.payload::<endor_core::EndorTypeObject>(definition, "Type")?;
if item.name() == "Block"
&& item.base().is_some_and(|base| {
store
.payload::<endor_core::EndorTypeObject>(base, "Type")
.is_ok_and(|base| base.base().is_none())
})
{
native_event = store.lookup_method(definition, "onEvent")?;
}
current = item.base();
}
custom_event = event.is_some() && event != native_event;
}
let mut click_connection = false;
for connection in &data.subscriptions {
dependencies.push(Dependency {
object: *connection,
property: None,
});
let item = store
.payload::<crate::event_dispatch::Connection>(*connection, "Connection")?;
click_connection |= item.kind == "click" && item.handler.is_some();
}
let children = values.split_off(values.len() - data.children.len());
let kind = match &data.kind {
@@ -226,7 +248,10 @@ impl RenderCache {
block_id: block.to_string(),
protocol_key,
slots: data.children.iter().map(|c| c.slot).collect(),
interactive: matches!(&data.kind, Kind::Button) || data.click.is_some(),
interactive: matches!(&data.kind, Kind::Button)
|| data.click.is_some()
|| click_connection
|| custom_event,
kind,
};
let old = self.remove(block);
+209
View File
@@ -0,0 +1,209 @@
//! Resolve pointer targets in paint order through source/native hitTest methods.
//! Both press and release selectors share one read-only VM call and budget.
use crate::{mount::Mount, ui_bridge::attach_desktop_type};
use endor_core::{
CodeLayout, ContextNativeFunction, EndorCode, EndorCodeObject, Instruction, NativeCallable,
NativeContext, NativeResult, ObjectRef, Opcode, Value, Vm, VmError, WeakObjectRef,
};
#[derive(Debug)]
struct Boolean;
impl NativeCallable for Boolean {
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let [Value::Bool(value)] = args else {
return Err(VmError::InvalidArgument("hitTest must return Bool"));
};
Ok(NativeResult::Return(Value::Bool(*value)))
}
}
#[derive(Debug)]
struct SameTarget;
impl NativeCallable for SameTarget {
fn invoke(&self, _: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
let value = match args {
[Value::Object(first), Value::Object(second)] if first == second => {
Value::Object(*first)
}
_ => Value::Nil,
};
Ok(NativeResult::Return(value))
}
}
fn code(
cx: &mut NativeContext<'_>,
instructions: Vec<Instruction>,
constants: Vec<Value>,
layout: CodeLayout,
) -> Result<ObjectRef, VmError> {
let code = EndorCode::from_instructions(&instructions, constants, layout)?.with_location(
"<pointer hitTest>",
"<tessera>",
1,
);
let code = cx.allocate(EndorCodeObject::new(code))?;
let globals = cx.new_dict()?;
cx.new_function("pointer hitTest", code, globals)
}
fn selector(
cx: &mut NativeContext<'_>,
candidates: &[WeakObjectRef],
point: [f32; 2],
desktop: ObjectRef,
) -> Result<ObjectRef, VmError> {
let validate = cx.allocate(ContextNativeFunction::new(
"validate hitTest",
Boolean,
vec![],
))?;
let mut instructions = Vec::new();
let mut constants = vec![Value::Object(validate)];
for weak in candidates.iter().rev() {
let mount = cx
.upgrade(*weak)?
.ok_or(VmError::InvalidArgument("pointer Mount expired"))?;
let target = cx.store().payload::<Mount>(mount, "Mount")?.clone();
if !target.mounted || !target.geometry.interactive {
continue;
}
let geometry = &target.geometry;
let x = point[0] - geometry.x;
let y = point[1] - geometry.y;
if x < 0.0 || y < 0.0 || x > geometry.width || y > geometry.height {
continue;
}
let definition = cx
.store()
.type_of(target.block)?
.ok_or(VmError::InvalidArgument("untyped hit target"))?;
let function = cx
.store()
.lookup_method(definition, "hitTest")?
.ok_or(VmError::InvalidArgument("missing hitTest method"))?;
let local = cx.new_object()?;
attach_desktop_type(cx, local, "Point", desktop)?;
cx.set_attr(local, "x", Value::Number((x as f64).into()))?;
cx.set_attr(local, "y", Value::Number((y as f64).into()))?;
let start = constants.len() as u32;
constants.extend([
Value::Object(function),
Value::Object(target.block),
Value::Object(mount),
Value::Object(local),
]);
let skip = (Instruction::encode_all(&[
Instruction::new(Opcode::LoadConst, start + 2),
Instruction::simple(Opcode::ReturnValue),
])?
.len()
/ 2) as u32;
instructions.extend([
Instruction::new(Opcode::LoadConst, 0),
Instruction::simple(Opcode::PushNull),
Instruction::new(Opcode::LoadConst, start),
Instruction::simple(Opcode::PushNull),
Instruction::new(Opcode::LoadConst, start + 1),
Instruction::new(Opcode::LoadConst, start + 2),
Instruction::new(Opcode::LoadConst, start + 3),
Instruction::new(Opcode::Call, 3),
Instruction::new(Opcode::Call, 1),
Instruction::new(Opcode::PopJumpIfFalse, skip),
Instruction::new(Opcode::LoadConst, start + 2),
Instruction::simple(Opcode::ReturnValue),
]);
}
let end = constants.len() as u32;
constants.push(Value::Nil);
instructions.extend([
Instruction::new(Opcode::LoadConst, end),
Instruction::simple(Opcode::ReturnValue),
]);
code(cx, instructions, constants, CodeLayout::default())
}
#[derive(Debug)]
struct Prepare {
candidates: Vec<WeakObjectRef>,
press: [f32; 2],
release: [f32; 2],
desktop: ObjectRef,
}
impl NativeCallable for Prepare {
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
let down = selector(cx, &self.candidates, self.press, self.desktop)?;
let up = selector(cx, &self.candidates, self.release, self.desktop)?;
let compare = cx.allocate(ContextNativeFunction::new(
"same pointer target",
SameTarget,
vec![],
))?;
let instructions = vec![
Instruction::new(Opcode::LoadConst, 0),
Instruction::simple(Opcode::PushNull),
Instruction::new(Opcode::Call, 0),
Instruction::new(Opcode::StoreFast, 0),
Instruction::new(Opcode::LoadConst, 1),
Instruction::simple(Opcode::PushNull),
Instruction::new(Opcode::Call, 0),
Instruction::new(Opcode::StoreFast, 1),
Instruction::new(Opcode::LoadConst, 2),
Instruction::simple(Opcode::PushNull),
Instruction::new(Opcode::LoadFast, 0),
Instruction::new(Opcode::LoadFast, 1),
Instruction::new(Opcode::Call, 2),
Instruction::simple(Opcode::ReturnValue),
];
let driver = code(
cx,
instructions,
vec![
Value::Object(down),
Value::Object(up),
Value::Object(compare),
],
CodeLayout {
locals: vec!["press".into(), "release".into()],
..CodeLayout::default()
},
)?;
Ok(NativeResult::Invoke {
function: driver,
arguments: vec![],
})
}
}
/// Returned Mount owns one root. No-hit and mismatched targets return None.
pub fn resolve(
vm: &mut Vm,
desktop: ObjectRef,
candidates: Vec<WeakObjectRef>,
press: [f32; 2],
release: [f32; 2],
) -> anyhow::Result<Option<ObjectRef>> {
let prepare = vm.allocate(ContextNativeFunction::new(
"prepare pointer hitTest",
Prepare {
candidates,
press,
release,
desktop,
},
vec![desktop],
))?;
let result = vm.call_read_only_with_limits(
prepare,
&[],
endor_core::ExecutionLimits {
max_frames: 128,
max_instructions: 1_000_000,
},
);
vm.release(prepare)?;
let (value, _) = result.map_err(|error| crate::endor_host::runtime_error(vm, error))?;
match value {
Value::Object(mount) => Ok(Some(mount)),
Value::Nil => Ok(None),
value => {
vm.release_value(value)?;
anyhow::bail!("invalid hitTest target")
}
}
}
+20 -5
View File
@@ -1043,7 +1043,10 @@ impl UiBridge {
Ok(())
}
pub fn dispatch(&self, vm: &mut Vm, input: &crate::events::Input) -> Result<()> {
let (weak, desktop) = {
input
.validate()
.map_err(|message| anyhow::anyhow!(message))?;
let (weak, desktop, candidates) = {
let state = self
.session
.lock()
@@ -1058,11 +1061,19 @@ impl UiBridge {
.get(&input.mount)
.ok_or_else(|| anyhow::anyhow!("Mount is no longer active"))?,
state.desktop,
state.mounts.ordered(),
)
};
let mount = vm
.upgrade(weak)?
.ok_or_else(|| anyhow::anyhow!("Mount has expired"))?;
let mount = if let (Some(press), Some(release)) = (input.press_point, input.point) {
let Some(mount) = crate::source_hit::resolve(vm, desktop, candidates, press, release)?
else {
return Ok(());
};
mount
} else {
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 {
@@ -1071,7 +1082,11 @@ impl UiBridge {
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,
"focus",
Value::Number((data.geometry.id 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)]),