terminal_graphics_interactive 0.2.0

Keyboard and mouse input for terminal_graphics: build interactive terminal games, art and animations. Almost no unsafe.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! `Terminal`: raw mode, mouse reporting and a background reader, in one guard.

use crate::event::Event;
use crate::input::{Input, MAX_TIMEOUT};
use crate::raw_mode::{RawMode, MOUSE_OFF, MOUSE_ON};
use crate::renderer::Renderer;
use std::io::{self, Read, Write};
use std::sync::mpsc::{channel, Receiver};
use std::thread;
use std::time::Duration;
use terminal_graphics::Display;

const HIDE_CURSOR: &str = "\x1b[?25l";
const SHOW_CURSOR: &str = "\x1b[?25h";
const ENTER_ALT_SCREEN: &str = "\x1b[?1049h";
const LEAVE_ALT_SCREEN: &str = "\x1b[?1049l";

/// Ask the terminal to wrap pasted text in markers, so it arrives as one
/// `Event::Paste` and not as key presses.
const PASTE_ON: &str = "\x1b[?2004h";
const PASTE_OFF: &str = "\x1b[?2004l";

/// Report the pointer as it moves, even with no button held. Off by default,
/// because it makes the terminal send an event for every cell the pointer
/// crosses, which is a lot of traffic over a slow link.
const MOVEMENT_ON: &str = "\x1b[?1003h";
const MOVEMENT_OFF: &str = "\x1b[?1003l";

/// Used when the terminal does not answer the size query.
const DEFAULT_SIZE: (u32, u32) = (80, 24);

/// How long to wait for the terminal to answer the size query.
const SIZE_REPLY_WAIT: Duration = Duration::from_millis(300);

/// Asks whether the terminal supports the kitty keyboard protocol: the
/// keyboard flags query `ESC [ ? u`, then a device attributes query `ESC [ c`
/// that every terminal answers, so its reply marks the end of the answers.
const KEY_RELEASE_QUERY: &str = "\x1b[?u\x1b[c";

/// Pushes progressive enhancement flags 1 | 2 | 8: disambiguate escape codes,
/// report event types (so releases arrive), and report all keys as escape codes.
const KEY_RELEASE_ON: &str = "\x1b[>11u";

/// Pops the flags pushed by `KEY_RELEASE_ON`.
const KEY_RELEASE_OFF: &str = "\x1b[<u";

/// How long to wait for the terminal to answer the key release query.
const KEY_RELEASE_WAIT: Duration = Duration::from_millis(300);

/// What is printed when a session starts: mouse reporting on, cursor hidden.
fn start_sequence() -> String {
    format!("{ENTER_ALT_SCREEN}{MOUSE_ON}{PASTE_ON}{HIDE_CURSOR}")
}

/// What is printed when a session ends: undo everything `start_sequence` did.
fn stop_sequence() -> String {
    format!("{MOVEMENT_OFF}{MOUSE_OFF}{PASTE_OFF}{SHOW_CURSOR}{LEAVE_ALT_SCREEN}")
}

/// What is printed when a session ends if key releases were turned on. The
/// terminal keeps a separate keyboard mode stack for the alternate screen, which
/// was entered after the flags were pushed, so the pop must come while still on
/// it: before `LEAVE_ALT_SCREEN` in `stop_sequence`.
fn key_release_off_before_stop(enabled: bool) -> String {
    if enabled {
        format!("{KEY_RELEASE_OFF}{}", stop_sequence())
    } else {
        stop_sequence()
    }
}

/// Asks the terminal about key releases, and turns them on if it answers the
/// query. Returns whether they are on. `input` must be the one the terminal
/// reads, so the answer is found among the bytes it sends.
fn negotiate_key_release(input: &mut Input, out: &mut impl Write) -> io::Result<bool> {
    out.write_all(KEY_RELEASE_QUERY.as_bytes())?;
    out.flush()?;
    if !input.request_key_release_support(KEY_RELEASE_WAIT) {
        return Ok(false);
    }
    out.write_all(KEY_RELEASE_ON.as_bytes())?;
    out.flush()?;
    Ok(true)
}

