rs-rich-interact 0.0.12

Interactive terminal components for rs-rich: event loop, viewport, item model, headless testing
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
//! The termwiz backend (#678): raw mode, the alternate screen, the
//! terminal's size and its events through termwiz's terminal, on Unix and
//! Windows.
//!
//! termwiz probes the terminal (terminfo, `COLORTERM`, `NO_COLOR`) for its
//! capabilities: the session reads the colours and whether the mouse and
//! bracketed paste are to be used from that probe, and turns the mouse and
//! paste on itself, as with every backend, so termwiz is told to leave
//! them alone.
//!
//! On Unix the session reads the terminal itself and has a termwiz
//! [`InputParser`] read the bytes, rather than termwiz's `poll_input`, which
//! reads a sequence a read cut short (`ESC [ 1 ; 5`, then `C`) as typed
//! characters: here, as with the termion backend, the parser waits up to
//! 100 ms for the rest. Resizes come from SIGWINCH through a pipe. The
//! terminal itself lives behind a lock, which the signal thread takes to
//! give the terminal back for a suspend; reading never holds it.

use std::io;
use std::sync::Mutex;
use std::time::Duration;
#[cfg(unix)]
use std::time::Instant;

use rich::ColorSystem;
use termwiz::caps::{Capabilities, ColorLevel, ProbeHints};
use termwiz::input::InputEvent;
#[cfg(unix)]
use termwiz::input::InputParser;
use termwiz::terminal::{SystemTerminal, Terminal};

use super::{Output, WakeFd};

/// termwiz's terminal, kept where the panic hook and the signal thread
/// reach it.
static TERMINAL: Mutex<Option<SystemTerminal>> = Mutex::new(None);

/// What termwiz's probe found, and the terminal's input.
pub(super) struct Probed {
    pub(super) mouse: bool,
    pub(super) paste: bool,
    pub(super) color: Option<ColorSystem>,
    /// Boxed: termwiz's parser is large, and this sits in the session.
    #[cfg(unix)]
    reader: Box<Reader>,
}

/// How long the reader waits after an ESC for what follows it before it is
/// the Escape key, as termion's reader does.
#[cfg(unix)]
const SEQUENCE_WAIT: Duration = Duration::from_millis(100);

/// How long the reader waits for the rest of a control sequence a read cut
/// short (`ESC [` and its parameters, which no one types): longer than for
/// an ESC alone, so a busy machine's late rest is not typed out.
#[cfg(unix)]
const CSI_WAIT: Duration = Duration::from_millis(500);

/// Reads the terminal, and parses what it read with termwiz's parser.
#[cfg(unix)]
struct Reader {
    tty: std::fs::File,
    parser: InputParser,
    /// A CSI sequence a read cut short, not yet given to the parser: it
    /// reads one it knows no key for (a mouse report) as typed characters
    /// at once, even told more may follow.
    held: Vec<u8>,
    events: std::collections::VecDeque<InputEvent>,
    /// When the last read was, while a sequence may be cut short (held, or
    /// in the parser): once [`SEQUENCE_WAIT`] passes with no more, it is
    /// read as it stands.
    last_read: Option<Instant>,
    /// Written to on SIGWINCH.
    resized: std::os::unix::net::UnixStream,
    resize_signal: signal_hook::SigId,
}

#[cfg(unix)]
impl Reader {
    fn open(tty: std::fs::File) -> io::Result<Reader> {
        let (resized, notify) = std::os::unix::net::UnixStream::pair()?;
        resized.set_nonblocking(true)?;
        notify.set_nonblocking(true)?;
        let resize_signal =
            signal_hook::low_level::pipe::register(signal_hook::consts::SIGWINCH, notify)?;
        Ok(Reader {
            tty,
            parser: InputParser::new(),
            held: Vec::new(),
            events: Default::default(),
            last_read: None,
            resized,
            resize_signal,
        })
    }

    /// Parse `bytes`, more of which may follow: a CSI sequence they end
    /// in the middle of, or an ESC they end with (a read can stop right
    /// after it), is held until its rest comes. The parser, given an ESC
    /// alone and its rest in the next call, reads a mouse report as typed
    /// characters.
    fn parse(&mut self, bytes: &[u8]) {
        self.held.extend_from_slice(bytes);
        let cut = match self.held.iter().rposition(|&b| b == 0x1b) {
            Some(at)
                if at + 1 == self.held.len()
                    || (self.held[at + 1] == b'['
                        && self.held[at + 2..]
                            .iter()
                            .all(|b| (0x20..=0x3f).contains(b))) =>
            {
                at
            }
            _ => self.held.len(),
        };
        let events = &mut self.events;
        self.parser
            .parse(&self.held[..cut], |event| events.push_back(event), true);
        self.held.drain(..cut);
        self.last_read = Some(Instant::now());
    }

