//! 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); 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>>, 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, parser: Box }, } /// What a terminal on another device asks of the connection. pub enum RemoteRequest { Input(Vec), 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)> { 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) { 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 { 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(&self, f: impl FnOnce(&mut Term) -> R) -> R { f(&mut self.term.lock()) } /// Sends bytes to the shell, as if typed. pub fn write(&self, bytes: impl Into>) { 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 { 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> { 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::().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::().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> { 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()); } }