Dispatch source hit testing for native pointer clicks
Verify Tessera GUI / verify (push) Failing after 7m31s
Verify Tessera GUI / verify (push) Failing after 7m31s
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -287,6 +287,16 @@ fn code_request(
|
||||
"X-Tessera-Revision",
|
||||
¤t.revision.to_string(),
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"X-Tessera-Session",
|
||||
¤t.document.frame.session.to_string(),
|
||||
);
|
||||
add_header(
|
||||
&mut response,
|
||||
"X-Tessera-Layout-Version",
|
||||
¤t.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
@@ -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
@@ -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
@@ -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>);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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)]),
|
||||
|
||||
Reference in New Issue
Block a user