telar_ui_core/keyboard.rs
1//! The keyboard as state rather than as events.
2//!
3//! Widgets are written against events — a press arrives, a handler runs — and that is the right shape for
4//! a button. It is the wrong shape for two other questions that come up constantly:
5//!
6//! - *"was `Shift` down when that click happened?"* A pointer event carries no modifiers, and a bare
7//! `Shift` press produces no key event to have tracked, so the answer exists nowhere in the event stream.
8//! - *"is `ArrowUp` held **right now**?"* Asked once per frame by anything that acts for as long as a key is
9//! down rather than at the moment it went down — a camera orbiting, a value stepping, a sprite walking.
10//!
11//! Both are answered by keeping the state as the events go past, which is what this does. The modifier half
12//! is fed by [`Event::ModifiersChanged`], which the platform layer re-sends on focus changes — so it stays
13//! right across the alt-tab-mid-chord case that reconstruction gets wrong.
14
15use platform_core::{Event, Key, ModifiersState};
16use rustc_hash::FxHashSet;
17use std::cell::RefCell;
18
19#[derive(Default)]
20struct Keyboard {
21 modifiers: ModifiersState,
22 held: FxHashSet<Key>,
23 pressed: FxHashSet<Key>,
24}
25
26thread_local! {
27 static KEYBOARD: RefCell<Keyboard> = RefCell::new(Keyboard::default());
28}
29
30/// Records what `event` says about the keyboard. The runner calls this for every event before dispatch.
31pub fn observe(event: &Event) {
32 KEYBOARD.with(|k| {
33 let mut k = k.borrow_mut();
34 match event {
35 Event::ModifiersChanged { modifiers } => k.modifiers = *modifiers,
36 Event::KeyPressed { key, modifiers } => {
37 k.modifiers = *modifiers;
38 // `insert` reports whether the key was absent, which is what separates a first press from the OS repeating one that was already down.
39 if k.held.insert(key.clone()) {
40 k.pressed.insert(key.clone());
41 }
42 }
43 Event::KeyReleased { key, modifiers } => {
44 k.modifiers = *modifiers;
45 k.held.remove(key);
46 }
47 // A window that loses focus never sends the releases for what was held, and the keys are not held any more by the time it comes back. Letting them rot would leave whatever they drive running until the user pressed and released the same key again.
48 Event::FocusChanged { is_focused: false } => {
49 k.held.clear();
50 k.pressed.clear();
51 k.modifiers = ModifiersState::default();
52 }
53 _ => {}
54 }
55 });
56}
57
58/// Forgets the presses that belong to the frame just finished. The runner calls this once per frame,
59/// after dispatch, so [`key_pressed`] answers for exactly one frame.
60pub fn end_frame() {
61 KEYBOARD.with(|k| k.borrow_mut().pressed.clear());
62}
63
64/// The modifier keys held right now.
65///
66/// Authoritative rather than reconstructed: it comes from the platform's own reading, including the one it
67/// re-sends when the window regains focus. Read it inside a pointer handler to tell a plain click from a
68/// `Shift`-click.
69pub fn modifiers() -> ModifiersState {
70 KEYBOARD.with(|k| k.borrow().modifiers)
71}
72
73/// Whether `key` is down right now, however long it has been down.
74pub fn key_held(key: &Key) -> bool {
75 KEYBOARD.with(|k| k.borrow().held.contains(key))
76}
77
78/// Whether `key` went down during this frame. False for a key the OS is repeating, which is what makes it
79/// the one to drive a once-per-press action while [`key_held`] drives a continuous one.
80pub fn key_pressed(key: &Key) -> bool {
81 KEYBOARD.with(|k| k.borrow().pressed.contains(key))
82}
83
84/// Drops all keyboard state; parallels the other per-tree resets on teardown and hot reload.
85pub fn reset() {
86 KEYBOARD.with(|k| *k.borrow_mut() = Keyboard::default());
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use platform_core::NamedKey;
93
94 fn up() -> Key {
95 Key::Named(NamedKey::ArrowUp)
96 }
97
98 fn shift() -> ModifiersState {
99 ModifiersState {
100 is_shift: true,
101 ..ModifiersState::default()
102 }
103 }
104
105 fn fresh() {
106 reset();
107 }
108
109 #[test]
110 fn a_key_stays_held_until_it_is_released() {
111 fresh();
112 assert!(!key_held(&up()));
113 observe(&Event::KeyPressed {
114 key: up(),
115 modifiers: ModifiersState::default(),
116 });
117 assert!(key_held(&up()));
118 end_frame();
119 assert!(key_held(&up()), "holding outlives the frame it began in");
120 observe(&Event::KeyReleased {
121 key: up(),
122 modifiers: ModifiersState::default(),
123 });
124 assert!(!key_held(&up()));
125 }
126
127 #[test]
128 fn a_press_answers_for_one_frame_only() {
129 fresh();
130 observe(&Event::KeyPressed {
131 key: up(),
132 modifiers: ModifiersState::default(),
133 });
134 assert!(key_pressed(&up()));
135 end_frame();
136 assert!(!key_pressed(&up()));
137 }
138
139 /// The OS repeats a held key as fresh presses. Counting them would fire a once-per-press action forty
140 /// times a second for a user who simply never let go.
141 #[test]
142 fn a_repeated_key_is_not_a_new_press() {
143 fresh();
144 observe(&Event::KeyPressed {
145 key: up(),
146 modifiers: ModifiersState::default(),
147 });
148 end_frame();
149 observe(&Event::KeyPressed {
150 key: up(),
151 modifiers: ModifiersState::default(),
152 });
153 assert!(key_held(&up()));
154 assert!(!key_pressed(&up()), "the key never came back up");
155 }
156
157 /// The case the whole module exists for: `Shift` alone maps to no `Key`, so without its own event the
158 /// state would still read whatever the last typed character carried.
159 #[test]
160 fn a_bare_modifier_is_visible_without_any_key_event() {
161 fresh();
162 assert!(!modifiers().is_shift);
163 observe(&Event::ModifiersChanged { modifiers: shift() });
164 assert!(modifiers().is_shift);
165 }
166
167 /// Losing focus mid-chord is exactly where a reconstructed state goes wrong: the releases never come.
168 #[test]
169 fn losing_focus_forgets_what_was_held() {
170 fresh();
171 observe(&Event::KeyPressed {
172 key: up(),
173 modifiers: shift(),
174 });
175 assert!(key_held(&up()) && modifiers().is_shift);
176 observe(&Event::FocusChanged { is_focused: false });
177 assert!(!key_held(&up()));
178 assert!(!modifiers().is_shift);
179 }
180}