    /// Read what the terminal has, and parse it.
    fn fill(&mut self) -> io::Result<()> {
        use std::io::Read;
        let mut buffer = [0u8; 1024];
        match self.tty.read(&mut buffer) {
            // Nothing from a terminal that said it had input: it hung up.
            Ok(0) => Err(io::Error::new(
                io::ErrorKind::UnexpectedEof,
                "the terminal hung up",
            )),
            Ok(read) => {
                self.parse(&buffer[..read]);
                Ok(())
            }
            Err(e) if e.kind() == io::ErrorKind::Interrupted => Ok(()),
            Err(e) => Err(e),
        }
    }

    /// No more came: what is held (and what the parser holds) is read as
    /// it stands.
    fn flush(&mut self) {
        let events = &mut self.events;
        let held = std::mem::take(&mut self.held);
        self.parser
            .parse(&held, |event| events.push_back(event), false);
        self.last_read = None;
    }

    /// How long what is held may wait for its rest after the last read.
    fn sequence_wait(&self) -> Duration {
        if self.held.len() > 1 {
            CSI_WAIT
        } else {
            SEQUENCE_WAIT
        }
    }

    /// Whether the terminal has input before `until`; a signal does not cut
    /// the wait short.
    fn readable(&self, until: Instant) -> io::Result<bool> {
        use filedescriptor::{POLLIN, poll, pollfd};
        use std::os::unix::io::AsRawFd;
        loop {
            let mut ready = [pollfd {
                fd: self.tty.as_raw_fd(),
                events: POLLIN,
                revents: 0,
            }];
            let wait = until.saturating_duration_since(Instant::now());
            match poll(&mut ready, Some(wait)) {
                Ok(_) => return Ok(ready[0].revents != 0),
                Err(filedescriptor::Error::Poll(e))
                    if e.kind() == io::ErrorKind::Interrupted && Instant::now() < until => {}
                Err(filedescriptor::Error::Poll(e)) if e.kind() == io::ErrorKind::Interrupted => {
                    return Ok(false);
                }
                Err(e) => return Err(other(e)),
            }
        }
    }

    /// The next event within `wait` (or a little sooner: a wake on `wake`
    /// ends the wait).
    fn next(&mut self, wait: Duration, wake: Option<WakeFd>) -> io::Result<Option<InputEvent>> {
        use filedescriptor::{POLLIN, poll, pollfd};
        use std::io::Read;
        use std::os::unix::io::AsRawFd;
        if let Some(event) = self.events.pop_front() {
            return Ok(Some(event));
        }
        let wait = match self.last_read {
            Some(at) => {
                wait.min((at + self.sequence_wait()).saturating_duration_since(Instant::now()))
            }
            None => wait,
        };
        let mut ready = vec![
            pollfd {
                fd: self.tty.as_raw_fd(),
                events: POLLIN,
                revents: 0,
            },
            pollfd {
                fd: self.resized.as_raw_fd(),
                events: POLLIN,
                revents: 0,
            },
        ];
        // The waker's pipe: a wake ends the wait, and nothing is read.
        ready.extend(wake.map(|fd| pollfd {
            fd,
            events: POLLIN,
            revents: 0,
        }));
        match poll(&mut ready, Some(wait)) {
            Ok(_) => {}
            // A signal (SIGWINCH, SIGCONT): look again next time.
            Err(filedescriptor::Error::Poll(e)) if e.kind() == io::ErrorKind::Interrupted => {
                return Ok(None);
            }
            Err(e) => return Err(other(e)),
        }
        if ready[1].revents != 0 {
            let mut drained = [0u8; 64];
            while matches!(self.resized.read(&mut drained), Ok(n) if n > 0) {}
            let (cols, rows) = size()?;
            return Ok(Some(InputEvent::Resized {
                cols: cols.into(),
                rows: rows.into(),
            }));
        }
        if ready[0].revents != 0 {
            self.fill()?;
            // A sequence cut short: wait here for its rest, as the termion
            // backend does, rather than leave it to the next call, which
            // may come too late on a busy machine.
            while !self.held.is_empty() {
                let since = self.last_read.unwrap_or_else(Instant::now);
                if !self.readable(since + self.sequence_wait())? {
                    break;
                }
                self.fill()?;
            }
            if !self.held.is_empty() {
                self.flush();
            }
        } else if self
            .last_read
            .is_some_and(|at| at.elapsed() >= self.sequence_wait())
        {
            self.flush();
        }
        Ok(self.events.pop_front())
    }
}

#[cfg(unix)]
impl Drop for Reader {
    fn drop(&mut self) {
        signal_hook::low_level::unregister(self.resize_signal);
    }
}

fn other(error: impl std::fmt::Display) -> io::Error {
    io::Error::other(error.to_string())
}

