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;
7use crate::pointer::{CancelReason, EventTime, PointerInfo, ScrollPhase};
8
9/// Pointer button identifiers.
10///
11/// `Forward` and `Back` correspond to the auxiliary mouse buttons (mouse
12/// 4 / mouse 5) typically labelled "browser back / forward". Platforms
13/// that don't have those buttons simply never emit them.
14///
15/// `#[non_exhaustive]`: a stylus barrel button and an eraser-end press are
16/// buttons this enum will have to name, and neither exists yet. A downstream
17/// `match` therefore needs a `_` arm.
18#[non_exhaustive]
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub enum PointerButton {
21    /// Left-click (or main-action button on left-handed mice).
22    Primary,
23    /// Right-click.
24    Secondary,
25    /// Middle / wheel-click.
26    Middle,
27    /// "Back" auxiliary button (mouse 4 on most 5-button mice). Often
28    /// bound to "navigate back" in browsers.
29    Back,
30    /// "Forward" auxiliary button (mouse 5). Often bound to "navigate
31    /// forward".
32    Forward,
33}
34
35/// Set of pointer buttons a gesture recognizer is configured to fire
36/// for. Used by the four click-style recognizers (`TapRecognizer`,
37/// `DoubleTapRecognizer`, `TripleTapRecognizer`, `LongPressRecognizer`)
38/// and the matching widget-level builders (`accept_tap_buttons`, …).
39///
40/// Default for every recognizer is [`ButtonMask::PRIMARY`] — left-click
41/// only — which matches the user's expectation for a "tap" and keeps
42/// right-click free to open a context menu without spuriously
43/// activating the widget. Use [`ButtonMask::ALL`] or a hand-built
44/// `PRIMARY | SECONDARY` etc. to opt into broader button sets.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub struct ButtonMask(u8);
47
48impl ButtonMask {
49    /// Empty mask — no buttons accepted.
50    pub const NONE: Self = Self(0);
51    /// Left-click on most desktop pointing devices.
52    pub const PRIMARY: Self = Self(1 << 0);
53    /// Right-click on most desktop pointing devices.
54    pub const SECONDARY: Self = Self(1 << 1);
55    /// Middle / wheel-click.
56    pub const MIDDLE: Self = Self(1 << 2);
57    /// "Back" auxiliary button (mouse 4).
58    pub const BACK: Self = Self(1 << 3);
59    /// "Forward" auxiliary button (mouse 5).
60    pub const FORWARD: Self = Self(1 << 4);
61    /// All buttons currently representable by [`PointerButton`].
62    pub const ALL: Self = Self(0b0001_1111);
63
64    /// `true` when the mask contains the given button.
65    pub const fn contains(self, button: PointerButton) -> bool {
66        let bit = match button {
67            PointerButton::Primary => 1 << 0,
68            PointerButton::Secondary => 1 << 1,
69            PointerButton::Middle => 1 << 2,
70            PointerButton::Back => 1 << 3,
71            PointerButton::Forward => 1 << 4,
72        };
73        self.0 & bit != 0
74    }
75
76    /// `true` when no buttons are accepted.
77    pub const fn is_empty(self) -> bool {
78        self.0 == 0
79    }
80
81    /// Union — accept any button in either mask.
82    pub const fn union(self, other: Self) -> Self {
83        Self(self.0 | other.0)
84    }
85
86    /// Intersection — accept only buttons present in both masks.
87    pub const fn intersection(self, other: Self) -> Self {
88        Self(self.0 & other.0)
89    }
90}
91
92impl From<PointerButton> for ButtonMask {
93    fn from(button: PointerButton) -> Self {
94        match button {
95            PointerButton::Primary => Self::PRIMARY,
96            PointerButton::Secondary => Self::SECONDARY,
97            PointerButton::Middle => Self::MIDDLE,
98            PointerButton::Back => Self::BACK,
99            PointerButton::Forward => Self::FORWARD,
100        }
101    }
102}
103
104impl<const N: usize> From<[PointerButton; N]> for ButtonMask {
105    fn from(buttons: [PointerButton; N]) -> Self {
106        let mut mask = Self::NONE;
107        let mut i = 0;
108        while i < N {
109            mask = mask.union(ButtonMask::from(buttons[i]));
110            i += 1;
111        }
112        mask
113    }
114}
115
116impl std::ops::BitOr for ButtonMask {
117    type Output = Self;
118    fn bitor(self, rhs: Self) -> Self {
119        self.union(rhs)
120    }
121}
122
123impl std::ops::BitAnd for ButtonMask {
124    type Output = Self;
125    fn bitand(self, rhs: Self) -> Self {
126        self.intersection(rhs)
127    }
128}
129
130impl std::ops::BitOrAssign for ButtonMask {
131    fn bitor_assign(&mut self, rhs: Self) {
132        self.0 |= rhs.0;
133    }
134}
135
136impl std::ops::BitAndAssign for ButtonMask {
137    fn bitand_assign(&mut self, rhs: Self) {
138        self.0 &= rhs.0;
139    }
140}
141
142impl Default for ButtonMask {
143    fn default() -> Self {
144        Self::PRIMARY
145    }
146}
147
148/// Keyboard key identifiers.
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
150pub enum Key {
151    Space,
152    Enter,
153    Escape,
154    Tab,
155    Backspace,
156    Delete,
157    Insert,
158    ArrowUp,
159    ArrowDown,
160    ArrowLeft,
161    ArrowRight,
162    Home,
163    End,
164    PageUp,
165    PageDown,
166    // Letters
167    A,
168    B,
169    C,
170    D,
171    E,
172    F,
173    G,
174    H,
175    I,
176    J,
177    K,
178    L,
179    M,
180    N,
181    O,
182    P,
183    Q,
184    R,
185    S,
186    T,
187    U,
188    V,
189    W,
190    X,
191    Y,
192    Z,
193    // Function keys
194    F1,
195    F2,
196    F3,
197    F4,
198    F5,
199    F6,
200    F7,
201    F8,
202    F9,
203    F10,
204    F11,
205    F12,
206    F13,
207    F14,
208    F15,
209    F16,
210    F17,
211    F18,
212    F19,
213    F20,
214    F21,
215    F22,
216    F23,
217    F24,
218    // Other
219    /// Caps Lock. Delivered as a discrete key press/release (winit's
220    /// `ModifiersState` does not carry lock state), so consumers that
221    /// need the *active* lock state track it themselves on the
222    /// key-down edge. See `WindowState::caps_lock`.
223    CapsLock,
224    /// The dedicated context-menu key: `VK_APPS` on Windows (the key between
225    /// the right Alt and the right Ctrl on most PC layouts), `keysyms::Menu` on
226    /// X11 and Wayland.
227    ///
228    /// **macOS never produces it.** Its keyboards have no such key and
229    /// `winit-0.30.13`'s AppKit backend references the variant zero times, so
230    /// on that platform the only keyboard route to a context menu is a chord.
231    /// See the dispatcher's context-menu handling for the chords Teksilo
232    /// reserves.
233    ContextMenu,
234    Character(char),
235}
236
237impl Key {
238    /// Returns the character this key represents, if any.
239    /// Maps `Key::A`..`Key::Z` to `'a'`..`'z'` (lowercase) and
240    /// `Key::Character(ch)` to `ch`.
241    pub fn to_char(&self) -> Option<char> {
242        match self {
243            Key::A => Some('a'),
244            Key::B => Some('b'),
245            Key::C => Some('c'),
246            Key::D => Some('d'),
247            Key::E => Some('e'),
248            Key::F => Some('f'),
249            Key::G => Some('g'),
250            Key::H => Some('h'),
251            Key::I => Some('i'),
252            Key::J => Some('j'),
253            Key::K => Some('k'),
254            Key::L => Some('l'),
255            Key::M => Some('m'),
256            Key::N => Some('n'),
257            Key::O => Some('o'),
258            Key::P => Some('p'),
259            Key::Q => Some('q'),
260            Key::R => Some('r'),
261            Key::S => Some('s'),
262            Key::T => Some('t'),
263            Key::U => Some('u'),
264            Key::V => Some('v'),
265            Key::W => Some('w'),
266            Key::X => Some('x'),
267            Key::Y => Some('y'),
268            Key::Z => Some('z'),
269            Key::Character(ch) => Some(*ch),
270            _ => None,
271        }
272    }
273
274    /// The text the platform attaches to this key, for the handful of named
275    /// keys that carry any. Mirrors winit's `NamedKey::to_text`, which is
276    /// where these values reach the app from.
277    ///
278    /// Worth knowing because it is surprising: Escape arrives carrying
279    /// U+001B, so a widget that reads `KeyDown::text` sees text on a key
280    /// nobody thinks of as text. A `TextInputField` used to filter that
281    /// control character out, read the empty result as "input rejected" and
282    /// swallow the key — which is how Escape stopped bubbling out of a
283    /// focused field.
284    ///
285    /// Character keys are deliberately absent: `Key::A` is `None` here, and
286    /// the way to simulate typing is `type_text`, which already sends text.
287    /// The gap this closes is only the surprising one.
288    pub fn to_text(&self) -> Option<&'static str> {
289        match self {
290            Key::Enter => Some("\r"),
291            Key::Backspace => Some("\u{8}"),
292            Key::Tab => Some("\t"),
293            Key::Space => Some(" "),
294            Key::Escape => Some("\u{1b}"),
295            _ => None,
296        }
297    }
298}
299
300/// Keyboard modifier state.
301#[derive(
302    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
303)]
304pub struct Modifiers {
305    bits: u8,
306}
307
308impl Modifiers {
309    pub const NONE: Modifiers = Modifiers { bits: 0 };
310    pub const CTRL: Modifiers = Modifiers { bits: 1 };
311    pub const SHIFT: Modifiers = Modifiers { bits: 2 };
312    pub const ALT: Modifiers = Modifiers { bits: 4 };
313    pub const SUPER: Modifiers = Modifiers { bits: 8 };
314
315    /// The **primary accelerator** modifier for this platform: [`SUPER`]
316    /// (Command, ⌘) on macOS, [`CTRL`] everywhere else.
317    ///
318    /// Desktop platforms disagree about which physical key carries application
319    /// accelerators, and on macOS the disagreement is not cosmetic: Control is
320    /// reserved there for the text system and for the secondary click, while ⌘
321    /// is what a user presses for Save, Copy or Find. Code that hard-codes
322    /// [`CTRL`] to mean "the accelerator" therefore listens to the wrong key on
323    /// one of the three desktop platforms.
324    ///
325    /// Compare against this constant (or call [`Modifiers::command`]) and the
326    /// same code means Ctrl+A on Windows and Linux and ⌘A on macOS. This
327    /// mirrors Qt's `Qt::CTRL`, which likewise resolves to ⌘ on macOS, and the
328    /// convention the native menu bar already applies when it turns a declared
329    /// chord into an `NSMenuItem` key equivalent.
330    ///
331    /// [`SUPER`]: Modifiers::SUPER
332    /// [`CTRL`]: Modifiers::CTRL
333    pub const COMMAND: Modifiers = if cfg!(target_os = "macos") {
334        Self::SUPER
335    } else {
336        Self::CTRL
337    };
338
339    pub fn empty() -> Self {
340        Self::NONE
341    }
342
343    pub fn ctrl(self) -> bool {
344        self.bits & 1 != 0
345    }
346
347    pub fn shift(self) -> bool {
348        self.bits & 2 != 0
349    }
350
351    pub fn alt(self) -> bool {
352        self.bits & 4 != 0
353    }
354
355    pub fn super_key(self) -> bool {
356        self.bits & 8 != 0
357    }
358
359    /// Whether the platform's primary accelerator modifier
360    /// ([`Modifiers::COMMAND`]) is held: Command (⌘) on macOS, Control
361    /// everywhere else.
362    ///
363    /// Use this instead of [`ctrl`](Self::ctrl) wherever the chord means "the
364    /// accelerator" — select-all, the discontiguous-selection click, jump to
365    /// the end of a list. Keep [`ctrl`](Self::ctrl) for the chords that really
366    /// are Control on every platform, macOS included: Ctrl+Tab cycles tabs
367    /// there too (⌘Tab belongs to the application switcher and never reaches
368    /// an app).
369    pub fn command(self) -> bool {
370        self.contains(Self::COMMAND)
371    }
372
373    /// Whether every modifier in `other` is held.
374    pub fn contains(self, other: Modifiers) -> bool {
375        self.bits & other.bits == other.bits
376    }
377
378    /// These modifiers with `other` removed.
379    pub fn without(self, other: Modifiers) -> Modifiers {
380        Modifiers {
381            bits: self.bits & !other.bits,
382        }
383    }
384
385    /// These modifiers with a declared `CTRL` reinterpreted as the platform's
386    /// primary accelerator — see [`Modifiers::COMMAND`] and
387    /// [`KeyStroke::with_command_convention`](crate::shortcut::KeyStroke::with_command_convention),
388    /// which is where this is applied.
389    ///
390    /// A no-op off macOS (where `COMMAND` *is* `CTRL`), and a no-op for a chord
391    /// that already names `SUPER` explicitly: `Ctrl+Super` stays ⌃⌘, a genuine
392    /// two-modifier chord, rather than collapsing to one.
393    pub fn with_command_convention(self) -> Modifiers {
394        self.with_command_convention_using(Self::COMMAND)
395    }
396
397    /// The platform-parameterised core of
398    /// [`with_command_convention`](Self::with_command_convention). Split out so
399    /// the macOS branch is exercised by tests running on any host — the whole
400    /// point of the convention is behaviour a Linux CI cannot otherwise see.
401    ///
402    /// `pub(crate)` rather than private because the same split continues up the
403    /// stack: [`KeyStroke`](crate::shortcut::KeyStroke) and
404    /// [`Shortcut`](crate::shortcut::Shortcut) each carry a `_using` twin that
405    /// bottoms out here, so a shortcut's resolution can be asked "as macOS
406    /// would read it" from a Linux host without restating the rule.
407    pub(crate) fn with_command_convention_using(self, command: Modifiers) -> Modifiers {
408        if self.ctrl() && !self.super_key() {
409            self.without(Self::CTRL) | command
410        } else {
411            self
412        }
413    }
414}
415
416impl std::fmt::Display for Key {
417    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
418        match self {
419            Key::Space => f.write_str("Space"),
420            Key::Enter => f.write_str("Enter"),
421            Key::Escape => f.write_str("Esc"),
422            Key::Tab => f.write_str("Tab"),
423            Key::Backspace => f.write_str("Backspace"),
424            Key::Delete => f.write_str("Del"),
425            Key::Insert => f.write_str("Ins"),
426            Key::ArrowUp => f.write_str("Up"),
427            Key::ArrowDown => f.write_str("Down"),
428            Key::ArrowLeft => f.write_str("Left"),
429            Key::ArrowRight => f.write_str("Right"),
430            Key::Home => f.write_str("Home"),
431            Key::End => f.write_str("End"),
432            Key::PageUp => f.write_str("PageUp"),
433            Key::PageDown => f.write_str("PageDown"),
434            Key::A => f.write_str("A"),
435            Key::B => f.write_str("B"),
436            Key::C => f.write_str("C"),
437            Key::D => f.write_str("D"),
438            Key::E => f.write_str("E"),
439            Key::F => f.write_str("F"),
440            Key::G => f.write_str("G"),
441            Key::H => f.write_str("H"),
442            Key::I => f.write_str("I"),
443            Key::J => f.write_str("J"),
444            Key::K => f.write_str("K"),
445            Key::L => f.write_str("L"),
446            Key::M => f.write_str("M"),
447            Key::N => f.write_str("N"),
448            Key::O => f.write_str("O"),
449            Key::P => f.write_str("P"),
450            Key::Q => f.write_str("Q"),
451            Key::R => f.write_str("R"),
452            Key::S => f.write_str("S"),
453            Key::T => f.write_str("T"),
454            Key::U => f.write_str("U"),
455            Key::V => f.write_str("V"),
456            Key::W => f.write_str("W"),
457            Key::X => f.write_str("X"),
458            Key::Y => f.write_str("Y"),
459            Key::Z => f.write_str("Z"),
460            Key::F1 => f.write_str("F1"),
461            Key::F2 => f.write_str("F2"),
462            Key::F3 => f.write_str("F3"),
463            Key::F4 => f.write_str("F4"),
464            Key::F5 => f.write_str("F5"),
465            Key::F6 => f.write_str("F6"),
466            Key::F7 => f.write_str("F7"),
467            Key::F8 => f.write_str("F8"),
468            Key::F9 => f.write_str("F9"),
469            Key::F10 => f.write_str("F10"),
470            Key::F11 => f.write_str("F11"),
471            Key::F12 => f.write_str("F12"),
472            Key::F13 => f.write_str("F13"),
473            Key::F14 => f.write_str("F14"),
474            Key::F15 => f.write_str("F15"),
475            Key::F16 => f.write_str("F16"),
476            Key::F17 => f.write_str("F17"),
477            Key::F18 => f.write_str("F18"),
478            Key::F19 => f.write_str("F19"),
479            Key::F20 => f.write_str("F20"),
480            Key::F21 => f.write_str("F21"),
481            Key::F22 => f.write_str("F22"),
482            Key::F23 => f.write_str("F23"),
483            Key::F24 => f.write_str("F24"),
484            Key::CapsLock => f.write_str("CapsLock"),
485            Key::ContextMenu => f.write_str("Menu"),
486            Key::Character(c) => write!(f, "{}", c.to_uppercase()),
487        }
488    }
489}
490
491impl std::fmt::Display for Modifiers {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        if self.ctrl() {
494            f.write_str("Ctrl+")?;
495        }
496        if self.alt() {
497            f.write_str("Alt+")?;
498        }
499        if self.shift() {
500            f.write_str("Shift+")?;
501        }
502        if self.super_key() {
503            // Named for the key the user is looking at. This string reaches
504            // assistive tech through the accessibility tree's
505            // `keyboard_shortcut`, and a Mac screen-reader user announced
506            // "Super+S" for ⌘S has been told the wrong key.
507            f.write_str(if cfg!(target_os = "macos") {
508                "Cmd+"
509            } else {
510                "Super+"
511            })?;
512        }
513        Ok(())
514    }
515}
516
517impl std::ops::BitOr for Modifiers {
518    type Output = Self;
519    fn bitor(self, rhs: Self) -> Self {
520        Modifiers {
521            bits: self.bits | rhs.bits,
522        }
523    }
524}
525
526/// Scroll delta from mouse wheel or trackpad.
527#[derive(Debug, Clone, Copy, PartialEq)]
528pub enum ScrollDelta {
529    /// Line-based scrolling (mouse wheel).
530    Lines { x: f32, y: f32 },
531    /// Pixel-based scrolling (trackpad).
532    Pixels { x: f32, y: f32 },
533}
534
535/// Where a [`WidgetEvent::ScrollIntoView`] target should come to rest on the
536/// scroll container's vertical axis.
537///
538/// The horizontal axis is always revealed minimally — a fraction only has an
539/// obvious meaning for the axis the request is *about*, and pinning a caret
540/// vertically must not yank a horizontally-scrolled view sideways.
541#[derive(Debug, Clone, Copy, PartialEq)]
542pub enum ScrollAlign {
543    /// Scroll the least amount that makes the target fully visible, and not at
544    /// all when it already is. This is what focus-driven reveals and
545    /// [`EventContext::ensure_visible`](crate::widget::EventContext::ensure_visible)
546    /// use, and it is the behaviour every scroll container had before
547    /// alignment existed.
548    Minimal,
549    /// Pin the target at `f` of the way down the viewport — `0.0` flush with
550    /// the top, `0.5` centred, `1.0` flush with the bottom — **whether or not
551    /// it is already visible**. Being unconditional is the whole point: a
552    /// typewriter-scrolling caret that only moved the view when it fell off
553    /// the edge would not be pinned at all.
554    ///
555    /// The container still clamps to its scroll range, so a target near the
556    /// start or end of the content comes to rest as close to `f` as the range
557    /// allows. See [`ScrollArea::scroll_past_end`] for buying range past the
558    /// end of the content so the last line can still reach the pin.
559    ///
560    /// [`ScrollArea::scroll_past_end`]: https://docs.rs/teksilo-widgets
561    Fraction(f32),
562}
563
564/// Whether a [`WidgetEvent::ScrollIntoView`] should jump or glide.
565///
566/// Split out from the container's own `smooth_scrolling` setting because the
567/// right answer depends on the *request*, not the container: a caret pinned on
568/// every keystroke must snap (animating it is what produces the "screen
569/// bouncing" typewriter-mode users complain about in other editors), while the
570/// same container gliding for a page-down or a search hit reads as polish.
571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
572pub enum ScrollMotion {
573    /// Jump straight to the target offset.
574    Instant,
575    /// Animate to the target offset, if the container has smooth scrolling
576    /// enabled. Containers with `smooth_scrolling(false)` still jump.
577    Smooth,
578}
579
580/// Where the text of an [`WidgetEvent::ImeCommit`] came from: which part of the
581/// platform's text input system committed it.
582///
583/// `#[non_exhaustive]` because it is a platform input vocabulary that grows per
584/// OS, like [`CancelReason`]: match with a wildcard arm.
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
586#[non_exhaustive]
587pub enum ImeOrigin {
588    /// The end of a composition the user typed, such as a CJK candidate or a
589    /// dead-key accent; what winit reports as `Ime::Commit`. The default.
590    #[default]
591    Composition,
592    /// Speech the platform's dictation recognised (macOS Dictation, Windows
593    /// voice typing).
594    Dictation,
595    /// Text a system writing assistant produced to replace the user's (macOS
596    /// Writing Tools).
597    WritingTools,
598    /// Text the input system inserted outside a key press and outside a
599    /// composition, such as the emoji viewer or panel, AutoFill, or an accent
600    /// picked from the press-and-hold popup with the mouse.
601    Insertion,
602}
603
604/// Events dispatched to widgets.
605#[derive(Debug, Clone)]
606pub enum WidgetEvent {
607    /// A button went down.
608    PointerDown {
609        /// Where, in the receiving widget's own coordinate space (the router
610        /// localises it on delivery — see
611        /// [`WidgetTree::dispatch_pointer`](crate::WidgetTree::dispatch_pointer)).
612        position: Point,
613        /// Which button. A direct pointer reports
614        /// [`PointerButton::Primary`] for a contact.
615        button: PointerButton,
616        /// Modifier keys held when the press landed.
617        modifiers: Modifiers,
618        /// Who pressed — identity, kind, buttons, axes and timestamp.
619        ///
620        /// Read it through [`EventContext::pointer`](crate::widget::EventContext::pointer)
621        /// or its `pointer_kind()` shorthand rather than by destructuring, so a
622        /// widget that only needs "was this a finger?" does not have to name
623        /// the whole struct. Defaults to
624        /// [`PointerInfo::mouse`] at the epoch for every legacy construction
625        /// site and for [`pointer_down`](Self::pointer_down), so a site that
626        /// says nothing about pointers keeps meaning what it meant before the
627        /// touch programme.
628        pointer: PointerInfo,
629    },
630    /// A button came up.
631    PointerUp {
632        /// Where, in the receiving widget's own coordinate space.
633        position: Point,
634        /// Which button was released.
635        button: PointerButton,
636        /// Modifier keys held when the release landed.
637        modifiers: Modifiers,
638        /// Who released. See [`PointerDown::pointer`](Self::PointerDown).
639        pointer: PointerInfo,
640    },
641    /// A pointer moved. Sent whether or not a button is held; a contact only
642    /// ever moves with its button held, since a finger cannot hover.
643    PointerMove {
644        /// Where, in the receiving widget's own coordinate space.
645        position: Point,
646        /// Modifier keys held during the move.
647        ///
648        /// A drag decides what it means from the modifiers at the *move*, not
649        /// at the press — Shift extends a selection and Ctrl makes a marquee
650        /// additive from the moment the key goes down, mid-drag included.
651        /// Defaults to [`Modifiers::NONE`] for
652        /// [`pointer_move`](Self::pointer_move) and for a producer that tracks
653        /// no modifier state.
654        modifiers: Modifiers,
655        /// Who moved. See [`PointerDown::pointer`](Self::PointerDown).
656        pointer: PointerInfo,
657    },
658    /// The hover owner came onto this widget. Never sent for a contact: a
659    /// finger writes no hover (see
660    /// [`PointerTable::hover_owner`](crate::pointer::table::PointerTable::hover_owner)).
661    PointerEnter {
662        /// Who entered — a mouse, or a pen in proximity.
663        pointer: PointerInfo,
664    },
665    /// The hover owner left this widget.
666    PointerLeave {
667        /// Who left. See [`PointerEnter`](Self::PointerEnter).
668        pointer: PointerInfo,
669    },
670    Scroll {
671        delta: ScrollDelta,
672        /// Modifier keys held at the time of the scroll event.
673        /// Defaults to `Modifiers::NONE` for synthesized events
674        /// (tests, keyboard-driven scroll requests). Real-platform
675        /// scroll events populate this from the platform's tracked
676        /// modifier state — apps detect Ctrl-wheel-to-zoom by
677        /// inspecting `modifiers.ctrl()`.
678        modifiers: Modifiers,
679        /// Where the pointer was when the scroll happened, in **window**-logical
680        /// coordinates, or `None` when the producer has no position for it.
681        ///
682        /// The frame is in the name because it is the one positional field a
683        /// handler receives that is *not* localised to the receiving widget
684        /// (`localize_event` deliberately has no `Scroll` arm), and a widget
685        /// that reads it as a local point silently lands a cell or a row out.
686        /// Convert with the receiver's own bounds before using it as content
687        /// coordinates.
688        ///
689        /// It is window-space because both of its frame-sensitive uses need it
690        /// to be. The router **routes** by it — `Some` hit-tests, `None` falls
691        /// back to the hovered (else focused) widget — and hit-testing is
692        /// necessarily window-space. And `common/scrollable.rs`'s
693        /// `handle_scroll_event` feeds it to `pan_step` →
694        /// [`KineticScroller::pan`](crate::kinetic::KineticScroller::pan),
695        /// whose tracker follows the *pointer*: localisation resolves against
696        /// the captor's **current** bounds on every event, so a localised
697        /// position would feed that tracker samples polluted by the motion of
698        /// the very widget being measured.
699        ///
700        /// A mouse wheel has always been positionless and stays so — hover is
701        /// under the cursor, so hit-testing would find the same widget anyway.
702        /// A pan synthesised from a direct pointer *must* carry one, because a
703        /// contact never writes hover and a positionless pan would route
704        /// nowhere.
705        window_position: Option<Point>,
706        /// Where in a continuous scroll gesture this sample sits.
707        /// [`ScrollPhase::Discrete`] — a self-contained wheel notch — for
708        /// everything Teksilo produced before the touch programme.
709        phase: ScrollPhase,
710        /// Who scrolled. Defaults to
711        /// [`PointerInfo::mouse`](crate::pointer::PointerInfo::mouse) at the
712        /// epoch for every legacy construction site; a real sample carries the
713        /// pointer's identity, kind and timestamp.
714        pointer: PointerInfo,
715    },
716    /// A pointer interaction was revoked by the system rather than completed by
717    /// the user — see [`CancelReason`].
718    ///
719    /// Distinct from [`PointerUp`](Self::PointerUp) on purpose: an Up means the
720    /// user finished, so a drag drops and a tap fires; a cancel means the
721    /// interaction is being taken away, so state must be unwound and nothing
722    /// may activate.
723    ///
724    /// **Terminal**: no `PointerUp` follows for that pointer, and one that
725    /// arrives anyway is swallowed. Delivered by the cancel funnel,
726    /// [`WidgetTree::cancel_pointer`](crate::WidgetTree::cancel_pointer), to
727    /// the widget holding the pointer — or, failing that, to the last one that
728    /// accepted an event from it. A widget receives it through
729    /// `.on_pointer_cancel(..)` or through its raw `on_pointer_event` hook.
730    PointerCancel {
731        /// Where the pointer was last seen, in **window**-logical coordinates,
732        /// when the revoking path knows. A platform cancel usually carries no
733        /// position at all.
734        ///
735        /// Window-space, and named for it, for the same reason as
736        /// [`Scroll::window_position`](Self::Scroll) — but kept there by a
737        /// different mechanism, worth knowing before "fixing" either. The cancel
738        /// funnel delivers through the router's **non**-localising route
739        /// (`dispatch_to_widget_direct`), so what puts this value in window space
740        /// is simply that the funnel records the pointer table's own position
741        /// verbatim; `localize_event` having no `PointerCancel` arm is true but
742        /// would not matter on this path. A widget whose
743        /// `PointerDown`/`Move`/`Up` handling works in local coordinates must
744        /// convert before feeding this to the same sink.
745        window_position: Option<Point>,
746        /// Why the interaction was revoked.
747        reason: CancelReason,
748        /// Which pointer was revoked.
749        pointer: PointerInfo,
750    },
751    /// A key press.
752    KeyDown {
753        /// The key the chord names, on every platform: Alt+A is `Key::A`. On
754        /// macOS a press with Option (and neither Control nor Command) names
755        /// the key without Option too, though Option composed a character
756        /// from it, so an `Alt+A` shortcut fires there as elsewhere. One
757        /// exception: Option and Shift with any key but an ASCII letter (a
758        /// digit, punctuation, a Cyrillic or Greek letter) keep the composed
759        /// character as their key.
760        key: Key,
761        /// The modifiers held.
762        modifiers: Modifiers,
763        /// What the press typed, if anything: on macOS the character Option
764        /// composed ("å" for Option+A). `None` for a command chord (Control
765        /// without Alt, Super) and for a key that types nothing.
766        text: Option<String>,
767    },
768    /// A key release.
769    KeyUp {
770        /// The key the chord names, as for [`WidgetEvent::KeyDown`].
771        key: Key,
772        /// The modifiers held.
773        modifiers: Modifiers,
774    },
775    /// One update of the platform input method's composition (preedit).
776    ImeComposition {
777        /// The composition's current text. Empty ends the composition.
778        text: String,
779        /// The caret or selection inside `text`, in byte offsets of `text`.
780        cursor: Option<std::ops::Range<usize>>,
781        /// The range of the focused field's text (its character offsets) the
782        /// composition takes the place of, when the input method re-marks
783        /// text already in the field (Korean Hanja conversion,
784        /// reconversion). Sent with the composition's first update; the
785        /// field removes that text and shows the composition where it was,
786        /// and puts it back if the composition is cancelled. `None` shows
787        /// the composition at the selection.
788        replacement: Option<std::ops::Range<usize>>,
789    },
790    /// Text the platform's input system committed into the focused field.
791    ImeCommit {
792        /// The committed text.
793        text: String,
794        /// Which part of the input system committed it.
795        origin: ImeOrigin,
796        /// The range of the focused field's text this commit replaces, in that
797        /// field's character (Unicode scalar) offsets, when the input system
798        /// named one (a dictation correction, a Writing Tools replacement).
799        /// `None` replaces the selection, or inserts at the caret when there
800        /// is none.
801        replacement: Option<std::ops::Range<usize>>,
802    },
803    FocusGained {
804        origin: crate::focus::FocusOrigin,
805    },
806    FocusLost,
807    AccessAction {
808        action: accesskit::Action,
809        target: Option<crate::widget_id::WidgetId>,
810        /// Raw AccessKit NodeId from the original `ActionRequest`.
811        /// May be a synthetic (widget-emitted child) NodeId — use
812        /// `crate::accessibility::is_synthetic` to distinguish it
813        /// from a widget-derived NodeId. The widget that registered
814        /// the parent (retrieved via `tree.widget_for_synthetic`)
815        /// is the one set in `target`.
816        target_node: accesskit::NodeId,
817        /// Payload carried by the `ActionRequest`. For
818        /// `Action::SetTextSelection` this is
819        /// `ActionData::SetTextSelection(TextSelection)`, for
820        /// `Action::SetValue` it's `ActionData::Value(Box<str>)`,
821        /// for scroll actions it carries scroll offsets, etc.
822        /// Widgets that declare these actions must read the payload
823        /// to honour screen-reader-initiated requests.
824        data: Option<accesskit::ActionData>,
825    },
826    /// Dispatched by the framework to a clipping ancestor when a child
827    /// gains focus but is outside the viewport. The scroll area adjusts
828    /// its offset to make the target bounds visible, with an optional
829    /// margin around the target.
830    ///
831    /// `target_bounds` is where the **last layout** put the target (arena
832    /// bounds), so a handler converts it to content space with the offset
833    /// that layout placed its content at, and compares it with its **live**
834    /// viewport (or the offset a running tween is heading for). The two differ
835    /// when something scrolled the container since that layout: the focus
836    /// follow ahead of a handler's own `ensure_visible`, a wheel notch, an
837    /// offset written by the application, a glide. A container that marks
838    /// itself with `BuildContext::judge_reveals_live` is sent the event even
839    /// when the target looks visible from the laid-out bounds, so it must
840    /// move only as far as the target needs, and answer `Ignored` when it
841    /// does not move. `ScrollArea` and `GridView` are marked. A `SceneView`
842    /// is not: the walk tests its target through the live camera transform
843    /// already. The horizontal conversion assumes left-to-right placement;
844    /// `ScrollArea` does not mirror it for right-to-left content.
845    ScrollIntoView {
846        target_bounds: Rect,
847        /// Extra margin (in logical pixels) to keep around the target
848        /// when scrolling it into view. Defaults to 0.0.
849        margin: f32,
850        /// Where the target should end up on the scroll container's
851        /// **vertical** axis. [`ScrollAlign::Minimal`] (the default, and what
852        /// every focus-driven reveal uses) only scrolls when the target is not
853        /// already fully visible; [`ScrollAlign::Fraction`] *pins* it to a
854        /// fixed height in the viewport whether or not it was already visible.
855        align: ScrollAlign,
856        /// Whether the container should jump to the new offset or glide to it.
857        /// See [`ScrollMotion`].
858        motion: ScrollMotion,
859        /// Optional back-channel for the handling scroll container to report
860        /// how far the target moves from where `target_bounds` puts it
861        /// (`(dx, dy)` in content pixels): the move from the offset the last
862        /// layout placed its content at, which includes a move earlier in the
863        /// same turn even when this one moves nothing. When
864        /// several nested scroll containers must each reveal the same target,
865        /// the ancestor walk (`scroll_rect_into_view`) reads this after
866        /// dispatching to an inner container and shifts `target_bounds` by the
867        /// negated delta before asking the next (outer) one — so the outer sees
868        /// where the target will land once the inner's (deferred) scroll
869        /// applies, not its pre-scroll position. `None` disables reporting (the
870        /// nested-reveal refinement is unavailable). A handler that ignores it
871        /// still works for the common single-container case.
872        ///
873        /// `Arc<Mutex<..>>` (not `Rc<Cell<..>>`) so `WidgetEvent` stays `Send`
874        /// — some events are posted across threads. This one is only ever
875        /// touched on the dispatch thread, so the lock is always uncontended.
876        applied_scroll: Option<std::sync::Arc<std::sync::Mutex<teksilo_canvas::Point>>>,
877    },
878    /// A recognized gesture event, routed through the same preview/bubble system.
879    Gesture {
880        gesture: GestureEvent,
881    },
882}
883
884impl WidgetEvent {
885    /// An [`ImeCommit`](Self::ImeCommit) of `text` from a composition
886    /// ([`ImeOrigin::Composition`]) that names no range to replace.
887    pub fn ime_commit(text: impl Into<String>) -> Self {
888        Self::ImeCommit {
889            text: text.into(),
890            origin: ImeOrigin::Composition,
891            replacement: None,
892        }
893    }
894
895    /// An [`ImeComposition`](Self::ImeComposition) update that takes the
896    /// place of no existing text (`replacement` is `None`).
897    pub fn ime_composition(
898        text: impl Into<String>,
899        cursor: Option<std::ops::Range<usize>>,
900    ) -> Self {
901        Self::ImeComposition {
902            text: text.into(),
903            cursor,
904            replacement: None,
905        }
906    }
907
908    /// A wheel notch with no position — routed by the hovered (else focused)
909    /// widget, exactly as every scroll in Teksilo was before the touch
910    /// programme.
911    ///
912    /// It exists so that the three fields [`Scroll`](Self::Scroll) gained cost
913    /// each of its construction sites one line rather than five. The pointer
914    /// defaults to
915    /// [`PointerInfo::mouse`] at [`EventTime::ZERO`]: a free constructor has no
916    /// tree and therefore no clock, and nothing reads the timestamp of a
917    /// legacy-constructed event. A sample that has a real time enters through
918    /// [`WidgetTree::dispatch_scroll`](crate::WidgetTree::dispatch_scroll)
919    /// instead.
920    pub fn scroll(delta: ScrollDelta, modifiers: Modifiers) -> Self {
921        Self::Scroll {
922            delta,
923            modifiers,
924            window_position: None,
925            phase: ScrollPhase::Discrete,
926            pointer: PointerInfo::mouse(EventTime::ZERO),
927        }
928    }
929
930    /// A wheel notch routed by hit test at `position` rather than by hover.
931    ///
932    /// Use this where the producer genuinely knows where the pointer was; a
933    /// mouse-wheel translator should keep using [`scroll`](Self::scroll), whose
934    /// hover routing is what it has always had.
935    pub fn scroll_at(delta: ScrollDelta, modifiers: Modifiers, position: Point) -> Self {
936        Self::Scroll {
937            delta,
938            modifiers,
939            window_position: Some(position),
940            phase: ScrollPhase::Discrete,
941            pointer: PointerInfo::mouse(EventTime::ZERO),
942        }
943    }
944
945    /// A mouse press: [`PointerInfo::mouse`] at the epoch.
946    ///
947    /// This and its siblings are why adding `pointer` to the five `Pointer*`
948    /// variants was a one-line-per-site sweep rather than a rewrite. Use them
949    /// wherever the producer genuinely describes a mouse — every test that is
950    /// pinning mouse behaviour, and every synthesizer that has no pointer of
951    /// its own. A producer that *does* know which pointer it speaks for must
952    /// write the variant out and thread the real [`PointerInfo`], or
953    /// `ctx.pointer_kind()` reads `Mouse` for a finger and every direct-pointer
954    /// branch in the framework silently takes the indirect path.
955    pub fn pointer_down(position: Point, button: PointerButton, modifiers: Modifiers) -> Self {
956        Self::PointerDown {
957            position,
958            button,
959            modifiers,
960            pointer: PointerInfo::mouse(EventTime::ZERO),
961        }
962    }
963
964    /// A mouse release. See [`pointer_down`](Self::pointer_down).
965    pub fn pointer_up(position: Point, button: PointerButton, modifiers: Modifiers) -> Self {
966        Self::PointerUp {
967            position,
968            button,
969            modifiers,
970            pointer: PointerInfo::mouse(EventTime::ZERO),
971        }
972    }
973
974    /// A mouse move with no modifiers held. See
975    /// [`pointer_down`](Self::pointer_down); use
976    /// [`pointer_move_with`](Self::pointer_move_with) where the producer tracks
977    /// modifier state, since a drag reads Shift and Ctrl from the *move*.
978    pub fn pointer_move(position: Point) -> Self {
979        Self::pointer_move_with(position, Modifiers::NONE)
980    }
981
982    /// A mouse move carrying tracked modifier state. See
983    /// [`pointer_down`](Self::pointer_down).
984    pub fn pointer_move_with(position: Point, modifiers: Modifiers) -> Self {
985        Self::PointerMove {
986            position,
987            modifiers,
988            pointer: PointerInfo::mouse(EventTime::ZERO),
989        }
990    }
991
992    /// The mouse entered a widget. See [`pointer_down`](Self::pointer_down).
993    pub fn pointer_enter() -> Self {
994        Self::PointerEnter {
995            pointer: PointerInfo::mouse(EventTime::ZERO),
996        }
997    }
998
999    /// The mouse left a widget. See [`pointer_down`](Self::pointer_down).
1000    pub fn pointer_leave() -> Self {
1001        Self::PointerLeave {
1002            pointer: PointerInfo::mouse(EventTime::ZERO),
1003        }
1004    }
1005}
1006
1007/// The result of handling an event.
1008#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1009pub enum EventResponse {
1010    /// The event was handled; stop propagation.
1011    Handled,
1012    /// The event was not handled; let it bubble.
1013    Ignored,
1014}
1015
1016#[cfg(test)]
1017mod modifier_tests {
1018    use super::*;
1019
1020    // The convention itself, exercised on both platform settings from any host.
1021    // `Modifiers::COMMAND` resolves at compile time, so a Linux CI would
1022    // otherwise only ever see half of what this rule does — and the half it
1023    // cannot see is the one the rule exists for.
1024
1025    #[test]
1026    fn command_convention_rewrites_a_bare_ctrl_on_macos() {
1027        let mac = Modifiers::CTRL.with_command_convention_using(Modifiers::SUPER);
1028        assert_eq!(mac, Modifiers::SUPER);
1029
1030        let mac =
1031            (Modifiers::CTRL | Modifiers::SHIFT).with_command_convention_using(Modifiers::SUPER);
1032        assert_eq!(mac, Modifiers::SUPER | Modifiers::SHIFT);
1033    }
1034
1035    #[test]
1036    fn command_convention_is_a_no_op_where_command_is_ctrl() {
1037        for m in [
1038            Modifiers::CTRL,
1039            Modifiers::CTRL | Modifiers::SHIFT,
1040            Modifiers::ALT,
1041            Modifiers::NONE,
1042            Modifiers::SUPER,
1043        ] {
1044            assert_eq!(m.with_command_convention_using(Modifiers::CTRL), m);
1045        }
1046    }
1047
1048    #[test]
1049    fn command_convention_leaves_an_explicit_super_alone() {
1050        // A chord that already names Super is a deliberate ⌘ chord, and
1051        // `Ctrl+Super` is a genuine two-modifier chord — neither collapses.
1052        assert_eq!(
1053            Modifiers::SUPER.with_command_convention_using(Modifiers::SUPER),
1054            Modifiers::SUPER
1055        );
1056        let both = Modifiers::CTRL | Modifiers::SUPER;
1057        assert_eq!(both.with_command_convention_using(Modifiers::SUPER), both);
1058    }
1059
1060    #[test]
1061    fn command_convention_is_idempotent() {
1062        for command in [Modifiers::CTRL, Modifiers::SUPER] {
1063            for m in [
1064                Modifiers::CTRL,
1065                Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT,
1066                Modifiers::SUPER,
1067                Modifiers::NONE,
1068            ] {
1069                let once = m.with_command_convention_using(command);
1070                assert_eq!(once.with_command_convention_using(command), once);
1071            }
1072        }
1073    }
1074
1075    #[test]
1076    fn command_predicate_follows_the_platform() {
1077        // Whichever platform this runs on, `COMMAND` is one of the two, and
1078        // `command()` tracks exactly it.
1079        assert!(Modifiers::COMMAND.command());
1080        assert!(!Modifiers::ALT.command());
1081        assert!((Modifiers::COMMAND | Modifiers::SHIFT).command());
1082
1083        if cfg!(target_os = "macos") {
1084            assert_eq!(Modifiers::COMMAND, Modifiers::SUPER);
1085            assert!(!Modifiers::CTRL.command());
1086        } else {
1087            assert_eq!(Modifiers::COMMAND, Modifiers::CTRL);
1088            assert!(!Modifiers::SUPER.command());
1089        }
1090    }
1091
1092    #[test]
1093    fn contains_requires_every_named_modifier() {
1094        let cs = Modifiers::CTRL | Modifiers::SHIFT;
1095        assert!(cs.contains(Modifiers::CTRL));
1096        assert!(cs.contains(cs));
1097        assert!(!cs.contains(Modifiers::CTRL | Modifiers::ALT));
1098        assert!(cs.contains(Modifiers::NONE));
1099    }
1100
1101    #[test]
1102    fn without_clears_only_the_named_modifiers() {
1103        let all = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::SUPER;
1104        assert_eq!(
1105            all.without(Modifiers::SUPER),
1106            Modifiers::CTRL | Modifiers::SHIFT
1107        );
1108        assert_eq!(all.without(Modifiers::ALT), all);
1109    }
1110}
1111
1112#[cfg(test)]
1113mod ime_commit_tests {
1114    use super::*;
1115
1116    #[test]
1117    fn ime_composition_constructor_replaces_nothing() {
1118        match WidgetEvent::ime_composition("x", Some(0..1)) {
1119            WidgetEvent::ImeComposition {
1120                text,
1121                cursor,
1122                replacement,
1123            } => {
1124                assert_eq!(text, "x");
1125                assert_eq!(cursor, Some(0..1));
1126                assert!(replacement.is_none());
1127            }
1128            other => panic!("unexpected {other:?}"),
1129        }
1130    }
1131
1132    #[test]
1133    fn ime_commit_constructor_is_a_plain_composition_commit() {
1134        match WidgetEvent::ime_commit("x") {
1135            WidgetEvent::ImeCommit {
1136                text,
1137                origin,
1138                replacement,
1139            } => {
1140                assert_eq!(text, "x");
1141                assert_eq!(origin, ImeOrigin::Composition);
1142                assert!(replacement.is_none());
1143            }
1144            other => panic!("unexpected {other:?}"),
1145        }
1146    }
1147}