Skip to main content

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}