crcbl_core/input.rs
1//! Input vocabulary: the names every layer of the engine uses for keys,
2//! buttons, axes and the devices they came from.
3//!
4//! # Why this is in `crcbl-core` and not in `crcbl-shell`
5//!
6//! The shell-boundary rule in `docs/notes/backends.md` puts "input event
7//! normalisation into engine types" in `crcbl-core::input`, and the input
8//! design (`docs/notes/simulation.md`, _What the deleted 19-input plan left
9//! behind_) explains why: the action layer (`crcbl-input`, P2), the profile
10//! store (topic 14, where user rebinds are serialized), the UI (glyph hints),
11//! and the editor all have to *name* a key. If [`KeyCode`] lived in the
12//! windowing crate, the save-game crate would depend on a windowing library to
13//! spell `Space`, and a headless `crcbl sim` replay would link a shell it never
14//! opens.
15//!
16//! The split is therefore:
17//!
18//! ```text
19//! crcbl-core::input vocabulary — KeyCode, Keysym, PointerButton, Modifiers,
20//! ButtonState, ScrollDelta, DeviceId,
21//! ContactId, TouchPhase, CursorIcon
22//! crcbl-shell events — ShellEvent, which names windows and is only
23//! ever produced by a shell backend
24//! ```
25//!
26//! Nothing here knows what a window is, and nothing here allocates.
27//!
28//! # Physical versus logical: three coordinates for one keystroke
29//!
30//! A key press carries three independent facts, and conflating any two of them
31//! is the classic input bug:
32//!
33//! | Fact | Type | Depends on layout? | Used for |
34//! | --- | --- | --- | --- |
35//! | Which key was pressed | [`KeyCode`] | no | gameplay bindings ("W moves forward" stays under the W key on AZERTY) |
36//! | What the OS calls that key | [`Scancode`] | no | round-tripping keys we have no name for |
37//! | What symbol it produces | [`Keysym`] | **yes** | rebind menus, shortcut display, layout-sensitive shortcuts |
38//!
39//! And a fourth, which is not a keystroke at all: **text**. Composed text
40//! arrives as a separate commit string (`ShellEvent::TextCommit`) because IME
41//! input has no one-to-one relationship with key presses — one keystroke can
42//! commit five characters, five keystrokes can commit one, and a dead key
43//! commits nothing.
44//!
45//! # `Keysym` is the X11/XKB namespace
46//!
47//! [`Keysym`] holds an X11 keysym value, which is what `xkbcommon` produces.
48//! This is a decision, not an accident: two of the five planned shell backends
49//! (Wayland and X11 — the only two that exist at P0) produce these natively,
50//! and the other three need a translation table whatever namespace we pick.
51//! Inventing an engine-specific symbol enum would mean *three* tables instead of
52//! two plus a well-documented, stable, externally-specified numbering that
53//! already covers every keyboard symbol anyone has ever shipped.
54
55use core::fmt;
56
57bitflags::bitflags! {
58 /// Modifier keys held when an input event was produced.
59 ///
60 /// Latched state (Caps Lock, Num Lock) is included alongside held state
61 /// because a rebind UI and a shortcut matcher both need it, and asking the
62 /// shell for it later — after the event has been queued — would race with
63 /// the user letting go.
64 ///
65 /// Left and right modifiers are *not* distinguished here. The distinction
66 /// survives in [`KeyCode`] ([`ShiftLeft`](KeyCode::ShiftLeft) versus
67 /// [`ShiftRight`](KeyCode::ShiftRight)), which is where a game that wants to
68 /// bind them separately should look; a shortcut matcher never wants it, and
69 /// duplicating the axis here would mean every `contains(SHIFT)` check had to
70 /// remember to test two bits.
71 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
72 pub struct Modifiers: u32 {
73 /// Either Shift key is held.
74 const SHIFT = 1 << 0;
75 /// Either Control key is held.
76 const CTRL = 1 << 1;
77 /// Either Alt key is held (Option on macOS).
78 const ALT = 1 << 2;
79 /// Either Super key is held — Windows key, Command on macOS, Meta on
80 /// X11.
81 const SUPER = 1 << 3;
82 /// Caps Lock is latched on.
83 const CAPS_LOCK = 1 << 4;
84 /// Num Lock is latched on.
85 const NUM_LOCK = 1 << 5;
86 /// AltGr / ISO Level-3 Shift is held.
87 ///
88 /// Separate from [`ALT`](Self::ALT) because on many European layouts
89 /// AltGr produces characters, and a shortcut bound to `Alt+E` must not
90 /// fire when the user types `€`.
91 const ALT_GR = 1 << 6;
92 }
93}
94
95impl Modifiers {
96 /// The modifiers a shortcut matcher cares about: Shift, Ctrl, Alt, Super.
97 ///
98 /// Masks off latched state, which is never part of a chord.
99 pub const CHORD: Self = Self::SHIFT
100 .union(Self::CTRL)
101 .union(Self::ALT)
102 .union(Self::SUPER);
103
104 /// Just the chord bits of `self`.
105 #[inline]
106 #[must_use]
107 pub const fn chord(self) -> Self {
108 self.intersection(Self::CHORD)
109 }
110}
111
112/// Which physical device an event came from.
113///
114/// The input design requires a per-device id on raw events from day one,
115/// because local-multiplayer device-to-player assignment is post-MVP but is
116/// impossible to retrofit into an event stream that never said which keyboard
117/// pressed the key. Ids are unique for the lifetime of the process and are not
118/// reused when a device is unplugged.
119#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
120pub struct DeviceId(pub u32);
121
122impl DeviceId {
123 /// The device that events with no attributable source carry.
124 ///
125 /// Backends that cannot tell two keyboards apart (X11 core protocol without
126 /// XInput2, and the browser) use this for everything, and so do synthesized
127 /// events from a replay or a test script.
128 pub const UNKNOWN: Self = Self(0);
129}
130
131impl fmt::Display for DeviceId {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 if *self == Self::UNKNOWN {
134 f.write_str("device ?")
135 } else {
136 write!(f, "device {}", self.0)
137 }
138 }
139}
140
141/// Whether a two-state input element went down or came up.
142///
143/// One type for keyboard keys, pointer buttons and gamepad buttons: they are
144/// the same fact, and a separate `KeyState`/`MouseState` pair only creates
145/// conversion noise at the action layer, which treats all three identically.
146#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
147pub enum ButtonState {
148 /// The element went down.
149 Pressed,
150 /// The element came up.
151 Released,
152}
153
154impl ButtonState {
155 /// Whether this is [`ButtonState::Pressed`].
156 #[inline]
157 #[must_use]
158 pub const fn is_pressed(self) -> bool {
159 matches!(self, Self::Pressed)
160 }
161
162 /// [`Pressed`](Self::Pressed) for `true`, [`Released`](Self::Released) for
163 /// `false`.
164 #[inline]
165 #[must_use]
166 pub const fn from_pressed(pressed: bool) -> Self {
167 if pressed {
168 Self::Pressed
169 } else {
170 Self::Released
171 }
172 }
173}
174
175/// The raw, platform-assigned code for a physical key.
176///
177/// Opaque on purpose: its numbering is whatever the window system uses (Linux
178/// evdev codes offset by 8 on both Wayland and X11, `MapVirtualKey` scan codes
179/// on Win32, `kVK_*` on macOS). It exists so a key the engine has no
180/// [`KeyCode`] name for can still be bound, logged and round-tripped through a
181/// profile rather than being silently dropped.
182///
183/// Never compare a `Scancode` against a literal in engine code — that is
184/// platform sniffing with extra steps. Compare [`KeyCode`].
185#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
186pub struct Scancode(pub u32);
187
188impl fmt::Display for Scancode {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 write!(f, "scancode {}", self.0)
191 }
192}
193
194/// A layout-mapped key symbol, in the X11/XKB keysym numbering.
195///
196/// See the [module docs](self) for why that numbering. The values a game
197/// actually wants are usually reachable through [`Keysym::to_char`]; the raw
198/// number matters only for named function keys, which live above
199/// `0xFF00`.
200#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
201pub struct Keysym(pub u32);
202
203impl Keysym {
204 /// No symbol — a key that produces nothing in the current layout, or a
205 /// backend that could not map one. `XKB_KEY_NoSymbol`.
206 pub const NONE: Self = Self(0);
207
208 /// Start of the "Unicode keysym" block: `0x0100_0000 | codepoint`.
209 const UNICODE_BASE: u32 = 0x0100_0000;
210
211 /// The character this symbol produces, if it produces one.
212 ///
213 /// Implements the two rules XKB uses: keysyms `0x20..=0x7E` and
214 /// `0xA0..=0xFF` are Latin-1 directly, and everything else that maps to a
215 /// character is `0x0100_0000 | codepoint`. Function keys, modifiers and
216 /// dead keys return `None`.
217 ///
218 /// This deliberately does **not** cover the several hundred legacy
219 /// non-Latin-1 keysyms that predate the Unicode block (Greek, Cyrillic and
220 /// friends in `0x05xx`–`0x07xx`). Text input is delivered as committed
221 /// strings, not reconstructed from keysyms, so the table would exist only
222 /// to be wrong in a way nobody notices; a shortcut label falls back to the
223 /// [`KeyCode`] name.
224 #[must_use]
225 pub const fn to_char(self) -> Option<char> {
226 let codepoint = match self.0 {
227 0x20..=0x7E | 0xA0..=0xFF => self.0,
228 value if value >= Self::UNICODE_BASE => value - Self::UNICODE_BASE,
229 _ => return None,
230 };
231 char::from_u32(codepoint)
232 }
233
234 /// The keysym for a character, in the canonical form XKB produces.
235 #[must_use]
236 pub const fn from_char(character: char) -> Self {
237 let codepoint = character as u32;
238 match codepoint {
239 0x20..=0x7E | 0xA0..=0xFF => Self(codepoint),
240 _ => Self(Self::UNICODE_BASE | codepoint),
241 }
242 }
243
244 /// Whether this is [`Keysym::NONE`].
245 #[inline]
246 #[must_use]
247 pub const fn is_none(self) -> bool {
248 self.0 == Self::NONE.0
249 }
250}
251
252impl fmt::Display for Keysym {
253 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254 match self.to_char() {
255 Some(character) if !character.is_control() => write!(f, "'{character}'"),
256 _ => write!(f, "keysym {:#06x}", self.0),
257 }
258 }
259}
260
261/// A button on a pointing device.
262///
263/// Named by role, not by physical position: `Left` is the primary button even
264/// on a left-handed mouse, because that is the swap the OS already applied and
265/// the game must not apply again.
266#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
267pub enum PointerButton {
268 /// Primary button.
269 Left,
270 /// Secondary (context-menu) button.
271 Right,
272 /// Wheel click.
273 Middle,
274 /// Thumb button that means "back" — `BTN_SIDE`, `XBUTTON1`.
275 Back,
276 /// Thumb button that means "forward" — `BTN_EXTRA`, `XBUTTON2`.
277 Forward,
278 /// Anything else, by platform-assigned index.
279 ///
280 /// Bindable and loggable, never interpreted by the engine.
281 Other(u16),
282}
283
284/// One finger on a touchscreen, for as long as it stays down.
285///
286/// **Unique among the contacts that are down at the same moment, stable from the
287/// [`TouchPhase::Began`] that starts a gesture to the
288/// [`Ended`](TouchPhase::Ended) or [`Cancelled`](TouchPhase::Cancelled) that
289/// finishes it, and free to be reused after that.** Those three sentences are
290/// the whole contract, and the third is the one that bites: a consumer keying
291/// per-gesture state off this — which finger is holding the movement stick —
292/// must drop that state when the contact ends, or the next finger to land
293/// inherits it.
294///
295/// A contact is not a device. Two fingers on one screen are one [`DeviceId`] and
296/// two `ContactId`s, which is exactly why the id could not be folded into the
297/// device: a per-device registry cannot represent a second finger.
298///
299/// The numbering is the platform's (a browser `pointerId`, a `wl_touch` id, a
300/// Win32 pointer id) and is meaningless to compare against a literal or across
301/// windows — like [`Scancode`], it exists to be matched against *other* ids from
302/// the same stream.
303#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
304pub struct ContactId(pub u32);
305
306impl fmt::Display for ContactId {
307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
308 write!(f, "contact {}", self.0)
309 }
310}
311
312/// Where a [`ContactId`] is in its gesture.
313///
314/// A touchscreen has no "hover": a contact exists from the moment it lands until
315/// the moment it goes away, so this is the lifecycle rather than a button state
316/// — which is why touch does not reuse [`ButtonState`].
317#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
318pub enum TouchPhase {
319 /// The finger landed. The first event any [`ContactId`] gets.
320 Began,
321 /// It moved while down.
322 Moved,
323 /// The user lifted it. **The gesture completed**, and a consumer commits
324 /// whatever it meant — a tap fires, a stick centres, a drag lands where it
325 /// was let go.
326 Ended,
327 /// The system took the gesture away: the OS claimed it for an edge swipe,
328 /// palm rejection changed its mind, the window lost focus, the browser
329 /// decided the page was scrolling after all.
330 ///
331 /// **Not an [`Ended`](Self::Ended), and a consumer that treats it as one
332 /// fires actions the user did not ask for.** The position it carries is the
333 /// last one the platform knew, not a place anyone chose, so the answer is to
334 /// undo the interaction rather than complete it — the drag goes back, the
335 /// tap does not fire, the stick centres without the character having lunged.
336 /// What both share is that the contact is over: the id is gone either way,
337 /// and a consumer that ignores this one is left holding a finger that will
338 /// never move again.
339 Cancelled,
340}
341
342impl TouchPhase {
343 /// Whether this phase ends the contact — [`Ended`](Self::Ended) or
344 /// [`Cancelled`](Self::Cancelled).
345 ///
346 /// The test anything tracking live contacts writes; it is the *reason* they
347 /// differ that the two variants exist for, not the fact that they both stop.
348 #[inline]
349 #[must_use]
350 pub const fn ends_contact(self) -> bool {
351 matches!(self, Self::Ended | Self::Cancelled)
352 }
353
354 /// A short, stable name for logs and test assertions.
355 #[must_use]
356 pub const fn as_str(self) -> &'static str {
357 match self {
358 Self::Began => "Began",
359 Self::Moved => "Moved",
360 Self::Ended => "Ended",
361 Self::Cancelled => "Cancelled",
362 }
363 }
364}
365
366impl fmt::Display for TouchPhase {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 f.write_str(self.as_str())
369 }
370}
371
372/// How far a scroll event moved, in the units the device reported.
373///
374/// The two variants are not interchangeable and must not be collapsed by the
375/// shell: a notched wheel genuinely reports discrete clicks, a touchpad
376/// genuinely reports pixels, and the conversion factor between them is a
377/// *policy* decision (how many pixels is one line?) that belongs to the action
378/// layer and the UI, where it can be configured, not to a backend.
379#[derive(Clone, Copy, Debug, PartialEq)]
380pub enum ScrollDelta {
381 /// Discrete detents — `y` positive scrolls the content up (away from the
382 /// user), matching every platform's convention for a wheel.
383 Lines {
384 /// Horizontal detents.
385 x: f32,
386 /// Vertical detents.
387 y: f32,
388 },
389 /// Continuous motion in physical pixels, from a touchpad or high-resolution
390 /// wheel. Same sign convention as [`ScrollDelta::Lines`].
391 Pixels {
392 /// Horizontal pixels.
393 x: f64,
394 /// Vertical pixels.
395 y: f64,
396 },
397}
398
399impl ScrollDelta {
400 /// Whether this delta is exactly zero in both axes.
401 ///
402 /// Wayland's `wl_pointer.axis_stop` and X11's button-4/5 emulation both
403 /// produce zero-length scrolls; consumers usually want to drop them.
404 #[must_use]
405 pub fn is_zero(self) -> bool {
406 match self {
407 Self::Lines { x, y } => x == 0.0 && y == 0.0,
408 Self::Pixels { x, y } => x == 0.0 && y == 0.0,
409 }
410 }
411}
412
413/// Generates [`KeyCode`] plus its name table from one list.
414///
415/// Two derived tables (`as_str`, `from_name`) and a hundred-odd variants are
416/// exactly the kind of thing that rots when maintained by hand — one added key
417/// with a forgotten name arm is a binding that serializes and never loads back.
418macro_rules! key_codes {
419 ($($variant:ident => $name:literal),+ $(,)?) => {
420 /// A key identified by physical position, independent of keyboard
421 /// layout.
422 ///
423 /// Variant names follow the W3C UI Events `code` values, which name
424 /// positions after the US-QWERTY legends: [`KeyCode::KeyW`] is the key
425 /// *where W is on a US keyboard*, which on AZERTY is the key labelled
426 /// Z. That is precisely the property gameplay bindings want — WASD
427 /// stays a square under the left hand everywhere — and precisely the
428 /// property a rebind menu must *not* display, which is what
429 /// [`Keysym`](crate::input::Keysym) is for.
430 ///
431 /// # Non-exhaustive on purpose
432 ///
433 /// Unlike [`SurfaceTarget`](crate::SurfaceTarget), which is exhaustive
434 /// so that a new platform breaks every backend loudly, this enum is
435 /// `#[non_exhaustive]`: consumers match a handful of keys and fall
436 /// through on the rest, so adding a key must not be a breaking change.
437 /// A key nobody named is a key nobody bound, which degrades to "the
438 /// binding does not fire" — not to a wrong action.
439 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
440 #[non_exhaustive]
441 pub enum KeyCode {
442 $(
443 #[doc = concat!("The `", $name, "` key.")]
444 $variant,
445 )+
446 }
447
448 impl KeyCode {
449 /// Every key this engine has a name for, in declaration order.
450 ///
451 /// Used by the rebind UI to enumerate choices and by tests to prove
452 /// the name table round-trips.
453 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
454
455 /// The stable name of this key, as written in binding assets and
456 /// profiles.
457 ///
458 /// Stable across releases: it is a serialization format, not a
459 /// display string. A rebind menu shows the *keysym*, not this.
460 #[must_use]
461 pub const fn as_str(self) -> &'static str {
462 match self {
463 $(Self::$variant => $name,)+
464 }
465 }
466
467 /// Parses a name produced by [`KeyCode::as_str`].
468 ///
469 /// Not a [`FromStr`](core::str::FromStr) impl: the only possible
470 /// failure is "no such key", and an error type carrying that one
471 /// fact would be ceremony. Bindings for unknown names are kept as
472 /// [`Scancode`](crate::input::Scancode) by the profile loader
473 /// rather than dropped.
474 #[must_use]
475 pub fn from_name(name: &str) -> Option<Self> {
476 match name {
477 $($name => Some(Self::$variant),)+
478 _ => None,
479 }
480 }
481 }
482 };
483}
484
485key_codes! {
486 // Alphanumeric block.
487 KeyA => "KeyA", KeyB => "KeyB", KeyC => "KeyC", KeyD => "KeyD", KeyE => "KeyE",
488 KeyF => "KeyF", KeyG => "KeyG", KeyH => "KeyH", KeyI => "KeyI", KeyJ => "KeyJ",
489 KeyK => "KeyK", KeyL => "KeyL", KeyM => "KeyM", KeyN => "KeyN", KeyO => "KeyO",
490 KeyP => "KeyP", KeyQ => "KeyQ", KeyR => "KeyR", KeyS => "KeyS", KeyT => "KeyT",
491 KeyU => "KeyU", KeyV => "KeyV", KeyW => "KeyW", KeyX => "KeyX", KeyY => "KeyY",
492 KeyZ => "KeyZ",
493 Digit0 => "Digit0", Digit1 => "Digit1", Digit2 => "Digit2", Digit3 => "Digit3",
494 Digit4 => "Digit4", Digit5 => "Digit5", Digit6 => "Digit6", Digit7 => "Digit7",
495 Digit8 => "Digit8", Digit9 => "Digit9",
496
497 // Punctuation, in US-QWERTY positions.
498 Backquote => "Backquote", Minus => "Minus", Equal => "Equal",
499 BracketLeft => "BracketLeft", BracketRight => "BracketRight", Backslash => "Backslash",
500 Semicolon => "Semicolon", Quote => "Quote", Comma => "Comma", Period => "Period",
501 Slash => "Slash",
502
503 // Keys that only exist on some physical layouts. Present because a binding
504 // made on an ISO or JIS keyboard has to survive a round trip through a
505 // profile written on an ANSI one.
506 IntlBackslash => "IntlBackslash", IntlRo => "IntlRo", IntlYen => "IntlYen",
507
508 // Editing and whitespace.
509 Escape => "Escape", Tab => "Tab", CapsLock => "CapsLock", Space => "Space",
510 Backspace => "Backspace", Enter => "Enter", Delete => "Delete", Insert => "Insert",
511
512 // Modifiers, left and right distinguished.
513 ShiftLeft => "ShiftLeft", ShiftRight => "ShiftRight",
514 ControlLeft => "ControlLeft", ControlRight => "ControlRight",
515 AltLeft => "AltLeft", AltRight => "AltRight",
516 SuperLeft => "SuperLeft", SuperRight => "SuperRight",
517 ContextMenu => "ContextMenu",
518
519 // Navigation.
520 Home => "Home", End => "End", PageUp => "PageUp", PageDown => "PageDown",
521 ArrowUp => "ArrowUp", ArrowDown => "ArrowDown",
522 ArrowLeft => "ArrowLeft", ArrowRight => "ArrowRight",
523
524 // Function row.
525 F1 => "F1", F2 => "F2", F3 => "F3", F4 => "F4", F5 => "F5", F6 => "F6",
526 F7 => "F7", F8 => "F8", F9 => "F9", F10 => "F10", F11 => "F11", F12 => "F12",
527
528 // System keys.
529 PrintScreen => "PrintScreen", ScrollLock => "ScrollLock", Pause => "Pause",
530
531 // Numpad.
532 NumLock => "NumLock",
533 Numpad0 => "Numpad0", Numpad1 => "Numpad1", Numpad2 => "Numpad2",
534 Numpad3 => "Numpad3", Numpad4 => "Numpad4", Numpad5 => "Numpad5",
535 Numpad6 => "Numpad6", Numpad7 => "Numpad7", Numpad8 => "Numpad8",
536 Numpad9 => "Numpad9",
537 NumpadDivide => "NumpadDivide", NumpadMultiply => "NumpadMultiply",
538 NumpadSubtract => "NumpadSubtract", NumpadAdd => "NumpadAdd",
539 NumpadEnter => "NumpadEnter", NumpadDecimal => "NumpadDecimal",
540}
541
542impl fmt::Display for KeyCode {
543 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
544 f.write_str(self.as_str())
545 }
546}
547
548/// Generates [`CursorIcon`], [`CursorIcon::ALL`] and both directions of its
549/// keyword table from one list, so a shape cannot be added without its CSS
550/// keyword or left out of the list a parser searches.
551macro_rules! cursor_icons {
552 ($($(#[$meta:meta])* $variant:ident => $name:literal),+ $(,)?) => {
553 /// A standard cursor shape.
554 ///
555 /// Names rather than pixels, because every platform already has themed,
556 /// correctly scaled, user-configured cursors; `crcbl_shell::cursor`
557 /// explains the rest.
558 ///
559 /// **Exhaustive on purpose.** Every shell backend maps each shape to its
560 /// own stock cursor, and a shape added later must fail to compile in
561 /// each of them rather than fall to an arrow through a wildcard arm.
562 /// That makes adding a shape a breaking change, which is the price.
563 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
564 pub enum CursorIcon {
565 $($(#[$meta])* $variant,)+
566 }
567
568 impl CursorIcon {
569 /// Every shape, in declaration order.
570 pub const ALL: &'static [Self] = &[$(Self::$variant),+];
571
572 /// The CSS/`cursor-shape-v1` keyword for this shape.
573 ///
574 /// Backends map from this rather than from the variant name, because
575 /// the keyword is the string X11 cursor themes and the browser both
576 /// index by.
577 #[must_use]
578 pub const fn as_css_name(self) -> &'static str {
579 match self {
580 $(Self::$variant => $name,)+
581 }
582 }
583
584 /// The shape a CSS `cursor` keyword names, matched as CSS matches
585 /// keywords: ignoring ASCII case. `None` for a keyword with no shape
586 /// here, `auto` and `none` among them, which are not shapes.
587 #[must_use]
588 pub fn from_css_name(name: &str) -> Option<Self> {
589 Self::ALL
590 .iter()
591 .copied()
592 .find(|shape| shape.as_css_name().eq_ignore_ascii_case(name))
593 }
594 }
595 };
596}
597
598cursor_icons! {
599 /// The platform's normal arrow.
600 #[default]
601 Default => "default",
602 /// The "this is clickable" hand.
603 Pointer => "pointer",
604 /// I-beam, for text.
605 Text => "text",
606 /// Crosshair, for precise placement.
607 Crosshair => "crosshair",
608 /// Something is happening and input is blocked.
609 Wait => "wait",
610 /// Something is happening but input still works.
611 Progress => "progress",
612 /// This action is not allowed here.
613 NotAllowed => "not-allowed",
614 /// The thing under the pointer can be picked up.
615 Grab => "grab",
616 /// The thing under the pointer is being dragged.
617 Grabbing => "grabbing",
618 /// Move in any direction.
619 Move => "move",
620 /// Resize vertically.
621 ResizeNorthSouth => "ns-resize",
622 /// Resize horizontally.
623 ResizeEastWest => "ew-resize",
624 /// Resize along the ↗↙ diagonal.
625 ResizeNorthEastSouthWest => "nesw-resize",
626 /// Resize along the ↖↘ diagonal.
627 ResizeNorthWestSouthEast => "nwse-resize",
628 /// Split pane, vertical divider.
629 ResizeColumn => "col-resize",
630 /// Split pane, horizontal divider.
631 ResizeRow => "row-resize",
632}
633
634/// What a text field taking typing is for, as an input method needs to know
635/// it.
636///
637/// Here rather than in `crcbl-shell` for the reason [`CursorIcon`] is: a UI
638/// names it for its field, and a UI crate depends on this one, not on the
639/// shell.
640///
641/// **Exhaustive on purpose**, as [`CursorIcon`] is: a purpose added later
642/// must fail to compile in every backend's mapping rather than fall to
643/// [`Normal`](Self::Normal) through a wildcard arm, because a password read
644/// as ordinary text is one an input method may learn and suggest back.
645#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
646pub enum TextPurpose {
647 /// Ordinary text: the input method may complete, correct and remember.
648 #[default]
649 Normal,
650 /// A password: hidden as typed, and nothing typed may be stored, learned
651 /// or offered back as a suggestion.
652 Password,
653 /// A PIN: digits, hidden, and not stored, as a password.
654 Pin,
655}
656
657impl TextPurpose {
658 /// Whether what is typed must not be stored, learned or suggested.
659 #[must_use]
660 pub const fn is_sensitive(self) -> bool {
661 match self {
662 Self::Normal => false,
663 Self::Password | Self::Pin => true,
664 }
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671
672 #[test]
673 fn cursor_keywords_are_unique_css_shaped_and_parse_back() {
674 let shapes = CursorIcon::ALL;
675 let mut names: Vec<&str> = shapes.iter().map(|shape| shape.as_css_name()).collect();
676 let count = names.len();
677 names.sort_unstable();
678 names.dedup();
679 assert_eq!(names.len(), count, "cursor keywords must be unique");
680 assert!(
681 names
682 .iter()
683 .all(|name| name.chars().all(|c| c.is_ascii_lowercase() || c == '-')),
684 "keywords are CSS identifiers"
685 );
686 assert_eq!(CursorIcon::default().as_css_name(), "default");
687 for &shape in shapes {
688 assert_eq!(CursorIcon::from_css_name(shape.as_css_name()), Some(shape));
689 }
690 assert_eq!(
691 CursorIcon::from_css_name("Not-Allowed"),
692 Some(CursorIcon::NotAllowed),
693 "keywords ignore ASCII case"
694 );
695 for not_a_shape in ["auto", "none", "", "pointer ", "hand"] {
696 assert_eq!(
697 CursorIcon::from_css_name(not_a_shape),
698 None,
699 "{not_a_shape:?}"
700 );
701 }
702 }
703
704 #[test]
705 fn key_names_round_trip_and_are_unique() {
706 let mut names: Vec<&'static str> = KeyCode::ALL.iter().map(|key| key.as_str()).collect();
707 let count = names.len();
708 assert!(count > 100, "the table covers a full keyboard, got {count}");
709 names.sort_unstable();
710 names.dedup();
711 assert_eq!(names.len(), count, "key names must be unique");
712
713 for key in KeyCode::ALL {
714 assert_eq!(KeyCode::from_name(key.as_str()), Some(*key));
715 assert_eq!(key.to_string(), key.as_str());
716 }
717 assert_eq!(KeyCode::from_name("KeyÆ"), None);
718 }
719
720 #[test]
721 fn wasd_is_a_physical_square_not_a_layout_one() {
722 // The whole point of the type: these are positions, so a binding stored
723 // as a name resolves to the same position on any layout.
724 for name in ["KeyW", "KeyA", "KeyS", "KeyD"] {
725 assert!(KeyCode::from_name(name).is_some(), "{name}");
726 }
727 }
728
729 #[test]
730 fn keysyms_map_both_ways_over_the_xkb_rules() {
731 // Latin-1 range: the keysym *is* the codepoint.
732 assert_eq!(Keysym::from_char('a'), Keysym(0x61));
733 assert_eq!(Keysym(0x61).to_char(), Some('a'));
734 assert_eq!(Keysym::from_char('ä'), Keysym(0xE4));
735 assert_eq!(Keysym(0xE4).to_char(), Some('ä'));
736
737 // Everything else lives in the Unicode block.
738 let euro = Keysym::from_char('€');
739 assert_eq!(euro, Keysym(0x0100_20AC));
740 assert_eq!(euro.to_char(), Some('€'));
741
742 // Function keys and modifiers are not characters.
743 const XKB_KEY_F1: Keysym = Keysym(0xFFBE);
744 assert_eq!(XKB_KEY_F1.to_char(), None);
745 assert!(Keysym::NONE.is_none());
746 assert_eq!(Keysym::NONE.to_char(), None);
747
748 assert_eq!(Keysym::from_char('q').to_string(), "'q'");
749 assert_eq!(XKB_KEY_F1.to_string(), "keysym 0xffbe");
750 }
751
752 #[test]
753 fn every_printable_ascii_round_trips_through_a_keysym() {
754 for codepoint in 0x20u32..=0x7E {
755 let character = char::from_u32(codepoint).expect("ascii");
756 assert_eq!(Keysym::from_char(character).to_char(), Some(character));
757 }
758 }
759
760 #[test]
761 fn chord_modifiers_exclude_latched_state() {
762 let held = Modifiers::CTRL | Modifiers::SHIFT | Modifiers::CAPS_LOCK | Modifiers::NUM_LOCK;
763 assert_eq!(held.chord(), Modifiers::CTRL | Modifiers::SHIFT);
764 assert!(!Modifiers::CHORD.contains(Modifiers::ALT_GR));
765 assert_eq!(Modifiers::default(), Modifiers::empty());
766 }
767
768 #[test]
769 fn button_state_is_one_type_for_every_kind_of_button() {
770 assert!(ButtonState::from_pressed(true).is_pressed());
771 assert_eq!(ButtonState::from_pressed(false), ButtonState::Released);
772 }
773
774 #[test]
775 fn scroll_units_stay_distinct() {
776 let lines = ScrollDelta::Lines { x: 0.0, y: -1.0 };
777 let pixels = ScrollDelta::Pixels { x: 0.0, y: -13.0 };
778 assert_ne!(
779 format!("{lines:?}"),
780 format!("{pixels:?}"),
781 "a wheel detent and 13 px must not be the same value"
782 );
783 assert!(ScrollDelta::Lines { x: 0.0, y: 0.0 }.is_zero());
784 assert!(ScrollDelta::Pixels { x: 0.0, y: 0.0 }.is_zero());
785 assert!(!lines.is_zero());
786 assert!(!pixels.is_zero());
787 }
788
789 #[test]
790 fn device_ids_are_displayable_and_default_to_unknown() {
791 assert_eq!(DeviceId::default(), DeviceId::UNKNOWN);
792 assert_eq!(DeviceId::UNKNOWN.to_string(), "device ?");
793 assert_eq!(DeviceId(4).to_string(), "device 4");
794 assert_eq!(Scancode(30).to_string(), "scancode 30");
795 }
796}