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}