Skip to main content

teksilo_core/
event.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use teksilo_canvas::{Point, Rect};
5
6use crate::gesture::GestureEvent;
7
8/// Pointer button identifiers.
9///
10/// `Forward` and `Back` correspond to the auxiliary mouse buttons (mouse
11/// 4 / mouse 5) typically labelled "browser back / forward". Platforms
12/// that don't have those buttons simply never emit them.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum PointerButton {
15    /// Left-click (or main-action button on left-handed mice).
16    Primary,
17    /// Right-click.
18    Secondary,
19    /// Middle / wheel-click.
20    Middle,
21    /// "Back" auxiliary button (mouse 4 on most 5-button mice). Often
22    /// bound to "navigate back" in browsers.
23    Back,
24    /// "Forward" auxiliary button (mouse 5). Often bound to "navigate
25    /// forward".
26    Forward,
27}
28
29/// Set of pointer buttons a gesture recognizer is configured to fire
30/// for. Used by the four click-style recognizers (`TapRecognizer`,
31/// `DoubleTapRecognizer`, `TripleTapRecognizer`, `LongPressRecognizer`)
32/// and the matching widget-level builders (`accept_tap_buttons`, …).
33///
34/// Default for every recognizer is [`ButtonMask::PRIMARY`] — left-click
35/// only — which matches the user's expectation for a "tap" and keeps
36/// right-click free to open a context menu without spuriously
37/// activating the widget. Use [`ButtonMask::ALL`] or a hand-built
38/// `PRIMARY | SECONDARY` etc. to opt into broader button sets.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct ButtonMask(u8);
41
42impl ButtonMask {
43    /// Empty mask — no buttons accepted.
44    pub const NONE: Self = Self(0);
45    /// Left-click on most desktop pointing devices.
46    pub const PRIMARY: Self = Self(1 << 0);
47    /// Right-click on most desktop pointing devices.
48    pub const SECONDARY: Self = Self(1 << 1);
49    /// Middle / wheel-click.
50    pub const MIDDLE: Self = Self(1 << 2);
51    /// "Back" auxiliary button (mouse 4).
52    pub const BACK: Self = Self(1 << 3);
53    /// "Forward" auxiliary button (mouse 5).
54    pub const FORWARD: Self = Self(1 << 4);
55    /// All buttons currently representable by [`PointerButton`].
56    pub const ALL: Self = Self(0b0001_1111);
57
58    /// `true` when the mask contains the given button.
59    pub const fn contains(self, button: PointerButton) -> bool {
60        let bit = match button {
61            PointerButton::Primary => 1 << 0,
62            PointerButton::Secondary => 1 << 1,
63            PointerButton::Middle => 1 << 2,
64            PointerButton::Back => 1 << 3,
65            PointerButton::Forward => 1 << 4,
66        };
67        self.0 & bit != 0
68    }
69
70    /// `true` when no buttons are accepted.
71    pub const fn is_empty(self) -> bool {
72        self.0 == 0
73    }
74
75    /// Union — accept any button in either mask.
76    pub const fn union(self, other: Self) -> Self {
77        Self(self.0 | other.0)
78    }
79
80    /// Intersection — accept only buttons present in both masks.
81    pub const fn intersection(self, other: Self) -> Self {
82        Self(self.0 & other.0)
83    }
84}
85
86impl From<PointerButton> for ButtonMask {
87    fn from(button: PointerButton) -> Self {
88        match button {
89            PointerButton::Primary => Self::PRIMARY,
90            PointerButton::Secondary => Self::SECONDARY,
91            PointerButton::Middle => Self::MIDDLE,
92            PointerButton::Back => Self::BACK,
93            PointerButton::Forward => Self::FORWARD,
94        }
95    }
96}
97
98impl<const N: usize> From<[PointerButton; N]> for ButtonMask {
99    fn from(buttons: [PointerButton; N]) -> Self {
100        let mut mask = Self::NONE;
101        let mut i = 0;
102        while i < N {
103            mask = mask.union(ButtonMask::from(buttons[i]));
104            i += 1;
105        }
106        mask
107    }
108}
109
110impl std::ops::BitOr for ButtonMask {
111    type Output = Self;
112    fn bitor(self, rhs: Self) -> Self {
113        self.union(rhs)
114    }
115}
116
117impl std::ops::BitAnd for ButtonMask {
118    type Output = Self;
119    fn bitand(self, rhs: Self) -> Self {
120        self.intersection(rhs)
121    }
122}
123
124impl std::ops::BitOrAssign for ButtonMask {
125    fn bitor_assign(&mut self, rhs: Self) {
126        self.0 |= rhs.0;
127    }
128}
129
130impl std::ops::BitAndAssign for ButtonMask {
131    fn bitand_assign(&mut self, rhs: Self) {
132        self.0 &= rhs.0;
133    }
134}
135
136impl Default for ButtonMask {
137    fn default() -> Self {
138        Self::PRIMARY
139    }
140}
141
142/// Keyboard key identifiers.
143#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
144pub enum Key {
145    Space,
146    Enter,
147    Escape,
148    Tab,
149    Backspace,
150    Delete,
151    Insert,
152    ArrowUp,
153    ArrowDown,
154    ArrowLeft,
155    ArrowRight,
156    Home,
157    End,
158    PageUp,
159    PageDown,
160    // Letters
161    A,
162    B,
163    C,
164    D,
165    E,
166    F,
167    G,
168    H,
169    I,
170    J,
171    K,
172    L,
173    M,
174    N,
175    O,
176    P,
177    Q,
178    R,
179    S,
180    T,
181    U,
182    V,
183    W,
184    X,
185    Y,
186    Z,
187    // Function keys
188    F1,
189    F2,
190    F3,
191    F4,
192    F5,
193    F6,
194    F7,
195    F8,
196    F9,
197    F10,
198    F11,
199    F12,
200    F13,
201    F14,
202    F15,
203    F16,
204    F17,
205    F18,
206    F19,
207    F20,
208    F21,
209    F22,
210    F23,
211    F24,
212    // Other
213    /// Caps Lock. Delivered as a discrete key press/release (winit's
214    /// `ModifiersState` does not carry lock state), so consumers that
215    /// need the *active* lock state track it themselves on the
216    /// key-down edge. See `WindowState::caps_lock`.
217    CapsLock,
218    Character(char),
219}
220
221impl Key {
222    /// Returns the character this key represents, if any.
223    /// Maps `Key::A`..`Key::Z` to `'a'`..`'z'` (lowercase) and
224    /// `Key::Character(ch)` to `ch`.
225    pub fn to_char(&self) -> Option<char> {
226        match self {
227            Key::A => Some('a'),
228            Key::B => Some('b'),
229            Key::C => Some('c'),
230            Key::D => Some('d'),
231            Key::E => Some('e'),
232            Key::F => Some('f'),
233            Key::G => Some('g'),
234            Key::H => Some('h'),
235            Key::I => Some('i'),
236            Key::J => Some('j'),
237            Key::K => Some('k'),
238            Key::L => Some('l'),
239            Key::M => Some('m'),
240            Key::N => Some('n'),
241            Key::O => Some('o'),
242            Key::P => Some('p'),
243            Key::Q => Some('q'),
244            Key::R => Some('r'),
245            Key::S => Some('s'),
246            Key::T => Some('t'),
247            Key::U => Some('u'),
248            Key::V => Some('v'),
249            Key::W => Some('w'),
250            Key::X => Some('x'),
251            Key::Y => Some('y'),
252            Key::Z => Some('z'),
253            Key::Character(ch) => Some(*ch),
254            _ => None,
255        }
256    }
257
258    /// The text the platform attaches to this key, for the handful of named
259    /// keys that carry any. Mirrors winit's `NamedKey::to_text`, which is
260    /// where these values reach the app from.
261    ///
262    /// Worth knowing because it is surprising: Escape arrives carrying
263    /// U+001B, so a widget that reads `KeyDown::text` sees text on a key
264    /// nobody thinks of as text. A `TextInputField` used to filter that
265    /// control character out, read the empty result as "input rejected" and
266    /// swallow the key — which is how Escape stopped bubbling out of a
267    /// focused field.
268    ///
269    /// Character keys are deliberately absent: `Key::A` is `None` here, and
270    /// the way to simulate typing is `type_text`, which already sends text.
271    /// The gap this closes is only the surprising one.
272    pub fn to_text(&self) -> Option<&'static str> {
273        match self {
274            Key::Enter => Some("\r"),
275            Key::Backspace => Some("\u{8}"),
276            Key::Tab => Some("\t"),
277            Key::Space => Some(" "),
278            Key::Escape => Some("\u{1b}"),
279            _ => None,
280        }
281    }
282}
283
284/// Keyboard modifier state.
285#[derive(
286    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
287)]
288pub struct Modifiers {
289    bits: u8,
290}
291
292impl Modifiers {
293    pub const NONE: Modifiers = Modifiers { bits: 0 };
294    pub const CTRL: Modifiers = Modifiers { bits: 1 };
295    pub const SHIFT: Modifiers = Modifiers { bits: 2 };
296    pub const ALT: Modifiers = Modifiers { bits: 4 };
297    pub const SUPER: Modifiers = Modifiers { bits: 8 };
298
299    /// The **primary accelerator** modifier for this platform: [`SUPER`]
300    /// (Command, ⌘) on macOS, [`CTRL`] everywhere else.
301    ///
302    /// Desktop platforms disagree about which physical key carries application
303    /// accelerators, and on macOS the disagreement is not cosmetic: Control is
304    /// reserved there for the text system and for the secondary click, while ⌘
305    /// is what a user presses for Save, Copy or Find. Code that hard-codes
306    /// [`CTRL`] to mean "the accelerator" therefore listens to the wrong key on
307    /// one of the three desktop platforms.
308    ///
309    /// Compare against this constant (or call [`Modifiers::command`]) and the
310    /// same code means Ctrl+A on Windows and Linux and ⌘A on macOS. This
311    /// mirrors Qt's `Qt::CTRL`, which likewise resolves to ⌘ on macOS, and the
312    /// convention the native menu bar already applies when it turns a declared
313    /// chord into an `NSMenuItem` key equivalent.
314    ///
315    /// [`SUPER`]: Modifiers::SUPER
316    /// [`CTRL`]: Modifiers::CTRL
317    pub const COMMAND: Modifiers = if cfg!(target_os = "macos") {
318        Self::SUPER
319    } else {
320        Self::CTRL
321    };
322
323    pub fn empty() -> Self {
324        Self::NONE
325    }
326
327    pub fn ctrl(self) -> bool {
328        self.bits & 1 != 0
329    }
330
331    pub fn shift(self) -> bool {
332        self.bits & 2 != 0
333    }
334
335    pub fn alt(self) -> bool {
336        self.bits & 4 != 0
337    }
338
339    pub fn super_key(self) -> bool {
340        self.bits & 8 != 0
341    }
342
343    /// Whether the platform's primary accelerator modifier
344    /// ([`Modifiers::COMMAND`]) is held: Command (⌘) on macOS, Control
345    /// everywhere else.
346    ///
347    /// Use this instead of [`ctrl`](Self::ctrl) wherever the chord means "the
348    /// accelerator" — select-all, the discontiguous-selection click, jump to
349    /// the end of a list. Keep [`ctrl`](Self::ctrl) for the chords that really
350    /// are Control on every platform, macOS included: Ctrl+Tab cycles tabs
351    /// there too (⌘Tab belongs to the application switcher and never reaches
352    /// an app).
353    pub fn command(self) -> bool {
354        self.contains(Self::COMMAND)
355    }
356
357    /// Whether every modifier in `other` is held.
358    pub fn contains(self, other: Modifiers) -> bool {
359        self.bits & other.bits == other.bits
360    }
361
362    /// These modifiers with `other` removed.
363    pub fn without(self, other: Modifiers) -> Modifiers {
364        Modifiers {
365            bits: self.bits & !other.bits,
366        }
367    }
368
369    /// These modifiers with a declared `CTRL` reinterpreted as the platform's
370    /// primary accelerator — see [`Modifiers::COMMAND`] and
371    /// [`KeyStroke::with_command_convention`](crate::shortcut::KeyStroke::with_command_convention),
372    /// which is where this is applied.
373    ///
374    /// A no-op off macOS (where `COMMAND` *is* `CTRL`), and a no-op for a chord
375    /// that already names `SUPER` explicitly: `Ctrl+Super` stays ⌃⌘, a genuine
376    /// two-modifier chord, rather than collapsing to one.
377    pub fn with_command_convention(self) -> Modifiers {
378        self.with_command_convention_using(Self::COMMAND)
379    }
380
381    /// The platform-parameterised core of
382    /// [`with_command_convention`](Self::with_command_convention). Split out so
383    /// the macOS branch is exercised by tests running on any host — the whole
384    /// point of the convention is behaviour a Linux CI cannot otherwise see.
385    fn with_command_convention_using(self, command: Modifiers) -> Modifiers {
386        if self.ctrl() && !self.super_key() {
387            self.without(Self::CTRL) | command
388        } else {
389            self
390        }
391    }
392}
393
394impl std::fmt::Display for Key {
395    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        match self {
397            Key::Space => f.write_str("Space"),
398            Key::Enter => f.write_str("Enter"),
399            Key::Escape => f.write_str("Esc"),
400            Key::Tab => f.write_str("Tab"),
401            Key::Backspace => f.write_str("Backspace"),
402            Key::Delete => f.write_str("Del"),
403            Key::Insert => f.write_str("Ins"),
404            Key::ArrowUp => f.write_str("Up"),
405            Key::ArrowDown => f.write_str("Down"),
406            Key::ArrowLeft => f.write_str("Left"),
407            Key::ArrowRight => f.write_str("Right"),
408            Key::Home => f.write_str("Home"),
409            Key::End => f.write_str("End"),
410            Key::PageUp => f.write_str("PageUp"),
411            Key::PageDown => f.write_str("PageDown"),
412            Key::A => f.write_str("A"),
413            Key::B => f.write_str("B"),
414            Key::C => f.write_str("C"),
415            Key::D => f.write_str("D"),
416            Key::E => f.write_str("E"),
417            Key::F => f.write_str("F"),
418            Key::G => f.write_str("G"),
419            Key::H => f.write_str("H"),
420            Key::I => f.write_str("I"),
421            Key::J => f.write_str("J"),
422            Key::K => f.write_str("K"),
423            Key::L => f.write_str("L"),
424            Key::M => f.write_str("M"),
425            Key::N => f.write_str("N"),
426            Key::O => f.write_str("O"),
427            Key::P => f.write_str("P"),
428            Key::Q => f.write_str("Q"),
429            Key::R => f.write_str("R"),
430            Key::S => f.write_str("S"),
431            Key::T => f.write_str("T"),
432            Key::U => f.write_str("U"),
433            Key::V => f.write_str("V"),
434            Key::W => f.write_str("W"),
435            Key::X => f.write_str("X"),
436            Key::Y => f.write_str("Y"),
437            Key::Z => f.write_str("Z"),
438            Key::F1 => f.write_str("F1"),
439            Key::F2 => f.write_str("F2"),
440            Key::F3 => f.write_str("F3"),
441            Key::F4 => f.write_str("F4"),
442            Key::F5 => f.write_str("F5"),
443            Key::F6 => f.write_str("F6"),
444            Key::F7 => f.write_str("F7"),
445            Key::F8 => f.write_str("F8"),
446            Key::F9 => f.write_str("F9"),
447            Key::F10 => f.write_str("F10"),
448            Key::F11 => f.write_str("F11"),
449            Key::F12 => f.write_str("F12"),
450            Key::F13 => f.write_str("F13"),
451            Key::F14 => f.write_str("F14"),
452            Key::F15 => f.write_str("F15"),
453            Key::F16 => f.write_str("F16"),
454            Key::F17 => f.write_str("F17"),
455            Key::F18 => f.write_str("F18"),
456            Key::F19 => f.write_str("F19"),
457            Key::F20 => f.write_str("F20"),
458            Key::F21 => f.write_str("F21"),
459            Key::F22 => f.write_str("F22"),
460            Key::F23 => f.write_str("F23"),
461            Key::F24 => f.write_str("F24"),
462            Key::CapsLock => f.write_str("CapsLock"),
463            Key::Character(c) => write!(f, "{}", c.to_uppercase()),
464        }
465    }
466}
467
468impl std::fmt::Display for Modifiers {
469    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
470        if self.ctrl() {
471            f.write_str("Ctrl+")?;
472        }
473        if self.alt() {
474            f.write_str("Alt+")?;
475        }
476        if self.shift() {
477            f.write_str("Shift+")?;
478        }
479        if self.super_key() {
480            // Named for the key the user is looking at. This string reaches
481            // assistive tech through the accessibility tree's
482            // `keyboard_shortcut`, and a Mac screen-reader user announced
483            // "Super+S" for ⌘S has been told the wrong key.
484            f.write_str(if cfg!(target_os = "macos") {
485                "Cmd+"
486            } else {
487                "Super+"
488            })?;
489        }
490        Ok(())
491    }
492}
493
494impl std::ops::BitOr for Modifiers {
495    type Output = Self;
496    fn bitor(self, rhs: Self) -> Self {
497        Modifiers {
498            bits: self.bits | rhs.bits,
499        }
500    }
501}
502
503/// Scroll delta from mouse wheel or trackpad.
504#[derive(Debug, Clone, Copy, PartialEq)]
505pub enum ScrollDelta {
506    /// Line-based scrolling (mouse wheel).
507    Lines { x: f32, y: f32 },
508    /// Pixel-based scrolling (trackpad).
509    Pixels { x: f32, y: f32 },
510}
511
512/// Where a [`WidgetEvent::ScrollIntoView`] target should come to rest on the
513/// scroll container's vertical axis.
514///
515/// The horizontal axis is always revealed minimally — a fraction only has an
516/// obvious meaning for the axis the request is *about*, and pinning a caret
517/// vertically must not yank a horizontally-scrolled view sideways.
518#[derive(Debug, Clone, Copy, PartialEq)]
519pub enum ScrollAlign {
520    /// Scroll the least amount that makes the target fully visible, and not at
521    /// all when it already is. This is what focus-driven reveals and
522    /// [`EventContext::ensure_visible`](crate::widget::EventContext::ensure_visible)
523    /// use, and it is the behaviour every scroll container had before
524    /// alignment existed.
525    Minimal,
526    /// Pin the target at `f` of the way down the viewport — `0.0` flush with
527    /// the top, `0.5` centred, `1.0` flush with the bottom — **whether or not
528    /// it is already visible**. Being unconditional is the whole point: a
529    /// typewriter-scrolling caret that only moved the view when it fell off
530    /// the edge would not be pinned at all.
531    ///
532    /// The container still clamps to its scroll range, so a target near the
533    /// start or end of the content comes to rest as close to `f` as the range
534    /// allows. See [`ScrollArea::scroll_past_end`] for buying range past the
535    /// end of the content so the last line can still reach the pin.
536    ///
537    /// [`ScrollArea::scroll_past_end`]: https://docs.rs/teksilo-widgets
538    Fraction(f32),
539}
540
541/// Whether a [`WidgetEvent::ScrollIntoView`] should jump or glide.
542///
543/// Split out from the container's own `smooth_scrolling` setting because the
544/// right answer depends on the *request*, not the container: a caret pinned on
545/// every keystroke must snap (animating it is what produces the "screen
546/// bouncing" typewriter-mode users complain about in other editors), while the
547/// same container gliding for a page-down or a search hit reads as polish.
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum ScrollMotion {
550    /// Jump straight to the target offset.
551    Instant,
552    /// Animate to the target offset, if the container has smooth scrolling
553    /// enabled. Containers with `smooth_scrolling(false)` still jump.
554    Smooth,
555}
556
557/// Events dispatched to widgets.
558#[derive(Debug, Clone)]
559pub enum WidgetEvent {
560    PointerDown {
561        position: Point,
562        button: PointerButton,
563        modifiers: Modifiers,
564    },
565    PointerUp {
566        position: Point,
567        button: PointerButton,
568        modifiers: Modifiers,
569    },
570    PointerMove {
571        position: Point,
572    },
573    PointerEnter,
574    PointerLeave,
575    Scroll {
576        delta: ScrollDelta,
577        /// Modifier keys held at the time of the scroll event.
578        /// Defaults to `Modifiers::NONE` for synthesized events
579        /// (tests, keyboard-driven scroll requests). Real-platform
580        /// scroll events populate this from the platform's tracked
581        /// modifier state — apps detect Ctrl-wheel-to-zoom by
582        /// inspecting `modifiers.ctrl()`.
583        modifiers: Modifiers,
584    },
585    KeyDown {
586        key: Key,
587        modifiers: Modifiers,
588        text: Option<String>,
589    },
590    KeyUp {
591        key: Key,
592        modifiers: Modifiers,
593    },
594    ImeComposition {
595        text: String,
596        cursor: Option<std::ops::Range<usize>>,
597    },
598    ImeCommit {
599        text: String,
600    },
601    FocusGained {
602        origin: crate::focus::FocusOrigin,
603    },
604    FocusLost,
605    AccessAction {
606        action: accesskit::Action,
607        target: Option<crate::widget_id::WidgetId>,
608        /// Raw AccessKit NodeId from the original `ActionRequest`.
609        /// May be a synthetic (widget-emitted child) NodeId — use
610        /// `crate::accessibility::is_synthetic` to distinguish it
611        /// from a widget-derived NodeId. The widget that registered
612        /// the parent (retrieved via `tree.widget_for_synthetic`)
613        /// is the one set in `target`.
614        target_node: accesskit::NodeId,
615        /// Payload carried by the `ActionRequest`. For
616        /// `Action::SetTextSelection` this is
617        /// `ActionData::SetTextSelection(TextSelection)`, for
618        /// `Action::SetValue` it's `ActionData::Value(Box<str>)`,
619        /// for scroll actions it carries scroll offsets, etc.
620        /// Widgets that declare these actions must read the payload
621        /// to honour screen-reader-initiated requests.
622        data: Option<accesskit::ActionData>,
623    },
624    /// Dispatched by the framework to a clipping ancestor when a child
625    /// gains focus but is outside the viewport. The scroll area adjusts
626    /// its offset to make the target bounds visible, with an optional
627    /// margin around the target.
628    ScrollIntoView {
629        target_bounds: Rect,
630        /// Extra margin (in logical pixels) to keep around the target
631        /// when scrolling it into view. Defaults to 0.0.
632        margin: f32,
633        /// Where the target should end up on the scroll container's
634        /// **vertical** axis. [`ScrollAlign::Minimal`] (the default, and what
635        /// every focus-driven reveal uses) only scrolls when the target is not
636        /// already fully visible; [`ScrollAlign::Fraction`] *pins* it to a
637        /// fixed height in the viewport whether or not it was already visible.
638        align: ScrollAlign,
639        /// Whether the container should jump to the new offset or glide to it.
640        /// See [`ScrollMotion`].
641        motion: ScrollMotion,
642        /// Optional back-channel for the handling scroll container to report
643        /// how far it actually scrolled (`(dx, dy)` in content pixels). When
644        /// several nested scroll containers must each reveal the same target,
645        /// the ancestor walk (`scroll_rect_into_view`) reads this after
646        /// dispatching to an inner container and shifts `target_bounds` by the
647        /// negated delta before asking the next (outer) one — so the outer sees
648        /// where the target will land once the inner's (deferred) scroll
649        /// applies, not its pre-scroll position. `None` disables reporting (the
650        /// nested-reveal refinement is unavailable). A handler that ignores it
651        /// still works for the common single-container case.
652        ///
653        /// `Arc<Mutex<..>>` (not `Rc<Cell<..>>`) so `WidgetEvent` stays `Send`
654        /// — some events are posted across threads. This one is only ever
655        /// touched on the dispatch thread, so the lock is always uncontended.
656        applied_scroll: Option<std::sync::Arc<std::sync::Mutex<teksilo_canvas::Point>>>,
657    },
658    /// A recognized gesture event, routed through the same preview/bubble system.
659    Gesture {
660        gesture: GestureEvent,
661    },
662}
663
664/// The result of handling an event.
665#[derive(Debug, Clone, Copy, PartialEq, Eq)]
666pub enum EventResponse {
667    /// The event was handled; stop propagation.
668    Handled,
669    /// The event was not handled; let it bubble.
670    Ignored,
671}
672
673#[cfg(test)]
674mod modifier_tests {
675    use super::*;
676
677    // The convention itself, exercised on both platform settings from any host.
678    // `Modifiers::COMMAND` resolves at compile time, so a Linux CI would
679    // otherwise only ever see half of what this rule does — and the half it
680    // cannot see is the one the rule exists for.
681
682    #[test]
683    fn command_convention_rewrites_a_bare_ctrl_on_macos() {
684        let mac = Modifiers::CTRL.with_command_convention_using(Modifiers::SUPER);
685        assert_eq!(mac, Modifiers::SUPER);
686
687        let mac =
688            (Modifiers::CTRL | Modifiers::SHIFT).with_command_convention_using(Modifiers::SUPER);
689        assert_eq!(mac, Modifiers::SUPER | Modifiers::SHIFT);
690    }
691
692    #[test]
693    fn command_convention_is_a_no_op_where_command_is_ctrl() {
694        for m in [
695            Modifiers::CTRL,
696            Modifiers::CTRL | Modifiers::SHIFT,
697            Modifiers::ALT,
698            Modifiers::NONE,
699            Modifiers::SUPER,
700        ] {
701            assert_eq!(m.with_command_convention_using(Modifiers::CTRL), m);
702        }
703    }
704
705    #[test]
706    fn command_convention_leaves_an_explicit_super_alone() {
707        // A chord that already names Super is a deliberate ⌘ chord, and
708        // `Ctrl+Super` is a genuine two-modifier chord — neither collapses.
709        assert_eq!(
710            Modifiers::SUPER.with_command_convention_using(Modifiers::SUPER),
711            Modifiers::SUPER
712        );
713        let both = Modifiers::CTRL | Modifiers::SUPER;
714        assert_eq!(both.with_command_convention_using(Modifiers::SUPER), both);
715    }
716
717    #[test]
718    fn command_convention_is_idempotent() {
719        for command in [Modifiers::CTRL, Modifiers::SUPER] {
720            for m in [
721                Modifiers::CTRL,
722                Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT,
723                Modifiers::SUPER,
724                Modifiers::NONE,
725            ] {
726                let once = m.with_command_convention_using(command);
727                assert_eq!(once.with_command_convention_using(command), once);
728            }
729        }
730    }
731
732    #[test]
733    fn command_predicate_follows_the_platform() {
734        // Whichever platform this runs on, `COMMAND` is one of the two, and
735        // `command()` tracks exactly it.
736        assert!(Modifiers::COMMAND.command());
737        assert!(!Modifiers::ALT.command());
738        assert!((Modifiers::COMMAND | Modifiers::SHIFT).command());
739
740        if cfg!(target_os = "macos") {
741            assert_eq!(Modifiers::COMMAND, Modifiers::SUPER);
742            assert!(!Modifiers::CTRL.command());
743        } else {
744            assert_eq!(Modifiers::COMMAND, Modifiers::CTRL);
745            assert!(!Modifiers::SUPER.command());
746        }
747    }
748
749    #[test]
750    fn contains_requires_every_named_modifier() {
751        let cs = Modifiers::CTRL | Modifiers::SHIFT;
752        assert!(cs.contains(Modifiers::CTRL));
753        assert!(cs.contains(cs));
754        assert!(!cs.contains(Modifiers::CTRL | Modifiers::ALT));
755        assert!(cs.contains(Modifiers::NONE));
756    }
757
758    #[test]
759    fn without_clears_only_the_named_modifiers() {
760        let all = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::SUPER;
761        assert_eq!(
762            all.without(Modifiers::SUPER),
763            Modifiers::CTRL | Modifiers::SHIFT
764        );
765        assert_eq!(all.without(Modifiers::ALT), all);
766    }
767}