Skip to main content

datui_lib/
terminal_input.rs

1//! The one reader of the terminal.
2//!
3//! The run loop waits on a single channel: worker results, continuations and keys all
4//! wake it, so nothing waits out a poll interval for news that has already arrived.
5//! Terminal events get onto that channel through this thread, which is the only thing
6//! that reads them — through Crossterm's own `EventStream`, so Crossterm's parser and
7//! its reader lock are never raced by a second reader.
8//!
9//! The stream is polled by hand on a dedicated thread rather than on the shared Tokio
10//! runtime: a runtime busy with blocking work must not delay a key. A parked thread
11//! costs nothing while the terminal is quiet, and stopping it is deterministic, which
12//! matters to the Python binding: a reader left behind would read the REPL's input
13//! after the TUI is gone.
14
15use std::pin::Pin;
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::mpsc::Sender;
19use std::task::{Context, Poll, Wake, Waker};
20use std::thread::{JoinHandle, Thread};
21use std::time::Instant;
22
23use crossterm::event::{Event, EventStream};
24use futures_core::Stream;
25
26use crate::AppEvent;
27use crate::terminal_color::{self, ReplyScanner, Scanned};
28
29/// Wakes the reader thread when Crossterm has an event, or when it is told to stop.
30struct Unpark(Thread);
31
32impl Wake for Unpark {
33    fn wake(self: Arc<Self>) {
34        self.0.unpark();
35    }
36
37    fn wake_by_ref(self: &Arc<Self>) {
38        self.0.unpark();
39    }
40}
41
42/// The running reader. Stopped (and joined) on drop.
43pub struct TerminalInput {
44    stop: Arc<AtomicBool>,
45    thread: Option<JoinHandle<()>>,
46}
47
48impl TerminalInput {
49    /// Start forwarding every terminal event to `tx` as [`AppEvent::Terminal`]. A read
50    /// error ends the session as [`AppEvent::Crash`], as it did when the loop read the
51    /// terminal itself.
52    pub fn start(tx: Sender<AppEvent>) -> std::io::Result<Self> {
53        let stop = Arc::new(AtomicBool::new(false));
54        let stopping = Arc::clone(&stop);
55        let thread = std::thread::Builder::new()
56            .name("datui-input".into())
57            .spawn(move || read(tx, &stopping))?;
58        Ok(Self {
59            stop,
60            thread: Some(thread),
61        })
62    }
63
64    /// Stop reading. Returns once the reader has let go of the terminal, so whatever is
65    /// typed next goes to the shell (or the Python REPL), not to this session.
66    pub fn stop(&mut self) {
67        self.stop.store(true, Ordering::SeqCst);
68        if let Some(thread) = self.thread.take() {
69            thread.thread().unpark();
70            let _ = thread.join();
71        }
72    }
73}
74
75impl Drop for TerminalInput {
76    fn drop(&mut self) {
77        self.stop();
78    }
79}
80
81fn read(tx: Sender<AppEvent>, stop: &AtomicBool) {
82    let mut stream = EventStream::new();
83    let waker = Waker::from(Arc::new(Unpark(std::thread::current())));
84    let mut cx = Context::from_waker(&waker);
85    // The terminal's answer about its background arrives as keys; this takes it off.
86    let mut replies = ReplyScanner::default();
87    let mut scanned = Vec::new();
88    let mut held_since: Option<Instant> = None;
89    while !stop.load(Ordering::SeqCst) {
90        match Pin::new(&mut stream).poll_next(&mut cx) {
91            Poll::Ready(Some(Ok(event))) => {
92                replies.feed(event, terminal_color::armed(), &mut scanned);
93                held_since = replies
94                    .holding()
95                    .then(|| held_since.unwrap_or_else(Instant::now));
96                if pass_on(&tx, &mut scanned).is_err() {
97                    break;
98                }
99            }
100            Poll::Ready(Some(Err(e))) => {
101                let _ = tx.send(AppEvent::Crash(format!("Cannot read the terminal: {e}")));
102                break;
103            }
104            Poll::Ready(None) => break,
105            // A spurious unpark only costs one more poll.
106            Poll::Pending => match held_since {
107                None => std::thread::park(),
108                Some(since) => {
109                    let left = terminal_color::HOLD.saturating_sub(since.elapsed());
110                    if left.is_zero() {
111                        // Not a reply after all: the keys go on as typed.
112                        replies.flush(&mut scanned);
113                        held_since = None;
114                        if pass_on(&tx, &mut scanned).is_err() {
115                            break;
116                        }
117                    } else {
118                        std::thread::park_timeout(left);
119                    }
120                }
121            },
122        }
123    }
124    // Dropping the stream wakes Crossterm's own blocked poll and lets its thread end,
125    // releasing the terminal.
126    drop(stream);
127}
128
129/// Hand what the scanner let through to the loop: events through [`forward`], a reply
130/// as [`AppEvent::TerminalBackground`].
131fn pass_on(tx: &Sender<AppEvent>, scanned: &mut Vec<Scanned>) -> Result<(), ()> {
132    for item in scanned.drain(..) {
133        match item {
134            Scanned::Event(event) => forward(tx, event)?,
135            Scanned::Background(mode) => {
136                terminal_color::disarm();
137                if let Some(mode) = mode {
138                    tx.send(AppEvent::TerminalBackground(mode))
139                        .map_err(|_| ())?;
140                }
141            }
142        }
143    }
144    Ok(())
145}
146
147/// Hand one event to the loop. Only presses are keys: a terminal speaking the kitty
148/// protocol may report releases and repeats too, and the app acts on presses alone.
149/// Of the mouse, only what the app acts on ([`crate::pointer::wanted`]).
150fn forward(tx: &Sender<AppEvent>, event: Event) -> Result<(), ()> {
151    let event = match event {
152        Event::Key(key) if !key.is_press() => return Ok(()),
153        Event::Key(_) | Event::Resize(..) => event,
154        Event::Mouse(mouse) if crate::pointer::wanted(&mouse) => event,
155        // The terminal is back in front: its scheme may have changed meanwhile.
156        Event::FocusGained => return tx.send(AppEvent::TerminalFocused).map_err(|_| ()),
157        _ => return Ok(()),
158    };
159    tx.send(AppEvent::Terminal(event)).map_err(|_| ())
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crossterm::event::{
166        KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
167    };
168    use std::sync::mpsc;
169
170    /// Key releases and repeats never reach the app; key presses, resizes, a left
171    /// press with its drag and release, a right press and the wheel do, in order.
172    /// Motion with no button down does not.
173    #[test]
174    fn only_presses_resizes_the_mouse_buttons_and_the_wheel_are_forwarded() {
175        let (tx, rx) = mpsc::channel();
176        let press = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
177        let mut release = press;
178        release.kind = KeyEventKind::Release;
179        let mut repeat = press;
180        repeat.kind = KeyEventKind::Repeat;
181        let mouse = |kind| MouseEvent {
182            kind,
183            column: 3,
184            row: 4,
185            modifiers: KeyModifiers::NONE,
186        };
187        let click = mouse(MouseEventKind::Down(MouseButton::Left));
188        let wheel = mouse(MouseEventKind::ScrollDown);
189        for event in [
190            Event::Key(release),
191            Event::Key(press),
192            Event::Key(repeat),
193            Event::FocusLost,
194            Event::Mouse(mouse(MouseEventKind::Moved)),
195            Event::Mouse(mouse(MouseEventKind::Drag(MouseButton::Left))),
196            Event::Mouse(mouse(MouseEventKind::Up(MouseButton::Left))),
197            Event::Mouse(mouse(MouseEventKind::Down(MouseButton::Right))),
198            Event::Mouse(click),
199            Event::Mouse(wheel),
200            Event::Resize(80, 24),
201        ] {
202            forward(&tx, event).unwrap();
203        }
204        drop(tx);
205        let got: Vec<_> = rx
206            .iter()
207            .map(|e| match e {
208                AppEvent::Terminal(event) => event,
209                _ => panic!("only terminal events are sent"),
210            })
211            .collect();
212        assert_eq!(
213            got,
214            vec![
215                Event::Key(press),
216                Event::Mouse(mouse(MouseEventKind::Drag(MouseButton::Left))),
217                Event::Mouse(mouse(MouseEventKind::Up(MouseButton::Left))),
218                Event::Mouse(mouse(MouseEventKind::Down(MouseButton::Right))),
219                Event::Mouse(click),
220                Event::Mouse(wheel),
221                Event::Resize(80, 24)
222            ]
223        );
224    }
225
226    /// Focus coming back is news for the palette; focus leaving is not.
227    #[test]
228    fn focus_gained_is_sent_as_its_own_event() {
229        let (tx, rx) = mpsc::channel();
230        forward(&tx, Event::FocusLost).unwrap();
231        forward(&tx, Event::FocusGained).unwrap();
232        drop(tx);
233        let got: Vec<_> = rx.iter().collect();
234        assert!(matches!(got.as_slice(), [AppEvent::TerminalFocused]));
235    }
236
237    /// A reply the scanner took off the stream reaches the loop as the mode it names,
238    /// with the keys around it in order; one whose color could not be read is dropped.
239    #[test]
240    fn a_reply_is_sent_as_the_terminal_background() {
241        let (tx, rx) = mpsc::channel();
242        let key = |c| Event::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
243        let mut scanned = vec![
244            Scanned::Event(key('j')),
245            Scanned::Background(Some(crate::config::ThemeMode::Light)),
246            Scanned::Background(None),
247            Scanned::Event(key('k')),
248        ];
249        pass_on(&tx, &mut scanned).unwrap();
250        drop(tx);
251        let got: Vec<_> = rx.iter().collect();
252        assert!(matches!(
253            got.as_slice(),
254            [
255                AppEvent::Terminal(Event::Key(j)),
256                AppEvent::TerminalBackground(crate::config::ThemeMode::Light),
257                AppEvent::Terminal(Event::Key(k)),
258            ] if j.code == KeyCode::Char('j') && k.code == KeyCode::Char('k')
259        ));
260    }
261}