feat: dispatch managed events through capture and bubble subscriptions
Verify Tessera GUI / verify (push) Failing after 7m13s
Verify Tessera GUI / verify (push) Failing after 7m13s
This commit is contained in:
+2
-1
@@ -11,7 +11,8 @@
|
||||
| block.rs | Aster-managed Block/Cell/data graph, stable child slots, content-owned field storage, shared assignment, cycle validation and explicit text bindings |
|
||||
| snapshot.rs | Property dependencies across shared field-storage paths, reverse parent links and cached immutable visual derivation |
|
||||
| mount.rs | Managed per-occurrence contexts, weak reverse links, geometry and focus queries |
|
||||
| events.rs | Managed Event marker and bounded native input transport to the owning VM |
|
||||
| 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 |
|
||||
| layout.rs | Iterative measurement/arrangement, stable occurrence IDs, measurement cache and geometry budgets |
|
||||
| document.rs | Published source/AST/UI snapshot, revisions, invalidation, frame validation and display commands |
|
||||
| app.rs | Window events, redraw and system widget pointer/keyboard/IME routing |
|
||||
|
||||
@@ -111,6 +111,7 @@ pub struct Data {
|
||||
pub history_index: usize,
|
||||
pub text_binding: Option<(ObjectRef, String)>,
|
||||
pub click: Option<ObjectRef>,
|
||||
pub subscriptions: Vec<ObjectRef>,
|
||||
pub properties: Option<ObjectRef>,
|
||||
}
|
||||
|
||||
@@ -134,6 +135,7 @@ impl Data {
|
||||
history_index: 0,
|
||||
text_binding: None,
|
||||
click: None,
|
||||
subscriptions: vec![],
|
||||
properties: None,
|
||||
}
|
||||
}
|
||||
@@ -165,6 +167,9 @@ impl ObjectPayload for Data {
|
||||
if let Some((source, _)) = &self.text_binding {
|
||||
visit(*source);
|
||||
}
|
||||
for token in &self.subscriptions {
|
||||
visit(*token);
|
||||
}
|
||||
if let Some(handler) = self.click {
|
||||
visit(handler);
|
||||
}
|
||||
|
||||
@@ -795,6 +795,192 @@ mod tests {
|
||||
const EXAMPLE: &str = include_str!("../examples/hello.endor");
|
||||
const PAGE: &str = include_str!("../examples/page.endor");
|
||||
|
||||
fn event_session(extra: &str) -> (ReplSession, RenderDocument) {
|
||||
let source = format!("{}\n{}", include_str!("../examples/events.endor"), extra);
|
||||
ReplSession::load(&source, "app.main", 41, 1).unwrap()
|
||||
}
|
||||
|
||||
fn click_document(session: &mut ReplSession, document: &RenderDocument) -> RenderDocument {
|
||||
let mount = document
|
||||
.frame
|
||||
.mounts
|
||||
.iter()
|
||||
.find(|mount| mount.interactive)
|
||||
.unwrap();
|
||||
session
|
||||
.dispatch(&crate::events::Input {
|
||||
session: 41,
|
||||
layout_version: document.frame.layout_version,
|
||||
mount: mount.id,
|
||||
kind: "click".into(),
|
||||
})
|
||||
.unwrap()
|
||||
.document
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_event_route_orders_capture_method_listeners_and_bubble() {
|
||||
let (mut session, initial) = event_session("");
|
||||
click_document(&mut session, &initial).validate().unwrap();
|
||||
for (field, expected) in [
|
||||
("captureStep", "1"),
|
||||
("methodStep", "2"),
|
||||
("targetStep", "3"),
|
||||
("bubbleStep", "4"),
|
||||
] {
|
||||
assert_eq!(
|
||||
session
|
||||
.execute(&format!("model.{field}"), "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
session.execute("model.phase", "app.main").unwrap().value,
|
||||
"\"bubble\""
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("target.isConnected()", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"true"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnect_during_capture_skips_pending_listener_and_stop_preserves_current_group() {
|
||||
let (mut session, initial) = event_session(
|
||||
"function cancel(event: Event): Unit { target.disconnect() }\nconnection = root.on(\"click\", cancel, \"capture\")",
|
||||
);
|
||||
click_document(&mut session, &initial);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("model.targetStep", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"0"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("model.bubbleStep", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"3"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("target.isConnected()", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"false"
|
||||
);
|
||||
let (mut session, initial) = event_session(
|
||||
"function stop(event: Event): Unit { event.stopPropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
|
||||
);
|
||||
click_document(&mut session, &initial);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("model.targetStep", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"3"
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("model.bubbleStep", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn immediate_stop_skips_target_method_and_code_dispatch_returns_cancelled_event() {
|
||||
let (mut session, initial) = event_session(
|
||||
"function stop(event: Event): Unit { event.stopImmediatePropagation() }\nconnection = button.on(\"click\", stop, \"capture\")",
|
||||
);
|
||||
click_document(&mut session, &initial);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("model.count.value", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"1"
|
||||
);
|
||||
let (mut session, _) = event_session(
|
||||
"function prevent(event: Event): Unit { event.preventDefault() }\nconnection = button.on(\"custom\", prevent)\nresult = button.dispatch(Event(\"custom\"))",
|
||||
);
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("result.isDefaultPrevented()", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"true"
|
||||
);
|
||||
assert_eq!(
|
||||
session.execute("result.phase", "app.main").unwrap().value,
|
||||
"\"none\""
|
||||
);
|
||||
// Reusing a completed event is legal; its previous dispatch has no live guard.
|
||||
session
|
||||
.execute("button.dispatch(result)", "app.main")
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_failure_releases_dispatch_guard_and_token_does_not_retain_target() {
|
||||
let (mut session, _) = event_session("");
|
||||
session.execute("function fail(event: Event): Unit { page.render(1) }\nfailing = button.on(\"failure\", fail)\nfailedEvent = Event(\"failure\")", "app.main").unwrap();
|
||||
assert!(
|
||||
session
|
||||
.execute("button.dispatch(failedEvent)", "app.main")
|
||||
.is_err()
|
||||
);
|
||||
session
|
||||
.execute(
|
||||
"failing.disconnect()\nbutton.dispatch(failedEvent)",
|
||||
"app.main",
|
||||
)
|
||||
.unwrap();
|
||||
session
|
||||
.execute(
|
||||
"orphan = Button(\"unmounted\")\ntoken = orphan.on(\"click\", model.target)",
|
||||
"app.main",
|
||||
)
|
||||
.unwrap();
|
||||
let globals = session.candidate.globals.unwrap();
|
||||
let Value::Object(orphan) = session
|
||||
.candidate
|
||||
.vm
|
||||
.dict_get(globals, &Value::Text("orphan".into()))
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("Block expected")
|
||||
};
|
||||
let weak = session.candidate.vm.downgrade(orphan).unwrap();
|
||||
session
|
||||
.candidate
|
||||
.vm
|
||||
.dict_delete(globals, &Value::Text("orphan".into()))
|
||||
.unwrap();
|
||||
session
|
||||
.bridge
|
||||
.reclaim(&mut session.candidate.vm, &mut session.candidate.roots)
|
||||
.unwrap();
|
||||
assert!(session.candidate.vm.upgrade(weak).unwrap().is_none());
|
||||
assert_eq!(
|
||||
session
|
||||
.execute("token.isConnected()", "app.main")
|
||||
.unwrap()
|
||||
.value,
|
||||
"false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_native_subclasses_share_live_fields_and_release_replaced_storage() {
|
||||
let (mut session, initial) = ReplSession::load(
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
//! Managed subscriptions and occurrence-specific propagation. A validated VM
|
||||
//! function sequences calls, so all handlers share one execution budget.
|
||||
use crate::{block, mount::Mount, ui_bridge::attach_desktop_type};
|
||||
use endor_core::{
|
||||
CodeLayout, ContextNativeFunction, EndorCode, EndorCodeObject, Instruction, NativeCallable,
|
||||
NativeContext, NativeResult, ObjectPayload, ObjectRef, Opcode, Value, Vm, VmError,
|
||||
WeakObjectRef,
|
||||
};
|
||||
|
||||
const MAX_DELIVERIES: usize = 10_000;
|
||||
fn invalid(message: &'static str) -> VmError {
|
||||
VmError::InvalidArgument(message)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Event {
|
||||
dispatch: Option<WeakObjectRef>,
|
||||
group: usize,
|
||||
stopped_at: Option<usize>,
|
||||
immediate: bool,
|
||||
prevented: bool,
|
||||
}
|
||||
impl ObjectPayload for Event {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-event"
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Connection {
|
||||
pub handler: Option<ObjectRef>,
|
||||
pub kind: String,
|
||||
pub capture: bool,
|
||||
}
|
||||
impl ObjectPayload for Connection {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-event-connection"
|
||||
}
|
||||
fn trace(&self, visit: &mut dyn FnMut(ObjectRef)) {
|
||||
if let Some(handler) = self.handler {
|
||||
visit(handler);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A token never retains its target or callback. The Block owns the connection.
|
||||
#[derive(Debug)]
|
||||
pub struct Subscription {
|
||||
pub connection: WeakObjectRef,
|
||||
}
|
||||
impl ObjectPayload for Subscription {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-subscription"
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum Method {
|
||||
Stop,
|
||||
StopImmediate,
|
||||
Prevent,
|
||||
Prevented,
|
||||
Disconnect,
|
||||
Connected,
|
||||
}
|
||||
impl NativeCallable for Method {
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(owner)] = args else {
|
||||
return Err(invalid("event method receiver"));
|
||||
};
|
||||
let result = match self {
|
||||
Self::Disconnect | Self::Connected => {
|
||||
let weak = cx
|
||||
.store()
|
||||
.payload::<Subscription>(*owner, "Subscription")?
|
||||
.connection;
|
||||
let connection = cx.upgrade(weak)?;
|
||||
let connected = if let Some(connection) = connection {
|
||||
let mut data = cx
|
||||
.store()
|
||||
.payload::<Connection>(connection, "Connection")?
|
||||
.clone();
|
||||
let connected = data.handler.is_some();
|
||||
if matches!(self, Self::Disconnect) {
|
||||
data.handler = None;
|
||||
cx.replace_payload(connection, data)?;
|
||||
}
|
||||
connected
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if matches!(self, Self::Disconnect) {
|
||||
Value::Nil
|
||||
} else {
|
||||
Value::Bool(connected)
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let mut event = cx.store().payload::<Event>(*owner, "Event")?.clone();
|
||||
if matches!(self, Self::Prevented) {
|
||||
Value::Bool(event.prevented)
|
||||
} else {
|
||||
match self {
|
||||
Self::Stop | Self::StopImmediate => {
|
||||
event.stopped_at = Some(event.group);
|
||||
event.immediate |= matches!(self, Self::StopImmediate);
|
||||
}
|
||||
Self::Prevent => event.prevented = true,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
cx.replace_payload(*owner, event)?;
|
||||
Value::Nil
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(NativeResult::Return(result))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Constructor;
|
||||
impl NativeCallable for Constructor {
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, args: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let [Value::Object(definition), Value::Text(kind), values @ ..] = args else {
|
||||
return Err(invalid("Event requires an event kind"));
|
||||
};
|
||||
let fields = cx
|
||||
.store()
|
||||
.payload::<endor_core::EndorTypeObject>(*definition, "Type")?
|
||||
.fields()
|
||||
.to_vec();
|
||||
if fields.len() != values.len() {
|
||||
return Err(invalid("incorrect Event field arguments"));
|
||||
}
|
||||
let event = new_event(cx, *definition, kind)?;
|
||||
for (field, value) in fields.iter().zip(values) {
|
||||
cx.set_attr(event, &field.name, value.clone())?;
|
||||
}
|
||||
Ok(NativeResult::Return(Value::Object(event)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_event(
|
||||
cx: &mut NativeContext<'_>,
|
||||
definition: ObjectRef,
|
||||
kind: &str,
|
||||
) -> Result<ObjectRef, VmError> {
|
||||
if kind.is_empty() || kind.len() > 128 {
|
||||
return Err(invalid("event kind must contain 1..128 UTF-8 bytes"));
|
||||
}
|
||||
let event = cx.allocate(Event::default())?;
|
||||
cx.attach_type(event, definition)?;
|
||||
cx.set_attr(event, "kind", Value::Text(kind.into()))?;
|
||||
for name in ["target", "mount", "currentTarget", "currentMount"] {
|
||||
cx.set_attr(event, name, Value::Nil)?;
|
||||
}
|
||||
cx.set_attr(event, "phase", Value::Text("none".into()))?;
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
pub fn install(
|
||||
vm: &mut Vm,
|
||||
roots: &mut Vec<ObjectRef>,
|
||||
event_type: ObjectRef,
|
||||
token_type: ObjectRef,
|
||||
function_type: ObjectRef,
|
||||
) -> Result<(), VmError> {
|
||||
for (definition, name, method) in [
|
||||
(event_type, "stopPropagation", Method::Stop),
|
||||
(
|
||||
event_type,
|
||||
"stopImmediatePropagation",
|
||||
Method::StopImmediate,
|
||||
),
|
||||
(event_type, "preventDefault", Method::Prevent),
|
||||
(event_type, "isDefaultPrevented", Method::Prevented),
|
||||
(token_type, "disconnect", Method::Disconnect),
|
||||
(token_type, "isConnected", Method::Connected),
|
||||
] {
|
||||
let function = vm.allocate(ContextNativeFunction::new(name, method, vec![]))?;
|
||||
roots.push(function);
|
||||
vm.attach_type(function, function_type)?;
|
||||
vm.define_method(definition, name, function)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Handler {
|
||||
Token(ObjectRef),
|
||||
Legacy(ObjectRef),
|
||||
ObjectMethod,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Delivery {
|
||||
event: ObjectRef,
|
||||
owner: ObjectRef,
|
||||
mount: Option<ObjectRef>,
|
||||
group: usize,
|
||||
phase: &'static str,
|
||||
handler: Handler,
|
||||
}
|
||||
impl NativeCallable for Delivery {
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut event = cx.store().payload::<Event>(self.event, "Event")?.clone();
|
||||
if event.immediate || event.stopped_at.is_some_and(|group| group != self.group) {
|
||||
return Ok(NativeResult::Return(Value::Nil));
|
||||
}
|
||||
let (function, mut arguments) = match self.handler {
|
||||
Handler::Token(token) => {
|
||||
let Some(handler) = cx
|
||||
.store()
|
||||
.payload::<Connection>(token, "Connection")?
|
||||
.handler
|
||||
else {
|
||||
return Ok(NativeResult::Return(Value::Nil));
|
||||
};
|
||||
(handler, vec![])
|
||||
}
|
||||
Handler::Legacy(function) => (function, vec![]),
|
||||
Handler::ObjectMethod => {
|
||||
let Some(definition) = cx.store().type_of(self.owner)? else {
|
||||
return Err(invalid("untyped event target"));
|
||||
};
|
||||
let function = cx
|
||||
.store()
|
||||
.lookup_method(definition, "onEvent")?
|
||||
.ok_or(invalid("Block has no onEvent method"))?;
|
||||
(function, vec![Value::Object(self.owner)])
|
||||
}
|
||||
};
|
||||
event.group = self.group;
|
||||
cx.replace_payload(self.event, event)?;
|
||||
cx.set_attr(self.event, "phase", Value::Text(self.phase.into()))?;
|
||||
cx.set_attr(self.event, "currentTarget", Value::Object(self.owner))?;
|
||||
cx.set_attr(
|
||||
self.event,
|
||||
"currentMount",
|
||||
self.mount.map(Value::Object).unwrap_or(Value::Nil),
|
||||
)?;
|
||||
if matches!(self.handler, Handler::ObjectMethod)
|
||||
|| cx
|
||||
.store()
|
||||
.positional_parameter_count(function)?
|
||||
.is_some_and(|count| count > 0)
|
||||
{
|
||||
arguments.push(Value::Object(self.event));
|
||||
}
|
||||
Ok(NativeResult::Invoke {
|
||||
function,
|
||||
arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
struct Finish(ObjectRef);
|
||||
impl NativeCallable for Finish {
|
||||
fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result<NativeResult, VmError> {
|
||||
let mut event = cx.store().payload::<Event>(self.0, "Event")?.clone();
|
||||
event.dispatch = None;
|
||||
cx.replace_payload(self.0, event)?;
|
||||
cx.set_attr(self.0, "phase", Value::Text("none".into()))?;
|
||||
cx.set_attr(self.0, "currentTarget", Value::Nil)?;
|
||||
cx.set_attr(self.0, "currentMount", Value::Nil)?;
|
||||
Ok(NativeResult::Return(Value::Object(self.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot the selected occurrence's route and listener order. Disconnection
|
||||
/// before a listener's turn skips it; newly registered listeners wait for the
|
||||
/// next dispatch. A code event without a Mount visits only its target object.
|
||||
pub fn dispatch(
|
||||
cx: &mut NativeContext<'_>,
|
||||
owner: ObjectRef,
|
||||
event_ref: ObjectRef,
|
||||
mount: Option<ObjectRef>,
|
||||
desktop: ObjectRef,
|
||||
) -> Result<NativeResult, VmError> {
|
||||
let mut event = cx.store().payload::<Event>(event_ref, "Event")?.clone();
|
||||
if let Some(weak) = event.dispatch
|
||||
&& cx.upgrade(weak)?.is_some()
|
||||
{
|
||||
return Err(invalid("Event is already being dispatched"));
|
||||
}
|
||||
let Value::Text(kind) = cx.store().get_attr(event_ref, "kind")? else {
|
||||
return Err(invalid("Event kind must be text"));
|
||||
};
|
||||
if kind.is_empty() || kind.len() > 128 {
|
||||
return Err(invalid("invalid event kind"));
|
||||
}
|
||||
let mut route = vec![(owner, mount)];
|
||||
if let Some(mount) = mount {
|
||||
let target = cx.store().payload::<Mount>(mount, "Mount")?.clone();
|
||||
if !target.mounted || target.block != owner {
|
||||
return Err(invalid("event context does not belong to this Block"));
|
||||
}
|
||||
let mut parent = target.parent;
|
||||
let mut seen = std::collections::HashSet::from([mount]);
|
||||
while let Some(weak) = parent {
|
||||
let ancestor = cx
|
||||
.upgrade(weak)?
|
||||
.ok_or(invalid("event route has expired"))?;
|
||||
if !seen.insert(ancestor) {
|
||||
return Err(invalid("cyclic event route"));
|
||||
}
|
||||
let data = cx.store().payload::<Mount>(ancestor, "Mount")?;
|
||||
if !data.mounted {
|
||||
return Err(invalid("event route is detached"));
|
||||
}
|
||||
route.push((data.block, Some(ancestor)));
|
||||
parent = data.parent;
|
||||
}
|
||||
if let Some(weak) = target.page {
|
||||
let page = cx.upgrade(weak)?.ok_or(invalid("event Page has expired"))?;
|
||||
route.push((page, None));
|
||||
}
|
||||
}
|
||||
let guard = cx.new_object()?;
|
||||
let mut calls = Vec::new();
|
||||
let mut group = 0;
|
||||
// Each occurrence is its own propagation group, even for shared Blocks.
|
||||
let traversal = route
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.map(|(index, entry)| (index, entry, true))
|
||||
.chain(
|
||||
route
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, entry)| (index, entry, false)),
|
||||
);
|
||||
for (index, &(block, current_mount), capture) in traversal {
|
||||
let (_, data) = block::resolve(cx.store(), block)?;
|
||||
let mut handlers = Vec::new();
|
||||
for token in &data.subscriptions {
|
||||
let token_data = cx.store().payload::<Connection>(*token, "Connection")?;
|
||||
if token_data.handler.is_some()
|
||||
&& token_data.kind == kind
|
||||
&& token_data.capture == capture
|
||||
{
|
||||
handlers.push(Handler::Token(*token));
|
||||
}
|
||||
}
|
||||
if !capture {
|
||||
handlers.insert(0, Handler::ObjectMethod);
|
||||
if kind == "click"
|
||||
&& let Some(handler) = data.click
|
||||
{
|
||||
handlers.push(Handler::Legacy(handler));
|
||||
}
|
||||
}
|
||||
for handler in handlers {
|
||||
if calls.len() >= MAX_DELIVERIES {
|
||||
return Err(invalid("event delivery budget exceeded"));
|
||||
}
|
||||
let mut captures = vec![event_ref, guard, block];
|
||||
if let Some(mount) = current_mount {
|
||||
captures.push(mount);
|
||||
}
|
||||
match handler {
|
||||
Handler::Token(token) | Handler::Legacy(token) => captures.push(token),
|
||||
Handler::ObjectMethod => {}
|
||||
}
|
||||
let function = cx.allocate(ContextNativeFunction::new(
|
||||
"event delivery",
|
||||
Delivery {
|
||||
event: event_ref,
|
||||
owner: block,
|
||||
mount: current_mount,
|
||||
group,
|
||||
phase: if index == 0 {
|
||||
"target"
|
||||
} else if capture {
|
||||
"capture"
|
||||
} else {
|
||||
"bubble"
|
||||
},
|
||||
handler,
|
||||
},
|
||||
captures,
|
||||
))?;
|
||||
attach_desktop_type(cx, function, "Function", desktop)?;
|
||||
calls.push(function);
|
||||
}
|
||||
// Capture and bubble at the target are one group: stopPropagation at
|
||||
// target capture still permits other target listeners.
|
||||
if index != 0 || !capture {
|
||||
group += 1;
|
||||
}
|
||||
}
|
||||
let finish = cx.allocate(ContextNativeFunction::new(
|
||||
"event finish",
|
||||
Finish(event_ref),
|
||||
vec![event_ref, guard],
|
||||
))?;
|
||||
calls.push(finish);
|
||||
let mut instructions = Vec::with_capacity(calls.len() * 4 + 1);
|
||||
for index in 0..calls.len() {
|
||||
instructions.push(Instruction::new(Opcode::LoadConst, index as u32));
|
||||
instructions.push(Instruction::simple(Opcode::PushNull));
|
||||
instructions.push(Instruction::new(Opcode::Call, 0));
|
||||
if index + 1 != calls.len() {
|
||||
instructions.push(Instruction::simple(Opcode::PopTop));
|
||||
}
|
||||
}
|
||||
instructions.push(Instruction::simple(Opcode::ReturnValue));
|
||||
let code = EndorCode::from_instructions(
|
||||
&instructions,
|
||||
calls.into_iter().map(Value::Object).collect(),
|
||||
CodeLayout::default(),
|
||||
)?
|
||||
.with_location("<event dispatch>", "<tessera>", 1);
|
||||
let code = cx.allocate(EndorCodeObject::new(code))?;
|
||||
let globals = cx.new_dict()?;
|
||||
let driver = cx.new_function("event dispatch", code, globals)?;
|
||||
attach_desktop_type(cx, driver, "Function", desktop)?;
|
||||
event.dispatch = Some(cx.store().downgrade(guard)?);
|
||||
event.group = 0;
|
||||
event.stopped_at = None;
|
||||
event.immediate = false;
|
||||
event.prevented = false;
|
||||
cx.replace_payload(event_ref, event)?;
|
||||
cx.set_attr(event_ref, "target", Value::Object(owner))?;
|
||||
cx.set_attr(
|
||||
event_ref,
|
||||
"mount",
|
||||
mount.map(Value::Object).unwrap_or(Value::Nil),
|
||||
)?;
|
||||
Ok(NativeResult::Invoke {
|
||||
function: driver,
|
||||
arguments: vec![],
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Native input transport. Only opaque frame/mount identities cross to the VM worker.
|
||||
use endor_core::ObjectPayload;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
@@ -19,20 +18,6 @@ pub struct Input {
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Event;
|
||||
impl ObjectPayload for Event {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
fn kind_name(&self) -> &'static str {
|
||||
"tessera-event"
|
||||
}
|
||||
fn allows_attributes(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Client(SyncSender<Input>);
|
||||
impl Client {
|
||||
pub fn new(proxy: EventLoopProxy<crate::app::AppEvent>) -> anyhow::Result<Self> {
|
||||
|
||||
@@ -8,6 +8,7 @@ mod display_list;
|
||||
mod document;
|
||||
mod endor_host;
|
||||
mod endor_script;
|
||||
mod event_dispatch;
|
||||
mod events;
|
||||
mod geometry;
|
||||
mod layout;
|
||||
|
||||
+80
-29
@@ -31,6 +31,7 @@ pub const TYPE_NAMES: &[&str] = &[
|
||||
"Page",
|
||||
"Mount",
|
||||
"Event",
|
||||
"Subscription",
|
||||
];
|
||||
pub const QUERY_NAMES: &[&str] = &["isInstance", "isSubtype"];
|
||||
|
||||
@@ -206,6 +207,9 @@ enum Operation {
|
||||
Back,
|
||||
Forward,
|
||||
OnClick,
|
||||
On,
|
||||
Dispatch,
|
||||
OnEvent,
|
||||
Click,
|
||||
Present,
|
||||
}
|
||||
@@ -487,6 +491,9 @@ impl NativeCallable for Function {
|
||||
Replace | Move => (2, 2),
|
||||
Page | Unmount | Open | Close | Activate | Back | Forward | Children => (0, 0),
|
||||
Click => (0, 1),
|
||||
On => (2, 3),
|
||||
Dispatch => (1, 2),
|
||||
OnEvent => (1, 1),
|
||||
};
|
||||
if !(minimum..=maximum).contains(&args.len()) {
|
||||
return Err(invalid("incorrect UI method argument count"));
|
||||
@@ -869,38 +876,64 @@ impl NativeCallable for Function {
|
||||
data.click = Some(handler);
|
||||
result = Value::Object(owner);
|
||||
}
|
||||
Click => {
|
||||
let Some(handler) = data.click else {
|
||||
return Ok(NativeResult::Return(Value::Nil));
|
||||
On => {
|
||||
let kind = text(args.first())?;
|
||||
if kind.is_empty() || kind.len() > 128 {
|
||||
return Err(invalid("invalid event kind"));
|
||||
}
|
||||
let handler = object(args.get(1))?;
|
||||
let capture = match args.get(2) {
|
||||
None | Some(Value::Bool(false)) => false,
|
||||
Some(Value::Bool(true)) => true,
|
||||
Some(Value::Text(phase)) if phase == "capture" => true,
|
||||
Some(Value::Text(phase)) if phase == "bubble" => false,
|
||||
_ => return Err(invalid("phase must be capture or bubble")),
|
||||
};
|
||||
let event = cx.allocate(crate::events::Event)?;
|
||||
attach_named_type(cx, event, "Event", &self.session)?;
|
||||
cx.set_attr(event, "kind", Value::Text("click".into()))?;
|
||||
cx.set_attr(event, "target", Value::Object(owner))?;
|
||||
let context = if let Some(value) = args.first() {
|
||||
let mount = object(Some(value))?;
|
||||
let data = cx.store().payload::<crate::mount::Mount>(mount, "Mount")?;
|
||||
if !data.mounted || data.block != owner {
|
||||
return Err(invalid("click context does not belong to this Block"));
|
||||
let mut active = Vec::new();
|
||||
for token in &data.subscriptions {
|
||||
if cx
|
||||
.store()
|
||||
.payload::<crate::event_dispatch::Connection>(*token, "Connection")?
|
||||
.handler
|
||||
.is_some()
|
||||
{
|
||||
active.push(*token);
|
||||
}
|
||||
Value::Object(mount)
|
||||
}
|
||||
if active.len() >= MAX_UI_NODES {
|
||||
return Err(invalid("too many event subscriptions"));
|
||||
}
|
||||
let connection = cx.allocate(crate::event_dispatch::Connection {
|
||||
kind,
|
||||
handler: Some(handler),
|
||||
capture,
|
||||
})?;
|
||||
let token = cx.allocate(crate::event_dispatch::Subscription {
|
||||
connection: cx.store().downgrade(connection)?,
|
||||
})?;
|
||||
attach_named_type(cx, token, "Subscription", &self.session)?;
|
||||
active.push(connection);
|
||||
data.subscriptions = active;
|
||||
result = Value::Object(token);
|
||||
}
|
||||
OnEvent => return Ok(NativeResult::Return(Value::Nil)),
|
||||
Click | Dispatch => {
|
||||
let desktop = self
|
||||
.session
|
||||
.lock()
|
||||
.map_err(|_| invalid("UI session poisoned"))?
|
||||
.desktop;
|
||||
let (event, mount) = if matches!(self.operation, Click) {
|
||||
let definition = desktop_type(cx.store(), "Event", desktop)?;
|
||||
(
|
||||
crate::event_dispatch::new_event(cx, definition, "click")?,
|
||||
args.first(),
|
||||
)
|
||||
} else {
|
||||
Value::Nil
|
||||
(object(args.first())?, args.get(1))
|
||||
};
|
||||
cx.set_attr(event, "mount", context)?;
|
||||
let arguments = if cx
|
||||
.store()
|
||||
.positional_parameter_count(handler)?
|
||||
.is_some_and(|count| count > 0)
|
||||
{
|
||||
vec![Value::Object(event)]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
return Ok(NativeResult::Invoke {
|
||||
function: handler,
|
||||
arguments,
|
||||
});
|
||||
let mount = mount.map(|value| object(Some(value))).transpose()?;
|
||||
return crate::event_dispatch::dispatch(cx, owner, event, mount, desktop);
|
||||
}
|
||||
_ => return Err(invalid("unsupported Block operation")),
|
||||
}
|
||||
@@ -1175,10 +1208,22 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
("Button", "ContainerBlock", Some("button")),
|
||||
("Page", "ContainerBlock", Some("page")),
|
||||
("Mount", "Object", None),
|
||||
("Event", "Object", None),
|
||||
("Event", "Object", Some("event")),
|
||||
("Subscription", "Object", None),
|
||||
] {
|
||||
let base = object(Some(&vm.get_attr(ui, base_name)?))?;
|
||||
let constructor = match constructor_name {
|
||||
Some("event") => {
|
||||
let factory = vm.allocate(ContextNativeFunction::new(
|
||||
"event",
|
||||
crate::event_dispatch::Constructor,
|
||||
vec![],
|
||||
))?;
|
||||
roots.push(factory);
|
||||
let function_type = object(Some(&vm.get_attr(ui, "Function")?))?;
|
||||
vm.attach_type(factory, function_type)?;
|
||||
TypeConstructor::NativeTyped(factory)
|
||||
}
|
||||
Some(constructor_name) => {
|
||||
let operation = match constructor_name {
|
||||
"text" => Operation::Text,
|
||||
@@ -1243,6 +1288,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
for (type_name, name, operation) in [
|
||||
("Block", "mounts", Operation::Mounts),
|
||||
("Block", "onClick", Operation::OnClick),
|
||||
("Block", "on", Operation::On),
|
||||
("Block", "dispatch", Operation::Dispatch),
|
||||
("Block", "onEvent", Operation::OnEvent),
|
||||
("Block", "click", Operation::Click),
|
||||
("TextBlock", "setText", Operation::SetText),
|
||||
("TextBlock", "setColor", Operation::SetColor),
|
||||
@@ -1275,6 +1323,9 @@ pub fn install(vm: &mut Vm, roots: &mut Vec<ObjectRef>) -> Result<UiBridge> {
|
||||
vm.attach_type(method, function_type)?;
|
||||
vm.define_method(expected, name, method)?;
|
||||
}
|
||||
let event_type = object(Some(&vm.get_attr(ui, "Event")?))?;
|
||||
let token_type = object(Some(&vm.get_attr(ui, "Subscription")?))?;
|
||||
crate::event_dispatch::install(vm, roots, event_type, token_type, function_type)?;
|
||||
Ok(UiBridge { ui, session })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user