/// Probe the terminal and open it: keys from the terminal itself (on Unix,
/// `/dev/tty`, so they come when standard input is a pipe), modes written
/// to `output`.
pub(super) fn open(output: Output) -> io::Result<Probed> {
    let probed = Capabilities::new_from_env().map_err(other)?;
    let caps = Capabilities::new_with_hints(
        ProbeHints::new_from_env()
            .mouse_reporting(Some(false))
            .bracketed_paste(Some(false)),
    )
    .map_err(other)?;
    #[cfg(unix)]
    let tty = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .open("/dev/tty")?;
    #[cfg(unix)]
    let terminal = match output {
        Output::Stdout => SystemTerminal::new_with(caps, &tty, &io::stdout()),
        Output::Stderr => SystemTerminal::new_with(caps, &tty, &io::stderr()),
    };
    #[cfg(windows)]
    let terminal = {
        let _ = output;
        SystemTerminal::new(caps)
    };
    *super::lock(&TERMINAL, true)? = Some(terminal.map_err(other)?);
    Ok(Probed {
        mouse: probed.mouse_reporting(),
        paste: probed.bracketed_paste(),
        color: match probed.color_level() {
            ColorLevel::MonoChrome => None,
            ColorLevel::Sixteen => Some(ColorSystem::Standard),
            ColorLevel::TwoFiftySix => Some(ColorSystem::EightBit),
            ColorLevel::TrueColor => Some(ColorSystem::Truecolor),
        },
        #[cfg(unix)]
        reader: Box::new(Reader::open(tty)?),
    })
}

/// Run `f` on the terminal, if it is open.
fn with<R>(
    wait: bool,
    f: impl FnOnce(&mut SystemTerminal) -> termwiz::Result<R>,
) -> io::Result<Option<R>> {
    let mut terminal = super::lock(&TERMINAL, wait)?;
    match terminal.as_mut() {
        Some(terminal) => f(terminal).map(Some).map_err(other),
        None => Ok(None),
    }
}

pub(super) fn raw_mode(on: bool, wait: bool) -> io::Result<()> {
    with(wait, |terminal| {
        if on {
            terminal.set_raw_mode()?;
        } else {
            terminal.set_cooked_mode()?;
        }
        terminal.flush()
    })
    .map(drop)
}

pub(super) fn alternate_screen(on: bool, wait: bool) -> io::Result<()> {
    with(wait, |terminal| {
        if on {
            terminal.enter_alternate_screen()?;
        } else {
            terminal.exit_alternate_screen()?;
        }
        terminal.flush()
    })
    .map(drop)
}

/// The terminal's size, as termwiz measures it.
pub(super) fn size() -> io::Result<(u16, u16)> {
    let size = with(true, |terminal| terminal.get_screen_size())?
        .ok_or_else(|| other("the termwiz terminal is closed"))?;
    let clamp = |n: usize| u16::try_from(n).unwrap_or(u16::MAX);
    Ok((clamp(size.cols), clamp(size.rows)))
}

/// termwiz's next event within `wait` (or a little sooner).
pub(super) fn poll(
    probed: &mut Probed,
    wait: Duration,
    wake: Option<WakeFd>,
) -> io::Result<Option<InputEvent>> {
    #[cfg(unix)]
    {
        probed.reader.next(wait, wake)
    }
    #[cfg(not(unix))]
    {
        let _ = (probed, wake);
        Ok(with(true, |terminal| terminal.poll_input(Some(wait)))?.flatten())
    }
}

/// Bytes read from the terminal by someone else (keys that came with an
/// answer the session asked for), for the parser.
#[cfg(unix)]
pub(super) fn unread(probed: &mut Probed, bytes: &[u8]) {
    probed.reader.parse(bytes);
}

/// The session is over: drop termwiz's terminal, which puts back the mode
/// it found.
pub(super) fn release() {
    let terminal = super::lock(&TERMINAL, true)
        .ok()
        .and_then(|mut terminal| terminal.take());
    drop(terminal);
}

#[cfg(all(test, unix))]
mod tests {
    use super::*;

    /// What the reader parses from `chunks`, each as one read.
    fn events(chunks: &[&[u8]]) -> Vec<String> {
        let tty = std::fs::File::open("/dev/null").expect("/dev/null opens");
        let mut reader = Reader::open(tty).expect("a reader");
        for chunk in chunks {
            reader.parse(chunk);
        }
        reader.flush();
        reader
            .events
            .iter()
            .map(|event| format!("{event:?}"))
            .collect()
    }

    /// A read can stop right after an ESC: held, it joins the rest as one
    /// sequence (given to the parser alone, a mouse report came out as
    /// typed characters).
    #[test]
    fn an_escape_a_read_ends_with_waits_for_its_rest() {
        let whole = events(&[b"\x1b[<0;5;3M"]);
        assert_eq!(whole.len(), 1, "{whole:?}");
        assert!(whole[0].starts_with("Mouse"), "{whole:?}");
        for split in [
            &[&b"\x1b"[..], b"[<0;5;", b"3M"][..],
            &[b"\x1b", b"[<0;5;3M"],
            &[b"\x1b[<0;5;", b"3M"],
        ] {
            assert_eq!(events(split), whole, "{split:?}");
        }
        // Escape alone is still the key, once no more comes.
        let escape = events(&[b"\x1b"]);
        assert_eq!(escape.len(), 1, "{escape:?}");
        assert!(escape[0].contains("Escape"), "{escape:?}");
    }
}