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/// Events dispatched to widgets.
581#[derive(Debug, Clone)]
582pub enum WidgetEvent {
583 /// A button went down.
584 PointerDown {
585 /// Where, in the receiving widget's own coordinate space (the router
586 /// localises it on delivery — see
587 /// [`WidgetTree::dispatch_pointer`](crate::WidgetTree::dispatch_pointer)).
588 position: Point,
589 /// Which button. A direct pointer reports
590 /// [`PointerButton::Primary`] for a contact.
591 button: PointerButton,
592 /// Modifier keys held when the press landed.
593 modifiers: Modifiers,
594 /// Who pressed — identity, kind, buttons, axes and timestamp.
595 ///
596 /// Read it through [`EventContext::pointer`](crate::widget::EventContext::pointer)
597 /// or its `pointer_kind()` shorthand rather than by destructuring, so a
598 /// widget that only needs "was this a finger?" does not have to name
599 /// the whole struct. Defaults to
600 /// [`PointerInfo::mouse`] at the epoch for every legacy construction
601 /// site and for [`pointer_down`](Self::pointer_down), so a site that
602 /// says nothing about pointers keeps meaning what it meant before the
603 /// touch programme.
604 pointer: PointerInfo,
605 },
606 /// A button came up.
607 PointerUp {
608 /// Where, in the receiving widget's own coordinate space.
609 position: Point,
610 /// Which button was released.
611 button: PointerButton,
612 /// Modifier keys held when the release landed.
613 modifiers: Modifiers,
614 /// Who released. See [`PointerDown::pointer`](Self::PointerDown).
615 pointer: PointerInfo,
616 },
617 /// A pointer moved. Sent whether or not a button is held; a contact only
618 /// ever moves with its button held, since a finger cannot hover.
619 PointerMove {
620 /// Where, in the receiving widget's own coordinate space.
621 position: Point,
622 /// Modifier keys held during the move.
623 ///
624 /// A drag decides what it means from the modifiers at the *move*, not
625 /// at the press — Shift extends a selection and Ctrl makes a marquee
626 /// additive from the moment the key goes down, mid-drag included.
627 /// Defaults to [`Modifiers::NONE`] for
628 /// [`pointer_move`](Self::pointer_move) and for a producer that tracks
629 /// no modifier state.
630 modifiers: Modifiers,
631 /// Who moved. See [`PointerDown::pointer`](Self::PointerDown).
632 pointer: PointerInfo,
633 },
634 /// The hover owner came onto this widget. Never sent for a contact: a
635 /// finger writes no hover (see
636 /// [`PointerTable::hover_owner`](crate::pointer::table::PointerTable::hover_owner)).
637 PointerEnter {
638 /// Who entered — a mouse, or a pen in proximity.
639 pointer: PointerInfo,
640 },
641 /// The hover owner left this widget.
642 PointerLeave {
643 /// Who left. See [`PointerEnter`](Self::PointerEnter).
644 pointer: PointerInfo,
645 },
646 Scroll {
647 delta: ScrollDelta,
648 /// Modifier keys held at the time of the scroll event.
649 /// Defaults to `Modifiers::NONE` for synthesized events
650 /// (tests, keyboard-driven scroll requests). Real-platform
651 /// scroll events populate this from the platform's tracked
652 /// modifier state — apps detect Ctrl-wheel-to-zoom by
653 /// inspecting `modifiers.ctrl()`.
654 modifiers: Modifiers,
655 /// Where the pointer was when the scroll happened, in **window**-logical
656 /// coordinates, or `None` when the producer has no position for it.
657 ///
658 /// The frame is in the name because it is the one positional field a
659 /// handler receives that is *not* localised to the receiving widget
660 /// (`localize_event` deliberately has no `Scroll` arm), and a widget
661 /// that reads it as a local point silently lands a cell or a row out.
662 /// Convert with the receiver's own bounds before using it as content
663 /// coordinates.
664 ///
665 /// It is window-space because both of its frame-sensitive uses need it
666 /// to be. The router **routes** by it — `Some` hit-tests, `None` falls
667 /// back to the hovered (else focused) widget — and hit-testing is
668 /// necessarily window-space. And `common/scrollable.rs`'s
669 /// `handle_scroll_event` feeds it to `pan_step` →
670 /// [`KineticScroller::pan`](crate::kinetic::KineticScroller::pan),
671 /// whose tracker follows the *pointer*: localisation resolves against
672 /// the captor's **current** bounds on every event, so a localised
673 /// position would feed that tracker samples polluted by the motion of
674 /// the very widget being measured.
675 ///
676 /// A mouse wheel has always been positionless and stays so — hover is
677 /// under the cursor, so hit-testing would find the same widget anyway.
678 /// A pan synthesised from a direct pointer *must* carry one, because a
679 /// contact never writes hover and a positionless pan would route
680 /// nowhere.
681 window_position: Option<Point>,
682 /// Where in a continuous scroll gesture this sample sits.
683 /// [`ScrollPhase::Discrete`] — a self-contained wheel notch — for
684 /// everything Teksilo produced before the touch programme.
685 phase: ScrollPhase,
686 /// Who scrolled. Defaults to
687 /// [`PointerInfo::mouse`](crate::pointer::PointerInfo::mouse) at the
688 /// epoch for every legacy construction site; a real sample carries the
689 /// pointer's identity, kind and timestamp.
690 pointer: PointerInfo,
691 },
692 /// A pointer interaction was revoked by the system rather than completed by
693 /// the user — see [`CancelReason`].
694 ///
695 /// Distinct from [`PointerUp`](Self::PointerUp) on purpose: an Up means the
696 /// user finished, so a drag drops and a tap fires; a cancel means the
697 /// interaction is being taken away, so state must be unwound and nothing
698 /// may activate.
699 ///
700 /// **Terminal**: no `PointerUp` follows for that pointer, and one that
701 /// arrives anyway is swallowed. Delivered by the cancel funnel,
702 /// [`WidgetTree::cancel_pointer`](crate::WidgetTree::cancel_pointer), to
703 /// the widget holding the pointer — or, failing that, to the last one that
704 /// accepted an event from it. A widget receives it through
705 /// `.on_pointer_cancel(..)` or through its raw `on_pointer_event` hook.
706 PointerCancel {
707 /// Where the pointer was last seen, in **window**-logical coordinates,
708 /// when the revoking path knows. A platform cancel usually carries no
709 /// position at all.
710 ///
711 /// Window-space, and named for it, for the same reason as
712 /// [`Scroll::window_position`](Self::Scroll) — but kept there by a
713 /// different mechanism, worth knowing before "fixing" either. The cancel
714 /// funnel delivers through the router's **non**-localising route
715 /// (`dispatch_to_widget_direct`), so what puts this value in window space
716 /// is simply that the funnel records the pointer table's own position
717 /// verbatim; `localize_event` having no `PointerCancel` arm is true but
718 /// would not matter on this path. A widget whose
719 /// `PointerDown`/`Move`/`Up` handling works in local coordinates must
720 /// convert before feeding this to the same sink.
721 window_position: Option<Point>,
722 /// Why the interaction was revoked.
723 reason: CancelReason,
724 /// Which pointer was revoked.
725 pointer: PointerInfo,
726 },
727 KeyDown {
728 key: Key,
729 modifiers: Modifiers,
730 text: Option<String>,
731 },
732 KeyUp {
733 key: Key,
734 modifiers: Modifiers,
735 },
736 ImeComposition {
737 text: String,
738 cursor: Option<std::ops::Range<usize>>,
739 },
740 ImeCommit {
741 text: String,
742 },
743 FocusGained {
744 origin: crate::focus::FocusOrigin,
745 },
746 FocusLost,
747 AccessAction {
748 action: accesskit::Action,
749 target: Option<crate::widget_id::WidgetId>,
750 /// Raw AccessKit NodeId from the original `ActionRequest`.
751 /// May be a synthetic (widget-emitted child) NodeId — use
752 /// `crate::accessibility::is_synthetic` to distinguish it
753 /// from a widget-derived NodeId. The widget that registered
754 /// the parent (retrieved via `tree.widget_for_synthetic`)
755 /// is the one set in `target`.
756 target_node: accesskit::NodeId,
757 /// Payload carried by the `ActionRequest`. For
758 /// `Action::SetTextSelection` this is
759 /// `ActionData::SetTextSelection(TextSelection)`, for
760 /// `Action::SetValue` it's `ActionData::Value(Box<str>)`,
761 /// for scroll actions it carries scroll offsets, etc.
762 /// Widgets that declare these actions must read the payload
763 /// to honour screen-reader-initiated requests.
764 data: Option<accesskit::ActionData>,
765 },
766 /// Dispatched by the framework to a clipping ancestor when a child
767 /// gains focus but is outside the viewport. The scroll area adjusts
768 /// its offset to make the target bounds visible, with an optional
769 /// margin around the target.
770 ScrollIntoView {
771 target_bounds: Rect,
772 /// Extra margin (in logical pixels) to keep around the target
773 /// when scrolling it into view. Defaults to 0.0.
774 margin: f32,
775 /// Where the target should end up on the scroll container's
776 /// **vertical** axis. [`ScrollAlign::Minimal`] (the default, and what
777 /// every focus-driven reveal uses) only scrolls when the target is not
778 /// already fully visible; [`ScrollAlign::Fraction`] *pins* it to a
779 /// fixed height in the viewport whether or not it was already visible.
780 align: ScrollAlign,
781 /// Whether the container should jump to the new offset or glide to it.
782 /// See [`ScrollMotion`].
783 motion: ScrollMotion,
784 /// Optional back-channel for the handling scroll container to report
785 /// how far it actually scrolled (`(dx, dy)` in content pixels). When
786 /// several nested scroll containers must each reveal the same target,
787 /// the ancestor walk (`scroll_rect_into_view`) reads this after
788 /// dispatching to an inner container and shifts `target_bounds` by the
789 /// negated delta before asking the next (outer) one — so the outer sees
790 /// where the target will land once the inner's (deferred) scroll
791 /// applies, not its pre-scroll position. `None` disables reporting (the
792 /// nested-reveal refinement is unavailable). A handler that ignores it
793 /// still works for the common single-container case.
794 ///
795 /// `Arc<Mutex<..>>` (not `Rc<Cell<..>>`) so `WidgetEvent` stays `Send`
796 /// — some events are posted across threads. This one is only ever
797 /// touched on the dispatch thread, so the lock is always uncontended.
798 applied_scroll: Option<std::sync::Arc<std::sync::Mutex<teksilo_canvas::Point>>>,
799 },
800 /// A recognized gesture event, routed through the same preview/bubble system.
801 Gesture {
802 gesture: GestureEvent,
803 },
804}
805
806impl WidgetEvent {
807 /// A wheel notch with no position — routed by the hovered (else focused)
808 /// widget, exactly as every scroll in Teksilo was before the touch
809 /// programme.
810 ///
811 /// It exists so that the three fields [`Scroll`](Self::Scroll) gained cost
812 /// each of its construction sites one line rather than five. The pointer
813 /// defaults to
814 /// [`PointerInfo::mouse`] at [`EventTime::ZERO`]: a free constructor has no
815 /// tree and therefore no clock, and nothing reads the timestamp of a
816 /// legacy-constructed event. A sample that has a real time enters through
817 /// [`WidgetTree::dispatch_scroll`](crate::WidgetTree::dispatch_scroll)
818 /// instead.
819 pub fn scroll(delta: ScrollDelta, modifiers: Modifiers) -> Self {
820 Self::Scroll {
821 delta,
822 modifiers,
823 window_position: None,
824 phase: ScrollPhase::Discrete,
825 pointer: PointerInfo::mouse(EventTime::ZERO),
826 }
827 }
828
829 /// A wheel notch routed by hit test at `position` rather than by hover.
830 ///
831 /// Use this where the producer genuinely knows where the pointer was; a
832 /// mouse-wheel translator should keep using [`scroll`](Self::scroll), whose
833 /// hover routing is what it has always had.
834 pub fn scroll_at(delta: ScrollDelta, modifiers: Modifiers, position: Point) -> Self {
835 Self::Scroll {
836 delta,
837 modifiers,
838 window_position: Some(position),
839 phase: ScrollPhase::Discrete,
840 pointer: PointerInfo::mouse(EventTime::ZERO),
841 }
842 }
843
844 /// A mouse press: [`PointerInfo::mouse`] at the epoch.
845 ///
846 /// This and its siblings are why adding `pointer` to the five `Pointer*`
847 /// variants was a one-line-per-site sweep rather than a rewrite. Use them
848 /// wherever the producer genuinely describes a mouse — every test that is
849 /// pinning mouse behaviour, and every synthesizer that has no pointer of
850 /// its own. A producer that *does* know which pointer it speaks for must
851 /// write the variant out and thread the real [`PointerInfo`], or
852 /// `ctx.pointer_kind()` reads `Mouse` for a finger and every direct-pointer
853 /// branch in the framework silently takes the indirect path.
854 pub fn pointer_down(position: Point, button: PointerButton, modifiers: Modifiers) -> Self {
855 Self::PointerDown {
856 position,
857 button,
858 modifiers,
859 pointer: PointerInfo::mouse(EventTime::ZERO),
860 }
861 }
862
863 /// A mouse release. See [`pointer_down`](Self::pointer_down).
864 pub fn pointer_up(position: Point, button: PointerButton, modifiers: Modifiers) -> Self {
865 Self::PointerUp {
866 position,
867 button,
868 modifiers,
869 pointer: PointerInfo::mouse(EventTime::ZERO),
870 }
871 }
872
873 /// A mouse move with no modifiers held. See
874 /// [`pointer_down`](Self::pointer_down); use
875 /// [`pointer_move_with`](Self::pointer_move_with) where the producer tracks
876 /// modifier state, since a drag reads Shift and Ctrl from the *move*.
877 pub fn pointer_move(position: Point) -> Self {
878 Self::pointer_move_with(position, Modifiers::NONE)
879 }
880
881 /// A mouse move carrying tracked modifier state. See
882 /// [`pointer_down`](Self::pointer_down).
883 pub fn pointer_move_with(position: Point, modifiers: Modifiers) -> Self {
884 Self::PointerMove {
885 position,
886 modifiers,
887 pointer: PointerInfo::mouse(EventTime::ZERO),
888 }
889 }
890
891 /// The mouse entered a widget. See [`pointer_down`](Self::pointer_down).
892 pub fn pointer_enter() -> Self {
893 Self::PointerEnter {
894 pointer: PointerInfo::mouse(EventTime::ZERO),
895 }
896 }
897
898 /// The mouse left a widget. See [`pointer_down`](Self::pointer_down).
899 pub fn pointer_leave() -> Self {
900 Self::PointerLeave {
901 pointer: PointerInfo::mouse(EventTime::ZERO),
902 }
903 }
904}
905
906/// The result of handling an event.
907#[derive(Debug, Clone, Copy, PartialEq, Eq)]
908pub enum EventResponse {
909 /// The event was handled; stop propagation.
910 Handled,
911 /// The event was not handled; let it bubble.
912 Ignored,
913}
914
915#[cfg(test)]
916mod modifier_tests {
917 use super::*;
918
919 // The convention itself, exercised on both platform settings from any host.
920 // `Modifiers::COMMAND` resolves at compile time, so a Linux CI would
921 // otherwise only ever see half of what this rule does — and the half it
922 // cannot see is the one the rule exists for.
923
924 #[test]
925 fn command_convention_rewrites_a_bare_ctrl_on_macos() {
926 let mac = Modifiers::CTRL.with_command_convention_using(Modifiers::SUPER);
927 assert_eq!(mac, Modifiers::SUPER);
928
929 let mac =
930 (Modifiers::CTRL | Modifiers::SHIFT).with_command_convention_using(Modifiers::SUPER);
931 assert_eq!(mac, Modifiers::SUPER | Modifiers::SHIFT);
932 }
933
934 #[test]
935 fn command_convention_is_a_no_op_where_command_is_ctrl() {
936 for m in [
937 Modifiers::CTRL,
938 Modifiers::CTRL | Modifiers::SHIFT,
939 Modifiers::ALT,
940 Modifiers::NONE,
941 Modifiers::SUPER,
942 ] {
943 assert_eq!(m.with_command_convention_using(Modifiers::CTRL), m);
944 }
945 }
946
947 #[test]
948 fn command_convention_leaves_an_explicit_super_alone() {
949 // A chord that already names Super is a deliberate ⌘ chord, and
950 // `Ctrl+Super` is a genuine two-modifier chord — neither collapses.
951 assert_eq!(
952 Modifiers::SUPER.with_command_convention_using(Modifiers::SUPER),
953 Modifiers::SUPER
954 );
955 let both = Modifiers::CTRL | Modifiers::SUPER;
956 assert_eq!(both.with_command_convention_using(Modifiers::SUPER), both);
957 }
958
959 #[test]
960 fn command_convention_is_idempotent() {
961 for command in [Modifiers::CTRL, Modifiers::SUPER] {
962 for m in [
963 Modifiers::CTRL,
964 Modifiers::CTRL | Modifiers::SHIFT | Modifiers::ALT,
965 Modifiers::SUPER,
966 Modifiers::NONE,
967 ] {
968 let once = m.with_command_convention_using(command);
969 assert_eq!(once.with_command_convention_using(command), once);
970 }
971 }
972 }
973
974 #[test]
975 fn command_predicate_follows_the_platform() {
976 // Whichever platform this runs on, `COMMAND` is one of the two, and
977 // `command()` tracks exactly it.
978 assert!(Modifiers::COMMAND.command());
979 assert!(!Modifiers::ALT.command());
980 assert!((Modifiers::COMMAND | Modifiers::SHIFT).command());
981
982 if cfg!(target_os = "macos") {
983 assert_eq!(Modifiers::COMMAND, Modifiers::SUPER);
984 assert!(!Modifiers::CTRL.command());
985 } else {
986 assert_eq!(Modifiers::COMMAND, Modifiers::CTRL);
987 assert!(!Modifiers::SUPER.command());
988 }
989 }
990
991 #[test]
992 fn contains_requires_every_named_modifier() {
993 let cs = Modifiers::CTRL | Modifiers::SHIFT;
994 assert!(cs.contains(Modifiers::CTRL));
995 assert!(cs.contains(cs));
996 assert!(!cs.contains(Modifiers::CTRL | Modifiers::ALT));
997 assert!(cs.contains(Modifiers::NONE));
998 }
999
1000 #[test]
1001 fn without_clears_only_the_named_modifiers() {
1002 let all = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::SUPER;
1003 assert_eq!(
1004 all.without(Modifiers::SUPER),
1005 Modifiers::CTRL | Modifiers::SHIFT
1006 );
1007 assert_eq!(all.without(Modifiers::ALT), all);
1008 }
1009}