Vectrum
Sign In
405 lines · 14.0 KB · Rust
//! A shell in a pseudo-terminal, emulated by `alacritty_terminal`: the PTY,
//! the reader thread that parses the shell's output into a grid, and the
//! bytes keys and pastes send. `ui::terminal` draws the grid. A terminal can
//! also show a shell on another device: its output arrives over the remote
//! connection and is parsed here, and what's typed goes back the same way.

use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Instant;

use alacritty_terminal::event::{Event, EventListener, WindowSize};
use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg};
use alacritty_terminal::grid::Dimensions;
use alacritty_terminal::sync::FairMutex;
use alacritty_terminal::term::{self, Term};
use alacritty_terminal::tty;
use alacritty_terminal::vte::ansi;
use gpui_kit::Keystroke;

/// How many lines a terminal keeps above the screen.
const SCROLLBACK: usize = 10_000;

/// Hands the emulator's events to the view, which drains them on the main
/// thread.
#[derive(Clone)]
pub struct Listener(smol::channel::Sender<Event>);

impl EventListener for Listener {
    fn send_event(&self, event: Event) {
        let _ = self.0.try_send(event);
    }
}

/// A grid size, as the emulator measures one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GridSize {
    pub columns: usize,
    pub lines: usize,
}

impl Dimensions for GridSize {
    fn total_lines(&self) -> usize {
        self.lines
    }

    fn screen_lines(&self) -> usize {
        self.lines
    }

    fn columns(&self) -> usize {
        self.columns
    }
}

/// A running shell and its screen.
pub struct Terminal {
    term: Arc<FairMutex<Term<Listener>>>,
    backend: Backend,
    size: GridSize,
}

/// Where the shell runs.
enum Backend {
    /// Here, in a PTY whose reader thread feeds the screen.
    Local(EventLoopSender),
    /// On another device, which sends its output to [`Terminal::feed`].
    Remote { link: Box<dyn Fn(RemoteRequest)>, parser: Box<ansi::Processor> },
}

/// What a terminal on another device asks of the connection.
pub enum RemoteRequest {
    Input(Vec<u8>),
    Resize { columns: u16, lines: u16 },
    /// The terminal is gone; hang up on the shell.
    Close,
}

/// The shell, its folder and the environment it gets.
// Windows has a field more.
#[allow(clippy::needless_update)]
pub fn shell_options(cwd: &Path) -> tty::Options {
    tty::Options {
        shell: None,
        working_directory: Some(cwd.to_path_buf()),
        drain_on_exit: false,
        env: HashMap::from([
            ("TERM".to_string(), "xterm-256color".to_string()),
            ("COLORTERM".to_string(), "truecolor".to_string()),
            ("TERM_PROGRAM".to_string(), "vectrum-code".to_string()),
        ]),
        ..Default::default()
    }
}

impl Terminal {
    /// Starts the user's shell in `cwd`, with a screen of `size` cells of
    /// `cell` pixels each. Its events arrive on the returned channel.
    pub fn spawn(cwd: &Path, size: GridSize, cell: (u16, u16)) -> std::io::Result<(Self, smol::channel::Receiver<Event>)> {
        let (events, receiver) = smol::channel::unbounded();
        let listener = Listener(events);
        let term = Arc::new(FairMutex::new(Term::new(config(), &size, listener.clone())));
        let pty = tty::new(&shell_options(cwd), window_size(size, cell), 0)?;
        let event_loop = EventLoop::new(term.clone(), listener, pty, false, false)?;
        let sender = event_loop.channel();
        event_loop.spawn();
        Ok((Self { term, backend: Backend::Local(sender), size }, receiver))
    }

