Skip to main content

rdom_core/
event_detail.rs

1//! Event-detail taxonomy — the typed payload types that travel
2//! inside [`Event::detail`].
3//!
4//! ## Substrate rule
5//!
6//! These types live in `rdom-core` deliberately. `event.detail`
7//! is the single canonical carrier for typed key / mouse data
8//! after M4a step 8; that means the carrier types must live in
9//! the substrate, not in `rdom-tui` next to crossterm. A
10//! `crossterm::event::KeyCode → key: String` translation
11//! helper at the `rdom-tui` input boundary (M4a step 7) is the
12//! seam.
13//!
14//! ## Web fidelity
15//!
16//! - `MouseButton` mirrors the numeric `MouseEvent.button` values
17//!   defined at <https://www.w3.org/TR/uievents/#dom-mouseevent-button>.
18//! - `KeyboardModifiers` mirrors the four boolean accessors
19//!   `ctrlKey` / `shiftKey` / `altKey` / `metaKey` on
20//!   `KeyboardEvent` and `MouseEvent`.
21//! - `InputType` mirrors a named subset of
22//!   <https://w3c.github.io/input-events/#dom-inputevent-inputtype>,
23//!   with an `Other(String)` escape hatch.
24//! - `ToggleState` is the open/closed state shared by `<details>`
25//!   and `<dialog>` `toggle` events.
26
27/// DOM `MouseEvent.button` mapping.
28///
29/// DOM terminology calls button 1 (the middle/wheel button) the
30/// "auxiliary button"; our [`MouseButton::Middle`] variant carries
31/// that. Buttons 3+ (typically browser back/forward, then
32/// vendor-defined) fall into [`MouseButton::Other`].
33///
34/// Spec: <https://www.w3.org/TR/uievents/#dom-mouseevent-button>.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
36pub enum MouseButton {
37    /// Primary button (numeric value `0`). Usually the left button,
38    /// or the un-initialized state for events that don't carry a
39    /// button press.
40    Left,
41    /// Auxiliary button (numeric value `1`). Usually the
42    /// middle / wheel button.
43    Middle,
44    /// Secondary button (numeric value `2`). Usually the right
45    /// button.
46    Right,
47    /// Buttons 3 and above. Typically browser back (3) and
48    /// browser forward (4); 5+ is vendor-defined.
49    Other(i16),
50}
51
52/// Four-boolean modifier set, matching the
53/// `KeyboardEvent.{ctrl,shift,alt,meta}Key` and
54/// `MouseEvent.{ctrl,shift,alt,meta}Key` accessor shape.
55///
56/// `Default` is all-`false` — convenient for tests that synthesize
57/// events without modifiers.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
59pub struct KeyboardModifiers {
60    pub ctrl: bool,
61    pub shift: bool,
62    pub alt: bool,
63    pub meta: bool,
64}
65
66/// UI Events `InputEvent.inputType` value.
67///
68/// The enumerated variants are the named values from the spec's
69/// "Input Events Level 2" `inputType` attribute table that rdom
70/// actually emits. Anything outside the named list — typically
71/// composition events, formatting commands, or vendor extensions
72/// rdom doesn't model — falls into [`InputType::Other`] carrying
73/// the raw string.
74///
75/// Spec: <https://w3c.github.io/input-events/#dom-inputevent-inputtype>.
76#[derive(Debug, Clone, PartialEq, Eq, Hash)]
77pub enum InputType {
78    InsertText,
79    InsertReplacementText,
80    InsertLineBreak,
81    InsertParagraph,
82    InsertFromPaste,
83    InsertFromDrop,
84    DeleteContentBackward,
85    DeleteContentForward,
86    DeleteByCut,
87    DeleteWordBackward,
88    DeleteWordForward,
89    HistoryUndo,
90    HistoryRedo,
91    /// Catches anything not in the enumerated list.
92    Other(String),
93}
94
95/// Open/closed state for `<details>` and `<dialog>` `toggle`
96/// event detail.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98pub enum ToggleState {
99    Open,
100    Closed,
101}
102
103// ── EventDetail ─────────────────────────────────────────────────
104
105use crate::node_id::NodeId;
106
107/// Typed payload carried on [`Event::detail`](crate::Event#structfield.detail).
108///
109/// Replaces the pre-M4 `Option<String>` detail with a closed enum
110/// of typed variants. The [`EventDetail::String`] variant
111/// preserves the one-shot escape hatch authors used to lean on via
112/// `Event::new("custom").with_detail("payload")`.
113///
114/// ## Variant boxing
115///
116/// Variants whose largest field is a [`String`] are boxed; inline
117/// for fixed-size variants. This keeps the enum under 32 bytes on
118/// 64-bit (a const-assert below enforces that).
119///
120/// ## Accessor pattern
121///
122/// Listeners read typed detail through the `as_*` accessors:
123///
124/// ```
125/// # use rdom_core::{Event, EventDetail};
126/// let mut e = Event::new("custom");
127/// e.detail = EventDetail::String("payload".into());
128/// assert_eq!(e.detail.as_string(), Some("payload"));
129/// ```
130#[derive(Debug, Clone, PartialEq, Default)]
131#[non_exhaustive]
132pub enum EventDetail {
133    /// No detail attached. Default for plain events (`click`,
134    /// `focus`, `blur`, …) and the initial state on `Event::new`.
135    #[default]
136    None,
137    /// Free-form string payload. Used for `CustomEvent`-style
138    /// author-fired events; also the migration target for any
139    /// pre-M4 reader that stored a string in `Option<String>`.
140    String(String),
141    /// `transitionstart` / `transitionend` / `transitioncancel`
142    /// payload — emitted by `runtime::animation`.
143    Transition(Box<TransitionDetail>),
144    /// `beforeinput` / `input` event payload — emitted by
145    /// `<input>` / `<textarea>` and contenteditable elements.
146    Input(Box<InputDetail>),
147    /// `submit` event payload — emitted by `<form>`.
148    Submit(Box<SubmitDetail>),
149    /// `toggle` event payload — emitted by `<details>` and
150    /// `<dialog>`.
151    Toggle(Box<ToggleDetail>),
152    /// Pointer event payload — `click` / `mousedown` / `mouseup`
153    /// / `mousemove` / `wheel` / `contextmenu`. Inline; the
154    /// struct is fixed-size.
155    Mouse(MouseDetail),
156    /// `keydown` / `keypress` / `keyup` payload. Boxed because
157    /// the inner `key: String` would otherwise push the enum
158    /// past its 32-byte budget.
159    Keyboard(Box<KeyboardDetail>),
160}
161
162/// Permanent regression guard for [`EventDetail`]'s size budget.
163/// Failure means a redesign is needed (likely boxing the variant
164/// that grew).
165#[cfg(target_pointer_width = "64")]
166const _: () = assert!(std::mem::size_of::<EventDetail>() <= 32);
167
168impl EventDetail {
169    /// Borrow the payload as a `&str` iff this is
170    /// [`EventDetail::String`]. Returns `None` for every other
171    /// variant — typed-detail readers should use the matching
172    /// `as_*` accessor instead.
173    pub fn as_string(&self) -> Option<&str> {
174        match self {
175            EventDetail::String(s) => Some(s),
176            _ => None,
177        }
178    }
179
180    /// Borrow the transition payload iff this is
181    /// [`EventDetail::Transition`].
182    pub fn as_transition(&self) -> Option<&TransitionDetail> {
183        match self {
184            EventDetail::Transition(t) => Some(t),
185            _ => None,
186        }
187    }
188
189    /// Borrow the input payload iff this is
190    /// [`EventDetail::Input`].
191    pub fn as_input(&self) -> Option<&InputDetail> {
192        match self {
193            EventDetail::Input(i) => Some(i),
194            _ => None,
195        }
196    }
197
198    /// Borrow the submit payload iff this is
199    /// [`EventDetail::Submit`].
200    pub fn as_submit(&self) -> Option<&SubmitDetail> {
201        match self {
202            EventDetail::Submit(s) => Some(s),
203            _ => None,
204        }
205    }
206
207    /// Borrow the toggle payload iff this is
208    /// [`EventDetail::Toggle`].
209    pub fn as_toggle(&self) -> Option<&ToggleDetail> {
210        match self {
211            EventDetail::Toggle(t) => Some(t),
212            _ => None,
213        }
214    }
215
216    /// Borrow the mouse payload iff this is
217    /// [`EventDetail::Mouse`].
218    pub fn as_mouse(&self) -> Option<&MouseDetail> {
219        match self {
220            EventDetail::Mouse(m) => Some(m),
221            _ => None,
222        }
223    }
224
225    /// Borrow the keyboard payload iff this is
226    /// [`EventDetail::Keyboard`].
227    pub fn as_keyboard(&self) -> Option<&KeyboardDetail> {
228        match self {
229            EventDetail::Keyboard(k) => Some(k),
230            _ => None,
231        }
232    }
233}
234
235/// `transitionstart` / `transitionend` / `transitioncancel` event
236/// payload. CSS Transitions Level 1 §5.1.
237#[derive(Debug, Clone, PartialEq)]
238#[non_exhaustive]
239pub struct TransitionDetail {
240    /// Animatable property whose value crossed a transition
241    /// boundary, in CSS-canonical kebab-case (`"color"`,
242    /// `"background-color"`, …).
243    pub property_name: String,
244    /// Time elapsed since the transition started, in seconds.
245    /// For `transitionstart`, always 0.0.
246    pub elapsed: f64,
247    /// Pseudo-element associated with the transition, or
248    /// `None` if the transition is on the element itself.
249    pub pseudo_element: Option<String>,
250}
251
252impl TransitionDetail {
253    /// A transition event payload (`TransitionEventInit`): the property
254    /// in CSS kebab-case, the elapsed seconds and the pseudo-element
255    /// (`"::before"`), if any.
256    pub fn new(
257        property_name: impl Into<String>,
258        elapsed: f64,
259        pseudo_element: Option<String>,
260    ) -> Self {
261        Self {
262            property_name: property_name.into(),
263            elapsed,
264            pseudo_element,
265        }
266    }
267}
268
269/// `beforeinput` / `input` event payload, per UI Events / Input
270/// Events Level 2.
271#[derive(Debug, Clone, PartialEq, Eq, Hash)]
272#[non_exhaustive]
273pub struct InputDetail {
274    /// What kind of edit produced this event. See [`InputType`].
275    pub input_type: InputType,
276    /// Text being inserted, or `None` for deletion-style events.
277    pub data: Option<String>,
278    /// `true` if this event fires as part of an IME composition
279    /// sequence. rdom doesn't model IME directly; always `false`
280    /// in M4. Reserved for future polish.
281    pub is_composing: bool,
282}
283
284impl InputDetail {
285    /// An input event payload (`InputEventInit`) outside a composition
286    /// (`is_composing` false).
287    pub fn new(input_type: InputType, data: Option<String>) -> Self {
288        Self {
289            input_type,
290            data,
291            is_composing: false,
292        }
293    }
294}
295
296/// `submit` event payload, per HTML §4.10.21.3 form submission.
297///
298/// rdom has no navigation, so nothing is submitted *to* anything: the
299/// payload carries what a browser would have submitted with — the
300/// submitter and the effective `action` / `method` / `enctype` /
301/// `target` / no-validate state, each the submitter's `form*` override
302/// when it has one, else the form's attribute (HTML §4.10.19.6). Build
303/// it with [`Dom::submit_detail`](crate::Dom::submit_detail).
304///
305/// `#[non_exhaustive]`: construct with [`SubmitDetail::new`] or
306/// `Dom::submit_detail`, then set fields.
307#[derive(Debug, Clone, PartialEq, Eq, Hash)]
308#[non_exhaustive]
309pub struct SubmitDetail {
310    /// The element that triggered submission (the `<button>` /
311    /// `<input type="submit">` that was activated, or the form's default
312    /// button on implicit submission), or `None` when the form itself
313    /// submitted: implicit submission from a form with no submit button,
314    /// or `form.requestSubmit()` without a submitter.
315    pub submitter: Option<NodeId>,
316    /// The submitter's `formaction`, else the form's `action` attribute,
317    /// else `""`. The raw attribute value: rdom has no document URL to
318    /// resolve it against (DIVERGENCES).
319    pub action: String,
320    /// The submitter's `formmethod`, else the form's `method` (missing
321    /// and invalid values are [`FormMethod::Get`]).
322    pub method: FormMethod,
323    /// The submitter's `formenctype`, else the form's `enctype` (missing
324    /// and invalid values are [`FormEnctype::UrlEncoded`]).
325    pub enctype: FormEnctype,
326    /// The submitter's `formtarget`, else the form's `target`, else `""`.
327    pub target: String,
328    /// The submitter's no-validate state: it has `formnovalidate`, or the
329    /// form has `novalidate`. Constraint validation is skipped when set.
330    pub no_validate: bool,
331}
332
333impl SubmitDetail {
334    /// A payload for `submitter` with every other field at its HTML
335    /// default: empty `action` / `target`, [`FormMethod::Get`],
336    /// [`FormEnctype::UrlEncoded`], validation on. Prefer
337    /// [`Dom::submit_detail`](crate::Dom::submit_detail), which reads the
338    /// form and submitter attributes.
339    pub fn new(submitter: Option<NodeId>) -> Self {
340        Self {
341            submitter,
342            action: String::new(),
343            method: FormMethod::Get,
344            enctype: FormEnctype::UrlEncoded,
345            target: String::new(),
346            no_validate: false,
347        }
348    }
349}
350
351/// A form's submission method — the `method` / `formmethod` enumerated
352/// attribute (HTML §4.10.19.6). Keywords are ASCII case-insensitive;
353/// the missing and invalid value default is `Get`.
354#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
355#[non_exhaustive]
356pub enum FormMethod {
357    /// `get` — the default.
358    #[default]
359    Get,
360    /// `post`.
361    Post,
362    /// `dialog` — submission closes the form's nearest ancestor
363    /// `<dialog>` (HTML §4.10.21.3 step "method is dialog").
364    Dialog,
365}
366
367impl FormMethod {
368    /// Parse an attribute value; anything but `post` / `dialog` (ASCII
369    /// case-insensitive) is `Get`.
370    pub fn from_attribute(value: &str) -> Self {
371        if value.eq_ignore_ascii_case("post") {
372            Self::Post
373        } else if value.eq_ignore_ascii_case("dialog") {
374            Self::Dialog
375        } else {
376            Self::Get
377        }
378    }
379
380    /// The canonical keyword (`"get"`, `"post"`, `"dialog"`) — the
381    /// `form.method` IDL value.
382    pub fn as_str(self) -> &'static str {
383        match self {
384            Self::Get => "get",
385            Self::Post => "post",
386            Self::Dialog => "dialog",
387        }
388    }
389}
390
391/// A form's entry-list encoding — the `enctype` / `formenctype`
392/// enumerated attribute (HTML §4.10.19.6). Keywords are ASCII
393/// case-insensitive; the missing and invalid value default is
394/// `UrlEncoded`.
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
396#[non_exhaustive]
397pub enum FormEnctype {
398    /// `application/x-www-form-urlencoded` — the default.
399    #[default]
400    UrlEncoded,
401    /// `multipart/form-data`.
402    MultipartFormData,
403    /// `text/plain`.
404    TextPlain,
405}
406
407impl FormEnctype {
408    /// Parse an attribute value; unknown values are `UrlEncoded`.
409    pub fn from_attribute(value: &str) -> Self {
410        if value.eq_ignore_ascii_case("multipart/form-data") {
411            Self::MultipartFormData
412        } else if value.eq_ignore_ascii_case("text/plain") {
413            Self::TextPlain
414        } else {
415            Self::UrlEncoded
416        }
417    }
418
419    /// The canonical MIME type — the `form.enctype` IDL value.
420    pub fn as_str(self) -> &'static str {
421        match self {
422            Self::UrlEncoded => "application/x-www-form-urlencoded",
423            Self::MultipartFormData => "multipart/form-data",
424            Self::TextPlain => "text/plain",
425        }
426    }
427}
428
429/// `toggle` event payload — emitted by `<details>` and
430/// `<dialog>` when their open/closed state changes.
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
432#[non_exhaustive]
433pub struct ToggleDetail {
434    /// State before the toggle.
435    pub old_state: ToggleState,
436    /// State after the toggle.
437    pub new_state: ToggleState,
438}
439
440impl ToggleDetail {
441    /// A `toggle` payload (`ToggleEventInit`).
442    pub const fn new(old_state: ToggleState, new_state: ToggleState) -> Self {
443        Self {
444            old_state,
445            new_state,
446        }
447    }
448}
449
450/// Pointer event payload — `click`, `mousedown`, `mouseup`,
451/// `mousemove`, `wheel`, `contextmenu`.
452///
453/// Coordinates are in cell units (column / row) — terminals
454/// don't have subpixel positioning. `client_x` / `client_y`
455/// match the DOM `MouseEvent` field names regardless.
456///
457/// `wheel` events fold `WheelEvent` into the same struct: `delta_x`
458/// / `delta_y` are populated for `wheel` (positive = right / down,
459/// per DOM `WheelEvent.deltaX` / `deltaY`) and `0` for all other
460/// pointer events. Keeping one struct simplifies the substrate;
461/// `delta_z` and `delta_mode` are omitted because terminals don't
462/// surface them.
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
464#[non_exhaustive]
465pub struct MouseDetail {
466    /// Which button transitioned (for press/release/click), or
467    /// `MouseButton::Left` (DOM "main button" sentinel for `0`)
468    /// when no button is meaningful (e.g., `mousemove`, `wheel`).
469    pub button: MouseButton,
470    /// Bitmask of buttons currently held — bit 0 = Left, bit 1 =
471    /// Right, bit 2 = Middle. Matches the DOM `MouseEvent.buttons`
472    /// bitfield.
473    pub buttons: u8,
474    /// Column in cells. DOM `MouseEvent.clientX` analog.
475    pub client_x: i32,
476    /// Row in cells. DOM `MouseEvent.clientY` analog.
477    pub client_y: i32,
478    /// Horizontal wheel delta in cell units (positive = scroll
479    /// right). `0` for non-`wheel` events. DOM `WheelEvent.deltaX`.
480    pub delta_x: i32,
481    /// Vertical wheel delta in cell units (positive = scroll
482    /// down). `0` for non-`wheel` events. DOM `WheelEvent.deltaY`.
483    pub delta_y: i32,
484    /// Modifiers held when the event fired.
485    pub modifiers: KeyboardModifiers,
486}
487
488impl MouseDetail {
489    /// A pointer payload (`MouseEventInit`) at cell (`client_x`,
490    /// `client_y`): `button` transitioned, no button held, no wheel
491    /// delta, no modifier. The `with_*` builders set the rest.
492    pub const fn new(button: MouseButton, client_x: i32, client_y: i32) -> Self {
493        Self {
494            button,
495            buttons: 0,
496            client_x,
497            client_y,
498            delta_x: 0,
499            delta_y: 0,
500            modifiers: KeyboardModifiers {
501                ctrl: false,
502                shift: false,
503                alt: false,
504                meta: false,
505            },
506        }
507    }
508
509    /// Set the held-buttons bitmask (`MouseEvent.buttons`).
510    pub const fn with_buttons(mut self, buttons: u8) -> Self {
511        self.buttons = buttons;
512        self
513    }
514
515    /// Set the wheel deltas (`WheelEvent.deltaX` / `deltaY`).
516    pub const fn with_delta(mut self, delta_x: i32, delta_y: i32) -> Self {
517        self.delta_x = delta_x;
518        self.delta_y = delta_y;
519        self
520    }
521
522    /// Set the modifiers held.
523    pub const fn with_modifiers(mut self, modifiers: KeyboardModifiers) -> Self {
524        self.modifiers = modifiers;
525        self
526    }
527}
528
529/// `keydown` / `keypress` / `keyup` payload.
530#[derive(Debug, Clone, PartialEq, Eq, Hash)]
531#[non_exhaustive]
532pub struct KeyboardDetail {
533    /// DOM `KeyboardEvent.key` — the printable character or named
534    /// key value (`"Enter"`, `"ArrowLeft"`, `"a"`, `"F5"`, …).
535    /// Translation from `crossterm::KeyCode` lives in
536    /// `rdom-tui::tui_event::key_translate` (M4a step 7).
537    pub key: String,
538    /// Modifiers held during the press.
539    pub modifiers: KeyboardModifiers,
540    /// `true` for OS-generated repeats of a held key.
541    pub repeat: bool,
542}
543
544impl KeyboardDetail {
545    /// A key payload (`KeyboardEventInit`) for `key` with `modifiers`,
546    /// not a repeat.
547    pub fn new(key: impl Into<String>, modifiers: KeyboardModifiers) -> Self {
548        Self {
549            key: key.into(),
550            modifiers,
551            repeat: false,
552        }
553    }
554
555    /// Set `repeat` (an OS-generated repeat of a held key).
556    pub fn with_repeat(mut self, repeat: bool) -> Self {
557        self.repeat = repeat;
558        self
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565
566    // --- MouseButton ---
567
568    #[test]
569    fn mouse_button_left_middle_right_are_unit_variants() {
570        // Pattern-match each named variant to assert it exists
571        // and is a unit variant.
572        assert!(matches!(MouseButton::Left, MouseButton::Left));
573        assert!(matches!(MouseButton::Middle, MouseButton::Middle));
574        assert!(matches!(MouseButton::Right, MouseButton::Right));
575    }
576
577    #[test]
578    fn mouse_button_other_carries_i16() {
579        // 3 is browser-back per the DOM table; we don't bake that
580        // mapping into the type — `Other` is just the catch-all.
581        let back = MouseButton::Other(3);
582        match back {
583            MouseButton::Other(n) => assert_eq!(n, 3),
584            _ => panic!("Other(3) didn't match Other"),
585        }
586    }
587
588    #[test]
589    fn mouse_button_is_copy_and_eq() {
590        let b = MouseButton::Left;
591        let c = b; // Copy.
592        assert_eq!(b, c);
593        assert_ne!(MouseButton::Left, MouseButton::Right);
594        assert_ne!(MouseButton::Other(3), MouseButton::Other(4));
595    }
596
597    // --- KeyboardModifiers ---
598
599    #[test]
600    fn keyboard_modifiers_default_is_all_false() {
601        let m = KeyboardModifiers::default();
602        assert!(!m.ctrl);
603        assert!(!m.shift);
604        assert!(!m.alt);
605        assert!(!m.meta);
606    }
607
608    #[test]
609    fn keyboard_modifiers_field_struct_round_trips() {
610        let m = KeyboardModifiers {
611            ctrl: true,
612            shift: false,
613            alt: true,
614            meta: false,
615        };
616        assert!(m.ctrl);
617        assert!(!m.shift);
618        assert!(m.alt);
619        assert!(!m.meta);
620    }
621
622    #[test]
623    fn keyboard_modifiers_is_copy_and_eq() {
624        let a = KeyboardModifiers {
625            ctrl: true,
626            ..Default::default()
627        };
628        let b = a; // Copy.
629        assert_eq!(a, b);
630
631        let c = KeyboardModifiers {
632            shift: true,
633            ..Default::default()
634        };
635        assert_ne!(a, c);
636    }
637
638    // --- InputType ---
639
640    #[test]
641    fn input_type_named_variants_exist() {
642        // Round-trip every named variant through equality. This
643        // also serves as a compile-time inventory of the shipped
644        // set — adding a new variant requires updating this list.
645        let named = [
646            InputType::InsertText,
647            InputType::InsertReplacementText,
648            InputType::InsertLineBreak,
649            InputType::InsertParagraph,
650            InputType::InsertFromPaste,
651            InputType::InsertFromDrop,
652            InputType::DeleteContentBackward,
653            InputType::DeleteContentForward,
654            InputType::DeleteByCut,
655            InputType::DeleteWordBackward,
656            InputType::DeleteWordForward,
657            InputType::HistoryUndo,
658            InputType::HistoryRedo,
659        ];
660        // Each variant is distinct from its neighbors.
661        for (i, a) in named.iter().enumerate() {
662            for (j, b) in named.iter().enumerate() {
663                if i == j {
664                    assert_eq!(a, b);
665                } else {
666                    assert_ne!(a, b);
667                }
668            }
669        }
670    }
671
672    #[test]
673    fn input_type_other_carries_string() {
674        let it = InputType::Other("formatBold".into());
675        match &it {
676            InputType::Other(s) => assert_eq!(s, "formatBold"),
677            _ => panic!("Other didn't match Other"),
678        }
679    }
680
681    #[test]
682    fn input_type_other_differs_from_named_with_same_label() {
683        // `Other("insertText")` is not equal to the named
684        // `InsertText` variant — the named set is closed.
685        assert_ne!(InputType::Other("insertText".into()), InputType::InsertText);
686    }
687
688    // --- ToggleState ---
689
690    #[test]
691    fn toggle_state_variants_exist_and_differ() {
692        assert!(matches!(ToggleState::Open, ToggleState::Open));
693        assert!(matches!(ToggleState::Closed, ToggleState::Closed));
694        assert_ne!(ToggleState::Open, ToggleState::Closed);
695    }
696
697    #[test]
698    fn toggle_state_is_copy() {
699        let s = ToggleState::Open;
700        let t = s; // Copy.
701        assert_eq!(s, t);
702    }
703
704    // --- EventDetail ---
705
706    #[test]
707    fn event_detail_default_is_none() {
708        let d: EventDetail = Default::default();
709        assert!(matches!(d, EventDetail::None));
710    }
711
712    #[test]
713    fn event_detail_string_round_trip_via_as_string() {
714        // The canonical step-2 failing test: an `EventDetail::String`
715        // payload round-trips through `as_string()`. This is the
716        // migration target for every pre-M4 reader that used
717        // `event.detail.as_deref()`.
718        let d = EventDetail::String("payload".into());
719        assert_eq!(d.as_string(), Some("payload"));
720    }
721
722    #[test]
723    fn event_detail_as_string_returns_none_for_other_variants() {
724        assert_eq!(EventDetail::None.as_string(), None);
725        assert_eq!(
726            EventDetail::Mouse(MouseDetail {
727                button: MouseButton::Left,
728                buttons: 0,
729                client_x: 0,
730                client_y: 0,
731                delta_x: 0,
732                delta_y: 0,
733                modifiers: KeyboardModifiers::default(),
734            })
735            .as_string(),
736            None
737        );
738    }
739
740    #[test]
741    fn event_detail_as_transition_round_trips() {
742        let d = EventDetail::Transition(Box::new(TransitionDetail {
743            property_name: "color".into(),
744            elapsed: 0.25,
745            pseudo_element: None,
746        }));
747        let t = d.as_transition().expect("variant matches");
748        assert_eq!(t.property_name, "color");
749        assert!((t.elapsed - 0.25).abs() < f64::EPSILON);
750        assert!(t.pseudo_element.is_none());
751        assert_eq!(d.as_string(), None);
752    }
753
754    #[test]
755    fn event_detail_as_input_round_trips() {
756        let d = EventDetail::Input(Box::new(InputDetail {
757            input_type: InputType::InsertText,
758            data: Some("a".into()),
759            is_composing: false,
760        }));
761        let i = d.as_input().expect("variant matches");
762        assert_eq!(i.input_type, InputType::InsertText);
763        assert_eq!(i.data.as_deref(), Some("a"));
764        assert!(!i.is_composing);
765    }
766
767    #[test]
768    fn event_detail_as_submit_round_trips_with_none_submitter() {
769        let d = EventDetail::Submit(Box::new(SubmitDetail::new(None)));
770        let s = d.as_submit().expect("variant matches");
771        assert!(s.submitter.is_none());
772    }
773
774    #[test]
775    fn event_detail_as_toggle_round_trips() {
776        let d = EventDetail::Toggle(Box::new(ToggleDetail {
777            old_state: ToggleState::Closed,
778            new_state: ToggleState::Open,
779        }));
780        let t = d.as_toggle().expect("variant matches");
781        assert_eq!(t.old_state, ToggleState::Closed);
782        assert_eq!(t.new_state, ToggleState::Open);
783    }
784
785    #[test]
786    fn event_detail_as_mouse_round_trips() {
787        let d = EventDetail::Mouse(MouseDetail {
788            button: MouseButton::Right,
789            buttons: 0b010,
790            client_x: 12,
791            client_y: 7,
792            delta_x: 0,
793            delta_y: -1,
794            modifiers: KeyboardModifiers {
795                ctrl: true,
796                ..Default::default()
797            },
798        });
799        let m = d.as_mouse().expect("variant matches");
800        assert_eq!(m.button, MouseButton::Right);
801        assert_eq!(m.buttons, 0b010);
802        assert_eq!(m.client_x, 12);
803        assert_eq!(m.client_y, 7);
804        assert_eq!(m.delta_y, -1);
805        assert!(m.modifiers.ctrl);
806        assert!(!m.modifiers.shift);
807    }
808
809    #[test]
810    fn event_detail_as_keyboard_round_trips() {
811        let d = EventDetail::Keyboard(Box::new(KeyboardDetail {
812            key: "Enter".into(),
813            modifiers: KeyboardModifiers::default(),
814            repeat: false,
815        }));
816        let k = d.as_keyboard().expect("variant matches");
817        assert_eq!(k.key, "Enter");
818        assert!(!k.repeat);
819    }
820
821    #[test]
822    fn event_detail_accessor_cross_check() {
823        // A non-matching `as_*` accessor returns `None`, not panic
824        // or wrong-variant data.
825        let s = EventDetail::String("hello".into());
826        assert!(s.as_transition().is_none());
827        assert!(s.as_input().is_none());
828        assert!(s.as_submit().is_none());
829        assert!(s.as_toggle().is_none());
830        assert!(s.as_mouse().is_none());
831        assert!(s.as_keyboard().is_none());
832    }
833}