Skip to main content

pathland_core/
events.rs

1//! Typed raw-input events (host → guest).
2//!
3//! Events are the **raw inputs** the renderer reports: pointer down/move/up and
4//! key down/up, each carrying a host-resolved `targetId`. High-level
5//! interpretations (tap, drag, long-press) are built by the guest/app from
6//! these raw inputs and are NOT part of the protocol.
7//!
8//! Encode a raw input with `Event::encode`, decode a ring opcode with
9//! `Event::try_from`.
10
11use alloc::string::String;
12use alloc::string::ToString;
13use crate::category;
14use crate::flag;
15use crate::Opcode;
16
17/// A raw-input event (host → guest).
18///
19/// `Event` is **not** `Copy`: [`Event::TextChanged`] owns the new field text
20/// and [`Event::Navigate`] owns an optional URL. Simple events round-trip
21/// through `encode`/`try_from` as pure 16-byte opcodes; `TextChanged` and
22/// `Navigate` with a URL are encoded/decoded at the batch level (their strings
23/// live in the string section / event arena, referenced by a relative/absolute
24/// offset).
25#[derive(Debug, Clone, PartialEq)]
26pub enum Event {
27    /// Pointer button pressed at a viewport-relative point.
28    PointerDown {
29        target: u32,
30        x: f32,
31        y: f32,
32        secondary: bool,
33    },
34    /// Pointer moved; optionally entered/left the node (hover).
35    PointerMove {
36        target: u32,
37        x: f32,
38        y: f32,
39        hovering: bool,
40        leaving: bool,
41    },
42    /// Pointer button released at a viewport-relative point.
43    PointerUp {
44        target: u32,
45        x: f32,
46        y: f32,
47        secondary: bool,
48    },
49    /// Key pressed (possibly an auto-repeat).
50    KeyDown {
51        target: u32,
52        key_code: u16,
53        modifiers: u8,
54        repeat: bool,
55    },
56    /// Key released.
57    KeyUp {
58        target: u32,
59        key_code: u16,
60        modifiers: u8,
61    },
62    /// A control's value changed (host → guest). The renderer resolves the
63    /// semantic value (e.g. a slider thumb dragged along its track) and reports
64    /// it here; the guest/app writes it into its bound state.
65    ValueChanged {
66        target: u32,
67        value: f32,
68    },
69    /// A text field's value changed (host → guest). The new text is resolved
70    /// from the batch's string section (see the doc comment on this enum).
71    TextChanged {
72        target: u32,
73        value: String,
74    },
75    /// A node gained/lost focus (host → guest). Draft command 0x08.
76    FocusChanged { target: u32, focused: bool },
77    /// An editing session began/ended (host → guest). Draft command 0x09.
78    EditingChanged { target: u32, editing: bool },
79    /// The user committed a field (host → guest). Draft command 0x0A.
80    Submit { target: u32 },
81    /// A scroll container's offset changed (host → guest). Draft command 0x0B.
82    Scroll {
83        target: u32,
84        offset_x: f32,
85        offset_y: f32,
86    },
87    /// Raw wheel/trackpad deltas (host → guest). Draft command 0x0C.
88    Wheel {
89        target: u32,
90        delta_x: f32,
91        delta_y: f32,
92    },
93    /// A `DATE_PICKER`'s value changed (host → guest). Draft command 0x0D;
94    /// `days` is I32 (pre-1970 negative), `millis` is millis of day.
95    DateChanged {
96        target: u32,
97        days: i32,
98        millis: u32,
99    },
100    /// A global navigation request (host → guest). Draft command 0x0E; never
101    /// node-keyed. `url: Some(...)` carries the destination URL the platform
102    /// navigated to (browser `popstate`/back/forward/deep-link), resolved from
103    /// the string section / event arena; `url: None` is a native back request.
104    Navigate { url: Option<String> },
105    /// A bound `AUDIO`/`VIDEO` node's playback state changed (host → guest).
106    /// Draft command 0x0F; reported only when `PLAYBACK_STATE` is bound.
107    MediaPlayStateChanged { target: u32, playing: bool },
108    /// A bound media node's current playback position (host → guest). Draft
109    /// command 0x10; `seconds` is the position in seconds (f32), reported while
110    /// playing. An app-initiated seek (`MEDIA_POSITION` change) is not echoed.
111    MediaTimeUpdated { target: u32, seconds: f32 },
112    /// A bound media node reached the end of its media (host → guest). Draft
113    /// command 0x11.
114    MediaEnded { target: u32 },
115    /// A bound media node's volume changed (host → guest). Draft command 0x12;
116    /// `volume` is in the range 0..1 (f32).
117    MediaVolumeChanged { target: u32, volume: f32 },
118    /// A `SIZE_THAT_FITS` slot's selected candidate changed (host → guest).
119    /// Draft command 0x13; `index` is the derived fit index (f32), reported once
120    /// after the first measure and then only when it changes.
121    FitChanged { target: u32, index: f32 },
122}
123
124impl Event {
125    /// Encode this event as a 16-byte opcode.
126    ///
127    /// `TextChanged` and `Navigate` with a URL cannot be fully encoded here —
128    /// their strings live in the batch's string section / event arena — so this
129    /// emits the opcode with a zero `B` offset; the batch-aware
130    /// `encode_events`/`decode_events` pair in `pathland-core-transport` writes
131    /// the string and its real offset.
132    pub fn encode(&self) -> Opcode {
133        match self {
134            Event::PointerDown {
135                target,
136                x,
137                y,
138                secondary,
139            } => Opcode::new(
140                category::EVENT,
141                crate::event::POINTER_DOWN,
142                flag_for(*secondary, false, false),
143                *target,
144                x.to_bits(),
145                y.to_bits(),
146            ),
147            Event::PointerMove {
148                target,
149                x,
150                y,
151                hovering,
152                leaving,
153            } => Opcode::new(
154                category::EVENT,
155                crate::event::POINTER_MOVE,
156                flag_for(false, *hovering, *leaving),
157                *target,
158                x.to_bits(),
159                y.to_bits(),
160            ),
161            Event::PointerUp {
162                target,
163                x,
164                y,
165                secondary,
166            } => Opcode::new(
167                category::EVENT,
168                crate::event::POINTER_UP,
169                flag_for(*secondary, false, false),
170                *target,
171                x.to_bits(),
172                y.to_bits(),
173            ),
174            Event::KeyDown {
175                target,
176                key_code,
177                modifiers,
178                repeat,
179            } => Opcode::new(
180                category::EVENT,
181                crate::event::KEY_DOWN,
182                if *repeat { flag::KEY_REPEAT } else { 0 },
183                *target,
184                *key_code as u32,
185                *modifiers as u32,
186            ),
187            Event::KeyUp {
188                target,
189                key_code,
190                modifiers,
191            } => Opcode::new(
192                category::EVENT,
193                crate::event::KEY_UP,
194                0,
195                *target,
196                *key_code as u32,
197                *modifiers as u32,
198            ),
199            Event::ValueChanged { target, value } => Opcode::new(
200                category::EVENT,
201                crate::event::VALUE_CHANGED,
202                0,
203                *target,
204                value.to_bits(),
205                0,
206            ),
207            Event::TextChanged { target, .. } => Opcode::new(
208                category::EVENT,
209                crate::event::TEXT_CHANGED,
210                0,
211                *target,
212                0,
213                0,
214            ),
215            Event::FocusChanged { target, focused } => Opcode::new(
216                category::EVENT,
217                crate::event::FOCUS_CHANGED,
218                0,
219                *target,
220                u32::from(*focused),
221                0,
222            ),
223            Event::EditingChanged { target, editing } => Opcode::new(
224                category::EVENT,
225                crate::event::EDITING_CHANGED,
226                0,
227                *target,
228                u32::from(*editing),
229                0,
230            ),
231            Event::Submit { target } => Opcode::new(
232                category::EVENT,
233                crate::event::SUBMIT,
234                0,
235                *target,
236                0,
237                0,
238            ),
239            Event::Scroll {
240                target,
241                offset_x,
242                offset_y,
243            } => Opcode::new(
244                category::EVENT,
245                crate::event::SCROLL,
246                0,
247                *target,
248                offset_x.to_bits(),
249                offset_y.to_bits(),
250            ),
251            Event::Wheel {
252                target,
253                delta_x,
254                delta_y,
255            } => Opcode::new(
256                category::EVENT,
257                crate::event::WHEEL,
258                0,
259                *target,
260                delta_x.to_bits(),
261                delta_y.to_bits(),
262            ),
263            Event::DateChanged {
264                target,
265                days,
266                millis,
267            } => Opcode::new(
268                category::EVENT,
269                crate::event::DATE_CHANGED,
270                0,
271                *target,
272                *days as u32,
273                *millis,
274            ),
275            Event::Navigate { url } => Opcode::new(
276                category::EVENT,
277                crate::event::NAVIGATE,
278                if url.is_some() { flag::NAVIGATE_URL } else { 0 },
279                0,
280                0,
281                0,
282            ),
283            Event::MediaPlayStateChanged { target, playing } => Opcode::new(
284                category::EVENT,
285                crate::event::MEDIA_PLAY_STATE_CHANGED,
286                0,
287                *target,
288                u32::from(*playing),
289                0,
290            ),
291            Event::MediaTimeUpdated { target, seconds } => Opcode::new(
292                category::EVENT,
293                crate::event::MEDIA_TIME_UPDATED,
294                0,
295                *target,
296                seconds.to_bits(),
297                0,
298            ),
299            Event::MediaEnded { target } => Opcode::new(
300                category::EVENT,
301                crate::event::MEDIA_ENDED,
302                0,
303                *target,
304                0,
305                0,
306            ),
307            Event::MediaVolumeChanged { target, volume } => Opcode::new(
308                category::EVENT,
309                crate::event::MEDIA_VOLUME_CHANGED,
310                0,
311                *target,
312                volume.to_bits(),
313                0,
314            ),
315            Event::FitChanged { target, index } => Opcode::new(
316                category::EVENT,
317                crate::event::FIT_CHANGED,
318                0,
319                *target,
320                index.to_bits(),
321                0,
322            ),
323        }
324    }
325}
326
327/// Build a pointer-opcode flags word from the pointer/hover bits.
328#[inline]
329fn flag_for(secondary: bool, hovering: bool, leaving: bool) -> u16 {
330    let mut flags = 0u16;
331    if secondary {
332        flags |= flag::POINTER_SECONDARY;
333    }
334    if hovering {
335        flags |= flag::HOVER_ENTER;
336    }
337    if leaving {
338        flags |= flag::HOVER_LEAVE;
339    }
340    flags
341}
342
343/// Error decoding a raw-input event from an opcode.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub enum EventError {
346    /// The opcode is not an `EVENT` category opcode.
347    NotAnEvent,
348    /// The opcode is an EVENT category opcode but an unknown command.
349    UnknownCommand,
350    /// The opcode is `TEXT_CHANGED`, which needs the batch's string section to
351    /// resolve its text; decode it with `pathland-core-transport::decode_events`
352    /// instead of a bare opcode.
353    NeedsStringSection,
354}
355
356impl TryFrom<Opcode> for Event {
357    type Error = EventError;
358
359    fn try_from(op: Opcode) -> Result<Self, Self::Error> {
360        if op.category() != category::EVENT {
361            return Err(EventError::NotAnEvent);
362        }
363        let flags = op.flags();
364        match op.command() {
365            crate::event::POINTER_DOWN => Ok(Event::PointerDown {
366                target: op.a(),
367                x: op.b_f32(),
368                y: op.c_f32(),
369                secondary: flags & flag::POINTER_SECONDARY != 0,
370            }),
371            crate::event::POINTER_MOVE => Ok(Event::PointerMove {
372                target: op.a(),
373                x: op.b_f32(),
374                y: op.c_f32(),
375                hovering: flags & flag::HOVER_ENTER != 0,
376                leaving: flags & flag::HOVER_LEAVE != 0,
377            }),
378            crate::event::POINTER_UP => Ok(Event::PointerUp {
379                target: op.a(),
380                x: op.b_f32(),
381                y: op.c_f32(),
382                secondary: flags & flag::POINTER_SECONDARY != 0,
383            }),
384            crate::event::KEY_DOWN => Ok(Event::KeyDown {
385                target: op.a(),
386                key_code: op.b() as u16,
387                modifiers: op.c() as u8,
388                repeat: flags & flag::KEY_REPEAT != 0,
389            }),
390            crate::event::KEY_UP => Ok(Event::KeyUp {
391                target: op.a(),
392                key_code: op.b() as u16,
393                modifiers: op.c() as u8,
394            }),
395            crate::event::VALUE_CHANGED => Ok(Event::ValueChanged {
396                target: op.a(),
397                value: op.b_f32(),
398            }),
399            crate::event::FOCUS_CHANGED => Ok(Event::FocusChanged {
400                target: op.a(),
401                focused: op.b() != 0,
402            }),
403            crate::event::EDITING_CHANGED => Ok(Event::EditingChanged {
404                target: op.a(),
405                editing: op.b() != 0,
406            }),
407            crate::event::SUBMIT => Ok(Event::Submit { target: op.a() }),
408            crate::event::SCROLL => Ok(Event::Scroll {
409                target: op.a(),
410                offset_x: op.b_f32(),
411                offset_y: op.c_f32(),
412            }),
413            crate::event::WHEEL => Ok(Event::Wheel {
414                target: op.a(),
415                delta_x: op.b_f32(),
416                delta_y: op.c_f32(),
417            }),
418            crate::event::DATE_CHANGED => Ok(Event::DateChanged {
419                target: op.a(),
420                days: op.b() as i32,
421                millis: op.c(),
422            }),
423            crate::event::TEXT_CHANGED => Err(EventError::NeedsStringSection),
424            crate::event::NAVIGATE => {
425                if flags & flag::NAVIGATE_URL != 0 {
426                    Err(EventError::NeedsStringSection)
427                } else {
428                    Ok(Event::Navigate { url: None })
429                }
430            }
431            crate::event::MEDIA_PLAY_STATE_CHANGED => Ok(Event::MediaPlayStateChanged {
432                target: op.a(),
433                playing: op.b() != 0,
434            }),
435            crate::event::MEDIA_TIME_UPDATED => Ok(Event::MediaTimeUpdated {
436                target: op.a(),
437                seconds: op.b_f32(),
438            }),
439            crate::event::MEDIA_ENDED => Ok(Event::MediaEnded { target: op.a() }),
440            crate::event::MEDIA_VOLUME_CHANGED => Ok(Event::MediaVolumeChanged {
441                target: op.a(),
442                volume: op.b_f32(),
443            }),
444            crate::event::FIT_CHANGED => Ok(Event::FitChanged {
445                target: op.a(),
446                index: op.b_f32(),
447            }),
448            _ => Err(EventError::UnknownCommand),
449        }
450    }
451}
452
453/// Decode an event opcode for the **shared-memory path**, resolving
454/// `TEXT_CHANGED` text and `NAVIGATE` URLs from the host → guest event arena
455/// (`B` is an absolute offset into that region). Non-string events decode as
456/// usual.
457///
458/// Returns `None` for non-event opcodes, unknown commands, or string events
459/// whose offset cannot be resolved (invalid or out of range).
460pub fn decode_event(op: &Opcode, event_arena: &[u8]) -> Option<Event> {
461    if op.category() != category::EVENT {
462        return None;
463    }
464    if op.command() == crate::event::TEXT_CHANGED {
465        let text = crate::arena::get_str(event_arena, op.b()).ok()?;
466        return Some(Event::TextChanged {
467            target: op.a(),
468            value: text.to_string(),
469        });
470    }
471    if op.command() == crate::event::NAVIGATE && op.flags() & flag::NAVIGATE_URL != 0 {
472        let url = crate::arena::get_str(event_arena, op.b()).ok()?;
473        return Some(Event::Navigate {
474            url: Some(url.to_string()),
475        });
476    }
477    Event::try_from(*op).ok()
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn round_trip(e: Event) -> Event {
485        Event::try_from(Opcode::from_bytes(&e.encode().to_bytes())).unwrap()
486    }
487
488    #[test]
489    fn pointer_down_round_trips() {
490        let e = Event::PointerDown {
491            target: 5,
492            x: 10.0,
493            y: 20.0,
494            secondary: true,
495        };
496        assert_eq!(round_trip(e.clone()), e);
497    }
498
499    #[test]
500    fn pointer_move_round_trips_with_hover_flags() {
501        let e = Event::PointerMove {
502            target: 5,
503            x: 10.0,
504            y: 20.0,
505            hovering: true,
506            leaving: false,
507        };
508        assert_eq!(round_trip(e.clone()), e);
509
510        let leaving = Event::PointerMove {
511            target: 5,
512            x: 10.0,
513            y: 20.0,
514            hovering: false,
515            leaving: true,
516        };
517        assert_eq!(round_trip(leaving.clone()), leaving);
518    }
519
520    #[test]
521    fn pointer_up_round_trips() {
522        let e = Event::PointerUp {
523            target: 5,
524            x: 10.0,
525            y: 20.0,
526            secondary: false,
527        };
528        assert_eq!(round_trip(e.clone()), e);
529    }
530
531    #[test]
532    fn key_down_round_trips_with_repeat() {
533        let e = Event::KeyDown {
534            target: 5,
535            key_code: 0x41,
536            modifiers: 0x01,
537            repeat: true,
538        };
539        assert_eq!(round_trip(e.clone()), e);
540    }
541
542    #[test]
543    fn key_up_round_trips() {
544        let e = Event::KeyUp {
545            target: 5,
546            key_code: 0x41,
547            modifiers: 0x01,
548        };
549        assert_eq!(round_trip(e.clone()), e);
550    }
551
552    #[test]
553    fn value_changed_round_trips() {
554        let e = Event::ValueChanged {
555            target: 7,
556            value: 0.625,
557        };
558        assert_eq!(round_trip(e.clone()), e);
559    }
560
561    #[test]
562    fn draft_events_round_trip() {
563        for e in [
564            Event::FocusChanged { target: 1, focused: true },
565            Event::FocusChanged { target: 1, focused: false },
566            Event::EditingChanged { target: 2, editing: true },
567            Event::Submit { target: 3 },
568            Event::Scroll { target: 4, offset_x: 10.0, offset_y: 20.5 },
569            Event::Wheel { target: 5, delta_x: -1.5, delta_y: 2.0 },
570            Event::DateChanged { target: 6, days: 19_723, millis: 3_600_000 },
571            Event::DateChanged { target: 6, days: -1, millis: 0 },
572        ] {
573            assert_eq!(round_trip(e.clone()), e);
574        }
575    }
576
577    #[test]
578    fn media_events_round_trip() {
579        for e in [
580            Event::MediaPlayStateChanged { target: 7, playing: true },
581            Event::MediaPlayStateChanged { target: 7, playing: false },
582            Event::MediaTimeUpdated { target: 7, seconds: 12.5 },
583            Event::MediaEnded { target: 7 },
584            Event::MediaVolumeChanged { target: 7, volume: 0.5 },
585            Event::FitChanged { target: 42, index: 2.0 },
586        ] {
587            assert_eq!(round_trip(e.clone()), e);
588        }
589        // Conformance vector bytes (spec/CONFORMANCE.md vectors 23–26, 26a).
590        assert_eq!(
591            Event::MediaPlayStateChanged { target: 7, playing: true }.encode().to_bytes(),
592            [0x03, 0x0F, 0, 0, 7, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]
593        );
594        assert_eq!(
595            Event::MediaTimeUpdated { target: 7, seconds: 12.5 }.encode().to_bytes(),
596            [0x03, 0x10, 0, 0, 7, 0, 0, 0, 0, 0, 0x48, 0x41, 0, 0, 0, 0]
597        );
598        assert_eq!(
599            Event::MediaEnded { target: 7 }.encode().to_bytes(),
600            [0x03, 0x11, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
601        );
602        assert_eq!(
603            Event::MediaVolumeChanged { target: 7, volume: 0.5 }.encode().to_bytes(),
604            [0x03, 0x12, 0, 0, 7, 0, 0, 0, 0, 0, 0x00, 0x3F, 0, 0, 0, 0]
605        );
606        assert_eq!(
607            Event::FitChanged { target: 42, index: 2.0 }.encode().to_bytes(),
608            [0x03, 0x13, 0, 0, 0x2A, 0, 0, 0, 0, 0, 0, 0x40, 0, 0, 0, 0]
609        );
610    }
611
612    #[test]
613    fn non_event_opcode_is_rejected() {
614        let op = Opcode::new(crate::category::TREE, 0x01, 0, 1, 0, 0);
615        assert_eq!(Event::try_from(op), Err(EventError::NotAnEvent));
616    }
617
618    #[test]
619    fn unknown_event_command_is_rejected() {
620        // EVENT category with a command that does not exist in the protocol.
621        let op = Opcode::new(crate::category::EVENT, 0xFF, 0, 1, 0, 0);
622        assert_eq!(Event::try_from(op), Err(EventError::UnknownCommand));
623    }
624
625    #[test]
626    fn text_changed_needs_string_section() {
627        // `TextChanged` is only decodable from a batch (its text lives in the
628        // string section); a bare opcode cannot resolve it.
629        let op = Opcode::new(crate::category::EVENT, crate::event::TEXT_CHANGED, 0, 7, 0, 0);
630        assert_eq!(Event::try_from(op), Err(EventError::NeedsStringSection));
631    }
632
633    #[test]
634    fn navigate_back_round_trips() {
635        let e = Event::Navigate { url: None };
636        assert_eq!(round_trip(e.clone()), e);
637    }
638
639    #[test]
640    fn navigate_with_url_needs_string_section() {
641        // `Navigate` with a URL is only decodable where the URL can be resolved
642        // (batch string section / shared-memory event arena); a bare opcode
643        // cannot resolve it.
644        let op = Opcode::new(
645            crate::category::EVENT,
646            crate::event::NAVIGATE,
647            flag::NAVIGATE_URL,
648            0,
649            0,
650            0,
651        );
652        assert_eq!(Event::try_from(op), Err(EventError::NeedsStringSection));
653    }
654
655    #[test]
656    fn navigate_back_matches_conformance_vector_bytes() {
657        // EVENT:NAVIGATE (back, no URL) — vector 22.
658        let e = Event::Navigate { url: None };
659        assert_eq!(
660            e.encode().to_bytes(),
661            [0x03, 0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
662        );
663    }
664
665    #[test]
666    fn decode_event_resolves_navigate_url_from_arena() {
667        // The shared-memory path resolves a `Navigate` URL from the event arena.
668        use alloc::vec;
669        let mut arena = vec![0u8; 32];
670        // "[u32 len=9][/users/42]" at offset 0.
671        arena[0..4].copy_from_slice(&9u32.to_le_bytes());
672        arena[4..13].copy_from_slice(b"/users/42");
673        let op = Opcode::new(
674            crate::category::EVENT,
675            crate::event::NAVIGATE,
676            flag::NAVIGATE_URL,
677            0,
678            0,
679            0,
680        );
681        assert_eq!(
682            decode_event(&op, &arena),
683            Some(Event::Navigate {
684                url: Some("/users/42".into())
685            })
686        );
687    }
688
689    #[test]
690    fn pointer_move_round_trips_with_combined_hover_flags() {
691        // Both hovering and leaving set simultaneously must survive the round trip.
692        let e = Event::PointerMove {
693            target: 5,
694            x: 10.0,
695            y: 20.0,
696            hovering: true,
697            leaving: true,
698        };
699        assert_eq!(round_trip(e.clone()), e);
700        // The flags word carries both bits.
701        assert_eq!(e.encode().flags(), flag::HOVER_ENTER | flag::HOVER_LEAVE);
702    }
703
704    #[test]
705    fn matches_conformance_vector_bytes() {
706        // EVENT:POINTER_DOWN (target=5, x=10.0, y=20.0)
707        let down = Event::PointerDown {
708            target: 5,
709            x: 10.0,
710            y: 20.0,
711            secondary: false,
712        };
713        assert_eq!(
714            down.encode().to_bytes(),
715            [
716                0x03, 0x01, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x41, 0x00,
717                0x00, 0xA0, 0x41,
718            ]
719        );
720    }
721}