From 05cbceec246dc0b0896b133a46554132e025aeb4 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 23 Oct 2023 17:36:49 +0200 Subject: [PATCH] WIP --- Cargo.lock | 29 + Cargo.toml | 1 + crates/gpui2/src/app.rs | 18 +- crates/gpui2/src/geometry.rs | 23 +- crates/gpui2/src/window.rs | 12 + crates/project2/src/worktree.rs | 2 +- crates/terminal2/Cargo.toml | 38 + crates/terminal2/src/mappings/colors.rs | 130 ++ crates/terminal2/src/mappings/keys.rs | 457 +++++++ crates/terminal2/src/mappings/mod.rs | 3 + crates/terminal2/src/mappings/mouse.rs | 336 +++++ crates/terminal2/src/terminal2.rs | 1515 +++++++++++++++++++++ crates/terminal2/src/terminal_settings.rs | 164 +++ 13 files changed, 2720 insertions(+), 8 deletions(-) create mode 100644 crates/terminal2/Cargo.toml create mode 100644 crates/terminal2/src/mappings/colors.rs create mode 100644 crates/terminal2/src/mappings/keys.rs create mode 100644 crates/terminal2/src/mappings/mod.rs create mode 100644 crates/terminal2/src/mappings/mouse.rs create mode 100644 crates/terminal2/src/terminal2.rs create mode 100644 crates/terminal2/src/terminal_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 1061a3e618..39e6222c6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8374,6 +8374,35 @@ dependencies = [ "util", ] +[[package]] +name = "terminal2" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "anyhow", + "db2", + "dirs 4.0.0", + "futures 0.3.28", + "gpui2", + "itertools 0.10.5", + "lazy_static", + "libc", + "mio-extras", + "ordered-float 2.10.0", + "procinfo", + "rand 0.8.5", + "schemars", + "serde", + "serde_derive", + "settings2", + "shellexpand", + "smallvec", + "smol", + "theme2", + "thiserror", + "util", +] + [[package]] name = "terminal_view" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b62ceb32cb..400d471f54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ members = [ "crates/storybook2", "crates/sum_tree", "crates/terminal", + "crates/terminal2", "crates/text", "crates/theme", "crates/theme2", diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index d04893b092..b1dbf70357 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -9,11 +9,11 @@ use refineable::Refineable; use smallvec::SmallVec; use crate::{ - current_platform, image_cache::ImageCache, Action, AppMetadata, AssetSource, Context, - DispatchPhase, DisplayId, Executor, FocusEvent, FocusHandle, FocusId, KeyBinding, Keymap, - LayoutId, MainThread, MainThreadOnly, Platform, SharedString, SubscriberSet, Subscription, - SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem, View, Window, WindowContext, - WindowHandle, WindowId, + current_platform, image_cache::ImageCache, Action, AppMetadata, AssetSource, ClipboardItem, + Context, DispatchPhase, DisplayId, Executor, FocusEvent, FocusHandle, FocusId, KeyBinding, + Keymap, LayoutId, MainThread, MainThreadOnly, Platform, SharedString, SubscriberSet, + Subscription, SvgRenderer, Task, TextStyle, TextStyleRefinement, TextSystem, View, Window, + WindowContext, WindowHandle, WindowId, }; use anyhow::{anyhow, Result}; use collections::{HashMap, HashSet, VecDeque}; @@ -697,6 +697,14 @@ impl MainThread { self.platform().activate(ignoring_other_apps); } + pub fn write_to_clipboard(&self, item: ClipboardItem) { + self.platform().write_to_clipboard(item) + } + + pub fn read_from_clipboard(&self) -> Option { + self.platform().read_from_clipboard() + } + pub fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Result<()> { self.platform().write_credentials(url, username, password) } diff --git a/crates/gpui2/src/geometry.rs b/crates/gpui2/src/geometry.rs index 08602352ee..41b3b751f8 100644 --- a/crates/gpui2/src/geometry.rs +++ b/crates/gpui2/src/geometry.rs @@ -1,6 +1,7 @@ use core::fmt::Debug; use derive_more::{Add, AddAssign, Div, Mul, Neg, Sub, SubAssign}; use refineable::Refineable; +use serde_derive::{Deserialize, Serialize}; use std::{ cmp::{self, PartialOrd}, fmt, @@ -647,7 +648,21 @@ where impl Copy for Corners where T: Copy + Clone + Default + Debug {} -#[derive(Clone, Copy, Default, Add, AddAssign, Sub, SubAssign, Div, Neg, PartialEq, PartialOrd)] +#[derive( + Clone, + Copy, + Default, + Add, + AddAssign, + Sub, + SubAssign, + Div, + Neg, + PartialEq, + PartialOrd, + Serialize, + Deserialize, +)] #[repr(transparent)] pub struct Pixels(pub(crate) f32); @@ -684,6 +699,10 @@ impl MulAssign for Pixels { impl Pixels { pub const MAX: Pixels = Pixels(f32::MAX); + pub fn floor(&self) -> Self { + Self(self.0.floor()) + } + pub fn round(&self) -> Self { Self(self.0.round()) } @@ -1008,7 +1027,7 @@ pub fn rems(rems: f32) -> Rems { Rems(rems) } -pub fn px(pixels: f32) -> Pixels { +pub const fn px(pixels: f32) -> Pixels { Pixels(pixels) } diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 6712e4cec6..cb2460798f 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -1653,6 +1653,12 @@ impl<'a, 'w, S: 'static> std::ops::DerefMut for ViewContext<'a, 'w, S> { // #[derive(Clone, Copy, Eq, PartialEq, Hash)] slotmap::new_key_type! { pub struct WindowId; } +impl WindowId { + pub fn as_u64(&self) -> u64 { + self.0.as_ffi() + } +} + #[derive(PartialEq, Eq)] pub struct WindowHandle { id: WindowId, @@ -1694,6 +1700,12 @@ pub struct AnyWindowHandle { state_type: TypeId, } +impl AnyWindowHandle { + pub fn window_id(&self) -> WindowId { + self.id + } +} + #[cfg(any(test, feature = "test"))] impl From> for StackingOrder { fn from(small_vec: SmallVec<[u32; 16]>) -> Self { diff --git a/crates/project2/src/worktree.rs b/crates/project2/src/worktree.rs index 5c7d848a2d..fe621fdccb 100644 --- a/crates/project2/src/worktree.rs +++ b/crates/project2/src/worktree.rs @@ -1245,7 +1245,7 @@ impl LocalWorktree { .unbounded_send((self.snapshot(), Arc::from([]), Arc::from([]))) .ok(); - let worktree_id = cx.entity_id().as_u64(); + let worktree_id = cx.entity_id().; let _maintain_remote_snapshot = cx.executor().spawn(async move { let mut is_first = true; while let Some((snapshot, entry_changes, repo_changes)) = snapshots_rx.next().await { diff --git a/crates/terminal2/Cargo.toml b/crates/terminal2/Cargo.toml new file mode 100644 index 0000000000..3ca5dc9aba --- /dev/null +++ b/crates/terminal2/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "terminal2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/terminal2.rs" +doctest = false + + +[dependencies] +gpui2 = { path = "../gpui2" } +settings2 = { path = "../settings2" } +db2 = { path = "../db2" } +theme2 = { path = "../theme2" } +util = { path = "../util" } + +alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "33306142195b354ef3485ca2b1d8a85dfc6605ca" } +procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } +smallvec.workspace = true +smol.workspace = true +mio-extras = "2.0.6" +futures.workspace = true +ordered-float.workspace = true +itertools = "0.10" +dirs = "4.0.0" +shellexpand = "2.1.0" +libc = "0.2" +anyhow.workspace = true +schemars.workspace = true +thiserror.workspace = true +lazy_static.workspace = true +serde.workspace = true +serde_derive.workspace = true + +[dev-dependencies] +rand.workspace = true diff --git a/crates/terminal2/src/mappings/colors.rs b/crates/terminal2/src/mappings/colors.rs new file mode 100644 index 0000000000..5f80698e11 --- /dev/null +++ b/crates/terminal2/src/mappings/colors.rs @@ -0,0 +1,130 @@ +use alacritty_terminal::{ansi::Color as AnsiColor, term::color::Rgb as AlacRgb}; +use gpui2::color::Color; +use theme2::TerminalStyle; + +///Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent +pub fn convert_color(alac_color: &AnsiColor, style: &TerminalStyle) -> Color { + match alac_color { + //Named and theme defined colors + alacritty_terminal::ansi::Color::Named(n) => match n { + alacritty_terminal::ansi::NamedColor::Black => style.black, + alacritty_terminal::ansi::NamedColor::Red => style.red, + alacritty_terminal::ansi::NamedColor::Green => style.green, + alacritty_terminal::ansi::NamedColor::Yellow => style.yellow, + alacritty_terminal::ansi::NamedColor::Blue => style.blue, + alacritty_terminal::ansi::NamedColor::Magenta => style.magenta, + alacritty_terminal::ansi::NamedColor::Cyan => style.cyan, + alacritty_terminal::ansi::NamedColor::White => style.white, + alacritty_terminal::ansi::NamedColor::BrightBlack => style.bright_black, + alacritty_terminal::ansi::NamedColor::BrightRed => style.bright_red, + alacritty_terminal::ansi::NamedColor::BrightGreen => style.bright_green, + alacritty_terminal::ansi::NamedColor::BrightYellow => style.bright_yellow, + alacritty_terminal::ansi::NamedColor::BrightBlue => style.bright_blue, + alacritty_terminal::ansi::NamedColor::BrightMagenta => style.bright_magenta, + alacritty_terminal::ansi::NamedColor::BrightCyan => style.bright_cyan, + alacritty_terminal::ansi::NamedColor::BrightWhite => style.bright_white, + alacritty_terminal::ansi::NamedColor::Foreground => style.foreground, + alacritty_terminal::ansi::NamedColor::Background => style.background, + alacritty_terminal::ansi::NamedColor::Cursor => style.cursor, + alacritty_terminal::ansi::NamedColor::DimBlack => style.dim_black, + alacritty_terminal::ansi::NamedColor::DimRed => style.dim_red, + alacritty_terminal::ansi::NamedColor::DimGreen => style.dim_green, + alacritty_terminal::ansi::NamedColor::DimYellow => style.dim_yellow, + alacritty_terminal::ansi::NamedColor::DimBlue => style.dim_blue, + alacritty_terminal::ansi::NamedColor::DimMagenta => style.dim_magenta, + alacritty_terminal::ansi::NamedColor::DimCyan => style.dim_cyan, + alacritty_terminal::ansi::NamedColor::DimWhite => style.dim_white, + alacritty_terminal::ansi::NamedColor::BrightForeground => style.bright_foreground, + alacritty_terminal::ansi::NamedColor::DimForeground => style.dim_foreground, + }, + //'True' colors + alacritty_terminal::ansi::Color::Spec(rgb) => Color::new(rgb.r, rgb.g, rgb.b, u8::MAX), + //8 bit, indexed colors + alacritty_terminal::ansi::Color::Indexed(i) => get_color_at_index(&(*i as usize), style), + } +} + +///Converts an 8 bit ANSI color to it's GPUI equivalent. +///Accepts usize for compatibility with the alacritty::Colors interface, +///Other than that use case, should only be called with values in the [0,255] range +pub fn get_color_at_index(index: &usize, style: &TerminalStyle) -> Color { + match index { + //0-15 are the same as the named colors above + 0 => style.black, + 1 => style.red, + 2 => style.green, + 3 => style.yellow, + 4 => style.blue, + 5 => style.magenta, + 6 => style.cyan, + 7 => style.white, + 8 => style.bright_black, + 9 => style.bright_red, + 10 => style.bright_green, + 11 => style.bright_yellow, + 12 => style.bright_blue, + 13 => style.bright_magenta, + 14 => style.bright_cyan, + 15 => style.bright_white, + //16-231 are mapped to their RGB colors on a 0-5 range per channel + 16..=231 => { + let (r, g, b) = rgb_for_index(&(*index as u8)); //Split the index into it's ANSI-RGB components + let step = (u8::MAX as f32 / 5.).floor() as u8; //Split the RGB range into 5 chunks, with floor so no overflow + Color::new(r * step, g * step, b * step, u8::MAX) //Map the ANSI-RGB components to an RGB color + } + //232-255 are a 24 step grayscale from black to white + 232..=255 => { + let i = *index as u8 - 232; //Align index to 0..24 + let step = (u8::MAX as f32 / 24.).floor() as u8; //Split the RGB grayscale values into 24 chunks + Color::new(i * step, i * step, i * step, u8::MAX) //Map the ANSI-grayscale components to the RGB-grayscale + } + //For compatibility with the alacritty::Colors interface + 256 => style.foreground, + 257 => style.background, + 258 => style.cursor, + 259 => style.dim_black, + 260 => style.dim_red, + 261 => style.dim_green, + 262 => style.dim_yellow, + 263 => style.dim_blue, + 264 => style.dim_magenta, + 265 => style.dim_cyan, + 266 => style.dim_white, + 267 => style.bright_foreground, + 268 => style.black, //'Dim Background', non-standard color + _ => Color::new(0, 0, 0, 255), + } +} +///Generates the rgb channels in [0, 5] for a given index into the 6x6x6 ANSI color cube +///See: [8 bit ansi color](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit). +/// +///Wikipedia gives a formula for calculating the index for a given color: +/// +///index = 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5) +/// +///This function does the reverse, calculating the r, g, and b components from a given index. +fn rgb_for_index(i: &u8) -> (u8, u8, u8) { + debug_assert!((&16..=&231).contains(&i)); + let i = i - 16; + let r = (i - (i % 36)) / 36; + let g = ((i % 36) - (i % 6)) / 6; + let b = (i % 36) % 6; + (r, g, b) +} + +//Convenience method to convert from a GPUI color to an alacritty Rgb +pub fn to_alac_rgb(color: Color) -> AlacRgb { + AlacRgb::new(color.r, color.g, color.g) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_rgb_for_index() { + //Test every possible value in the color cube + for i in 16..=231 { + let (r, g, b) = crate::mappings::colors::rgb_for_index(&(i as u8)); + assert_eq!(i, 16 + 36 * r + 6 * g + b); + } + } +} diff --git a/crates/terminal2/src/mappings/keys.rs b/crates/terminal2/src/mappings/keys.rs new file mode 100644 index 0000000000..5a18e0ac2d --- /dev/null +++ b/crates/terminal2/src/mappings/keys.rs @@ -0,0 +1,457 @@ +/// The mappings defined in this file where created from reading the alacritty source +use alacritty_terminal::term::TermMode; +use gpui2::keymap_matcher::Keystroke; + +#[derive(Debug, PartialEq, Eq)] +pub enum Modifiers { + None, + Alt, + Ctrl, + Shift, + CtrlShift, + Other, +} + +impl Modifiers { + fn new(ks: &Keystroke) -> Self { + match (ks.alt, ks.ctrl, ks.shift, ks.cmd) { + (false, false, false, false) => Modifiers::None, + (true, false, false, false) => Modifiers::Alt, + (false, true, false, false) => Modifiers::Ctrl, + (false, false, true, false) => Modifiers::Shift, + (false, true, true, false) => Modifiers::CtrlShift, + _ => Modifiers::Other, + } + } + + fn any(&self) -> bool { + match &self { + Modifiers::None => false, + Modifiers::Alt => true, + Modifiers::Ctrl => true, + Modifiers::Shift => true, + Modifiers::CtrlShift => true, + Modifiers::Other => true, + } + } +} + +pub fn to_esc_str(keystroke: &Keystroke, mode: &TermMode, alt_is_meta: bool) -> Option { + let modifiers = Modifiers::new(keystroke); + + // Manual Bindings including modifiers + let manual_esc_str = match (keystroke.key.as_ref(), &modifiers) { + //Basic special keys + ("tab", Modifiers::None) => Some("\x09".to_string()), + ("escape", Modifiers::None) => Some("\x1b".to_string()), + ("enter", Modifiers::None) => Some("\x0d".to_string()), + ("enter", Modifiers::Shift) => Some("\x0d".to_string()), + ("backspace", Modifiers::None) => Some("\x7f".to_string()), + //Interesting escape codes + ("tab", Modifiers::Shift) => Some("\x1b[Z".to_string()), + ("backspace", Modifiers::Alt) => Some("\x1b\x7f".to_string()), + ("backspace", Modifiers::Shift) => Some("\x7f".to_string()), + ("home", Modifiers::Shift) if mode.contains(TermMode::ALT_SCREEN) => { + Some("\x1b[1;2H".to_string()) + } + ("end", Modifiers::Shift) if mode.contains(TermMode::ALT_SCREEN) => { + Some("\x1b[1;2F".to_string()) + } + ("pageup", Modifiers::Shift) if mode.contains(TermMode::ALT_SCREEN) => { + Some("\x1b[5;2~".to_string()) + } + ("pagedown", Modifiers::Shift) if mode.contains(TermMode::ALT_SCREEN) => { + Some("\x1b[6;2~".to_string()) + } + ("home", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOH".to_string()) + } + ("home", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[H".to_string()) + } + ("end", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOF".to_string()) + } + ("end", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[F".to_string()) + } + ("up", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOA".to_string()) + } + ("up", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[A".to_string()) + } + ("down", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOB".to_string()) + } + ("down", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[B".to_string()) + } + ("right", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOC".to_string()) + } + ("right", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[C".to_string()) + } + ("left", Modifiers::None) if mode.contains(TermMode::APP_CURSOR) => { + Some("\x1bOD".to_string()) + } + ("left", Modifiers::None) if !mode.contains(TermMode::APP_CURSOR) => { + Some("\x1b[D".to_string()) + } + ("back", Modifiers::None) => Some("\x7f".to_string()), + ("insert", Modifiers::None) => Some("\x1b[2~".to_string()), + ("delete", Modifiers::None) => Some("\x1b[3~".to_string()), + ("pageup", Modifiers::None) => Some("\x1b[5~".to_string()), + ("pagedown", Modifiers::None) => Some("\x1b[6~".to_string()), + ("f1", Modifiers::None) => Some("\x1bOP".to_string()), + ("f2", Modifiers::None) => Some("\x1bOQ".to_string()), + ("f3", Modifiers::None) => Some("\x1bOR".to_string()), + ("f4", Modifiers::None) => Some("\x1bOS".to_string()), + ("f5", Modifiers::None) => Some("\x1b[15~".to_string()), + ("f6", Modifiers::None) => Some("\x1b[17~".to_string()), + ("f7", Modifiers::None) => Some("\x1b[18~".to_string()), + ("f8", Modifiers::None) => Some("\x1b[19~".to_string()), + ("f9", Modifiers::None) => Some("\x1b[20~".to_string()), + ("f10", Modifiers::None) => Some("\x1b[21~".to_string()), + ("f11", Modifiers::None) => Some("\x1b[23~".to_string()), + ("f12", Modifiers::None) => Some("\x1b[24~".to_string()), + ("f13", Modifiers::None) => Some("\x1b[25~".to_string()), + ("f14", Modifiers::None) => Some("\x1b[26~".to_string()), + ("f15", Modifiers::None) => Some("\x1b[28~".to_string()), + ("f16", Modifiers::None) => Some("\x1b[29~".to_string()), + ("f17", Modifiers::None) => Some("\x1b[31~".to_string()), + ("f18", Modifiers::None) => Some("\x1b[32~".to_string()), + ("f19", Modifiers::None) => Some("\x1b[33~".to_string()), + ("f20", Modifiers::None) => Some("\x1b[34~".to_string()), + // NumpadEnter, Action::Esc("\n".into()); + //Mappings for caret notation keys + ("a", Modifiers::Ctrl) => Some("\x01".to_string()), //1 + ("A", Modifiers::CtrlShift) => Some("\x01".to_string()), //1 + ("b", Modifiers::Ctrl) => Some("\x02".to_string()), //2 + ("B", Modifiers::CtrlShift) => Some("\x02".to_string()), //2 + ("c", Modifiers::Ctrl) => Some("\x03".to_string()), //3 + ("C", Modifiers::CtrlShift) => Some("\x03".to_string()), //3 + ("d", Modifiers::Ctrl) => Some("\x04".to_string()), //4 + ("D", Modifiers::CtrlShift) => Some("\x04".to_string()), //4 + ("e", Modifiers::Ctrl) => Some("\x05".to_string()), //5 + ("E", Modifiers::CtrlShift) => Some("\x05".to_string()), //5 + ("f", Modifiers::Ctrl) => Some("\x06".to_string()), //6 + ("F", Modifiers::CtrlShift) => Some("\x06".to_string()), //6 + ("g", Modifiers::Ctrl) => Some("\x07".to_string()), //7 + ("G", Modifiers::CtrlShift) => Some("\x07".to_string()), //7 + ("h", Modifiers::Ctrl) => Some("\x08".to_string()), //8 + ("H", Modifiers::CtrlShift) => Some("\x08".to_string()), //8 + ("i", Modifiers::Ctrl) => Some("\x09".to_string()), //9 + ("I", Modifiers::CtrlShift) => Some("\x09".to_string()), //9 + ("j", Modifiers::Ctrl) => Some("\x0a".to_string()), //10 + ("J", Modifiers::CtrlShift) => Some("\x0a".to_string()), //10 + ("k", Modifiers::Ctrl) => Some("\x0b".to_string()), //11 + ("K", Modifiers::CtrlShift) => Some("\x0b".to_string()), //11 + ("l", Modifiers::Ctrl) => Some("\x0c".to_string()), //12 + ("L", Modifiers::CtrlShift) => Some("\x0c".to_string()), //12 + ("m", Modifiers::Ctrl) => Some("\x0d".to_string()), //13 + ("M", Modifiers::CtrlShift) => Some("\x0d".to_string()), //13 + ("n", Modifiers::Ctrl) => Some("\x0e".to_string()), //14 + ("N", Modifiers::CtrlShift) => Some("\x0e".to_string()), //14 + ("o", Modifiers::Ctrl) => Some("\x0f".to_string()), //15 + ("O", Modifiers::CtrlShift) => Some("\x0f".to_string()), //15 + ("p", Modifiers::Ctrl) => Some("\x10".to_string()), //16 + ("P", Modifiers::CtrlShift) => Some("\x10".to_string()), //16 + ("q", Modifiers::Ctrl) => Some("\x11".to_string()), //17 + ("Q", Modifiers::CtrlShift) => Some("\x11".to_string()), //17 + ("r", Modifiers::Ctrl) => Some("\x12".to_string()), //18 + ("R", Modifiers::CtrlShift) => Some("\x12".to_string()), //18 + ("s", Modifiers::Ctrl) => Some("\x13".to_string()), //19 + ("S", Modifiers::CtrlShift) => Some("\x13".to_string()), //19 + ("t", Modifiers::Ctrl) => Some("\x14".to_string()), //20 + ("T", Modifiers::CtrlShift) => Some("\x14".to_string()), //20 + ("u", Modifiers::Ctrl) => Some("\x15".to_string()), //21 + ("U", Modifiers::CtrlShift) => Some("\x15".to_string()), //21 + ("v", Modifiers::Ctrl) => Some("\x16".to_string()), //22 + ("V", Modifiers::CtrlShift) => Some("\x16".to_string()), //22 + ("w", Modifiers::Ctrl) => Some("\x17".to_string()), //23 + ("W", Modifiers::CtrlShift) => Some("\x17".to_string()), //23 + ("x", Modifiers::Ctrl) => Some("\x18".to_string()), //24 + ("X", Modifiers::CtrlShift) => Some("\x18".to_string()), //24 + ("y", Modifiers::Ctrl) => Some("\x19".to_string()), //25 + ("Y", Modifiers::CtrlShift) => Some("\x19".to_string()), //25 + ("z", Modifiers::Ctrl) => Some("\x1a".to_string()), //26 + ("Z", Modifiers::CtrlShift) => Some("\x1a".to_string()), //26 + ("@", Modifiers::Ctrl) => Some("\x00".to_string()), //0 + ("[", Modifiers::Ctrl) => Some("\x1b".to_string()), //27 + ("\\", Modifiers::Ctrl) => Some("\x1c".to_string()), //28 + ("]", Modifiers::Ctrl) => Some("\x1d".to_string()), //29 + ("^", Modifiers::Ctrl) => Some("\x1e".to_string()), //30 + ("_", Modifiers::Ctrl) => Some("\x1f".to_string()), //31 + ("?", Modifiers::Ctrl) => Some("\x7f".to_string()), //127 + _ => None, + }; + if manual_esc_str.is_some() { + return manual_esc_str; + } + + // Automated bindings applying modifiers + if modifiers.any() { + let modifier_code = modifier_code(keystroke); + let modified_esc_str = match keystroke.key.as_ref() { + "up" => Some(format!("\x1b[1;{}A", modifier_code)), + "down" => Some(format!("\x1b[1;{}B", modifier_code)), + "right" => Some(format!("\x1b[1;{}C", modifier_code)), + "left" => Some(format!("\x1b[1;{}D", modifier_code)), + "f1" => Some(format!("\x1b[1;{}P", modifier_code)), + "f2" => Some(format!("\x1b[1;{}Q", modifier_code)), + "f3" => Some(format!("\x1b[1;{}R", modifier_code)), + "f4" => Some(format!("\x1b[1;{}S", modifier_code)), + "F5" => Some(format!("\x1b[15;{}~", modifier_code)), + "f6" => Some(format!("\x1b[17;{}~", modifier_code)), + "f7" => Some(format!("\x1b[18;{}~", modifier_code)), + "f8" => Some(format!("\x1b[19;{}~", modifier_code)), + "f9" => Some(format!("\x1b[20;{}~", modifier_code)), + "f10" => Some(format!("\x1b[21;{}~", modifier_code)), + "f11" => Some(format!("\x1b[23;{}~", modifier_code)), + "f12" => Some(format!("\x1b[24;{}~", modifier_code)), + "f13" => Some(format!("\x1b[25;{}~", modifier_code)), + "f14" => Some(format!("\x1b[26;{}~", modifier_code)), + "f15" => Some(format!("\x1b[28;{}~", modifier_code)), + "f16" => Some(format!("\x1b[29;{}~", modifier_code)), + "f17" => Some(format!("\x1b[31;{}~", modifier_code)), + "f18" => Some(format!("\x1b[32;{}~", modifier_code)), + "f19" => Some(format!("\x1b[33;{}~", modifier_code)), + "f20" => Some(format!("\x1b[34;{}~", modifier_code)), + _ if modifier_code == 2 => None, + "insert" => Some(format!("\x1b[2;{}~", modifier_code)), + "pageup" => Some(format!("\x1b[5;{}~", modifier_code)), + "pagedown" => Some(format!("\x1b[6;{}~", modifier_code)), + "end" => Some(format!("\x1b[1;{}F", modifier_code)), + "home" => Some(format!("\x1b[1;{}H", modifier_code)), + _ => None, + }; + if modified_esc_str.is_some() { + return modified_esc_str; + } + } + + let alt_meta_binding = if alt_is_meta && modifiers == Modifiers::Alt && keystroke.key.is_ascii() + { + Some(format!("\x1b{}", keystroke.key)) + } else { + None + }; + + if alt_meta_binding.is_some() { + return alt_meta_binding; + } + + None +} + +/// Code Modifiers +/// ---------+--------------------------- +/// 2 | Shift +/// 3 | Alt +/// 4 | Shift + Alt +/// 5 | Control +/// 6 | Shift + Control +/// 7 | Alt + Control +/// 8 | Shift + Alt + Control +/// ---------+--------------------------- +/// from: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-PC-Style-Function-Keys +fn modifier_code(keystroke: &Keystroke) -> u32 { + let mut modifier_code = 0; + if keystroke.shift { + modifier_code |= 1; + } + if keystroke.alt { + modifier_code |= 1 << 1; + } + if keystroke.ctrl { + modifier_code |= 1 << 2; + } + modifier_code + 1 +} + +#[cfg(test)] +mod test { + use gpui2::keymap_matcher::Keystroke; + + use super::*; + + #[test] + fn test_scroll_keys() { + //These keys should be handled by the scrolling element directly + //Need to signify this by returning 'None' + let shift_pageup = Keystroke::parse("shift-pageup").unwrap(); + let shift_pagedown = Keystroke::parse("shift-pagedown").unwrap(); + let shift_home = Keystroke::parse("shift-home").unwrap(); + let shift_end = Keystroke::parse("shift-end").unwrap(); + + let none = TermMode::NONE; + assert_eq!(to_esc_str(&shift_pageup, &none, false), None); + assert_eq!(to_esc_str(&shift_pagedown, &none, false), None); + assert_eq!(to_esc_str(&shift_home, &none, false), None); + assert_eq!(to_esc_str(&shift_end, &none, false), None); + + let alt_screen = TermMode::ALT_SCREEN; + assert_eq!( + to_esc_str(&shift_pageup, &alt_screen, false), + Some("\x1b[5;2~".to_string()) + ); + assert_eq!( + to_esc_str(&shift_pagedown, &alt_screen, false), + Some("\x1b[6;2~".to_string()) + ); + assert_eq!( + to_esc_str(&shift_home, &alt_screen, false), + Some("\x1b[1;2H".to_string()) + ); + assert_eq!( + to_esc_str(&shift_end, &alt_screen, false), + Some("\x1b[1;2F".to_string()) + ); + + let pageup = Keystroke::parse("pageup").unwrap(); + let pagedown = Keystroke::parse("pagedown").unwrap(); + let any = TermMode::ANY; + + assert_eq!( + to_esc_str(&pageup, &any, false), + Some("\x1b[5~".to_string()) + ); + assert_eq!( + to_esc_str(&pagedown, &any, false), + Some("\x1b[6~".to_string()) + ); + } + + #[test] + fn test_plain_inputs() { + let ks = Keystroke { + ctrl: false, + alt: false, + shift: false, + cmd: false, + function: false, + key: "🖖🏻".to_string(), //2 char string + ime_key: None, + }; + assert_eq!(to_esc_str(&ks, &TermMode::NONE, false), None); + } + + #[test] + fn test_application_mode() { + let app_cursor = TermMode::APP_CURSOR; + let none = TermMode::NONE; + + let up = Keystroke::parse("up").unwrap(); + let down = Keystroke::parse("down").unwrap(); + let left = Keystroke::parse("left").unwrap(); + let right = Keystroke::parse("right").unwrap(); + + assert_eq!(to_esc_str(&up, &none, false), Some("\x1b[A".to_string())); + assert_eq!(to_esc_str(&down, &none, false), Some("\x1b[B".to_string())); + assert_eq!(to_esc_str(&right, &none, false), Some("\x1b[C".to_string())); + assert_eq!(to_esc_str(&left, &none, false), Some("\x1b[D".to_string())); + + assert_eq!( + to_esc_str(&up, &app_cursor, false), + Some("\x1bOA".to_string()) + ); + assert_eq!( + to_esc_str(&down, &app_cursor, false), + Some("\x1bOB".to_string()) + ); + assert_eq!( + to_esc_str(&right, &app_cursor, false), + Some("\x1bOC".to_string()) + ); + assert_eq!( + to_esc_str(&left, &app_cursor, false), + Some("\x1bOD".to_string()) + ); + } + + #[test] + fn test_ctrl_codes() { + let letters_lower = 'a'..='z'; + let letters_upper = 'A'..='Z'; + let mode = TermMode::ANY; + + for (lower, upper) in letters_lower.zip(letters_upper) { + assert_eq!( + to_esc_str( + &Keystroke::parse(&format!("ctrl-{}", lower)).unwrap(), + &mode, + false + ), + to_esc_str( + &Keystroke::parse(&format!("ctrl-shift-{}", upper)).unwrap(), + &mode, + false + ), + "On letter: {}/{}", + lower, + upper + ) + } + } + + #[test] + fn alt_is_meta() { + let ascii_printable = ' '..='~'; + for character in ascii_printable { + assert_eq!( + to_esc_str( + &Keystroke::parse(&format!("alt-{}", character)).unwrap(), + &TermMode::NONE, + true + ) + .unwrap(), + format!("\x1b{}", character) + ); + } + + let gpui_keys = [ + "up", "down", "right", "left", "f1", "f2", "f3", "f4", "F5", "f6", "f7", "f8", "f9", + "f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18", "f19", "f20", "insert", + "pageup", "pagedown", "end", "home", + ]; + + for key in gpui_keys { + assert_ne!( + to_esc_str( + &Keystroke::parse(&format!("alt-{}", key)).unwrap(), + &TermMode::NONE, + true + ) + .unwrap(), + format!("\x1b{}", key) + ); + } + } + + #[test] + fn test_modifier_code_calc() { + // Code Modifiers + // ---------+--------------------------- + // 2 | Shift + // 3 | Alt + // 4 | Shift + Alt + // 5 | Control + // 6 | Shift + Control + // 7 | Alt + Control + // 8 | Shift + Alt + Control + // ---------+--------------------------- + // from: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-PC-Style-Function-Keys + assert_eq!(2, modifier_code(&Keystroke::parse("shift-A").unwrap())); + assert_eq!(3, modifier_code(&Keystroke::parse("alt-A").unwrap())); + assert_eq!(4, modifier_code(&Keystroke::parse("shift-alt-A").unwrap())); + assert_eq!(5, modifier_code(&Keystroke::parse("ctrl-A").unwrap())); + assert_eq!(6, modifier_code(&Keystroke::parse("shift-ctrl-A").unwrap())); + assert_eq!(7, modifier_code(&Keystroke::parse("alt-ctrl-A").unwrap())); + assert_eq!( + 8, + modifier_code(&Keystroke::parse("shift-ctrl-alt-A").unwrap()) + ); + } +} diff --git a/crates/terminal2/src/mappings/mod.rs b/crates/terminal2/src/mappings/mod.rs new file mode 100644 index 0000000000..d58dd27f96 --- /dev/null +++ b/crates/terminal2/src/mappings/mod.rs @@ -0,0 +1,3 @@ +pub mod colors; +pub mod keys; +pub mod mouse; diff --git a/crates/terminal2/src/mappings/mouse.rs b/crates/terminal2/src/mappings/mouse.rs new file mode 100644 index 0000000000..26697d2f06 --- /dev/null +++ b/crates/terminal2/src/mappings/mouse.rs @@ -0,0 +1,336 @@ +use std::cmp::{max, min}; +use std::iter::repeat; + +use alacritty_terminal::grid::Dimensions; +/// Most of the code, and specifically the constants, in this are copied from Alacritty, +/// with modifications for our circumstances +use alacritty_terminal::index::{Column as GridCol, Line as GridLine, Point, Side}; +use alacritty_terminal::term::TermMode; +use gpui2::platform; +use gpui2::scene::MouseScrollWheel; +use gpui2::{ + geometry::vector::Vector2F, + platform::{MouseButtonEvent, MouseMovedEvent, ScrollWheelEvent}, +}; + +use crate::TerminalSize; + +struct Modifiers { + ctrl: bool, + shift: bool, + alt: bool, +} + +impl Modifiers { + fn from_moved(e: &MouseMovedEvent) -> Self { + Modifiers { + ctrl: e.ctrl, + shift: e.shift, + alt: e.alt, + } + } + + fn from_button(e: &MouseButtonEvent) -> Self { + Modifiers { + ctrl: e.ctrl, + shift: e.shift, + alt: e.alt, + } + } + + fn from_scroll(scroll: &ScrollWheelEvent) -> Self { + Modifiers { + ctrl: scroll.ctrl, + shift: scroll.shift, + alt: scroll.alt, + } + } +} + +enum MouseFormat { + SGR, + Normal(bool), +} + +impl MouseFormat { + fn from_mode(mode: TermMode) -> Self { + if mode.contains(TermMode::SGR_MOUSE) { + MouseFormat::SGR + } else if mode.contains(TermMode::UTF8_MOUSE) { + MouseFormat::Normal(true) + } else { + MouseFormat::Normal(false) + } + } +} + +#[derive(Debug)] +enum MouseButton { + LeftButton = 0, + MiddleButton = 1, + RightButton = 2, + LeftMove = 32, + MiddleMove = 33, + RightMove = 34, + NoneMove = 35, + ScrollUp = 64, + ScrollDown = 65, + Other = 99, +} + +impl MouseButton { + fn from_move(e: &MouseMovedEvent) -> Self { + match e.pressed_button { + Some(b) => match b { + platform::MouseButton::Left => MouseButton::LeftMove, + platform::MouseButton::Middle => MouseButton::MiddleMove, + platform::MouseButton::Right => MouseButton::RightMove, + platform::MouseButton::Navigate(_) => MouseButton::Other, + }, + None => MouseButton::NoneMove, + } + } + + fn from_button(e: &MouseButtonEvent) -> Self { + match e.button { + platform::MouseButton::Left => MouseButton::LeftButton, + platform::MouseButton::Right => MouseButton::MiddleButton, + platform::MouseButton::Middle => MouseButton::RightButton, + platform::MouseButton::Navigate(_) => MouseButton::Other, + } + } + + fn from_scroll(e: &ScrollWheelEvent) -> Self { + if e.delta.raw().y() > 0. { + MouseButton::ScrollUp + } else { + MouseButton::ScrollDown + } + } + + fn is_other(&self) -> bool { + match self { + MouseButton::Other => true, + _ => false, + } + } +} + +pub fn scroll_report( + point: Point, + scroll_lines: i32, + e: &MouseScrollWheel, + mode: TermMode, +) -> Option>> { + if mode.intersects(TermMode::MOUSE_MODE) { + mouse_report( + point, + MouseButton::from_scroll(e), + true, + Modifiers::from_scroll(e), + MouseFormat::from_mode(mode), + ) + .map(|report| repeat(report).take(max(scroll_lines, 1) as usize)) + } else { + None + } +} + +pub fn alt_scroll(scroll_lines: i32) -> Vec { + let cmd = if scroll_lines > 0 { b'A' } else { b'B' }; + + let mut content = Vec::with_capacity(scroll_lines.abs() as usize * 3); + for _ in 0..scroll_lines.abs() { + content.push(0x1b); + content.push(b'O'); + content.push(cmd); + } + content +} + +pub fn mouse_button_report( + point: Point, + e: &MouseButtonEvent, + pressed: bool, + mode: TermMode, +) -> Option> { + let button = MouseButton::from_button(e); + if !button.is_other() && mode.intersects(TermMode::MOUSE_MODE) { + mouse_report( + point, + button, + pressed, + Modifiers::from_button(e), + MouseFormat::from_mode(mode), + ) + } else { + None + } +} + +pub fn mouse_moved_report(point: Point, e: &MouseMovedEvent, mode: TermMode) -> Option> { + let button = MouseButton::from_move(e); + + if !button.is_other() && mode.intersects(TermMode::MOUSE_MOTION | TermMode::MOUSE_DRAG) { + //Only drags are reported in drag mode, so block NoneMove. + if mode.contains(TermMode::MOUSE_DRAG) && matches!(button, MouseButton::NoneMove) { + None + } else { + mouse_report( + point, + button, + true, + Modifiers::from_moved(e), + MouseFormat::from_mode(mode), + ) + } + } else { + None + } +} + +pub fn mouse_side(pos: Vector2F, cur_size: TerminalSize) -> alacritty_terminal::index::Direction { + if cur_size.cell_width as usize == 0 { + return Side::Right; + } + let x = pos.0.x() as usize; + let cell_x = x.saturating_sub(cur_size.cell_width as usize) % cur_size.cell_width as usize; + let half_cell_width = (cur_size.cell_width / 2.0) as usize; + let additional_padding = (cur_size.width() - cur_size.cell_width * 2.) % cur_size.cell_width; + let end_of_grid = cur_size.width() - cur_size.cell_width - additional_padding; + //Width: Pixels or columns? + if cell_x > half_cell_width + // Edge case when mouse leaves the window. + || x as f32 >= end_of_grid + { + Side::Right + } else { + Side::Left + } +} + +pub fn grid_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point { + let col = pos.x() / cur_size.cell_width; + let col = min(GridCol(col as usize), cur_size.last_column()); + let line = pos.y() / cur_size.line_height; + let line = min(line as i32, cur_size.bottommost_line().0); + Point::new(GridLine(line - display_offset as i32), col) +} + +///Generate the bytes to send to the terminal, from the cell location, a mouse event, and the terminal mode +fn mouse_report( + point: Point, + button: MouseButton, + pressed: bool, + modifiers: Modifiers, + format: MouseFormat, +) -> Option> { + if point.line < 0 { + return None; + } + + let mut mods = 0; + if modifiers.shift { + mods += 4; + } + if modifiers.alt { + mods += 8; + } + if modifiers.ctrl { + mods += 16; + } + + match format { + MouseFormat::SGR => { + Some(sgr_mouse_report(point, button as u8 + mods, pressed).into_bytes()) + } + MouseFormat::Normal(utf8) => { + if pressed { + normal_mouse_report(point, button as u8 + mods, utf8) + } else { + normal_mouse_report(point, 3 + mods, utf8) + } + } + } +} + +fn normal_mouse_report(point: Point, button: u8, utf8: bool) -> Option> { + let Point { line, column } = point; + let max_point = if utf8 { 2015 } else { 223 }; + + if line >= max_point || column >= max_point { + return None; + } + + let mut msg = vec![b'\x1b', b'[', b'M', 32 + button]; + + let mouse_pos_encode = |pos: usize| -> Vec { + let pos = 32 + 1 + pos; + let first = 0xC0 + pos / 64; + let second = 0x80 + (pos & 63); + vec![first as u8, second as u8] + }; + + if utf8 && column >= 95 { + msg.append(&mut mouse_pos_encode(column.0)); + } else { + msg.push(32 + 1 + column.0 as u8); + } + + if utf8 && line >= 95 { + msg.append(&mut mouse_pos_encode(line.0 as usize)); + } else { + msg.push(32 + 1 + line.0 as u8); + } + + Some(msg) +} + +fn sgr_mouse_report(point: Point, button: u8, pressed: bool) -> String { + let c = if pressed { 'M' } else { 'm' }; + + let msg = format!( + "\x1b[<{};{};{}{}", + button, + point.column + 1, + point.line + 1, + c + ); + + msg +} + +#[cfg(test)] +mod test { + use crate::mappings::mouse::grid_point; + + #[test] + fn test_mouse_to_selection() { + let term_width = 100.; + let term_height = 200.; + let cell_width = 10.; + let line_height = 20.; + let mouse_pos_x = 100.; //Window relative + let mouse_pos_y = 100.; //Window relative + let origin_x = 10.; + let origin_y = 20.; + + let cur_size = crate::TerminalSize::new( + line_height, + cell_width, + gpui::geometry::vector::vec2f(term_width, term_height), + ); + + let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y); + let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in + let mouse_pos = mouse_pos - origin; + let point = grid_point(mouse_pos, cur_size, 0); + assert_eq!( + point, + alacritty_terminal::index::Point::new( + alacritty_terminal::index::Line(((mouse_pos_y - origin_y) / line_height) as i32), + alacritty_terminal::index::Column(((mouse_pos_x - origin_x) / cell_width) as usize), + ) + ); + } +} diff --git a/crates/terminal2/src/terminal2.rs b/crates/terminal2/src/terminal2.rs new file mode 100644 index 0000000000..4d194cfde8 --- /dev/null +++ b/crates/terminal2/src/terminal2.rs @@ -0,0 +1,1515 @@ +pub mod mappings; +pub use alacritty_terminal; +pub mod terminal_settings; + +use alacritty_terminal::{ + ansi::{ClearMode, Handler}, + config::{Config, Program, PtyConfig, Scrolling}, + event::{Event as AlacTermEvent, EventListener, Notify, WindowSize}, + event_loop::{EventLoop, Msg, Notifier}, + grid::{Dimensions, Scroll as AlacScroll}, + index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint}, + selection::{Selection, SelectionRange, SelectionType}, + sync::FairMutex, + term::{ + cell::Cell, + color::Rgb, + search::{Match, RegexIter, RegexSearch}, + RenderableCursor, TermMode, + }, + tty::{self, setup_env}, + Term, +}; +use anyhow::{bail, Result}; + +use futures::{ + channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender}, + FutureExt, +}; + +use mappings::mouse::{ + alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report, +}; + +use procinfo::LocalProcessInfo; +use serde::{Deserialize, Serialize}; +use terminal_settings::{AlternateScroll, Shell, TerminalBlink, TerminalSettings}; +use util::truncate_and_trailoff; + +use std::{ + cmp::min, + collections::{HashMap, VecDeque}, + fmt::Display, + ops::{Deref, Index, RangeInclusive}, + os::unix::prelude::AsRawFd, + path::PathBuf, + sync::Arc, + time::{Duration, Instant}, +}; +use thiserror::Error; + +use gpui2::{ + px, AnyWindowHandle, AppContext, ClipboardItem, EventEmitter, Keystroke, MainThread, + ModelContext, Modifiers, MouseButton, MouseDragEvent, MouseScrollWheel, MouseUp, Pixels, Point, + Task, TouchPhase, +}; + +use crate::mappings::{ + colors::{get_color_at_index, to_alac_rgb}, + keys::to_esc_str, +}; +use lazy_static::lazy_static; + +///Scrolling is unbearably sluggish by default. Alacritty supports a configurable +///Scroll multiplier that is set to 3 by default. This will be removed when I +///Implement scroll bars. +const SCROLL_MULTIPLIER: f32 = 4.; +const MAX_SEARCH_LINES: usize = 100; +const DEBUG_TERMINAL_WIDTH: Pixels = px(500.); +const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.); +const DEBUG_CELL_WIDTH: Pixels = px(5.); +const DEBUG_LINE_HEIGHT: Pixels = px(5.); + +lazy_static! { + // Regex Copied from alacritty's ui_config.rs and modified its declaration slightly: + // * avoid Rust-specific escaping. + // * use more strict regex for `file://` protocol matching: original regex has `file:` inside, but we want to avoid matching `some::file::module` strings. + static ref URL_REGEX: RegexSearch = RegexSearch::new(r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`]+"#).unwrap(); + + static ref WORD_REGEX: RegexSearch = RegexSearch::new(r#"[\w.\[\]:/@\-~]+"#).unwrap(); +} + +///Upward flowing events, for changing the title and such +#[derive(Clone, Debug)] +pub enum Event { + TitleChanged, + BreadcrumbsChanged, + CloseTerminal, + Bell, + Wakeup, + BlinkChanged, + SelectionsChanged, + NewNavigationTarget(Option), + Open(MaybeNavigationTarget), +} + +/// A string inside terminal, potentially useful as a URI that can be opened. +#[derive(Clone, Debug)] +pub enum MaybeNavigationTarget { + /// HTTP, git, etc. string determined by the [`URL_REGEX`] regex. + Url(String), + /// File system path, absolute or relative, existing or not. + /// Might have line and column number(s) attached as `file.rs:1:23` + PathLike(String), +} + +#[derive(Clone)] +enum InternalEvent { + ColorRequest(usize, Arc String + Sync + Send + 'static>), + Resize(TerminalSize), + Clear, + // FocusNextMatch, + Scroll(AlacScroll), + ScrollToAlacPoint(AlacPoint), + SetSelection(Option<(Selection, AlacPoint)>), + UpdateSelection(Point), + // Adjusted mouse position, should open + FindHyperlink(Point, bool), + Copy, +} + +///A translation struct for Alacritty to communicate with us from their event loop +#[derive(Clone)] +pub struct ZedListener(UnboundedSender); + +impl EventListener for ZedListener { + fn send_event(&self, event: AlacTermEvent) { + self.0.unbounded_send(event).ok(); + } +} + +pub fn init(cx: &mut AppContext) { + settings2::register::(cx); +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +pub struct TerminalSize { + pub cell_width: Pixels, + pub line_height: Pixels, + pub height: Pixels, + pub width: Pixels, +} + +impl TerminalSize { + pub fn new(line_height: Pixels, cell_width: Pixels, size: Point) -> Self { + TerminalSize { + cell_width, + line_height, + width: size.x(), + height: size.y(), + } + } + + pub fn num_lines(&self) -> usize { + (self.height / self.line_height).floor() as usize + } + + pub fn num_columns(&self) -> usize { + (self.width / self.cell_width).floor() as usize + } + + pub fn height(&self) -> Pixels { + self.height + } + + pub fn width(&self) -> Pixels { + self.width + } + + pub fn cell_width(&self) -> Pixels { + self.cell_width + } + + pub fn line_height(&self) -> Pixels { + self.line_height + } +} +impl Default for TerminalSize { + fn default() -> Self { + TerminalSize::new( + DEBUG_LINE_HEIGHT, + DEBUG_CELL_WIDTH, + Point::new(DEBUG_TERMINAL_WIDTH, DEBUG_TERMINAL_HEIGHT), + ) + } +} + +impl From for WindowSize { + fn from(val: TerminalSize) -> Self { + WindowSize { + num_lines: val.num_lines() as u16, + num_cols: val.num_columns() as u16, + cell_width: f32::from(val.cell_width()) as u16, + cell_height: f32::from(val.line_height()) as u16, + } + } +} + +impl Dimensions for TerminalSize { + /// Note: this is supposed to be for the back buffer's length, + /// but we exclusively use it to resize the terminal, which does not + /// use this method. We still have to implement it for the trait though, + /// hence, this comment. + fn total_lines(&self) -> usize { + self.screen_lines() + } + + fn screen_lines(&self) -> usize { + self.num_lines() + } + + fn columns(&self) -> usize { + self.num_columns() + } +} + +#[derive(Error, Debug)] +pub struct TerminalError { + pub directory: Option, + pub shell: Shell, + pub source: std::io::Error, +} + +impl TerminalError { + pub fn fmt_directory(&self) -> String { + self.directory + .clone() + .map(|path| { + match path + .into_os_string() + .into_string() + .map_err(|os_str| format!(" {}", os_str.to_string_lossy())) + { + Ok(s) => s, + Err(s) => s, + } + }) + .unwrap_or_else(|| { + let default_dir = + dirs::home_dir().map(|buf| buf.into_os_string().to_string_lossy().to_string()); + match default_dir { + Some(dir) => format!(" {}", dir), + None => "".to_string(), + } + }) + } + + pub fn shell_to_string(&self) -> String { + match &self.shell { + Shell::System => "".to_string(), + Shell::Program(p) => p.to_string(), + Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")), + } + } + + pub fn fmt_shell(&self) -> String { + match &self.shell { + Shell::System => "".to_string(), + Shell::Program(s) => s.to_string(), + Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")), + } + } +} + +impl Display for TerminalError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let dir_string: String = self.fmt_directory(); + let shell = self.fmt_shell(); + + write!( + f, + "Working directory: {} Shell command: `{}`, IOError: {}", + dir_string, shell, self.source + ) + } +} + +pub struct TerminalBuilder { + terminal: Terminal, + events_rx: UnboundedReceiver, +} + +impl TerminalBuilder { + pub fn new( + working_directory: Option, + shell: Shell, + mut env: HashMap, + blink_settings: Option, + alternate_scroll: AlternateScroll, + window: AnyWindowHandle, + ) -> Result { + let pty_config = { + let alac_shell = match shell.clone() { + Shell::System => None, + Shell::Program(program) => Some(Program::Just(program)), + Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }), + }; + + PtyConfig { + shell: alac_shell, + working_directory: working_directory.clone(), + hold: false, + } + }; + + //TODO: Properly set the current locale, + env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string()); + env.insert("ZED_TERM".to_string(), true.to_string()); + + let alac_scrolling = Scrolling::default(); + // alac_scrolling.set_history((BACK_BUFFER_SIZE * 2) as u32); + + let config = Config { + pty_config: pty_config.clone(), + env, + scrolling: alac_scrolling, + ..Default::default() + }; + + setup_env(&config); + + //Spawn a task so the Alacritty EventLoop can communicate with us in a view context + //TODO: Remove with a bounded sender which can be dispatched on &self + let (events_tx, events_rx) = unbounded(); + //Set up the terminal... + let mut term = Term::new( + &config, + &TerminalSize::default(), + ZedListener(events_tx.clone()), + ); + + //Start off blinking if we need to + if let Some(TerminalBlink::On) = blink_settings { + term.set_mode(alacritty_terminal::ansi::Mode::BlinkingCursor) + } + + //Alacritty defaults to alternate scrolling being on, so we just need to turn it off. + if let AlternateScroll::Off = alternate_scroll { + term.unset_mode(alacritty_terminal::ansi::Mode::AlternateScroll) + } + + let term = Arc::new(FairMutex::new(term)); + + //Setup the pty... + let pty = match tty::new( + &pty_config, + TerminalSize::default().into(), + window.window_id().as_u64(), + ) { + Ok(pty) => pty, + Err(error) => { + bail!(TerminalError { + directory: working_directory, + shell, + source: error, + }); + } + }; + + let fd = pty.file().as_raw_fd(); + let shell_pid = pty.child().id(); + + //And connect them together + let event_loop = EventLoop::new( + term.clone(), + ZedListener(events_tx.clone()), + pty, + pty_config.hold, + false, + ); + + //Kick things off + let pty_tx = event_loop.channel(); + let _io_thread = event_loop.spawn(); + + let terminal = Terminal { + pty_tx: Notifier(pty_tx), + term, + events: VecDeque::with_capacity(10), //Should never get this high. + last_content: Default::default(), + last_mouse: None, + matches: Vec::new(), + last_synced: Instant::now(), + sync_task: None, + selection_head: None, + shell_fd: fd as u32, + shell_pid, + foreground_process_info: None, + breadcrumb_text: String::new(), + scroll_px: 0., + last_mouse_position: None, + next_link_id: 0, + selection_phase: SelectionPhase::Ended, + cmd_pressed: false, + hovered_word: false, + }; + + Ok(TerminalBuilder { + terminal, + events_rx, + }) + } + + pub fn subscribe(mut self, cx: &mut ModelContext) -> Terminal { + //Event loop + cx.spawn(|this, mut cx| async move { + use futures::StreamExt; + + while let Some(event) = self.events_rx.next().await { + this.update(&mut cx, |this, cx| { + //Process the first event immediately for lowered latency + this.process_event(&event, cx); + })?; + + 'outer: loop { + let mut events = vec![]; + let mut timer = cx.executor().timer(Duration::from_millis(4)).fuse(); + let mut wakeup = false; + loop { + futures::select_biased! { + _ = timer => break, + event = self.events_rx.next() => { + if let Some(event) = event { + if matches!(event, AlacTermEvent::Wakeup) { + wakeup = true; + } else { + events.push(event); + } + + if events.len() > 100 { + break; + } + } else { + break; + } + }, + } + } + + if events.is_empty() && wakeup == false { + smol::future::yield_now().await; + break 'outer; + } else { + this.update(&mut cx, |this, cx| { + if wakeup { + this.process_event(&AlacTermEvent::Wakeup, cx); + } + + for event in events { + this.process_event(&event, cx); + } + })?; + smol::future::yield_now().await; + } + } + } + + anyhow::Ok(()) + }) + .detach(); + + self.terminal + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IndexedCell { + pub point: AlacPoint, + pub cell: Cell, +} + +impl Deref for IndexedCell { + type Target = Cell; + + #[inline] + fn deref(&self) -> &Cell { + &self.cell + } +} + +// TODO: Un-pub +#[derive(Clone)] +pub struct TerminalContent { + pub cells: Vec, + pub mode: TermMode, + pub display_offset: usize, + pub selection_text: Option, + pub selection: Option, + pub cursor: RenderableCursor, + pub cursor_char: char, + pub size: TerminalSize, + pub last_hovered_word: Option, +} + +#[derive(Clone)] +pub struct HoveredWord { + pub word: String, + pub word_match: RangeInclusive, + pub id: usize, +} + +impl Default for TerminalContent { + fn default() -> Self { + TerminalContent { + cells: Default::default(), + mode: Default::default(), + display_offset: Default::default(), + selection_text: Default::default(), + selection: Default::default(), + cursor: RenderableCursor { + shape: alacritty_terminal::ansi::CursorShape::Block, + point: AlacPoint::new(Line(0), Column(0)), + }, + cursor_char: Default::default(), + size: Default::default(), + last_hovered_word: None, + } + } +} + +#[derive(PartialEq, Eq)] +pub enum SelectionPhase { + Selecting, + Ended, +} + +pub struct Terminal { + pty_tx: Notifier, + term: Arc>>, + events: VecDeque, + /// This is only used for mouse mode cell change detection + last_mouse: Option<(AlacPoint, AlacDirection)>, + /// This is only used for terminal hovered word checking + last_mouse_position: Option>, + pub matches: Vec>, + pub last_content: TerminalContent, + last_synced: Instant, + sync_task: Option>, + pub selection_head: Option, + pub breadcrumb_text: String, + shell_pid: u32, + shell_fd: u32, + pub foreground_process_info: Option, + scroll_px: f32, + next_link_id: usize, + selection_phase: SelectionPhase, + cmd_pressed: bool, + hovered_word: bool, +} + +impl Terminal { + fn process_event(&mut self, event: &AlacTermEvent, cx: &mut MainThread>) { + match event { + AlacTermEvent::Title(title) => { + self.breadcrumb_text = title.to_string(); + cx.emit(Event::BreadcrumbsChanged); + } + AlacTermEvent::ResetTitle => { + self.breadcrumb_text = String::new(); + cx.emit(Event::BreadcrumbsChanged); + } + AlacTermEvent::ClipboardStore(_, data) => { + cx.write_to_clipboard(ClipboardItem::new(data.to_string())) + } + AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty(format( + &cx.read_from_clipboard() + .map(|ci| ci.text().to_string()) + .unwrap_or_else(|| "".to_string()), + )), + AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.clone()), + AlacTermEvent::TextAreaSizeRequest(format) => { + self.write_to_pty(format(self.last_content.size.into())) + } + AlacTermEvent::CursorBlinkingChange => { + cx.emit(Event::BlinkChanged); + } + AlacTermEvent::Bell => { + cx.emit(Event::Bell); + } + AlacTermEvent::Exit => cx.emit(Event::CloseTerminal), + AlacTermEvent::MouseCursorDirty => { + //NOOP, Handled in render + } + AlacTermEvent::Wakeup => { + cx.emit(Event::Wakeup); + + if self.update_process_info() { + cx.emit(Event::TitleChanged); + } + } + AlacTermEvent::ColorRequest(idx, fun_ptr) => { + self.events + .push_back(InternalEvent::ColorRequest(*idx, fun_ptr.clone())); + } + } + } + + /// Update the cached process info, returns whether the Zed-relevant info has changed + fn update_process_info(&mut self) -> bool { + let mut pid = unsafe { libc::tcgetpgrp(self.shell_fd as i32) }; + if pid < 0 { + pid = self.shell_pid as i32; + } + + if let Some(process_info) = LocalProcessInfo::with_root_pid(pid as u32) { + let res = self + .foreground_process_info + .as_ref() + .map(|old_info| { + process_info.cwd != old_info.cwd || process_info.name != old_info.name + }) + .unwrap_or(true); + + self.foreground_process_info = Some(process_info.clone()); + + res + } else { + false + } + } + + ///Takes events from Alacritty and translates them to behavior on this view + fn process_terminal_event( + &mut self, + event: &InternalEvent, + term: &mut Term, + cx: &mut ModelContext, + ) { + match event { + InternalEvent::ColorRequest(index, format) => { + let color = term.colors()[*index].unwrap_or_else(|| { + let term_style = &theme::current(cx).terminal; + to_alac_rgb(get_color_at_index(index, &term_style)) + }); + self.write_to_pty(format(color)) + } + InternalEvent::Resize(mut new_size) => { + new_size.height = f32::max(new_size.line_height, new_size.height); + new_size.width = f32::max(new_size.cell_width, new_size.width); + + self.last_content.size = new_size.clone(); + + self.pty_tx.0.send(Msg::Resize((new_size).into())).ok(); + + term.resize(new_size); + } + InternalEvent::Clear => { + // Clear back buffer + term.clear_screen(ClearMode::Saved); + + let cursor = term.grid().cursor.point; + + // Clear the lines above + term.grid_mut().reset_region(..cursor.line); + + // Copy the current line up + let line = term.grid()[cursor.line][..Column(term.grid().columns())] + .iter() + .cloned() + .enumerate() + .collect::>(); + + for (i, cell) in line { + term.grid_mut()[Line(0)][Column(i)] = cell; + } + + // Reset the cursor + term.grid_mut().cursor.point = + AlacPoint::new(Line(0), term.grid_mut().cursor.point.column); + let new_cursor = term.grid().cursor.point; + + // Clear the lines below the new cursor + if (new_cursor.line.0 as usize) < term.screen_lines() - 1 { + term.grid_mut().reset_region((new_cursor.line + 1)..); + } + + cx.emit(Event::Wakeup); + } + InternalEvent::Scroll(scroll) => { + term.scroll_display(*scroll); + self.refresh_hovered_word(); + } + InternalEvent::SetSelection(selection) => { + term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); + + if let Some((_, head)) = selection { + self.selection_head = Some(*head); + } + cx.emit(Event::SelectionsChanged) + } + InternalEvent::UpdateSelection(position) => { + if let Some(mut selection) = term.selection.take() { + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ); + + let side = mouse_side(*position, self.last_content.size); + + selection.update(point, side); + term.selection = Some(selection); + + self.selection_head = Some(point); + cx.emit(Event::SelectionsChanged) + } + } + + InternalEvent::Copy => { + if let Some(txt) = term.selection_to_string() { + cx.write_to_clipboard(ClipboardItem::new(txt)) + } + } + InternalEvent::ScrollToAlacPoint(point) => { + term.scroll_to_point(*point); + self.refresh_hovered_word(); + } + InternalEvent::FindHyperlink(position, open) => { + let prev_hovered_word = self.last_content.last_hovered_word.take(); + + let point = grid_point( + *position, + self.last_content.size, + term.grid().display_offset(), + ) + .grid_clamp(term, Boundary::Grid); + + let link = term.grid().index(point).hyperlink(); + let found_word = if link.is_some() { + let mut min_index = point; + loop { + let new_min_index = min_index.sub(term, Boundary::Cursor, 1); + if new_min_index == min_index { + break; + } else if term.grid().index(new_min_index).hyperlink() != link { + break; + } else { + min_index = new_min_index + } + } + + let mut max_index = point; + loop { + let new_max_index = max_index.add(term, Boundary::Cursor, 1); + if new_max_index == max_index { + break; + } else if term.grid().index(new_max_index).hyperlink() != link { + break; + } else { + max_index = new_max_index + } + } + + let url = link.unwrap().uri().to_owned(); + let url_match = min_index..=max_index; + + Some((url, true, url_match)) + } else if let Some(word_match) = regex_match_at(term, point, &WORD_REGEX) { + let maybe_url_or_path = + term.bounds_to_string(*word_match.start(), *word_match.end()); + let original_match = word_match.clone(); + let (sanitized_match, sanitized_word) = + if maybe_url_or_path.starts_with('[') && maybe_url_or_path.ends_with(']') { + ( + Match::new( + word_match.start().add(term, Boundary::Cursor, 1), + word_match.end().sub(term, Boundary::Cursor, 1), + ), + maybe_url_or_path[1..maybe_url_or_path.len() - 1].to_owned(), + ) + } else { + (word_match, maybe_url_or_path) + }; + + let is_url = match regex_match_at(term, point, &URL_REGEX) { + Some(url_match) => { + // `]` is a valid symbol in the `file://` URL, so the regex match will include it + // consider that when ensuring that the URL match is the same as the original word + if sanitized_match != original_match { + url_match.start() == sanitized_match.start() + && url_match.end() == original_match.end() + } else { + url_match == sanitized_match + } + } + None => false, + }; + Some((sanitized_word, is_url, sanitized_match)) + } else { + None + }; + + match found_word { + Some((maybe_url_or_path, is_url, url_match)) => { + if *open { + let target = if is_url { + MaybeNavigationTarget::Url(maybe_url_or_path) + } else { + MaybeNavigationTarget::PathLike(maybe_url_or_path) + }; + cx.emit(Event::Open(target)); + } else { + self.update_selected_word( + prev_hovered_word, + url_match, + maybe_url_or_path, + is_url, + cx, + ); + } + self.hovered_word = true; + } + None => { + if self.hovered_word { + cx.emit(Event::NewNavigationTarget(None)); + } + self.hovered_word = false; + } + } + } + } + } + + fn update_selected_word( + &mut self, + prev_word: Option, + word_match: RangeInclusive, + word: String, + is_url: bool, + cx: &mut ModelContext, + ) { + if let Some(prev_word) = prev_word { + if prev_word.word == word && prev_word.word_match == word_match { + self.last_content.last_hovered_word = Some(HoveredWord { + word, + word_match, + id: prev_word.id, + }); + return; + } + } + + self.last_content.last_hovered_word = Some(HoveredWord { + word: word.clone(), + word_match, + id: self.next_link_id(), + }); + let navigation_target = if is_url { + MaybeNavigationTarget::Url(word) + } else { + MaybeNavigationTarget::PathLike(word) + }; + cx.emit(Event::NewNavigationTarget(Some(navigation_target))); + } + + fn next_link_id(&mut self) -> usize { + let res = self.next_link_id; + self.next_link_id = self.next_link_id.wrapping_add(1); + res + } + + pub fn last_content(&self) -> &TerminalContent { + &self.last_content + } + + //To test: + //- Activate match on terminal (scrolling and selection) + //- Editor search snapping behavior + + pub fn activate_match(&mut self, index: usize) { + if let Some(search_match) = self.matches.get(index).cloned() { + self.set_selection(Some((make_selection(&search_match), *search_match.end()))); + + self.events + .push_back(InternalEvent::ScrollToAlacPoint(*search_match.start())); + } + } + + pub fn select_matches(&mut self, matches: Vec>) { + let matches_to_select = self + .matches + .iter() + .filter(|self_match| matches.contains(self_match)) + .cloned() + .collect::>(); + for match_to_select in matches_to_select { + self.set_selection(Some(( + make_selection(&match_to_select), + *match_to_select.end(), + ))); + } + } + + pub fn select_all(&mut self) { + let term = self.term.lock(); + let start = AlacPoint::new(term.topmost_line(), Column(0)); + let end = AlacPoint::new(term.bottommost_line(), term.last_column()); + drop(term); + self.set_selection(Some((make_selection(&(start..=end)), end))); + } + + fn set_selection(&mut self, selection: Option<(Selection, AlacPoint)>) { + self.events + .push_back(InternalEvent::SetSelection(selection)); + } + + pub fn copy(&mut self) { + self.events.push_back(InternalEvent::Copy); + } + + pub fn clear(&mut self) { + self.events.push_back(InternalEvent::Clear) + } + + ///Resize the terminal and the PTY. + pub fn set_size(&mut self, new_size: TerminalSize) { + self.events.push_back(InternalEvent::Resize(new_size)) + } + + ///Write the Input payload to the tty. + fn write_to_pty(&self, input: String) { + self.pty_tx.notify(input.into_bytes()); + } + + fn write_bytes_to_pty(&self, input: Vec) { + self.pty_tx.notify(input); + } + + pub fn input(&mut self, input: String) { + self.events + .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); + self.events.push_back(InternalEvent::SetSelection(None)); + + self.write_to_pty(input); + } + + pub fn input_bytes(&mut self, input: Vec) { + self.events + .push_back(InternalEvent::Scroll(AlacScroll::Bottom)); + self.events.push_back(InternalEvent::SetSelection(None)); + + self.write_bytes_to_pty(input); + } + + pub fn try_keystroke(&mut self, keystroke: &Keystroke, alt_is_meta: bool) -> bool { + let esc = to_esc_str(keystroke, &self.last_content.mode, alt_is_meta); + if let Some(esc) = esc { + self.input(esc); + true + } else { + false + } + } + + pub fn try_modifiers_change(&mut self, modifiers: &Modifiers) -> bool { + let changed = self.cmd_pressed != modifiers.cmd; + if !self.cmd_pressed && modifiers.cmd { + self.refresh_hovered_word(); + } + self.cmd_pressed = modifiers.cmd; + changed + } + + ///Paste text into the terminal + pub fn paste(&mut self, text: &str) { + let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) { + format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~") + } else { + text.replace("\r\n", "\r").replace('\n', "\r") + }; + + self.input(paste_text); + } + + pub fn try_sync(&mut self, cx: &mut ModelContext) { + let term = self.term.clone(); + + let mut terminal = if let Some(term) = term.try_lock_unfair() { + term + } else if self.last_synced.elapsed().as_secs_f32() > 0.25 { + term.lock_unfair() //It's been too long, force block + } else if let None = self.sync_task { + //Skip this frame + let delay = cx.executor().timer(Duration::from_millis(16)); + self.sync_task = Some(cx.spawn(|weak_handle, mut cx| async move { + delay.await; + cx.update(|cx| { + if let Some(handle) = weak_handle.upgrade(cx) { + handle.update(cx, |terminal, cx| { + terminal.sync_task.take(); + cx.notify(); + }); + } + }); + })); + return; + } else { + //No lock and delayed rendering already scheduled, nothing to do + return; + }; + + //Note that the ordering of events matters for event processing + while let Some(e) = self.events.pop_front() { + self.process_terminal_event(&e, &mut terminal, cx) + } + + self.last_content = Self::make_content(&terminal, &self.last_content); + self.last_synced = Instant::now(); + } + + fn make_content(term: &Term, last_content: &TerminalContent) -> TerminalContent { + let content = term.renderable_content(); + TerminalContent { + cells: content + .display_iter + //TODO: Add this once there's a way to retain empty lines + // .filter(|ic| { + // !ic.flags.contains(Flags::HIDDEN) + // && !(ic.bg == Named(NamedColor::Background) + // && ic.c == ' ' + // && !ic.flags.contains(Flags::INVERSE)) + // }) + .map(|ic| IndexedCell { + point: ic.point, + cell: ic.cell.clone(), + }) + .collect::>(), + mode: content.mode, + display_offset: content.display_offset, + selection_text: term.selection_to_string(), + selection: content.selection, + cursor: content.cursor, + cursor_char: term.grid()[content.cursor.point].c, + size: last_content.size, + last_hovered_word: last_content.last_hovered_word.clone(), + } + } + + pub fn focus_in(&self) { + if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) { + self.write_to_pty("\x1b[I".to_string()); + } + } + + pub fn focus_out(&mut self) { + self.last_mouse_position = None; + if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) { + self.write_to_pty("\x1b[O".to_string()); + } + } + + pub fn mouse_changed(&mut self, point: AlacPoint, side: AlacDirection) -> bool { + match self.last_mouse { + Some((old_point, old_side)) => { + if old_point == point && old_side == side { + false + } else { + self.last_mouse = Some((point, side)); + true + } + } + None => { + self.last_mouse = Some((point, side)); + true + } + } + } + + pub fn mouse_mode(&self, shift: bool) -> bool { + self.last_content.mode.intersects(TermMode::MOUSE_MODE) && !shift + } + + pub fn mouse_move(&mut self, e: &MouseMovedEvent, origin: Point) { + let position = e.position.sub(origin); + self.last_mouse_position = Some(position); + if self.mouse_mode(e.shift) { + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + let side = mouse_side(position, self.last_content.size); + + if self.mouse_changed(point, side) { + if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) { + self.pty_tx.notify(bytes); + } + } + } else if self.cmd_pressed { + self.word_from_position(Some(position)); + } + } + + fn word_from_position(&mut self, position: Option>) { + if self.selection_phase == SelectionPhase::Selecting { + self.last_content.last_hovered_word = None; + } else if let Some(position) = position { + self.events + .push_back(InternalEvent::FindHyperlink(position, false)); + } + } + + pub fn mouse_drag(&mut self, e: MouseDrag, origin: Point) { + let position = e.position.sub(origin); + self.last_mouse_position = Some(position); + + if !self.mouse_mode(e.shift) { + self.selection_phase = SelectionPhase::Selecting; + // Alacritty has the same ordering, of first updating the selection + // then scrolling 15ms later + self.events + .push_back(InternalEvent::UpdateSelection(position)); + + // Doesn't make sense to scroll the alt screen + if !self.last_content.mode.contains(TermMode::ALT_SCREEN) { + let scroll_delta = match self.drag_line_delta(e) { + Some(value) => value, + None => return, + }; + + let scroll_lines = (scroll_delta / self.last_content.size.line_height) as i32; + + self.events + .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines))); + } + } + } + + fn drag_line_delta(&mut self, e: MouseDrag) -> Option { + //TODO: Why do these need to be doubled? Probably the same problem that the IME has + let top = e.region.origin_y() + (self.last_content.size.line_height * 2.); + let bottom = e.region.lower_left().y() - (self.last_content.size.line_height * 2.); + let scroll_delta = if e.position.y() < top { + (top - e.position.y()).powf(1.1) + } else if e.position.y() > bottom { + -((e.position.y() - bottom).powf(1.1)) + } else { + return None; //Nothing to do + }; + Some(scroll_delta) + } + + pub fn mouse_down(&mut self, e: &MouseDown, origin: Point) { + let position = e.position.sub(origin); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + + if self.mouse_mode(e.shift) { + if let Some(bytes) = mouse_button_report(point, e, true, self.last_content.mode) { + self.pty_tx.notify(bytes); + } + } else if e.button == MouseButton::Left { + let position = e.position.sub(origin); + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + + // Use .opposite so that selection is inclusive of the cell clicked. + let side = mouse_side(position, self.last_content.size); + + let selection_type = match e.click_count { + 0 => return, //This is a release + 1 => Some(SelectionType::Simple), + 2 => Some(SelectionType::Semantic), + 3 => Some(SelectionType::Lines), + _ => None, + }; + + let selection = + selection_type.map(|selection_type| Selection::new(selection_type, point, side)); + + if let Some(sel) = selection { + self.events + .push_back(InternalEvent::SetSelection(Some((sel, point)))); + } + } + } + + pub fn mouse_up(&mut self, e: &MouseUp, origin: Point, cx: &mut ModelContext) { + let setting = settings2::get::(cx); + + let position = e.position.sub(origin); + if self.mouse_mode(e.shift) { + let point = grid_point( + position, + self.last_content.size, + self.last_content.display_offset, + ); + + if let Some(bytes) = mouse_button_report(point, e, false, self.last_content.mode) { + self.pty_tx.notify(bytes); + } + } else { + if e.button == MouseButton::Left && setting.copy_on_select { + self.copy(); + } + + //Hyperlinks + if self.selection_phase == SelectionPhase::Ended { + let mouse_cell_index = content_index_for_mouse(position, &self.last_content.size); + if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() { + cx.platform().open_url(link.uri()); + } else if self.cmd_pressed { + self.events + .push_back(InternalEvent::FindHyperlink(position, true)); + } + } + } + + self.selection_phase = SelectionPhase::Ended; + self.last_mouse = None; + } + + ///Scroll the terminal + pub fn scroll_wheel(&mut self, e: MouseScrollWheel, origin: Point) { + let mouse_mode = self.mouse_mode(e.shift); + + if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) { + if mouse_mode { + let point = grid_point( + e.position.sub(origin), + self.last_content.size, + self.last_content.display_offset, + ); + + if let Some(scrolls) = + scroll_report(point, scroll_lines as i32, &e, self.last_content.mode) + { + for scroll in scrolls { + self.pty_tx.notify(scroll); + } + }; + } else if self + .last_content + .mode + .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) + && !e.shift + { + self.pty_tx.notify(alt_scroll(scroll_lines)) + } else { + if scroll_lines != 0 { + let scroll = AlacScroll::Delta(scroll_lines); + + self.events.push_back(InternalEvent::Scroll(scroll)); + } + } + } + } + + fn refresh_hovered_word(&mut self) { + self.word_from_position(self.last_mouse_position); + } + + fn determine_scroll_lines(&mut self, e: &MouseScrollWheel, mouse_mode: bool) -> Option { + let scroll_multiplier = if mouse_mode { 1. } else { SCROLL_MULTIPLIER }; + let line_height = self.last_content.size.line_height; + match e.phase { + /* Reset scroll state on started */ + Some(TouchPhase::Started) => { + self.scroll_px = 0.; + None + } + /* Calculate the appropriate scroll lines */ + Some(gpui2::TouchPhase::Moved) => { + let old_offset = (self.scroll_px / line_height) as i32; + + self.scroll_px += e.delta.pixel_delta(line_height).y() * scroll_multiplier; + + let new_offset = (self.scroll_px / line_height) as i32; + + // Whenever we hit the edges, reset our stored scroll to 0 + // so we can respond to changes in direction quickly + self.scroll_px %= self.last_content.size.height; + + Some(new_offset - old_offset) + } + /* Fall back to delta / line_height */ + None => Some( + ((e.delta.pixel_delta(line_height).y() * scroll_multiplier) / line_height) as i32, + ), + _ => None, + } + } + + pub fn find_matches( + &mut self, + searcher: RegexSearch, + cx: &mut ModelContext, + ) -> Task>> { + let term = self.term.clone(); + cx.executor().spawn(async move { + let term = term.lock(); + + all_search_matches(&term, &searcher).collect() + }) + } + + pub fn title(&self) -> String { + self.foreground_process_info + .as_ref() + .map(|fpi| { + format!( + "{} — {}", + truncate_and_trailoff( + &fpi.cwd + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_default(), + 25 + ), + truncate_and_trailoff( + &{ + format!( + "{}{}", + fpi.name, + if fpi.argv.len() >= 1 { + format!(" {}", (&fpi.argv[1..]).join(" ")) + } else { + "".to_string() + } + ) + }, + 25 + ) + ) + }) + .unwrap_or_else(|| "Terminal".to_string()) + } + + pub fn can_navigate_to_selected_word(&self) -> bool { + self.cmd_pressed && self.hovered_word + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + self.pty_tx.0.send(Msg::Shutdown).ok(); + } +} + +impl EventEmitter for Terminal { + type Event = Event; +} + +/// Based on alacritty/src/display/hint.rs > regex_match_at +/// Retrieve the match, if the specified point is inside the content matching the regex. +fn regex_match_at(term: &Term, point: AlacPoint, regex: &RegexSearch) -> Option { + visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point)) +} + +/// Copied from alacritty/src/display/hint.rs: +/// Iterate over all visible regex matches. +pub fn visible_regex_match_iter<'a, T>( + term: &'a Term, + regex: &'a RegexSearch, +) -> impl Iterator + 'a { + let viewport_start = Line(-(term.grid().display_offset() as i32)); + let viewport_end = viewport_start + term.bottommost_line(); + let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0))); + let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0))); + start.line = start.line.max(viewport_start - MAX_SEARCH_LINES); + end.line = end.line.min(viewport_end + MAX_SEARCH_LINES); + + RegexIter::new(start, end, AlacDirection::Right, term, regex) + .skip_while(move |rm| rm.end().line < viewport_start) + .take_while(move |rm| rm.start().line <= viewport_end) +} + +fn make_selection(range: &RangeInclusive) -> Selection { + let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left); + selection.update(*range.end(), AlacDirection::Right); + selection +} + +fn all_search_matches<'a, T>( + term: &'a Term, + regex: &'a RegexSearch, +) -> impl Iterator + 'a { + let start = AlacPoint::new(term.grid().topmost_line(), Column(0)); + let end = AlacPoint::new(term.grid().bottommost_line(), term.grid().last_column()); + RegexIter::new(start, end, AlacDirection::Right, term, regex) +} + +fn content_index_for_mouse(pos: AlacPoint, size: &TerminalSize) -> usize { + let col = (pos.x() / size.cell_width()).round() as usize; + + let clamped_col = min(col, size.columns() - 1); + + let row = (pos.y() / size.line_height()).round() as usize; + + let clamped_row = min(row, size.screen_lines() - 1); + + clamped_row * size.columns() + clamped_col +} + +#[cfg(test)] +mod tests { + use alacritty_terminal::{ + index::{AlacColumn, Line}, + term::cell::Cell, + }; + use gpui2::geometry::vecto::vec2f; + use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng}; + + use crate::{content_index_for_mouse, IndexedCell, TerminalContent, TerminalSize}; + + #[test] + fn test_mouse_to_cell_test() { + let mut rng = thread_rng(); + const ITERATIONS: usize = 10; + const PRECISION: usize = 1000; + + for _ in 0..ITERATIONS { + let viewport_cells = rng.gen_range(15..20); + let cell_size = rng.gen_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32; + + let size = crate::TerminalSize { + cell_width: cell_size, + line_height: cell_size, + height: cell_size * (viewport_cells as f32), + width: cell_size * (viewport_cells as f32), + }; + + let cells = get_cells(size, &mut rng); + let content = convert_cells_to_content(size, &cells); + + for row in 0..(viewport_cells - 1) { + let row = row as usize; + for col in 0..(viewport_cells - 1) { + let col = col as usize; + + let row_offset = rng.gen_range(0..PRECISION) as f32 / PRECISION as f32; + let col_offset = rng.gen_range(0..PRECISION) as f32 / PRECISION as f32; + + let mouse_pos = vec2f( + col as f32 * cell_size + col_offset, + row as f32 * cell_size + row_offset, + ); + + let content_index = content_index_for_mouse(mouse_pos, &content.size); + let mouse_cell = content.cells[content_index].c; + let real_cell = cells[row][col]; + + assert_eq!(mouse_cell, real_cell); + } + } + } + } + + #[test] + fn test_mouse_to_cell_clamp() { + let mut rng = thread_rng(); + + let size = crate::TerminalSize { + cell_width: 10., + line_height: 10., + height: 100., + width: 100., + }; + + let cells = get_cells(size, &mut rng); + let content = convert_cells_to_content(size, &cells); + + assert_eq!( + content.cells[content_index_for_mouse(vec2f(-10., -10.), &content.size)].c, + cells[0][0] + ); + assert_eq!( + content.cells[content_index_for_mouse(vec2f(1000., 1000.), &content.size)].c, + cells[9][9] + ); + } + + fn get_cells(size: TerminalSize, rng: &mut ThreadRng) -> Vec> { + let mut cells = Vec::new(); + + for _ in 0..((size.height() / size.line_height()) as usize) { + let mut row_vec = Vec::new(); + for _ in 0..((size.width() / size.cell_width()) as usize) { + let cell_char = rng.sample(Alphanumeric) as char; + row_vec.push(cell_char) + } + cells.push(row_vec) + } + + cells + } + + fn convert_cells_to_content(size: TerminalSize, cells: &Vec>) -> TerminalContent { + let mut ic = Vec::new(); + + for row in 0..cells.len() { + for col in 0..cells[row].len() { + let cell_char = cells[row][col]; + ic.push(IndexedCell { + point: Point::new(Line(row as i32), Column(col)), + cell: Cell { + c: cell_char, + ..Default::default() + }, + }); + } + } + + TerminalContent { + cells: ic, + size, + ..Default::default() + } + } +} diff --git a/crates/terminal2/src/terminal_settings.rs b/crates/terminal2/src/terminal_settings.rs new file mode 100644 index 0000000000..9d04b38988 --- /dev/null +++ b/crates/terminal2/src/terminal_settings.rs @@ -0,0 +1,164 @@ +use std::{collections::HashMap, path::PathBuf}; + +use gpui2::{fonts, AppContext}; +use schemars::JsonSchema; +use serde_derive::{Deserialize, Serialize}; + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TerminalDockPosition { + Left, + Bottom, + Right, +} + +#[derive(Deserialize)] +pub struct TerminalSettings { + pub shell: Shell, + pub working_directory: WorkingDirectory, + font_size: Option, + pub font_family: Option, + pub line_height: TerminalLineHeight, + pub font_features: Option, + pub env: HashMap, + pub blinking: TerminalBlink, + pub alternate_scroll: AlternateScroll, + pub option_as_meta: bool, + pub copy_on_select: bool, + pub dock: TerminalDockPosition, + pub default_width: f32, + pub default_height: f32, + pub detect_venv: VenvSettings, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum VenvSettings { + #[default] + Off, + On { + activate_script: Option, + directories: Option>, + }, +} + +pub struct VenvSettingsContent<'a> { + pub activate_script: ActivateScript, + pub directories: &'a [PathBuf], +} + +impl VenvSettings { + pub fn as_option(&self) -> Option { + match self { + VenvSettings::Off => None, + VenvSettings::On { + activate_script, + directories, + } => Some(VenvSettingsContent { + activate_script: activate_script.unwrap_or(ActivateScript::Default), + directories: directories.as_deref().unwrap_or(&[]), + }), + } + } +} + +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ActivateScript { + #[default] + Default, + Csh, + Fish, + Nushell, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)] +pub struct TerminalSettingsContent { + pub shell: Option, + pub working_directory: Option, + pub font_size: Option, + pub font_family: Option, + pub line_height: Option, + pub font_features: Option, + pub env: Option>, + pub blinking: Option, + pub alternate_scroll: Option, + pub option_as_meta: Option, + pub copy_on_select: Option, + pub dock: Option, + pub default_width: Option, + pub default_height: Option, + pub detect_venv: Option, +} + +impl TerminalSettings { + pub fn font_size(&self, cx: &AppContext) -> Option { + self.font_size + .map(|size| theme2::adjusted_font_size(size, cx)) + } +} + +impl settings2::Setting for TerminalSettings { + const KEY: Option<&'static str> = Some("terminal"); + + type FileContent = TerminalSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)] +#[serde(rename_all = "snake_case")] +pub enum TerminalLineHeight { + #[default] + Comfortable, + Standard, + Custom(f32), +} + +impl TerminalLineHeight { + pub fn value(&self) -> f32 { + match self { + TerminalLineHeight::Comfortable => 1.618, + TerminalLineHeight::Standard => 1.3, + TerminalLineHeight::Custom(line_height) => f32::max(*line_height, 1.), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TerminalBlink { + Off, + TerminalControlled, + On, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Shell { + System, + Program(String), + WithArguments { program: String, args: Vec }, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AlternateScroll { + On, + Off, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectory { + CurrentProjectDirectory, + FirstProjectDirectory, + AlwaysHome, + Always { directory: String }, +}