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::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}