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}