/// Moves the cursor to the far bottom right corner (terminals stop at the
/// edge), asks where it is, and returns it home. The reply is the size.
fn size_query_sequence() -> String {
    "".to_string()
}

/// The size to use for a terminal's reply `(width, height)`, if there was one.
fn size_from_reply(reply: Option<(u32, u32)>) -> (u32, u32) {
    match reply {
        Some((width, height)) if width > 0 && height > 0 => (width, height),
        _ => DEFAULT_SIZE,
    }
}

/// Reads `reader` on a background thread and sends each chunk it gets down the
/// returned channel. The channel closes when the reader ends or fails, and the
/// thread ends when the receiver is dropped.
fn spawn_reader(mut reader: impl Read + Send + 'static) -> io::Result<Receiver<Vec<u8>>> {
    let (tx, rx) = channel();
    thread::Builder::new()
        .name("terminal_graphics_interactive-input".into())
        .spawn(move || {
            let mut buffer = [0u8; 1024];
            loop {
                match reader.read(&mut buffer) {
                    Ok(0) => break,
                    Ok(n) => {
                        if tx.send(buffer[..n].to_vec()).is_err() {
                            break;
                        }
                    }
                    Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
                    Err(_) => break,
                }
            }
        })?;
    Ok(rx)
}

/// An interactive terminal session: keyboard and mouse input for a program
/// that draws with [`Display`](terminal_graphics::Display).
///
/// [`Terminal::start`] switches to the alternate screen (your scrollback is
/// restored on exit), puts the terminal in raw mode, asks it to report the
/// mouse, hides the cursor, finds out the window size, and starts reading
/// input in the background.
/// Dropping the `Terminal` undoes all of it (including during a panic), so
/// the shell is left as it was found.
///
/// It works in Linux and macOS terminals, Windows PowerShell and Windows
/// Terminal, and over SSH when a terminal was allocated (`ssh -t`). Without a
/// terminal, [`Terminal::start`] returns an error that says so.
///
/// Ctrl+C does not stop the program while a `Terminal` exists. It arrives as
/// an event (`Key` with `ctrl` set and `Char('c')`), so check for it and exit
/// your loop, or the only way out is closing the window.
///
/// Use `\r\n`, not `\n`, if you print lines yourself during a session.
///
/// ```no_run
/// use std::time::Duration;
/// use terminal_graphics_interactive::{Event, Terminal};
///
/// let mut terminal = Terminal::start()?;
/// loop {
///     // wait up to 50 ms for input, then do a frame of animation
///     if let Some(Event::Key(key)) = terminal.poll(Duration::from_millis(50))? {
///         if key.is_char('q') || key.is_ctrl('c') {
///             break;
///         }
///     }
/// }
/// # Ok::<(), std::io::Error>(())
/// ```
pub struct Terminal {
    input: Input,
    size: (u32, u32),
    renderer: Renderer,
    /// Whether key releases were turned on by `enable_key_release`.
    key_release: bool,
    /// Declared last so the terminal is restored after the sequences below are printed.
    _raw: RawMode,
}

impl Terminal {
    /// Starts an interactive session. See the [type documentation](Terminal).
    pub fn start() -> io::Result<Terminal> {
        let raw = RawMode::enable()?;
        let mut out = io::stdout();
        out.write_all(start_sequence().as_bytes())?;
        out.flush()?;
        let chunks = spawn_reader(io::stdin())?;
        let mut input = Input::new(chunks);

        // ask the terminal how big it is; if it never answers, assume 80x24
        out.write_all(size_query_sequence().as_bytes())?;
        out.flush()?;
        let size = size_from_reply(input.request_size(SIZE_REPLY_WAIT));

        Ok(Terminal {
            input,
            size,
            renderer: Renderer::new(),
            key_release: false,
            _raw: raw,
        })
    }

