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