    /// A screen for a shell on another device, which `link` talks to. The
    /// caller has asked for the shell, at `size`, already.
    pub fn remote(size: GridSize, link: impl Fn(RemoteRequest) + 'static) -> (Self, smol::channel::Receiver<Event>) {
        let (events, receiver) = smol::channel::unbounded();
        let term = Arc::new(FairMutex::new(Term::new(config(), &size, Listener(events))));
        let backend = Backend::Remote { link: Box::new(link), parser: Box::default() };
        (Self { term, backend, size }, receiver)
    }

    /// Takes output from a shell on another device.
    pub fn feed(&mut self, bytes: &[u8]) {
        if let Backend::Remote { parser, .. } = &mut self.backend {
            parser.advance(&mut *self.term.lock(), bytes);
        }
    }

    /// When a synchronized update the shell started has to be shown even
    /// though it hasn't ended it.
    pub fn sync_deadline(&self) -> Option<Instant> {
        match &self.backend {
            Backend::Remote { parser, .. } => parser.sync_timeout().sync_timeout(),
            Backend::Local(_) => None,
        }
    }

    /// Shows a synchronized update that ran past its deadline.
    pub fn end_sync(&mut self) {
        if let Backend::Remote { parser, .. } = &mut self.backend
            && parser.sync_timeout().sync_timeout().is_some_and(|deadline| deadline <= Instant::now())
        {
            parser.stop_sync(&mut *self.term.lock());
        }
    }

    /// Runs `f` with the screen locked.
    pub fn with_term<R>(&self, f: impl FnOnce(&mut Term<Listener>) -> R) -> R {
        f(&mut self.term.lock())
    }

    /// Sends bytes to the shell, as if typed.
    pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
        let bytes = bytes.into();
        if bytes.is_empty() {
            return;
        }
        match &self.backend {
            Backend::Local(sender) => {
                let _ = sender.send(Msg::Input(bytes));
            }
            Backend::Remote { link, .. } => link(RemoteRequest::Input(bytes.into_owned())),
        }
    }

    /// Pastes text, bracketed when the program asked for that.
    pub fn paste(&self, text: &str) {
        let bracketed = self.with_term(|screen| screen.mode().contains(term::TermMode::BRACKETED_PASTE));
        self.write(paste_bytes(text, bracketed));
    }

    pub fn size(&self) -> GridSize {
        self.size
    }

    /// Fits the screen, and the shell's idea of it, to a new size.
    pub fn resize(&mut self, size: GridSize, cell: (u16, u16)) {
        if size == self.size || size.columns == 0 || size.lines == 0 {
            return;
        }
        self.size = size;
        self.term.lock().resize(size);
        let window = window_size(size, cell);
        match &self.backend {
            Backend::Local(sender) => {
                let _ = sender.send(Msg::Resize(window));
            }
            Backend::Remote { link, .. } => link(RemoteRequest::Resize { columns: window.num_cols, lines: window.num_lines }),
        }
    }
}

impl Drop for Terminal {
    /// Stops the reader thread, which hangs up on the shell as it goes, or
    /// asks the other device to hang up.
    fn drop(&mut self) {
        match &self.backend {
            Backend::Local(sender) => {
                let _ = sender.send(Msg::Shutdown);
            }
            Backend::Remote { link, .. } => link(RemoteRequest::Close),
        }
    }
}

fn config() -> term::Config {
    term::Config { scrolling_history: SCROLLBACK, ..Default::default() }
}

fn window_size(size: GridSize, (cell_width, cell_height): (u16, u16)) -> WindowSize {
    WindowSize {
        num_lines: size.lines.min(u16::MAX as usize) as u16,
        num_cols: size.columns.min(u16::MAX as usize) as u16,
        cell_width,
        cell_height,
    }
}

/// What a paste sends: line breaks as Enter, and inside bracketed paste
/// markers when the program asked for them (with any markers in the text
/// taken out, so a paste can't end itself early).
pub fn paste_bytes(text: &str, bracketed: bool) -> Vec<u8> {
    if bracketed {
        let clean = text.replace('\x1b', "");
        let mut bytes = b"\x1b[200~".to_vec();
        bytes.extend_from_slice(clean.as_bytes());
        bytes.extend_from_slice(b"\x1b[201~");
        bytes
    } else {
        text.replace("\r\n", "\r").replace('\n', "\r").into_bytes()
    }
}