    /// Asks the terminal to report key releases as `Event::KeyUp`, so a game
    /// can tell when a key goes up. Returns whether they will arrive.
    ///
    /// `true` means the terminal answered the support query: a key press is
    /// `Event::Key`, and its release is `Event::KeyUp`. With key releases on,
    /// every key arrives this way, including Enter, Esc and plain letters.
    /// `false` means the terminal did not answer (or did not say it supports
    /// the protocol), so nothing changes. Then a game should fall back to
    /// timing-based held-key detection; the `Keys` type in the game module
    /// does that for you. The answer takes up to 300 ms to arrive, so call
    /// this once, at the start. Key releases are switched off again when the
    /// session ends.
    ///
    /// Terminals known to support it are kitty, WezTerm, Ghostty, foot,
    /// Alacritty, iTerm2 and recent Windows Terminal. It has not been checked
    /// on a physical terminal yet, so try it in the one you use.
    ///
    /// ```no_run
    /// use terminal_graphics_interactive::{Event, Terminal};
    ///
    /// let mut terminal = Terminal::start()?;
    /// if terminal.enable_key_release()? {
    ///     // a release arrives as `Event::KeyUp` after its `Event::Key` press
    ///     loop {
    ///         if let Event::KeyUp(key) = terminal.wait()? {
    ///             if key.is_char('q') {
    ///                 break;
    ///             }
    ///         }
    ///     }
    /// }
    /// # Ok::<(), std::io::Error>(())
    /// ```
    pub fn enable_key_release(&mut self) -> io::Result<bool> {
        if self.key_release {
            return Ok(true);
        }
        let mut out = io::stdout();
        self.key_release = negotiate_key_release(&mut self.input, &mut out)?;
        Ok(self.key_release)
    }

    /// Whether `Event::KeyUp` events will arrive, that is, whether
    /// [`Terminal::enable_key_release`] found the terminal supports them and
    /// turned them on.
    ///
    /// ```no_run
    /// use terminal_graphics_interactive::Terminal;
    ///
    /// let mut terminal = Terminal::start()?;
    /// let releases = terminal.enable_key_release()?;
    /// assert_eq!(terminal.has_key_release(), releases);
    /// # Ok::<(), std::io::Error>(())
    /// ```
    pub fn has_key_release(&self) -> bool {
        self.key_release
    }

    /// The size of the terminal window in cells, `(width, height)`, measured
    /// when the session started. It is `(80, 24)` if the terminal did not
    /// answer the query. Make a [`Display`] one row shorter than the height,
    /// so it fits with [`Terminal::draw`].
    pub fn size(&self) -> (u32, u32) {
        self.size
    }

    /// Also report the mouse pointer as it moves with no button held, as
    /// `MouseKind::Moved` events. Without this only presses, releases, drags
    /// and scrolling are reported. It is switched off again when the session
    /// ends. Expect many events: poll often.
    pub fn report_mouse_movement(&mut self) -> io::Result<()> {
        let mut out = io::stdout();
        out.write_all(MOVEMENT_ON.as_bytes())?;
        out.flush()
    }

    /// Shows `display` at the top left of the terminal, replacing what was
    /// there. The top left cell of the display is the terminal's `(0, 0)`, so
    /// [`Mouse::cell_in`](crate::Mouse::cell_in) maps a mouse event straight to
    /// a cell. The display must be at most one row shorter than the terminal
    /// (`height() - 1`) and no wider than it, or it will be drawn off screen.
    ///
    /// Only the cells that changed since the last call are sent, so a picture
    /// that stays still costs almost nothing over a slow link. The first call
    /// draws every cell. Text printed by hand between frames is not redrawn:
    /// the next frame only sends the cells of `display` that changed.
    ///
    /// Call it once per frame.
    pub fn draw(&mut self, display: &Display) -> io::Result<()> {
        let mut out = io::stdout().lock();
        self.renderer.draw_to(display, &mut out)?;
        out.flush()
    }

