Skip to main content

whisker_runtime/
event.rs

1//! Typed event objects deserialized from the [`WhiskerValue`] body
2//! Lynx hands an event handler.
3//!
4//! Mirrors Lynx's event hierarchy (see
5//! <https://lynxjs.org/api/lynx-api/event/event.html>):
6//!
7//!   - [`Event`] — base shape every event carries (`type`,
8//!     `timestamp`, `target`, `currentTarget`).
9//!   - [`TouchEvent`] — `tap` / `longpress` / `touchstart` /
10//!     `touchmove` / `touchend` / `touchcancel` / `click`. Adds the
11//!     primary-touch [`Point`] `detail` plus `touches` /
12//!     `changedTouches` arrays.
13//!   - [`AnimationEvent`] — `animationstart` / `animationend` / … /
14//!     `transitionend`. Adds the animation `detail`.
15//!   - [`CustomEvent`] — component state-change events (`scroll`,
16//!     input `change`, …). Carries an opaque [`WhiskerValue`]
17//!     `detail`.
18//!
19//! A built-in builder's `on_<event>` method, or a
20//! `#[whisker::module_component]` `on_<event>: TouchEvent` prop,
21//! receives the event body as a [`WhiskerValue`] and recovers the
22//! struct via [`WhiskerValue::deserialize_into`]. Every field is
23//! `#[serde(default)]` so a body missing an optional key (or an
24//! engine that names one slightly differently) degrades to a
25//! zero-valued field rather than dropping the handler call.
26
27use std::collections::BTreeMap;
28use std::fmt;
29
30use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
31use serde::Deserialize;
32
33use crate::value::WhiskerValue;
34use crate::view::{set_event_listener, Element};
35
36/// Re-export so `event::BindType` sits next to `event::bind_typed` /
37/// `event::bind_unit` — the propagation type these take. Canonical
38/// definition lives in [`crate::view`].
39pub use crate::view::BindType;
40
41/// Register a **typed** event handler on `handle`.
42///
43/// The event body crosses the bridge as a [`WhiskerValue`]; this
44/// deserializes it into `E` before calling `handler`. Used by the
45/// built-in builders' `on_<event>` methods and by
46/// `#[whisker::module_component]` for typed `on_<event>: E` props.
47///
48/// **The handler always fires when the event fires.** "The event
49/// happened" is the primary signal; the typed payload is
50/// supplementary. So if the body is absent (a bodyless event arrives
51/// as [`WhiskerValue::Null`]) or its shape doesn't match `E`, the
52/// handler is still called with `E::default()` — and the mismatch is
53/// logged with the raw value so it stays diagnosable (the Case ②
54/// philosophy: conversion mistakes are loggable, not invisible)
55/// rather than silently swallowing the whole event.
56pub fn bind_typed<E, F>(handle: Element, event_name: &'static str, bind_type: BindType, handler: F)
57where
58    E: DeserializeOwned + Default + 'static,
59    F: Fn(E) + 'static,
60{
61    set_event_listener(
62        handle,
63        event_name,
64        bind_type,
65        Box::new(move |value: WhiskerValue| {
66            let ev = value.deserialize_into::<E>().unwrap_or_else(|err| {
67                eprintln!(
68                    "[whisker] event `{event_name}`: payload did not deserialize into `{}`: \
69                     {err} (raw: {value:?}); calling handler with default",
70                    std::any::type_name::<E>(),
71                );
72                E::default()
73            });
74            handler(ev);
75        }),
76    );
77}
78
79/// Register an event handler that ignores the payload.
80///
81/// For `on_<event>: ()` props / call sites that only care that the
82/// event fired. Wraps a `Fn()` into the value-carrying primitive.
83pub fn bind_unit<F>(handle: Element, event_name: &str, bind_type: BindType, handler: F)
84where
85    F: Fn() + 'static,
86{
87    set_event_listener(
88        handle,
89        event_name,
90        bind_type,
91        Box::new(move |_value: WhiskerValue| handler()),
92    );
93}
94
95/// The element an event targets / is listening on. Shared by
96/// `target` (where the event originated) and `currentTarget` (the
97/// element whose handler is firing).
98#[derive(Debug, Clone, Default)]
99#[non_exhaustive]
100pub struct Target {
101    /// The element's `id` attribute (empty when unset).
102    pub id: String,
103    /// Lynx Engine's unique element identifier (its "sign").
104    pub uid: i64,
105    /// `data-*` attributes attached to the element, keyed without
106    /// the `data-` prefix.
107    pub dataset: BTreeMap<String, WhiskerValue>,
108}
109
110// The platform reporter hands us the *raw* event body, where `target`
111// and `currentTarget` are plain integer signs (Lynx
112// `LynxEvent.generateEventBody`: `body["target"] = targetSign`). The
113// richer `{id, dataset, uid}` object is only synthesized downstream in
114// the JS layer, which Whisker bypasses. So `Target` must deserialize
115// from EITHER an integer (→ `uid`, with empty `id`/`dataset`) or a
116// `{id, uid, dataset}` object — a hard "expected struct, got number"
117// error here would otherwise fail the *whole* event struct and blank
118// every field (including `detail`).
119impl<'de> Deserialize<'de> for Target {
120    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
121    where
122        D: Deserializer<'de>,
123    {
124        struct TargetVisitor;
125        impl<'de> Visitor<'de> for TargetVisitor {
126            type Value = Target;
127            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
128                f.write_str("an element sign (integer) or a target object")
129            }
130            fn visit_i64<E: de::Error>(self, v: i64) -> Result<Target, E> {
131                Ok(Target {
132                    uid: v,
133                    ..Default::default()
134                })
135            }
136            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Target, E> {
137                Ok(Target {
138                    uid: v as i64,
139                    ..Default::default()
140                })
141            }
142            fn visit_f64<E: de::Error>(self, v: f64) -> Result<Target, E> {
143                Ok(Target {
144                    uid: v as i64,
145                    ..Default::default()
146                })
147            }
148            fn visit_unit<E: de::Error>(self) -> Result<Target, E> {
149                Ok(Target::default())
150            }
151            fn visit_none<E: de::Error>(self) -> Result<Target, E> {
152                Ok(Target::default())
153            }
154            fn visit_map<A>(self, map: A) -> Result<Target, A::Error>
155            where
156                A: MapAccess<'de>,
157            {
158                #[derive(Deserialize)]
159                struct Obj {
160                    #[serde(default)]
161                    id: String,
162                    #[serde(default)]
163                    uid: i64,
164                    #[serde(default)]
165                    dataset: BTreeMap<String, WhiskerValue>,
166                }
167                let o = Obj::deserialize(de::value::MapAccessDeserializer::new(map))?;
168                Ok(Target {
169                    id: o.id,
170                    uid: o.uid,
171                    dataset: o.dataset,
172                })
173            }
174        }
175        deserializer.deserialize_any(TargetVisitor)
176    }
177}
178
179/// A 2-D point in LynxView coordinates — the `detail` of a
180/// [`TouchEvent`] (position of the first touch point).
181#[derive(Debug, Clone, Copy, Default, Deserialize)]
182#[non_exhaustive]
183pub struct Point {
184    #[serde(default)]
185    pub x: f64,
186    #[serde(default)]
187    pub y: f64,
188}
189
190/// A single active touch point inside a [`TouchEvent`].
191#[derive(Debug, Clone, Copy, Default, Deserialize)]
192#[serde(rename_all = "camelCase")]
193#[non_exhaustive]
194pub struct Touch {
195    /// Stable id for the lifetime of one finger's touch sequence.
196    #[serde(default)]
197    pub identifier: i64,
198    /// Position in the touched element's coordinate space.
199    #[serde(default)]
200    pub x: f64,
201    #[serde(default)]
202    pub y: f64,
203    /// Position in LynxView coordinates.
204    #[serde(default)]
205    pub page_x: f64,
206    #[serde(default)]
207    pub page_y: f64,
208    /// Position in window coordinates.
209    #[serde(default)]
210    pub client_x: f64,
211    #[serde(default)]
212    pub client_y: f64,
213}
214
215/// Base event shape — fields present on every Lynx event.
216#[derive(Debug, Clone, Default, Deserialize)]
217#[non_exhaustive]
218pub struct Event {
219    /// Event name (`"tap"`, `"touchstart"`, …).
220    #[serde(rename = "type", default)]
221    pub kind: String,
222    /// Milliseconds since the event was generated.
223    #[serde(default)]
224    pub timestamp: f64,
225    /// The element the event originated on.
226    #[serde(default)]
227    pub target: Target,
228    /// The element whose listener is firing.
229    #[serde(rename = "currentTarget", default)]
230    pub current_target: Target,
231}
232
233/// Touch / tap / click event. The `detail` is the first touch
234/// point's LynxView-coordinate position; `touches` /
235/// `changed_touches` carry the full per-finger detail.
236#[derive(Debug, Clone, Default, Deserialize)]
237#[serde(rename_all = "camelCase")]
238#[non_exhaustive]
239pub struct TouchEvent {
240    #[serde(rename = "type", default)]
241    pub kind: String,
242    #[serde(default)]
243    pub timestamp: f64,
244    #[serde(default)]
245    pub target: Target,
246    #[serde(default)]
247    pub current_target: Target,
248    /// Position of the first touch point (LynxView coordinates).
249    #[serde(default)]
250    pub detail: Point,
251    /// All touch points currently on the surface.
252    #[serde(default)]
253    pub touches: Vec<Touch>,
254    /// Touch points whose state changed in this event.
255    #[serde(default)]
256    pub changed_touches: Vec<Touch>,
257}
258
259/// Keyframe / transition animation lifecycle event.
260#[derive(Debug, Clone, Default, Deserialize)]
261#[non_exhaustive]
262pub struct AnimationEvent {
263    #[serde(rename = "type", default)]
264    pub kind: String,
265    #[serde(default)]
266    pub timestamp: f64,
267    #[serde(default)]
268    pub target: Target,
269    #[serde(rename = "currentTarget", default)]
270    pub current_target: Target,
271    /// `"keyframe-animation"` or `"transition-animation"`.
272    #[serde(rename = "animation_type", default)]
273    pub animation_type: String,
274    /// `@keyframes` name or the transitioned CSS property.
275    #[serde(rename = "animation_name", default)]
276    pub animation_name: String,
277    #[serde(rename = "new_animator", default)]
278    pub new_animator: bool,
279}
280
281/// A component state-change event (`scroll`, input `change`, …).
282/// The payload shape is component-specific, so `detail` stays an
283/// opaque [`WhiskerValue`] the handler inspects itself.
284#[derive(Debug, Clone, Default, Deserialize)]
285#[non_exhaustive]
286pub struct CustomEvent {
287    #[serde(rename = "type", default)]
288    pub kind: String,
289    #[serde(default)]
290    pub timestamp: f64,
291    #[serde(default)]
292    pub target: Target,
293    #[serde(rename = "currentTarget", default)]
294    pub current_target: Target,
295    /// Component-supplied state. `WhiskerValue::Null` when absent.
296    #[serde(default)]
297    pub detail: WhiskerValue,
298}
299
300/// A 2-D size — `width` / `height` in px.
301#[derive(Debug, Clone, Copy, Default, Deserialize)]
302#[non_exhaustive]
303pub struct Size {
304    #[serde(default)]
305    pub width: f64,
306    #[serde(default)]
307    pub height: f64,
308}
309
310// scroll_view events.
311
312/// `<scroll_view>` scroll events — `scroll`, `scrolltoupper`,
313/// `scrolltolower`, `scrollend`, `contentsizechanged`. The `detail`
314/// carries the current scroll geometry. (CustomEvent → target-only, so
315/// these have no catch/capture variants — see Lynx `CustomEvent`
316/// defaults `Capture::kNo, Bubbles::kNo`.)
317#[derive(Debug, Clone, Default, Deserialize)]
318#[non_exhaustive]
319pub struct ScrollEvent {
320    #[serde(rename = "type", default)]
321    pub kind: String,
322    #[serde(default)]
323    pub timestamp: f64,
324    #[serde(default)]
325    pub target: Target,
326    #[serde(rename = "currentTarget", default)]
327    pub current_target: Target,
328    #[serde(default)]
329    pub detail: ScrollDetail,
330}
331
332/// Scroll geometry carried by a [`ScrollEvent`] (the event body's
333/// `detail` dict — see Lynx `LynxScrollEventManager`).
334#[derive(Debug, Clone, Copy, Default, Deserialize)]
335#[serde(rename_all = "camelCase")]
336#[non_exhaustive]
337pub struct ScrollDetail {
338    /// Horizontal content offset (px).
339    #[serde(default)]
340    pub scroll_left: f64,
341    /// Vertical content offset (px).
342    #[serde(default)]
343    pub scroll_top: f64,
344    /// Total scrollable content width (px).
345    #[serde(default)]
346    pub scroll_width: f64,
347    /// Total scrollable content height (px).
348    #[serde(default)]
349    pub scroll_height: f64,
350    /// Horizontal delta since the previous scroll event (px).
351    #[serde(default)]
352    pub delta_x: f64,
353    /// Vertical delta since the previous scroll event (px).
354    #[serde(default)]
355    pub delta_y: f64,
356    /// Whether the user's finger is currently dragging the scroll view.
357    #[serde(default)]
358    pub is_dragging: bool,
359}
360
361// text events.
362
363/// `layout` on `<text>` — fired after text layout completes.
364#[derive(Debug, Clone, Default, Deserialize)]
365#[non_exhaustive]
366pub struct TextLayoutEvent {
367    #[serde(rename = "type", default)]
368    pub kind: String,
369    #[serde(default)]
370    pub timestamp: f64,
371    #[serde(default)]
372    pub target: Target,
373    #[serde(rename = "currentTarget", default)]
374    pub current_target: Target,
375    #[serde(default)]
376    pub detail: TextLayoutDetail,
377}
378
379/// Layout info carried by a [`TextLayoutEvent`].
380#[derive(Debug, Clone, Default, Deserialize)]
381#[serde(rename_all = "camelCase")]
382#[non_exhaustive]
383pub struct TextLayoutDetail {
384    /// Number of laid-out lines.
385    #[serde(default)]
386    pub line_count: i64,
387    /// Per-line ranges (and ellipsis info for truncated lines).
388    #[serde(default)]
389    pub lines: Vec<TextLineInfo>,
390    /// Laid-out content size.
391    #[serde(default)]
392    pub size: Size,
393}
394
395/// One laid-out text line inside a [`TextLayoutDetail`].
396#[derive(Debug, Clone, Copy, Default, Deserialize)]
397#[serde(rename_all = "camelCase")]
398#[non_exhaustive]
399pub struct TextLineInfo {
400    /// Character index of the line's first glyph.
401    #[serde(default)]
402    pub start: i64,
403    /// Character index just past the line's last glyph.
404    #[serde(default)]
405    pub end: i64,
406    /// Number of characters replaced by the truncation ellipsis (0 if
407    /// the line isn't truncated).
408    #[serde(default)]
409    pub ellipsis_count: i64,
410}
411
412/// `selectionchange` on `<text>` — the selected text range changed.
413#[derive(Debug, Clone, Default, Deserialize)]
414#[non_exhaustive]
415pub struct SelectionChangeEvent {
416    #[serde(rename = "type", default)]
417    pub kind: String,
418    #[serde(default)]
419    pub timestamp: f64,
420    #[serde(default)]
421    pub target: Target,
422    #[serde(rename = "currentTarget", default)]
423    pub current_target: Target,
424    #[serde(default)]
425    pub detail: SelectionDetail,
426}
427
428/// Selection range carried by a [`SelectionChangeEvent`].
429#[derive(Debug, Clone, Default, Deserialize)]
430#[non_exhaustive]
431pub struct SelectionDetail {
432    /// Start character index, or -1 when there's no selection.
433    #[serde(default)]
434    pub start: i64,
435    /// End character index, or -1 when there's no selection.
436    #[serde(default)]
437    pub end: i64,
438    /// `"forward"` or `"backward"`.
439    #[serde(default)]
440    pub direction: String,
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn touch_event_from_value_tree() {
449        // Shape mirrors Lynx's `generateEventBody` for a tap.
450        let v = WhiskerValue::map([
451            ("type", WhiskerValue::String("tap".into())),
452            ("timestamp", WhiskerValue::Float(123.0)),
453            (
454                "detail",
455                WhiskerValue::map([
456                    ("x", WhiskerValue::Float(10.5)),
457                    ("y", WhiskerValue::Float(20.0)),
458                ]),
459            ),
460            (
461                "target",
462                WhiskerValue::map([
463                    ("id", WhiskerValue::String("btn".into())),
464                    ("uid", WhiskerValue::Int(7)),
465                ]),
466            ),
467            (
468                "touches",
469                WhiskerValue::Array(vec![WhiskerValue::map([
470                    ("identifier", WhiskerValue::Int(0)),
471                    ("pageX", WhiskerValue::Float(10.5)),
472                    ("pageY", WhiskerValue::Float(20.0)),
473                ])]),
474            ),
475        ]);
476
477        let e: TouchEvent = v.deserialize_into().expect("deserialize TouchEvent");
478        assert_eq!(e.kind, "tap");
479        assert_eq!(e.detail.x, 10.5);
480        assert_eq!(e.target.id, "btn");
481        assert_eq!(e.target.uid, 7);
482        assert_eq!(e.touches.len(), 1);
483        assert_eq!(e.touches[0].page_x, 10.5);
484    }
485
486    #[test]
487    fn missing_fields_default_rather_than_fail() {
488        // A body with only some keys (e.g. an event Lynx fills
489        // partially) must still deserialize — every field defaults.
490        let e: TouchEvent = WhiskerValue::map([("type", WhiskerValue::String("touchend".into()))])
491            .deserialize_into()
492            .expect("partial body deserializes");
493        assert_eq!(e.kind, "touchend");
494        assert!(e.touches.is_empty());
495        assert_eq!(e.detail.x, 0.0);
496    }
497
498    #[test]
499    fn custom_event_keeps_opaque_detail() {
500        let v = WhiskerValue::map([(
501            "detail",
502            WhiskerValue::map([("scrollTop", WhiskerValue::Int(42))]),
503        )]);
504        let e: CustomEvent = v.deserialize_into().expect("deserialize CustomEvent");
505        match e.detail {
506            WhiskerValue::Map(m) => assert_eq!(m.get("scrollTop"), Some(&WhiskerValue::Int(42))),
507            other => panic!("expected Map detail, got {other:?}"),
508        }
509    }
510
511    #[test]
512    fn scroll_event_detail_camel_case_mapping() {
513        // Mirrors Lynx's LynxScrollEventManager detail dict.
514        let v = WhiskerValue::map([
515            ("type", WhiskerValue::String("scroll".into())),
516            (
517                "detail",
518                WhiskerValue::map([
519                    ("scrollLeft", WhiskerValue::Float(0.0)),
520                    ("scrollTop", WhiskerValue::Float(120.0)),
521                    ("scrollHeight", WhiskerValue::Float(2000.0)),
522                    ("scrollWidth", WhiskerValue::Float(375.0)),
523                    ("deltaY", WhiskerValue::Float(12.0)),
524                    ("isDragging", WhiskerValue::Bool(true)),
525                ]),
526            ),
527        ]);
528        let e: ScrollEvent = v.deserialize_into().expect("deserialize ScrollEvent");
529        assert_eq!(e.kind, "scroll");
530        assert_eq!(e.detail.scroll_top, 120.0);
531        assert_eq!(e.detail.scroll_height, 2000.0);
532        assert_eq!(e.detail.delta_y, 12.0);
533        assert!(e.detail.is_dragging);
534        // Absent key degrades to default rather than failing.
535        assert_eq!(e.detail.delta_x, 0.0);
536    }
537
538    #[test]
539    fn text_layout_event_nested_lines_and_size() {
540        let v = WhiskerValue::map([
541            ("type", WhiskerValue::String("layout".into())),
542            (
543                "detail",
544                WhiskerValue::map([
545                    ("lineCount", WhiskerValue::Int(2)),
546                    (
547                        "size",
548                        WhiskerValue::map([
549                            ("width", WhiskerValue::Float(300.0)),
550                            ("height", WhiskerValue::Float(40.0)),
551                        ]),
552                    ),
553                    (
554                        "lines",
555                        WhiskerValue::Array(vec![
556                            WhiskerValue::map([
557                                ("start", WhiskerValue::Int(0)),
558                                ("end", WhiskerValue::Int(10)),
559                                ("ellipsisCount", WhiskerValue::Int(0)),
560                            ]),
561                            WhiskerValue::map([
562                                ("start", WhiskerValue::Int(10)),
563                                ("end", WhiskerValue::Int(18)),
564                                ("ellipsisCount", WhiskerValue::Int(3)),
565                            ]),
566                        ]),
567                    ),
568                ]),
569            ),
570        ]);
571        let e: TextLayoutEvent = v.deserialize_into().expect("deserialize TextLayoutEvent");
572        assert_eq!(e.detail.line_count, 2);
573        assert_eq!(e.detail.size.width, 300.0);
574        assert_eq!(e.detail.lines.len(), 2);
575        assert_eq!(e.detail.lines[1].end, 18);
576        assert_eq!(e.detail.lines[1].ellipsis_count, 3);
577    }
578
579    #[test]
580    fn integer_target_signs_dont_blank_the_event() {
581        // The REAL reporter body: `target` / `currentTarget` are plain
582        // integer signs (LynxEvent.generateEventBody), not objects.
583        // Target's int-or-object deserialize must accept that so the
584        // sibling `detail` still populates (a type mismatch here used
585        // to fail the whole struct → all-zero payload).
586        let v = WhiskerValue::map([
587            ("type", WhiskerValue::String("scroll".into())),
588            ("target", WhiskerValue::Int(33)),
589            ("currentTarget", WhiskerValue::Int(33)),
590            (
591                "detail",
592                WhiskerValue::map([
593                    ("scrollLeft", WhiskerValue::Float(640.0)),
594                    ("scrollWidth", WhiskerValue::Float(832.0)),
595                    ("isDragging", WhiskerValue::Bool(true)),
596                ]),
597            ),
598        ]);
599        let e: ScrollEvent = v
600            .deserialize_into()
601            .expect("deserialize with integer target");
602        assert_eq!(e.target.uid, 33); // sign mapped to uid
603        assert_eq!(e.target.id, ""); // raw body carries no id
604        assert_eq!(e.current_target.uid, 33);
605        // The sibling detail must survive the integer target.
606        assert_eq!(e.detail.scroll_left, 640.0);
607        assert_eq!(e.detail.scroll_width, 832.0);
608        assert!(e.detail.is_dragging);
609    }
610
611    #[test]
612    fn touch_event_integer_target() {
613        let v = WhiskerValue::map([
614            ("type", WhiskerValue::String("tap".into())),
615            ("target", WhiskerValue::Int(7)),
616            (
617                "detail",
618                WhiskerValue::map([
619                    ("x", WhiskerValue::Float(10.0)),
620                    ("y", WhiskerValue::Float(20.0)),
621                ]),
622            ),
623        ]);
624        let e: TouchEvent = v
625            .deserialize_into()
626            .expect("deserialize TouchEvent int target");
627        assert_eq!(e.target.uid, 7);
628        assert_eq!(e.detail.x, 10.0);
629    }
630}