/// What a key sends to the shell, as xterm encodes it. Plain typing (with
/// at most Shift held) isn't encoded here: it arrives as text through the
/// view's input handler, so input methods work. `app_cursor` is the
/// program's cursor key mode (DECCKM).
pub fn key_bytes(keystroke: &Keystroke, app_cursor: bool) -> Option<Vec<u8>> {
    let modifiers = &keystroke.modifiers;
    if modifiers.platform || modifiers.function {
        return None;
    }
    let (ctrl, alt, shift) = (modifiers.control, modifiers.alt, modifiers.shift);
    // xterm's modifier parameter: 1 + Shift + 2·Alt + 4·Ctrl.
    let param = 1 + u8::from(shift) + 2 * u8::from(alt) + 4 * u8::from(ctrl);
    let escaped = |bytes: &[u8]| {
        let mut out = Vec::with_capacity(bytes.len() + 1);
        if alt {
            out.push(0x1b);
        }
        out.extend_from_slice(bytes);
        out
    };
    // Cursor keys: `ESC [ A`, `ESC O A` in application mode, `ESC [ 1 ; 5 A`
    // with modifiers.
    let cursor = |letter: char| {
        Some(match param {
            1 if app_cursor => format!("\x1bO{letter}").into_bytes(),
            1 => format!("\x1b[{letter}").into_bytes(),
            _ => format!("\x1b[1;{param}{letter}").into_bytes(),
        })
    };
    // Editing and function keys: `ESC [ 3 ~`, `ESC [ 3 ; 5 ~`.
    let tilde = |code: u8| {
        Some(match param {
            1 => format!("\x1b[{code}~").into_bytes(),
            _ => format!("\x1b[{code};{param}~").into_bytes(),
        })
    };
    let key = keystroke.key.as_str();
    match key {
        "enter" => return Some(escaped(b"\r")),
        "tab" if shift => return Some(b"\x1b[Z".to_vec()),
        "tab" => return Some(escaped(b"\t")),
        "escape" => return Some(escaped(b"\x1b")),
        "backspace" if ctrl => return Some(escaped(b"\x08")),
        "backspace" => return Some(escaped(b"\x7f")),
        "up" => return cursor('A'),
        "down" => return cursor('B'),
        "right" => return cursor('C'),
        "left" => return cursor('D'),
        "home" => return cursor('H'),
        "end" => return cursor('F'),
        "insert" => return tilde(2),
        "delete" => return tilde(3),
        "pageup" => return tilde(5),
        "pagedown" => return tilde(6),
        "f1" | "f2" | "f3" | "f4" => {
            let letter = (b'P' + key[1..].parse::<u8>().ok()? - 1) as char;
            return Some(match param {
                1 => format!("\x1bO{letter}").into_bytes(),
                _ => format!("\x1b[1;{param}{letter}").into_bytes(),
            });
        }
        _ => {}
    }
    if let Some(number) = key.strip_prefix('f').and_then(|n| n.parse::<u8>().ok()) {
        let code = match number {
            5 => 15,
            6 => 17,
            7 => 18,
            8 => 19,
            9 => 20,
            10 => 21,
            11 => 23,
            12 => 24,
            _ => return None,
        };
        return tilde(code);
    }
    let character = match key {
        "space" => ' ',
        _ => {
            let mut chars = key.chars();
            let first = chars.next()?;
            if chars.next().is_some() {
                return None;
            }
            first
        }
    };
    if ctrl {
        let control = match character.to_ascii_lowercase() {
            c @ 'a'..='z' => c as u8 - b'a' + 1,
            ' ' | '@' | '2' => 0,
            '[' | '3' => 0x1b,
            '\\' | '4' => 0x1c,
            ']' | '5' => 0x1d,
            '^' | '6' => 0x1e,
            '_' | '-' | '/' | '7' => 0x1f,
            '?' | '8' => 0x7f,
            _ => return None,
        };
        return Some(escaped(&[control]));
    }
    if alt {
        let text = keystroke.key_char.clone().unwrap_or_else(|| {
            if shift { character.to_uppercase().collect() } else { character.to_string() }
        });
        return Some(escaped(text.as_bytes()));
    }
    None
}

