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    /// The dedicated context-menu key: `VK_APPS` on Windows (the key between
219    /// the right Alt and the right Ctrl on most PC layouts), `keysyms::Menu` on
220    /// X11 and Wayland.
221    ///
222    /// **macOS never produces it.** Its keyboards have no such key and
223    /// `winit-0.30.13`'s AppKit backend references the variant zero times, so
224    /// on that platform the only keyboard route to a context menu is a chord.
225    /// See the dispatcher's context-menu handling for the chords Teksilo
226    /// reserves.
227    ContextMenu,
228    Character(char),
229}
230
231impl Key {
232    /// Returns the character this key represents, if any.
233    /// Maps `Key::A`..`Key::Z` to `'a'`..`'z'` (lowercase) and
234    /// `Key::Character(ch)` to `ch`.
235    pub fn to_char(&self) -> Option<char> {
236        match self {
237            Key::A => Some('a'),
238            Key::B => Some('b'),
239            Key::C => Some('c'),
240            Key::D => Some('d'),
241            Key::E => Some('e'),
242            Key::F => Some('f'),
243            Key::G => Some('g'),
244            Key::H => Some('h'),
245            Key::I => Some('i'),
246            Key::J => Some('j'),
247            Key::K => Some('k'),
248            Key::L => Some('l'),
249            Key::M => Some('m'),
250            Key::N => Some('n'),
251            Key::O => Some('o'),
252            Key::P => Some('p'),
253            Key::Q => Some('q'),
254            Key::R => Some('r'),
255            Key::S => Some('s'),
256            Key::T => Some('t'),
257            Key::U => Some('u'),
258            Key::V => Some('v'),
259            Key::W => Some('w'),
260            Key::X => Some('x'),
261            Key::Y => Some('y'),
262            Key::Z => Some('z'),
263            Key::Character(ch) => Some(*ch),
264            _ => None,
265        }
266    }
267
268    /// The text the platform attaches to this key, for the handful of named
269    /// keys that carry any. Mirrors winit's `NamedKey::to_text`, which is
270    /// where these values reach the app from.
271    ///
272    /// Worth knowing because it is surprising: Escape arrives carrying
273    /// U+001B, so a widget that reads `KeyDown::text` sees text on a key
274    /// nobody thinks of as text. A `TextInputField` used to filter that
275    /// control character out, read the empty result as "input rejected" and
276    /// swallow the key — which is how Escape stopped bubbling out of a
277    /// focused field.
278    ///
279    /// Character keys are deliberately absent: `Key::A` is `None` here, and
280    /// the way to simulate typing is `type_text`, which already sends text.
281    /// The gap this closes is only the surprising one.
282    pub fn to_text(&self) -> Option<&'static str> {
283        match self {
284            Key::Enter => Some("\r"),
285            Key::Backspace => Some("\u{8}"),
286            Key::Tab => Some("\t"),
287            Key::Space => Some(" "),
288            Key::Escape => Some("\u{1b}"),
289            _ => None,
290        }
291    }
292}
293
294/// Keyboard modifier state.
295#[derive(
296    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
297)]
298pub struct Modifiers {
299    bits: u8,
300}
301
302impl Modifiers {
303    pub const NONE: Modifiers = Modifiers { bits: 0 };
304    pub const CTRL: Modifiers = Modifiers { bits: 1 };
305    pub const SHIFT: Modifiers = Modifiers { bits: 2 };
306    pub const ALT: Modifiers = Modifiers { bits: 4 };
307    pub const SUPER: Modifiers = Modifiers { bits: 8 };
308
309    /// The **primary accelerator** modifier for this platform: [`SUPER`]
310    /// (Command, ⌘) on macOS, [`CTRL`] everywhere else.
311    ///
312    /// Desktop platforms disagree about which physical key carries application
313    /// accelerators, and on macOS the disagreement is not cosmetic: Control is
314    /// reserved there for the text system and for the secondary click, while ⌘
315    /// is what a user presses for Save, Copy or Find. Code that hard-codes
316    /// [`CTRL`] to mean "the accelerator" therefore listens to the wrong key on
317    /// one of the three desktop platforms.
318    ///
319    /// Compare against this constant (or call [`Modifiers::command`]) and the
320    /// same code means Ctrl+A on Windows and Linux and ⌘A on macOS. This
321    /// mirrors Qt's `Qt::CTRL`, which likewise resolves to ⌘ on macOS, and the
322    /// convention the native menu bar already applies when it turns a declared
323    /// chord into an `NSMenuItem` key equivalent.
324    ///
325    /// [`SUPER`]: Modifiers::SUPER
326    /// [`CTRL`]: Modifiers::CTRL
327    pub const COMMAND: Modifiers = if cfg!(target_os = "macos") {
328        Self::SUPER
329    } else {
330        Self::CTRL
331    };
332
333    pub fn empty() -> Self {
334        Self::NONE
335    }
336
337    pub fn ctrl(self) -> bool {
338        self.bits & 1 != 0
339    }
340
341    pub fn shift(self) -> bool {
342        self.bits & 2 != 0
343    }
344
345    pub fn alt(self) -> bool {
346        self.bits & 4 != 0
347    }
348
349    pub fn super_key(self) -> bool {
350        self.bits & 8 != 0
351    }
352
353    /// Whether the platform's primary accelerator modifier
354    /// ([`Modifiers::COMMAND`]) is held: Command (⌘) on macOS, Control
355    /// everywhere else.
356    ///
357    /// Use this instead of [`ctrl`](Self::ctrl) wherever the chord means "the
358    /// accelerator" — select-all, the discontiguous-selection click, jump to
359    /// the end of a list. Keep [`ctrl`](Self::ctrl) for the chords that really
360    /// are Control on every platform, macOS included: Ctrl+Tab cycles tabs
361    /// there too (⌘Tab belongs to the application switcher and never reaches
362    /// an app).
363    pub fn command(self) -> bool {
364        self.contains(Self::COMMAND)
365    }
366
367    /// Whether every modifier in `other` is held.
368    pub fn contains(self, other: Modifiers) -> bool {
369        self.bits & other.bits == other.bits
370    }
371
372    /// These modifiers with `other` removed.
373    pub fn without(self, other: Modifiers) -> Modifiers {
374        Modifiers {
375            bits: self.bits & !other.bits,
376        }
377    }
378
379    /// These modifiers with a declared `CTRL` reinterpreted as the platform's
380    /// primary accelerator — see [`Modifiers::COMMAND`] and
381    /// [`KeyStroke::with_command_convention`](crate::shortcut::KeyStroke::with_command_convention),
382    /// which is where this is applied.
383    ///
384    /// A no-op off macOS (where `COMMAND` *is* `CTRL`), and a no-op for a chord
385    /// that already names `SUPER` explicitly: `Ctrl+Super` stays ⌃⌘, a genuine
386    /// two-modifier chord, rather than collapsing to one.
387    pub fn with_command_convention(self) -> Modifiers {
388        self.with_command_convention_using(Self::COMMAND)
389    }
390
391    /// The platform-parameterised core of
392    /// [`with_command_convention`](Self::with_command_convention). Split out so
393    /// the macOS branch is exercised by tests running on any host — the whole
394    /// point of the convention is behaviour a Linux CI cannot otherwise see.
395    ///
396    /// `pub(crate)` rather than private because the same split continues up the
397    /// stack: [`KeyStroke`](crate::shortcut::KeyStroke) and
398    /// [`Shortcut`](crate::shortcut::Shortcut) each carry a `_using` twin that
399    /// bottoms out here, so a shortcut's resolution can be asked "as macOS
400    /// would read it" from a Linux host without restating the rule.
401    pub(crate) fn with_command_convention_using(self, command: Modifiers) -> Modifiers {
402        if self.ctrl() && !self.super_key() {
403            self.without(Self::CTRL) | command
404        } else {
405            self
406        }
407    }
408}
409
410impl std::fmt::Display for Key {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        match self {
413            Key::Space => f.write_str("Space"),
414            Key::Enter => f.write_str("Enter"),
415            Key::Escape => f.write_str("Esc"),
416            Key::Tab => f.write_str("Tab"),
417            Key::Backspace => f.write_str("Backspace"),
418            Key::Delete => f.write_str("Del"),
419            Key::Insert => f.write_str("Ins"),
420            Key::ArrowUp => f.write_str("Up"),
421            Key::ArrowDown => f.write_str("Down"),
422            Key::ArrowLeft => f.write_str("Left"),
423            Key::ArrowRight => f.write_str("Right"),
424            Key::Home => f.write_str("Home"),
425            Key::End => f.write_str("End"),
426            Key::PageUp => f.write_str("PageUp"),
427            Key::PageDown => f.write_str("PageDown"),
428            Key::A => f.write_str("A"),
429            Key::B => f.write_str("B"),
430            Key::C => f.write_str("C"),
431            Key::D => f.write_str("D"),
432            Key::E => f.write_str("E"),
433            Key::F => f.write_str("F"),
434            Key::G => f.write_str("G"),
435            Key::H => f.write_str("H"),
436            Key::I => f.write_str("I"),
437            Key::J => f.write_str("J"),
438            Key::K => f.write_str("K"),
439            Key::L => f.write_str("L"),
440            Key::M => f.write_str("M"),
441            Key::N => f.write_str("N"),
442            Key::O => f.write_str("O"),
443            Key::P => f.write_str("P"),
444            Key::Q => f.write_str("Q"),
445            Key::R => f.write_str("R"),
446            Key::S => f.write_str("S"),
447            Key::T => f.write_str("T"),
448            Key::U => f.write_str("U"),
449            Key::V => f.write_str("V"),
450            Key::W => f.write_str("W"),
451            Key::X => f.write_str("X"),
452            Key::Y => f.write_str("Y"),
453            Key::Z => f.write_str("Z"),
454            Key::F1 => f.write_str("F1"),
455            Key::F2 => f.write_str("F2"),
456            Key::F3 => f.write_str("F3"),
457            Key::F4 => f.write_str("F4"),
458            Key::F5 => f.write_str("F5"),
459            Key::F6 => f.write_str("F6"),
460            Key::F7 => f.write_str("F7"),
461            Key::F8 => f.write_str("F8"),
462            Key::F9 => f.write_str("F9"),
463            Key::F10 => f.write_str("F10"),
464            Key::F11 => f.write_str("F11"),
465            Key::F12 => f.write_str("F12"),
466            Key::F13 => f.write_str("F13"),
467            Key::F14 => f.write_str("F14"),
468            Key::F15 => f.write_str("F15"),
469            Key::F16 => f.write_str("F16"),
470            Key::F17 => f.write_str("F17"),
471            Key::F18 => f.write_str("F18"),
472            Key::F19 => f.write_str("F19"),
473            Key::F20 => f.write_str("F20"),
474            Key::F21 => f.write_str("F21"),
475            Key::F22 => f.write_str("F22"),
476            Key::F23 => f.write_str("F23"),
477            Key::F24 => f.write_str("F24"),
478            Key::CapsLock => f.write_str("CapsLock"),
479            Key::ContextMenu => f.write_str("Menu"),
480            Key::Character(c) => write!(f, "{}", c.to_uppercase()),
481        }
482    }
483}
484
485impl std::fmt::Display for Modifiers {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        if self.ctrl() {
488            f.write_str("Ctrl+")?;
489        }
490        if self.alt() {
491            f.write_str("Alt+")?;
492        }
493        if self.shift() {
494            f.write_str("Shift+")?;
495        }
496        if self.super_key() {
497            // Named for the key the user is looking at. This string reaches
498            // assistive tech through the accessibility tree's
499            // `keyboard_shortcut`, and a Mac screen-reader user announced
500            // "Super+S" for ⌘S has been told the wrong key.
501            f.write_str(if cfg!(target_os = "macos") {
502                "Cmd+"
503            } else {
504                "Super+"
505            })?;
506        }
507        Ok(())
508    }
509}
510
511impl std::ops::BitOr for Modifiers {
512    type Output = Self;
513    fn bitor(self, rhs: Self) -> Self {
514        Modifiers {
515            bits: self.bits | rhs.bits,
516        }
517    }
518}
519
520/// Scroll delta from mouse wheel or trackpad.
521#[derive(Debug, Clone, Copy, PartialEq)]
522pub enum ScrollDelta {
523    /// Line-based scrolling (mouse wheel).
524    Lines { x: f32, y: f32 },
525    /// Pixel-based scrolling (trackpad).
526    Pixels { x: f32, y: f32 },
527}
528
529/// Where a [`WidgetEvent::ScrollIntoView`] target should come to rest on the
530/// scroll container's vertical axis.
531///
532/// The horizontal axis is always revealed minimally — a fraction only has an
533/// obvious meaning for the axis the request is *about*, and pinning a caret
534/// vertically must not yank a horizontally-scrolled view sideways.
535#[derive(Debug, Clone, Copy, PartialEq)]
536pub enum ScrollAlign {
537    /// Scroll the least amount that makes the target fully visible, and not at
538    /// all when it already is. This is what focus-driven reveals and
539    /// [`EventContext::ensure_visible`](crate::widget::EventContext::ensure_visible)
540    /// use, and it is the behaviour every scroll container had before
541    /// alignment existed.
542    Minimal,
543    /// Pin the target at `f` of the way down the viewport — `0.0` flush with
544    /// the top, `0.5` centred, `1.0` flush with the bottom — **whether or not
545    /// it is already visible**. Being unconditional is the whole point: a
546    /// typewriter-scrolling caret that only moved the view when it fell off
547    /// the edge would not be pinned at all.
548    ///
549    /// The container still clamps to its scroll range, so a target near the
550    /// start or end of the content comes to rest as close to `f` as the range
551    /// allows. See [`ScrollArea::scroll_past_end`] for buying range past the
552    /// end of the content so the last line can still reach the pin.
553    ///
554    /// [`ScrollArea::scroll_past_end`]: https://docs.rs/teksilo-widgets
555    Fraction(f32),
556}
557
558/// Whether a [`WidgetEvent::ScrollIntoView`] should jump or glide.
559///
560/// Split out from the container's own `smooth_scrolling` setting because the
561/// right answer depends on the *request*, not the container: a caret pinned on
562/// every keystroke must snap (animating it is what produces the "screen
563/// bouncing" typewriter-mode users complain about in other editors), while the
564/// same container gliding for a page-down or a search hit reads as polish.
565#[derive(Debug, Clone, Copy, PartialEq, Eq)]
566pub enum ScrollMotion {
567    /// Jump straight to the target offset.
568    Instant,
569    /// Animate to the target offset, if the container has smooth scrolling
570    /// enabled. Containers with `smooth_scrolling(false)` still jump.
571    Smooth,
572}
573
574/// Events dispatched to widgets.
575#[derive(Debug, Clone)]
576pub enum WidgetEvent {
577    PointerDown {
578        position: Point,
579        button: PointerButton,
580        modifiers: Modifiers,
581    },
582    PointerUp {
583        position: Point,
584        button: PointerButton,
585        modifiers: Modifiers,
586    },
587    PointerMove {
588        position: Point,
589    },
590    PointerEnter,
591    PointerLeave,
592    Scroll {
593        delta: ScrollDelta,
594        /// Modifier keys held at the time of the scroll event.
595        /// Defaults to `Modifiers::NONE` for synthesized events
596        /// (tests, keyboard-driven scroll requests). Real-platform
597        /// scroll events populate this from the platform's tracked
598        /// modifier state — apps detect Ctrl-wheel-to-zoom by
599        /// inspecting `modifiers.ctrl()`.
600        modifiers: Modifiers,
601    },
602    KeyDown {
603        key: Key,
604        modifiers: Modifiers,
605        text: Option<String>,
606    },
607    KeyUp {
608        key: Key,
609        modifiers: Modifiers,
610    },
611    ImeComposition {
612        text: String,
613        cursor: Option<std::ops::Range<usize>>,
614    },
615    ImeCommit {
616        text: String,
617    },
618    FocusGained {
619        origin: crate::focus::FocusOrigin,
620    },
621    FocusLost,
622    AccessAction {
623        action: accesskit::Action,
624        target: Option<crate::widget_id::WidgetId>,
625        /// Raw AccessKit NodeId from the original `ActionRequest`.
626        /// May be a synthetic (widget-emitted child) NodeId — use
627        /// `crate::accessibility::is_synthetic` to distinguish it
628        /// from a widget-derived NodeId. The widget that registered
629        /// the parent (retrieved via `tree.widget_for_synthetic`)
630        /// is the one set in `target`.
631        target_node: accesskit::NodeId,
632        /// Payload carried by the `ActionRequest`. For
633        /// `Action::SetTextSelection` this is
634        /// `ActionData::SetTextSelection(TextSelection)`, for
635        /// `Action::SetValue` it's `ActionData::Value(Box<str>)`,
636        /// for scroll actions it carries scroll offsets, etc.
637        /// Widgets that declare these actions must read the payload
638        /// to honour screen-reader-initiated requests.
639        data: Option<accesskit::ActionData>,
640    },
641    /// Dispatched by the framework to a clipping ancestor when a child
642    /// gains focus but is outside the viewport. The scroll area adjusts
643    /// its offset to make the target bounds visible, with an optional
644    /// margin around the target.
645    ScrollIntoView {
646        target_bounds: Rect,
647        /// Extra margin (in logical pixels) to keep around the target
648        /// when scrolling it into view. Defaults to 0.0.
649        margin: f32,
650        /// Where the target should end up on the scroll container's
651        /// **vertical** axis. [`ScrollAlign::Minimal`] (the default, and what
652        /// every focus-driven reveal uses) only scrolls when the target is not
653        /// already fully visible; [`ScrollAlign::Fraction`] *pins* it to a
654        /// fixed height in the viewport whether or not it was already visible.
655        align: ScrollAlign,
656        /// Whether the container should jump to the new offset or glide to it.
657        /// See [`ScrollMotion`].
658        motion: ScrollMotion,
659        /// Optional back-channel for the handling scroll container to report
660        /// how far it actually scrolled (`(dx, dy)` in content pixels). When
661        /// several nested scroll containers must each reveal the same target,
662        /// the ancestor walk (`scroll_rect_into_view`) reads this after
663        /// dispatching to an inner container and shifts `target_bounds` by the
664        /// negated delta before asking the next (outer) one — so the outer sees
665        /// where the target will land once the inner's (deferred) scroll
666        /// applies, not its pre-scroll position. `None` disables reporting (the
667        /// nested-reveal refinement is unavailable). A handler that ignores it
668        /// still works for the common single-container case.
669        ///
670        /// `Arc<Mutex<..>>` (not `Rc<Cell<..>>`) so `WidgetEvent` stays `Send`
671        /// — some events are posted across threads. This one is only ever
672        /// touched on the dispatch thread, so the lock is always uncontended.
673        applied_scroll: Option<std::sync::Arc<std::sync::Mutex<teksilo_canvas::Point>>>,
674    },
675    /// A recognized gesture event, routed through the same preview/bubble system.
676    Gesture {
677        gesture: GestureEvent,
678    },
679}
680
681/// The result of handling an event.
682#[derive(Debug, Clone, Copy, PartialEq, Eq)]
683pub enum EventResponse {
684    /// The event was handled; stop propagation.
685    Handled,
686    /// The event was not handled; let it bubble.
687    Ignored,
688}
689
690#[cfg(test)]
691mod modifier_tests {
692    use super::*;
693
694    // The convention itself, exercised on both platform settings from any host.
695    // `Modifiers::COMMAND` resolves at compile time, so a Linux CI would
696    // otherwise only ever see half of what this rule does — and the half it
697    // cannot see is the one the rule exists for.
698
699    #[test]
700    fn command_convention_rewrites_a_bare_ctrl_on_macos() {
701        let mac = Modifiers::CTRL.with_command_convention_using(Modifiers::SUPER);
702        assert_eq!(mac, Modifiers::SUPER);
703
704        let mac =
705            (Modifiers::CTRL | Modifiers::SHIFT).with_command_convention_using(Modifiers::SUPER);
706        assert_eq!(mac, Modifiers::SUPER | Modifiers::SHIFT);
707    }
708
709    #[test]
710    fn command_convention_is_a_no_op_where_command_is_ctrl() {
711        for m in [
712            Modifiers::CTRL,
713            Modifiers::CTRL | Modifiers::SHIFT,
714            Modifiers::ALT,
715            Modifiers::NONE,
716            Modifiers::SUPER,
717        ] {
718            assert_eq!(m.with_command_convention_using(Modifiers::CTRL), m);
719        }
720    }
721
722    #[test]
723    fn command_convention_leaves_an_explicit_super_alone() {
724        // A chord that already names Super is a deliberate ⌘ chord, and
725        // `Ctrl+Super` is a genuine two-modifier chord — neither collapses.
726        assert_eq!(
727            Modifiers::SUPER.with_command_convention_using(Modifiers::SUPER),
728            Modifiers::SUPER
729        );
730        let both = Modifiers::CTRL | Modifiers::SUPER;
731        assert_eq!(both.with_command_convention_using(Modifiers::SUPER), both);
732    }
733
734    #[test]
735    fn command_convention_is_idempotent() {
736        for command in [Modifiers::CTRL, Modifiers::SUPER] {
737            for m in [
738                Modifiers::CTRL,
739                Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT,
740                Modifiers::SUPER,
741                Modifiers::NONE,
742            ] {
743                let once = m.with_command_convention_using(command);
744                assert_eq!(once.with_command_convention_using(command), once);
745            }
746        }
747    }
748
749    #[test]
750    fn command_predicate_follows_the_platform() {
751        // Whichever platform this runs on, `COMMAND` is one of the two, and
752        // `command()` tracks exactly it.
753        assert!(Modifiers::COMMAND.command());
754        assert!(!Modifiers::ALT.command());
755        assert!((Modifiers::COMMAND | Modifiers::SHIFT).command());
756
757        if cfg!(target_os = "macos") {
758            assert_eq!(Modifiers::COMMAND, Modifiers::SUPER);
759            assert!(!Modifiers::CTRL.command());
760        } else {
761            assert_eq!(Modifiers::COMMAND, Modifiers::CTRL);
762            assert!(!Modifiers::SUPER.command());
763        }
764    }
765
766    #[test]
767    fn contains_requires_every_named_modifier() {
768        let cs = Modifiers::CTRL | Modifiers::SHIFT;
769        assert!(cs.contains(Modifiers::CTRL));
770        assert!(cs.contains(cs));
771        assert!(!cs.contains(Modifiers::CTRL | Modifiers::ALT));
772        assert!(cs.contains(Modifiers::NONE));
773    }
774
775    #[test]
776    fn without_clears_only_the_named_modifiers() {
777        let all = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::SUPER;
778        assert_eq!(
779            all.without(Modifiers::SUPER),
780            Modifiers::CTRL | Modifiers::SHIFT
781        );
782        assert_eq!(all.without(Modifiers::ALT), all);
783    }
784}