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