Vectrum
Sign In
323 lines · 11.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.

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

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 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>>>,
    sender: EventLoopSender,
    size: GridSize,
}

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 config = term::Config { scrolling_history: SCROLLBACK, ..Default::default() };
        let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone())));
        let 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()),
            ]),
        };
        let pty = tty::new(&options, 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, sender, size }, receiver))
    }

    /// 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() {
            let _ = self.sender.send(Msg::Input(bytes));
        }
    }

    /// 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 _ = self.sender.send(Msg::Resize(window_size(size, cell)));
    }
}

impl Drop for Terminal {
    /// Stops the reader thread, which hangs up on the shell as it goes.
    fn drop(&mut self) {
        let _ = self.sender.send(Msg::Shutdown);
    }
}

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());
    }
}