    /// Waits up to `timeout` for the next event. `Ok(None)` means nothing
    /// happened in that time, which is the cue to draw the next animation
    /// frame. `Duration::ZERO` checks without waiting.
    ///
    /// Returns an error if the input has ended (for example stdin was closed).
    pub fn poll(&mut self, timeout: Duration) -> io::Result<Option<Event>> {
        self.input.poll(timeout)
    }

    /// Waits for the next event, as long as it takes.
    pub fn wait(&mut self) -> io::Result<Event> {
        loop {
            if let Some(event) = self.input.poll(MAX_TIMEOUT)? {
                return Ok(event);
            }
        }
    }
}

impl Drop for Terminal {
    fn drop(&mut self) {
        let mut out = io::stdout();
        // nothing useful can be done if the terminal is already gone
        let _ = out.write_all(key_release_off_before_stop(self.key_release).as_bytes());
        let _ = out.flush();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;
    use std::time::Duration;

    #[test]
    fn start_turns_on_mouse_reporting_and_hides_the_cursor() {
        let start = start_sequence();
        assert!(start.contains(MOUSE_ON));
        assert!(start.contains(HIDE_CURSOR));
    }

    #[test]
    fn stop_turns_mouse_reporting_off_and_shows_the_cursor_again() {
        let stop = stop_sequence();
        assert!(stop.contains(MOUSE_OFF));
        assert!(stop.contains(SHOW_CURSOR));
    }

    #[test]
    fn start_enters_the_alternate_screen_and_stop_leaves_it_last() {
        assert!(start_sequence().contains(ENTER_ALT_SCREEN));
        let stop = stop_sequence();
        assert!(stop.ends_with(LEAVE_ALT_SCREEN), "{stop:?}");
    }

    #[test]
    fn start_asks_for_bracketed_paste_and_stop_turns_it_off_before_leaving() {
        assert!(start_sequence().contains("\x1b[?2004h"));
        let stop = stop_sequence();
        let off = stop.find("\x1b[?2004l").expect("turns bracketed paste off");
        let leave = stop
            .find(LEAVE_ALT_SCREEN)
            .expect("leaves the alternate screen");
        assert!(
            off < leave,
            "paste mode is reset while still on the alternate screen"
        );
    }

    #[test]
    fn movement_reporting_has_its_own_on_and_off_sequences() {
        assert_eq!(MOVEMENT_ON, "\x1b[?1003h");
        assert_eq!(MOVEMENT_OFF, "\x1b[?1003l");
    }

    #[test]
    fn stop_always_turns_movement_reporting_off_before_leaving() {
        // it may have been turned on at any time, so stop does not track it
        let stop = stop_sequence();
        let off = stop
            .find(MOVEMENT_OFF)
            .expect("turns movement reporting off");
        let leave = stop.find(LEAVE_ALT_SCREEN).unwrap();
        assert!(off < leave);
    }

    #[test]
    fn the_size_query_goes_to_the_far_corner_asks_for_the_cursor_and_comes_back() {
        let query = size_query_sequence();
        let corner = query
            .find("\x1b[999;999H")
            .expect("moves to the far corner");
        let ask = query.find("\x1b[6n").expect("asks for the cursor position");
        assert!(corner < ask);
        assert!(
            query.ends_with("\x1b[H"),
            "returns the cursor home: {query:?}"
        );
    }

    #[test]
    fn size_from_a_reply_uses_it() {
        assert_eq!(size_from_reply(Some((120, 40))), (120, 40));
    }

    #[test]
    fn size_without_a_usable_reply_falls_back_to_80_by_24() {
        assert_eq!(size_from_reply(None), (80, 24));
        assert_eq!(size_from_reply(Some((0, 0))), (80, 24));
        assert_eq!(size_from_reply(Some((0, 30))), (80, 24));
        assert_eq!(size_from_reply(Some((90, 0))), (80, 24));
    }

    #[test]
    fn the_reader_sends_what_it_reads_then_closes_the_channel() {
        let rx = spawn_reader(Cursor::new(b"hello".to_vec())).unwrap();
        let mut all = Vec::new();
        while let Ok(chunk) = rx.recv_timeout(Duration::from_secs(2)) {
            assert!(!chunk.is_empty(), "empty chunks must not be sent");
            all.extend(chunk);
        }
        assert_eq!(all, b"hello");
        // closed, not just quiet
        assert!(matches!(
            rx.recv_timeout(Duration::from_millis(50)),
            Err(std::sync::mpsc::RecvTimeoutError::Disconnected)
        ));
    }

    #[test]
    fn the_reader_stops_when_the_reader_fails() {
        struct Broken;
        impl Read for Broken {
            fn read(&mut self, _: &mut [u8]) -> io::Result<usize> {
                Err(io::Error::new(io::ErrorKind::Other, "boom"))
            }
        }
        let rx = spawn_reader(Broken).unwrap();
        assert!(matches!(
            rx.recv_timeout(Duration::from_secs(2)),
            Err(std::sync::mpsc::RecvTimeoutError::Disconnected)
        ));
    }

    #[test]
    fn the_reader_stops_when_the_receiver_is_dropped() {
        // an endless reader: the thread must notice nobody is listening
        struct Endless;
        impl Read for Endless {
            fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
                buf[0] = b'x';
                Ok(1)
            }
        }
        let rx = spawn_reader(Endless).unwrap();
        assert!(rx.recv_timeout(Duration::from_secs(2)).is_ok());
        drop(rx);
        // nothing to assert beyond not hanging the test process: the thread
        // exits on its next failed send
    }

