retroglyph_core/event/mod.rs
1//! Input event system.
2//!
3//! [`Terminal::poll`](crate::terminal::Terminal::poll) returns an optional [`Event`] with support for
4//! keyboard ([`KeyEvent`], all standard keys plus [`KeyModifiers`]), mouse ([`MouseEvent`]:
5//! buttons, movement, scroll), touch (synthesized into the same mouse events on the
6//! software/WASM backend), window resize, and close events.
7//! [`has_input`](crate::terminal::Terminal::has_input) checks for a pending event without blocking. Resize
8//! events are applied to the grid automatically, before the event reaches your code.
9
10mod key;
11mod mouse;
12
13pub use key::{KeyCode, KeyEvent, KeyEventKind, KeyLocation, KeyModifiers, KeyState, ModifierKey};
14pub use mouse::{MouseButton, MouseEvent, MouseEventKind, PhysicalPos};
15
16use alloc::collections::VecDeque;
17use alloc::string::String;
18
19/// The system's light/dark color-scheme preference, as reported by the
20/// windowing/browser layer.
21///
22/// Currently just these two variants: every source that can report this
23/// (winit's `Theme`, the browser's `prefers-color-scheme` media query) only
24/// ever resolves to one of exactly these two, and a backend that can't
25/// determine a preference simply never emits [`Event::ThemeChanged`] rather
26/// than emitting a third "unknown" case for callers to handle. Marked
27/// `#[non_exhaustive]` for consistency with sibling public enums, in case a
28/// future source (e.g. a `HighContrast` case) needs to be added.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[non_exhaustive]
32pub enum SystemTheme {
33 /// The system prefers a light color scheme.
34 Light,
35 /// The system prefers a dark color scheme.
36 Dark,
37}
38
39#[derive(Debug, Clone, PartialEq)]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41#[non_exhaustive]
42/// Terminal input event.
43///
44/// Does not derive `Eq`/`Hash`: [`MouseEvent`] does not (its `MouseEventKind::Scroll` variant's
45/// `f32` fields implement neither).
46pub enum Event {
47 /// Keyboard event.
48 Key(KeyEvent),
49 /// Mouse event.
50 Mouse(MouseEvent),
51 /// Terminal window resized to the given `(cols, rows)`.
52 ///
53 /// When this event comes from [`Terminal::poll`](crate::terminal::Terminal::poll) (or the other
54 /// [`Terminal`](crate::terminal::Terminal) methods that route through it), the grid has already been
55 /// resized to these dimensions by the time the event reaches your code; the payload is there
56 /// so the app can react, for example recomputing layout or redrawing. A consumer driving
57 /// [`Input::poll_event`](crate::backend::Input::poll_event) directly on a raw backend gets no
58 /// such guarantee and must resize the grid itself.
59 Resize(u16, u16),
60 /// Window closed.
61 Close,
62 /// The system's light/dark color-scheme preference changed, or was
63 /// determined for the first time at startup.
64 ///
65 /// Only backends with a real source of truth for this emit it: the
66 /// windowed (winit) backend, on both native and wasm (winit's web
67 /// target derives it from the browser's `prefers-color-scheme` media
68 /// query, including live updates). Character-mode backends (crossterm)
69 /// have no equivalent free API (see the windowed backend's own docs
70 /// for why) and never emit this; an app that wants a default should
71 /// pick one itself rather than waiting for an event that may never
72 /// arrive.
73 ThemeChanged(SystemTheme),
74 /// Pasted text, delivered as a single event rather than individual key
75 /// presses.
76 ///
77 /// Not emitted by all backends: see each backend's own docs for
78 /// whether and how it sources this. Content is forwarded verbatim from
79 /// the source, including embedded newlines; the receiving app is
80 /// responsible for any filtering it needs.
81 Paste(String),
82 /// The terminal or application window gained input focus.
83 ///
84 /// This reflects OS/terminal-level focus, not in-app widget focus (see
85 /// `retroglyph-ui`' focus ring for that).
86 FocusGained,
87 /// The terminal or application window lost input focus.
88 ///
89 /// This reflects OS/terminal-level focus, not in-app widget focus (see
90 /// `retroglyph-ui`' focus ring for that).
91 FocusLost,
92 /// An application-defined event injected from outside the normal input
93 /// source (e.g. a network, audio, or timer thread), carrying an opaque
94 /// tag the app assigns its own meaning to.
95 ///
96 /// Only emitted by backends with a real cross-thread injection point:
97 /// the windowed (winit) backend's `EventProxy`
98 /// (`retroglyph_window::winit::EventProxy::send_event`), which forwards
99 /// the `u64` unchanged. The payload is a plain `u64`
100 /// rather than an arbitrary boxed value: it keeps `Event` cheaply
101 /// `Clone`/`PartialEq` (a `Box<dyn Any>` could not derive either) and
102 /// needs no generic parameter threaded through every crate that names
103 /// `Event`. Treat it as a correlation id: look up
104 /// the real payload in whatever shared state or channel the sending
105 /// thread already placed it in.
106 Custom(u64),
107}
108
109/// Whether `new` should replace the queue's current tail event instead of being pushed alongside
110/// it, when a backend is appending `new` to a `Vec`/`VecDeque` of pending events.
111///
112/// True for two consecutive [`Event::Mouse`] events both carrying [`MouseEventKind::Moved`], or
113/// both carrying [`MouseEventKind::Drag`] with the same button: a queue owner (winit, the wasm
114/// FFI boundary, `Headless`) can be fed pointer-move/drag events far faster than a consumer
115/// drains them, and only the most recent position matters once it does (whether or not a button
116/// is held), so collapsing either run in place keeps the queue from growing unbounded
117/// (retroglyph#294, retroglyph#768, retroglyph#942). A `Drag` only coalesces with another `Drag`
118/// carrying the *same* button, so a button change mid-drag is never swallowed into the wrong
119/// button's position. `Scroll` deliberately does not coalesce despite also being high-frequency:
120/// its `dx`/`dy` are deltas, not absolute state, so collapsing a run would discard real scroll
121/// distance rather than a stale intermediate value. Every other event kind (clicks, keys, resize,
122/// ...) always returns `false`.
123#[must_use]
124pub const fn coalesces_with(new: &Event, existing: &Event) -> bool {
125 matches!(
126 (new, existing),
127 (
128 Event::Mouse(MouseEvent {
129 kind: MouseEventKind::Moved,
130 ..
131 }),
132 Event::Mouse(MouseEvent {
133 kind: MouseEventKind::Moved,
134 ..
135 }),
136 )
137 ) || matches!(
138 (new, existing),
139 (
140 Event::Mouse(MouseEvent {
141 kind: MouseEventKind::Drag(new_button),
142 ..
143 }),
144 Event::Mouse(MouseEvent {
145 kind: MouseEventKind::Drag(existing_button),
146 ..
147 }),
148 ) if *new_button as u8 == *existing_button as u8
149 )
150}
151
152/// Appends `event` to `queue`, replacing the tail in place instead of growing the queue when
153/// [`coalesces_with`] says the two coalesce.
154///
155/// The sole enforcement point for the coalescing rule: every backend-owned event queue (`Headless`,
156/// `retroglyph-window`'s `WindowBackend`, `retroglyph-software`'s `SoftwareRenderer`,
157/// `retroglyph-terminal-wasm`'s `TerminalWasm`) should push through this rather than re-checking
158/// `coalesces_with` against its own tail. [`Terminal::requeue_events`](crate::terminal::Terminal::requeue_events)
159/// deliberately does not use this: it replays already-drained events back onto the queue in
160/// order, where coalescing them again would silently drop events a caller already observed.
161///
162/// Returns `true` if `event` replaced the tail, `false` if it was pushed onto the queue.
163pub fn push_coalesced(queue: &mut VecDeque<Event>, event: Event) -> bool {
164 if let Some(tail) = queue.back_mut()
165 && coalesces_with(&event, tail)
166 {
167 *tail = event;
168 return true;
169 }
170 queue.push_back(event);
171 false
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::grid::Pos;
178
179 #[test]
180 fn test_paste_event_carries_text() {
181 use alloc::string::ToString as _;
182
183 let event = Event::Paste("hello".to_string());
184 let Event::Paste(text) = event else {
185 panic!("Expected Event::Paste");
186 };
187 assert_eq!(text, "hello");
188 }
189
190 #[test]
191 fn test_custom_event_carries_opaque_id() {
192 let event = Event::Custom(42);
193 let Event::Custom(id) = event else {
194 panic!("Expected Event::Custom");
195 };
196 assert_eq!(id, 42);
197 assert_ne!(Event::Custom(1), Event::Custom(2));
198 }
199
200 #[test]
201 fn test_focus_gained_and_lost_are_distinct() {
202 assert!(matches!(Event::FocusGained, Event::FocusGained));
203 assert!(matches!(Event::FocusLost, Event::FocusLost));
204 assert_ne!(Event::FocusGained, Event::FocusLost);
205 }
206
207 fn moved_at(x: u16, y: u16) -> Event {
208 Event::Mouse(MouseEvent {
209 kind: MouseEventKind::Moved,
210 position: Pos::new(x, y),
211 pixel_position: None,
212 modifiers: KeyModifiers::NONE,
213 })
214 }
215
216 #[test]
217 fn coalesces_with_true_for_two_consecutive_moved_events() {
218 assert!(coalesces_with(&moved_at(1, 1), &moved_at(0, 0)));
219 }
220
221 #[test]
222 fn coalesces_with_false_for_non_moved_mouse_events() {
223 let down = Event::Mouse(MouseEvent {
224 kind: MouseEventKind::Down(MouseButton::Left),
225 position: Pos::new(0, 0),
226 pixel_position: None,
227 modifiers: KeyModifiers::NONE,
228 });
229 assert!(!coalesces_with(&moved_at(1, 1), &down));
230 assert!(!coalesces_with(&down, &moved_at(0, 0)));
231 }
232
233 #[test]
234 fn coalesces_with_false_for_non_mouse_events() {
235 let key = Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
236 assert!(!coalesces_with(&moved_at(1, 1), &key));
237 assert!(!coalesces_with(&key, &moved_at(0, 0)));
238 }
239
240 fn drag_at(x: u16, y: u16, button: MouseButton) -> Event {
241 Event::Mouse(MouseEvent {
242 kind: MouseEventKind::Drag(button),
243 position: Pos::new(x, y),
244 pixel_position: None,
245 modifiers: KeyModifiers::NONE,
246 })
247 }
248
249 #[test]
250 fn coalesces_with_true_for_two_consecutive_drag_events_same_button() {
251 assert!(coalesces_with(
252 &drag_at(1, 1, MouseButton::Left),
253 &drag_at(0, 0, MouseButton::Left),
254 ));
255 }
256
257 #[test]
258 fn coalesces_with_false_for_drag_events_with_different_buttons() {
259 assert!(!coalesces_with(
260 &drag_at(1, 1, MouseButton::Right),
261 &drag_at(0, 0, MouseButton::Left),
262 ));
263 assert!(!coalesces_with(
264 &drag_at(1, 1, MouseButton::Left),
265 &drag_at(0, 0, MouseButton::Middle),
266 ));
267 }
268
269 #[test]
270 fn coalesces_with_false_for_scroll_events() {
271 let scroll = |dy: f32| {
272 Event::Mouse(MouseEvent {
273 kind: MouseEventKind::Scroll { dx: 0.0, dy },
274 position: Pos::new(0, 0),
275 pixel_position: None,
276 modifiers: KeyModifiers::NONE,
277 })
278 };
279 assert!(!coalesces_with(&scroll(1.0), &scroll(1.0)));
280 }
281
282 #[test]
283 fn coalesces_with_false_for_two_non_mouse_events() {
284 assert!(!coalesces_with(&Event::Close, &Event::Resize(1, 1)));
285 }
286
287 #[cfg(feature = "serde")]
288 #[test]
289 fn event_round_trips_through_serde_json() {
290 let event = Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::SHIFT));
291 let json = serde_json::to_string(&event).expect("serialize");
292 assert_eq!(
293 serde_json::from_str::<Event>(&json).expect("deserialize"),
294 event
295 );
296 }
297
298 #[cfg(feature = "serde")]
299 #[test]
300 fn system_theme_round_trips_through_serde_json() {
301 let json = serde_json::to_string(&SystemTheme::Dark).expect("serialize");
302 assert_eq!(
303 serde_json::from_str::<SystemTheme>(&json).expect("deserialize"),
304 SystemTheme::Dark
305 );
306 }
307}