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