diff --git a/README.md b/README.md index d080251..b282227 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,12 @@ return. Bound text tracks its source property; clean derived subtrees and unchan Source native subclass construction takes native arguments first, followed by all inherited and own declared fields. For example, a TextBlock subclass with one field accepts `CustomText("text", 2, "#242422", fieldValue)`; optional native arguments can be omitted before the required field suffix. Field storage belongs to the current shared Block content: replacement updates aliases and bindings and releases the previous fields through Aster. This is an implementation subset; field annotations are not yet runtime checked. +Events use `block.on("click", callback)` or `block.on("click", callback, "capture")`, returning a managed Subscription with `disconnect()` and `isConnected()`. A Block owns its connections; tokens refer to connections weakly and do not retain the target or callback. Dropping a token leaves the listener registered. Disconnection releases the callback and is safe to repeat. + +`block.dispatch(Event("custom"))` returns the dispatched Event. `block.click()` creates a click Event and uses the same dispatcher as native activation. An optional Mount selects one occurrence: `block.dispatch(event, mount)` or `block.click(mount)`. Without a Mount only the target is visited. With one, capture walks from Page through ancestors, then target capture, the object's `onEvent(self, event)` method and target listeners, then ancestors/Page in bubble order. The target method runs before non-capture target listeners. Event fields expose `kind`, `target`, `mount`, `currentTarget`, `currentMount` and `phase` (`capture`, `target`, `bubble`, then `none`). Source subclasses can override onEvent. + +Event methods `stopPropagation()`, `stopImmediatePropagation()`, `preventDefault()` and `isDefaultPrevented()` control delivery or report cancellation; there is currently no additional host default action to cancel. stopPropagation permits remaining listeners on the current occurrence, while the immediate variant stops them too. Listener order is snapshotted at dispatch start; disconnection before a listener's turn skips it, new connections wait for the next dispatch. The existing onClick setter remains a single compatibility callback, called after other non-capture listeners on that Block. All handlers share the VM call budget, with an explicit 10000-delivery limit that reports failure instead of truncating propagation. Reentrant dispatch of the same active Event is rejected; a completed Event can be reused. See [events.endor](examples/events.endor). + Toggle developer tools with Command-Option-I or F12 on macOS, Control-Shift-I or F12 on Windows/Linux. The compact Console, Sources, Network and Settings tabs share a draggable dock edge. The Sources tree/source diff --git a/examples/README.md b/examples/README.md index 5bc9366..f97e814 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,6 +2,7 @@ | File | Purpose | | --- | --- | +| [events.endor](events.endor) | Managed event subscriptions, source onEvent override and numbered capture/target/bubble steps from native input. | | [native-subclasses.endor](native-subclasses.endor) | Real native TextBlock/Button/Page source subclasses, inherited fields, method overrides and shared field bindings. | | [classes.endor](classes.endor) | Endor class fields, inheritance, overriding, a retained receiver and Event arguments; native keyboard input updates shared model properties. | | [managed-types.endor](managed-types.endor) | Callable managed Endor types, one shared counter, two bound displays and a language callback invoked from native Button input. | diff --git a/examples/events.endor b/examples/events.endor new file mode 100644 index 0000000..740d84c --- /dev/null +++ b/examples/events.endor @@ -0,0 +1,39 @@ +class EventLog { + count: Object + captureStep: Number + methodStep: Number + targetStep: Number + bubbleStep: Number + phase: String + function capture(self: EventLog, event: Event): Unit { + self.count.increment() + self.captureStep = self.count.value + self.phase = event.phase + } + function target(self: EventLog, event: Event): Unit { + self.count.increment() + self.targetStep = self.count.value + self.phase = event.phase + } + function bubble(self: EventLog, event: Event): Unit { + self.count.increment() + self.bubbleStep = self.count.value + self.phase = event.phase + } +} +class EventButton: Button { + model: EventLog + function onEvent(self: EventButton, event: Event): Unit { + self.model.count.increment() + self.model.methodStep = self.model.count.value + self.model.phase = event.phase + } +} +model = EventLog(ui.state(0), 0, 0, 0, 0, "等待点击") +button = EventButton("捕获 → 对象方法 → 监听器 → 冒泡", model) +root = Column(24, 16, TextBlock("同一对象系统中的事件分派", 2), button, ui.textOf(model.count, "value"), ui.textOf(model, "phase")) +capture = root.on("click", model.capture, "capture") +target = button.on("click", model.target) +bubble = root.on("click", model.bubble) +page = Page() +page.render(root) diff --git a/src/README.md b/src/README.md index e1e4db4..9dd5bbc 100644 --- a/src/README.md +++ b/src/README.md @@ -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 | diff --git a/src/block.rs b/src/block.rs index 76146ce..c8897be 100644 --- a/src/block.rs +++ b/src/block.rs @@ -111,6 +111,7 @@ pub struct Data { pub history_index: usize, pub text_binding: Option<(ObjectRef, String)>, pub click: Option, + pub subscriptions: Vec, pub properties: Option, } @@ -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); } diff --git a/src/endor_host.rs b/src/endor_host.rs index cb37480..6df0515 100644 --- a/src/endor_host.rs +++ b/src/endor_host.rs @@ -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( diff --git a/src/event_dispatch.rs b/src/event_dispatch.rs new file mode 100644 index 0000000..b5140fc --- /dev/null +++ b/src/event_dispatch.rs @@ -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, + group: usize, + stopped_at: Option, + 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, + 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 { + 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::(*owner, "Subscription")? + .connection; + let connection = cx.upgrade(weak)?; + let connected = if let Some(connection) = connection { + let mut data = cx + .store() + .payload::(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::(*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 { + let [Value::Object(definition), Value::Text(kind), values @ ..] = args else { + return Err(invalid("Event requires an event kind")); + }; + let fields = cx + .store() + .payload::(*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 { + 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, + 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, + group: usize, + phase: &'static str, + handler: Handler, +} +impl NativeCallable for Delivery { + fn invoke(&self, cx: &mut NativeContext<'_>, _: &[Value]) -> Result { + let mut event = cx.store().payload::(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::(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 { + let mut event = cx.store().payload::(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, + desktop: ObjectRef, +) -> Result { + let mut event = cx.store().payload::(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")?.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::(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::(*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("", "", 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![], + }) +} diff --git a/src/events.rs b/src/events.rs index 1703c8b..5ea6267 100644 --- a/src/events.rs +++ b/src/events.rs @@ -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); impl Client { pub fn new(proxy: EventLoopProxy) -> anyhow::Result { diff --git a/src/main.rs b/src/main.rs index c18e775..71a4019 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod display_list; mod document; mod endor_host; mod endor_script; +mod event_dispatch; mod events; mod geometry; mod layout; diff --git a/src/ui_bridge.rs b/src/ui_bridge.rs index fe222c3..b05d22a 100644 --- a/src/ui_bridge.rs +++ b/src/ui_bridge.rs @@ -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::(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::(*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) -> Result { ("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) -> Result { 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) -> Result { 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 }) } diff --git a/vendor/aster b/vendor/aster index be9a032..2b5539c 160000 --- a/vendor/aster +++ b/vendor/aster @@ -1 +1 @@ -Subproject commit be9a032647488154ddbfdd33b232fa7db6346762 +Subproject commit 2b5539cd06f4f4068882e4e6d12d6deb9728bf6a