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