Skip to main content

kimun_notes/app/
events.rs

1//! The **Input source** seam (CONTEXT.md § App shell), merged with the app
2//! channel into the one `next()` the **App loop** awaits.
3//!
4//! Two kinds of event reach the loop. Terminal-originated ones — key, mouse,
5//! paste, resize — come from the input source: crossterm's `EventStream` in
6//! the app, any `Stream<Item = AppEvent>` in tests. App-originated ones —
7//! autosave done, indexing done, a server answer — come from spawned tasks
8//! through the app channel. The loop needs to tell them apart: app messages
9//! are peeked without blocking and coalesced into one frame; a real input
10//! event always forces a fresh await and its own draw. That is why the seam
11//! is the input *stream* and not one merged stream handed to the loop.
12//!
13//! The input source ends only when the terminal is gone (or a script ran
14//! out), and the loop treats its end as quit.
15
16use std::io;
17use std::pin::Pin;
18
19use crossterm::event::{Event as CrosstermEvent, EventStream, KeyEventKind};
20use futures::{Stream, StreamExt};
21use tokio::sync::mpsc;
22use tokio::sync::mpsc::error::TryRecvError;
23
24use crate::app::ctrl_h::CtrlHPolicy;
25use crate::components::events::{AppEvent, AppTx, InputEvent};
26
27/// Terminal-originated events, already decoded. Boxed rather than generic so
28/// the loop, the app and `main` carry no type parameter for it (the same line
29/// ADR-0009 draws for the editor backend).
30pub type InputSource = Pin<Box<dyn Stream<Item = AppEvent> + Send>>;
31
32/// Owns the app channel and the input source. Exposes a single `next()`
33/// await point for the loop.
34pub struct EventHandler {
35    tx: AppTx,
36    rx: mpsc::UnboundedReceiver<AppEvent>,
37    input: InputSource,
38}
39
40impl EventHandler {
41    /// The app's handler: crossterm reads the terminal. `ctrl_h` is the
42    /// session's resolved Ctrl-H rule, applied as events are decoded — no
43    /// `Default`, because guessing that rule is exactly what this type must
44    /// not do.
45    pub fn new(ctrl_h: CtrlHPolicy) -> Self {
46        Self::from_input(crossterm_input(ctrl_h))
47    }
48
49    /// A handler over any input source — a `futures::stream::iter` of scripted
50    /// events in tests, a replayed recording, anything that yields `AppEvent`.
51    /// The stream is fused here, so an adapter need not be.
52    pub fn from_input(input: impl Stream<Item = AppEvent> + Send + 'static) -> Self {
53        let (tx, rx) = mpsc::unbounded_channel();
54        Self {
55            tx,
56            rx,
57            input: Box::pin(input.fuse()),
58        }
59    }
60
61    /// Returns a cloned sender. Pass this to screens and components as `&AppTx`.
62    pub fn app_sender(&self) -> AppTx {
63        self.tx.clone()
64    }
65
66    /// Non-blocking peek of the app channel only. Input is never polled here:
67    /// the loop coalesces queued app messages between blocking awaits, and a
68    /// real input event must always get its own `next()` and its own draw.
69    ///
70    /// `Disconnected` is structurally unreachable: `self.tx` is owned by this
71    /// handler and live across the `&mut self` borrow, so at least one sender
72    /// always exists while `try_next` runs.
73    pub fn try_next(&mut self) -> Option<AppEvent> {
74        match self.rx.try_recv() {
75            Ok(msg) => Some(msg),
76            Err(TryRecvError::Empty) => None,
77            Err(TryRecvError::Disconnected) => {
78                unreachable!(
79                    "EventHandler::tx is owned by this struct and the `&mut self` borrow \
80                     guarantees it outlives this call; channel cannot be Disconnected here"
81                )
82            }
83        }
84    }
85
86    /// Wait for the next event. App messages first (`biased`), then input.
87    /// An exhausted input source yields `Quit`, every time it is asked.
88    pub async fn next(&mut self) -> AppEvent {
89        tokio::select! {
90            biased;
91            Some(msg) = self.rx.recv() => msg,
92            event = self.input.next() => event.unwrap_or(AppEvent::Quit),
93        }
94    }
95}
96
97/// The crossterm adapter: the terminal's event stream, decoded.
98fn crossterm_input(ctrl_h: CtrlHPolicy) -> impl Stream<Item = AppEvent> + Send {
99    EventStream::new().filter_map(move |event| {
100        tracing::debug!("RAW EVENT: {:?}", event);
101        futures::future::ready(decode(event, ctrl_h))
102    })
103}
104
105/// What one crossterm event means to the loop. Key releases are dropped (the
106/// kitty protocol reports them; the app acts on presses), a resize is a
107/// redraw, focus and unknown events are nothing, and a read error is logged
108/// and skipped rather than ending the source.
109///
110/// This is also where `ctrl_h` is applied. The rewrite belongs here, at the
111/// one place every terminal key enters the app, rather than in the shortcut
112/// tier or a backend: both of those would have to agree about it, and a
113/// Backspace the editor sees as a chord is the same bug either way.
114pub(crate) fn decode(event: io::Result<CrosstermEvent>, ctrl_h: CtrlHPolicy) -> Option<AppEvent> {
115    match event {
116        Ok(CrosstermEvent::Key(key)) if key.kind != KeyEventKind::Release => {
117            Some(AppEvent::Input(InputEvent::Key(ctrl_h.apply(key))))
118        }
119        Ok(CrosstermEvent::Mouse(mouse)) => Some(AppEvent::Input(InputEvent::Mouse(mouse))),
120        Ok(CrosstermEvent::Paste(text)) => Some(AppEvent::Input(InputEvent::Paste(text))),
121        Ok(CrosstermEvent::Resize(_, _)) => Some(AppEvent::Redraw),
122        Ok(_) => None,
123        Err(e) => {
124            tracing::warn!("terminal input error: {e}");
125            None
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use futures::stream;
134    use ratatui::crossterm::event::{
135        Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers,
136    };
137
138    fn key(code: KeyCode, kind: KeyEventKind) -> CrosstermEvent {
139        CrosstermEvent::Key(KeyEvent {
140            code,
141            modifiers: KeyModifiers::NONE,
142            kind,
143            state: KeyEventState::NONE,
144        })
145    }
146
147    /// The rule that changes nothing, for the tests that are not about it.
148    const CHORD: CtrlHPolicy = CtrlHPolicy::Chord;
149
150    #[test]
151    fn decode_keeps_presses_and_drops_releases() {
152        assert!(matches!(
153            decode(Ok(key(KeyCode::Char('a'), KeyEventKind::Press)), CHORD),
154            Some(AppEvent::Input(InputEvent::Key(k))) if k.code == KeyCode::Char('a')
155        ));
156        assert!(decode(Ok(key(KeyCode::Char('a'), KeyEventKind::Release)), CHORD).is_none());
157    }
158
159    #[test]
160    fn decode_turns_a_resize_into_a_redraw_and_skips_errors() {
161        assert!(matches!(
162            decode(Ok(CrosstermEvent::Resize(80, 24)), CHORD),
163            Some(AppEvent::Redraw)
164        ));
165        assert!(decode(Err(std::io::Error::other("hangup")), CHORD).is_none());
166    }
167
168    /// The reported bug: a terminal whose Backspace key sends `0x08` reaches
169    /// crossterm as Ctrl-H, which the shortcut tier claims as a binding
170    /// (`FocusSidebar`, by default) before the editor ever sees it. Under the
171    /// Backspace rule the seam hands the loop a Backspace instead.
172    #[test]
173    fn decode_applies_the_ctrl_h_rule() {
174        let ctrl_h = CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
175        assert!(matches!(
176            decode(Ok(ctrl_h.clone()), CtrlHPolicy::Backspace),
177            Some(AppEvent::Input(InputEvent::Key(k)))
178                if k.code == KeyCode::Backspace && k.modifiers == KeyModifiers::NONE
179        ));
180        assert!(matches!(
181            decode(Ok(ctrl_h), CHORD),
182            Some(AppEvent::Input(InputEvent::Key(k)))
183                if k.code == KeyCode::Char('h') && k.modifiers == KeyModifiers::CONTROL
184        ));
185    }
186
187    /// A release still drops under the rewrite: the rule decides *which* key
188    /// an event is, never whether the loop hears about it.
189    #[test]
190    fn the_ctrl_h_rule_does_not_revive_releases() {
191        let release = CrosstermEvent::Key(KeyEvent::new_with_kind(
192            KeyCode::Char('h'),
193            KeyModifiers::CONTROL,
194            KeyEventKind::Release,
195        ));
196        assert!(decode(Ok(release), CtrlHPolicy::Backspace).is_none());
197    }
198
199    #[tokio::test]
200    async fn a_scripted_input_source_is_delivered_in_order() {
201        let mut events = EventHandler::from_input(stream::iter([
202            AppEvent::Redraw,
203            AppEvent::Input(InputEvent::Paste("p".into())),
204        ]));
205        assert!(matches!(events.next().await, AppEvent::Redraw));
206        assert!(matches!(
207            events.next().await,
208            AppEvent::Input(InputEvent::Paste(s)) if s == "p"
209        ));
210    }
211
212    #[tokio::test]
213    async fn app_messages_are_drained_before_input() {
214        let mut events = EventHandler::from_input(stream::iter([AppEvent::Redraw]));
215        events.app_sender().send(AppEvent::Quit).unwrap();
216        // Biased: the queued app message wins even though input is ready.
217        assert!(matches!(events.next().await, AppEvent::Quit));
218        assert!(matches!(events.next().await, AppEvent::Redraw));
219    }
220
221    #[tokio::test]
222    async fn an_exhausted_input_source_yields_quit() {
223        let mut events = EventHandler::from_input(stream::empty());
224        assert!(matches!(events.next().await, AppEvent::Quit));
225        // …and keeps yielding it: the loop may ask once more on its way out.
226        assert!(matches!(events.next().await, AppEvent::Quit));
227    }
228
229    #[test]
230    fn try_next_only_peeks_the_app_channel() {
231        let mut events = EventHandler::from_input(stream::iter([AppEvent::Redraw]));
232        assert!(events.try_next().is_none());
233        events.app_sender().send(AppEvent::Redraw).unwrap();
234        assert!(matches!(events.try_next(), Some(AppEvent::Redraw)));
235    }
236}