rskit-cli 0.2.0-alpha.2

CLI framework: progress bars, structured output, signal handling
Documentation
//! A raw-mode terminal over [crossterm], enabling live arrow-key navigation.
//!
//! `RichTerminal` is compiled only when the `interactive` feature is enabled. It
//! puts the controlling terminal into raw mode for the duration of a key-driven
//! prompt, decodes platform key events into rskit's own [`Key`] vocabulary, and
//! redraws frames in place via cursor movement. Raw mode is restored on
//! [`Terminal::end_interactive`] and, as a panic-safety net, on drop.
//!
//! Its raw-mode I/O path needs a real TTY, so that path is not exercised by
//! unit tests; the shared prompt-kind logic is covered through
//! [`ScriptedTerminal`](super::ScriptedTerminal). The pure key decoder is
//! unit-tested at its boundary (see the `map_key` tests below).

use std::io::{self, IsTerminal, Write};

use crossterm::cursor::MoveToPreviousLine;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, read};
use crossterm::terminal::{Clear, ClearType, disable_raw_mode, enable_raw_mode};
use crossterm::{execute, queue};
use rskit_errors::{AppError, AppResult};

use super::{Capabilities, Terminal};
use crate::prompt::key::Key;

/// A raw-mode [`Terminal`] that reads individual keys and redraws frames.
///
/// Render output is written to stderr, matching the stream prompts style against.
pub struct RichTerminal {
    writer: io::Stderr,
    raw: bool,
}

impl RichTerminal {
    /// Build a rich terminal rendering to stderr.
    ///
    /// # Errors
    ///
    /// Returns an error unless both stderr and stdin are terminals: frames are
    /// drawn to stderr, while raw-mode key input is read from stdin, so both
    /// streams must be a real TTY.
    pub fn stderr() -> AppResult<Self> {
        let writer = io::stderr();
        if !writer.is_terminal() {
            return Err(AppError::invalid_input(
                "prompt",
                "rich terminal requires an interactive stderr",
            ));
        }
        if !io::stdin().is_terminal() {
            return Err(AppError::invalid_input(
                "prompt",
                "rich terminal requires an interactive stdin for key input",
            ));
        }
        Ok(Self { writer, raw: false })
    }
}

impl Drop for RichTerminal {
    fn drop(&mut self) {
        if self.raw {
            let _ = disable_raw_mode();
        }
    }
}

const fn map_key(event: KeyEvent) -> Key {
    let ctrl = event.modifiers.contains(KeyModifiers::CONTROL);
    match event.code {
        KeyCode::Char('c' | 'd') if ctrl => Key::Interrupt,
        KeyCode::Char(' ') => Key::Space,
        KeyCode::Char(c) => Key::Char(c),
        KeyCode::Enter => Key::Enter,
        KeyCode::Backspace => Key::Backspace,
        KeyCode::Esc => Key::Escape,
        KeyCode::Tab => Key::Tab,
        KeyCode::Up => Key::Up,
        KeyCode::Down => Key::Down,
        KeyCode::Left => Key::Left,
        KeyCode::Right => Key::Right,
        KeyCode::Home => Key::Home,
        KeyCode::End => Key::End,
        _ => Key::Unknown,
    }
}

impl Terminal for RichTerminal {
    fn capabilities(&self) -> Capabilities {
        Capabilities::key_driven()
    }

    fn read_line(&mut self) -> AppResult<Option<String>> {
        Err(AppError::invalid_input(
            "prompt",
            "rich terminal reads keys, not lines",
        ))
    }

    fn read_key(&mut self) -> AppResult<Key> {
        loop {
            match read().map_err(AppError::internal)? {
                Event::Key(event) if event.kind == KeyEventKind::Press => {
                    return Ok(map_key(event));
                }
                _ => {}
            }
        }
    }

    fn write(&mut self, text: &str) -> AppResult<()> {
        write!(self.writer, "{text}").map_err(AppError::internal)
    }

    fn write_line(&mut self, text: &str) -> AppResult<()> {
        // Raw mode disables the kernel's LF->CRLF translation, so emit an
        // explicit carriage return; in cooked mode a bare LF is correct (and a
        // stray CR would misplace the cursor).
        let newline = if self.raw { "\r\n" } else { "\n" };
        write!(self.writer, "{text}{newline}").map_err(AppError::internal)
    }

    fn flush(&mut self) -> AppResult<()> {
        self.writer.flush().map_err(AppError::internal)
    }

    fn clear_last_lines(&mut self, count: u16) -> AppResult<()> {
        if count == 0 {
            return Ok(());
        }
        queue!(
            self.writer,
            MoveToPreviousLine(count),
            Clear(ClearType::FromCursorDown)
        )
        .map_err(AppError::internal)?;
        self.flush()
    }

    fn begin_interactive(&mut self) -> AppResult<()> {
        if !self.raw {
            enable_raw_mode().map_err(AppError::internal)?;
            self.raw = true;
        }
        Ok(())
    }

    fn end_interactive(&mut self) -> AppResult<()> {
        // Idempotent: only tear down (and clear the current line) when we were
        // actually in raw mode, so a stray or repeated call can't clobber
        // terminal output that we never rendered over.
        if !self.raw {
            return Ok(());
        }
        disable_raw_mode().map_err(AppError::internal)?;
        self.raw = false;
        execute!(self.writer, Clear(ClearType::CurrentLine)).map_err(AppError::internal)
    }
}

#[cfg(test)]
mod tests {
    use super::{Key, KeyCode, KeyEvent, KeyModifiers, RichTerminal, map_key};

    #[test]
    fn requires_a_terminal_stderr() {
        // RichTerminal needs both stdin (key input) and stderr (rendering) to be
        // a TTY. The test harness usually redirects both, but under --nocapture
        // or some CI runners they can be real terminals, so gate the assertion on
        // the actual stream status to stay deterministic.
        use std::io::{IsTerminal, stderr, stdin};

        let result = RichTerminal::stderr();
        if stdin().is_terminal() && stderr().is_terminal() {
            assert!(result.is_ok());
        } else {
            assert!(result.is_err());
        }
    }

    #[test]
    fn maps_control_keys_to_interrupt() {
        let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(map_key(ctrl_c), Key::Interrupt);
        let plain_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE);
        assert_eq!(map_key(plain_c), Key::Char('c'));
    }

    #[test]
    fn maps_space_bar_to_space_not_char() {
        // Regression: the space bar arrives as KeyCode::Char(' '); it must decode
        // to Key::Space so multi-select's toggle arm is reachable on a real TTY.
        // Feeding Key::Space from a scripted double is not enough — the decoder
        // itself must emit it.
        let space = KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE);
        assert_eq!(map_key(space), Key::Space);
    }

    #[test]
    fn maps_navigation_keys() {
        assert_eq!(map_key(KeyEvent::from(KeyCode::Up)), Key::Up);
        assert_eq!(map_key(KeyEvent::from(KeyCode::Enter)), Key::Enter);
        assert_eq!(map_key(KeyEvent::from(KeyCode::Backspace)), Key::Backspace);
        assert_eq!(map_key(KeyEvent::from(KeyCode::Esc)), Key::Escape);
        assert_eq!(map_key(KeyEvent::from(KeyCode::F(5))), Key::Unknown);
    }
}