    #[test]
    fn the_key_release_sequences_have_the_documented_text() {
        assert_eq!(KEY_RELEASE_QUERY, "\x1b[?u\x1b[c");
        assert_eq!(KEY_RELEASE_ON, "\x1b[>11u");
        assert_eq!(KEY_RELEASE_OFF, "\x1b[<u");
        assert_eq!(KEY_RELEASE_WAIT, Duration::from_millis(300));
    }

    #[test]
    fn with_key_release_on_it_is_turned_off_before_leaving_the_alternate_screen() {
        // the terminal keeps a separate key mode stack for the alternate
        // screen, so the pop has to happen while we are still on it
        let stop = key_release_off_before_stop(true);
        let off = stop.find(KEY_RELEASE_OFF).expect("turns key release off");
        let leave = stop
            .find(LEAVE_ALT_SCREEN)
            .expect("leaves the alternate screen");
        assert!(off < leave, "{stop:?}");
    }

    #[test]
    fn without_key_release_the_stop_sequence_is_unchanged() {
        assert_eq!(key_release_off_before_stop(false), stop_sequence());
    }

    #[test]
    fn negotiating_turns_key_release_on_when_the_terminal_supports_it() {
        let (tx, rx) = channel();
        let mut input = Input::new(rx);
        tx.send(b"\x1b[?11u\x1b[?62c".to_vec()).unwrap();
        let mut out = Vec::new();
        assert!(negotiate_key_release(&mut input, &mut out).unwrap());
        assert_eq!(
            out,
            format!("{KEY_RELEASE_QUERY}{KEY_RELEASE_ON}").into_bytes()
        );
    }

    #[test]
    fn negotiating_leaves_key_release_off_when_only_the_attributes_reply_comes() {
        let (tx, rx) = channel();
        let mut input = Input::new(rx);
        tx.send(b"\x1b[?62c".to_vec()).unwrap();
        let mut out = Vec::new();
        assert!(!negotiate_key_release(&mut input, &mut out).unwrap());
        assert_eq!(out, KEY_RELEASE_QUERY.as_bytes());
    }

    #[test]
    fn negotiating_leaves_key_release_off_when_nothing_answers() {
        let (_tx, rx) = channel();
        let mut input = Input::new(rx);
        let mut out = Vec::new();
        assert!(!negotiate_key_release(&mut input, &mut out).unwrap());
        assert_eq!(out, KEY_RELEASE_QUERY.as_bytes());
    }
}