#[cfg(test)]
mod tests {
    use super::{key_bytes, paste_bytes};
    use gpui_kit::Keystroke;

    fn bytes(key: &str) -> Option<Vec<u8>> {
        key_bytes(&Keystroke::parse(key).unwrap(), false)
    }

    #[test]
    fn plain_typing_is_left_to_the_input_handler() {
        assert_eq!(bytes("a"), None);
        assert_eq!(bytes("shift-a"), None);
        assert_eq!(bytes("space"), None);
        assert_eq!(bytes("super-c"), None);
    }

    #[test]
    fn named_keys() {
        assert_eq!(bytes("enter"), Some(b"\r".to_vec()));
        assert_eq!(bytes("backspace"), Some(b"\x7f".to_vec()));
        assert_eq!(bytes("ctrl-backspace"), Some(b"\x08".to_vec()));
        assert_eq!(bytes("tab"), Some(b"\t".to_vec()));
        assert_eq!(bytes("shift-tab"), Some(b"\x1b[Z".to_vec()));
        assert_eq!(bytes("escape"), Some(b"\x1b".to_vec()));
        assert_eq!(bytes("delete"), Some(b"\x1b[3~".to_vec()));
        assert_eq!(bytes("pageup"), Some(b"\x1b[5~".to_vec()));
        assert_eq!(bytes("f1"), Some(b"\x1bOP".to_vec()));
        assert_eq!(bytes("f5"), Some(b"\x1b[15~".to_vec()));
        assert_eq!(bytes("f12"), Some(b"\x1b[24~".to_vec()));
    }

    #[test]
    fn cursor_keys_follow_the_cursor_mode() {
        assert_eq!(bytes("up"), Some(b"\x1b[A".to_vec()));
        assert_eq!(key_bytes(&Keystroke::parse("up").unwrap(), true), Some(b"\x1bOA".to_vec()));
        assert_eq!(bytes("ctrl-left"), Some(b"\x1b[1;5D".to_vec()));
        assert_eq!(bytes("shift-end"), Some(b"\x1b[1;2F".to_vec()));
        assert_eq!(bytes("alt-delete"), Some(b"\x1b[3;3~".to_vec()));
    }

    #[test]
    fn control_combinations() {
        assert_eq!(bytes("ctrl-c"), Some(vec![3]));
        assert_eq!(bytes("ctrl-shift-a"), Some(vec![1]));
        assert_eq!(bytes("ctrl-space"), Some(vec![0]));
        assert_eq!(bytes("ctrl-["), Some(vec![0x1b]));
        assert_eq!(bytes("ctrl-/"), Some(vec![0x1f]));
        assert_eq!(bytes("ctrl-alt-x"), Some(vec![0x1b, 0x18]));
    }

    #[test]
    fn alt_prefixes_escape() {
        assert_eq!(bytes("alt-b"), Some(b"\x1bb".to_vec()));
        assert_eq!(bytes("alt-enter"), Some(b"\x1b\r".to_vec()));
    }

    #[test]
    fn pastes() {
        assert_eq!(paste_bytes("a\nb\r\nc", false), b"a\rb\rc".to_vec());
        assert_eq!(paste_bytes("x\x1b[201~y", true), b"\x1b[200~x[201~y\x1b[201~".to_vec());
    }
}