diff --git a/Cargo.lock b/Cargo.lock index 8925fa3fe7..07e688c6b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -59,6 +59,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_config_derive" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77044c45bdb871e501b5789ad16293ecb619e5733b60f4bb01d1cb31c463c336" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "alacritty_terminal" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02fb5d4af84e39f9754d039ff6de2233c8996dbae0af74910156e559e5766e2f" +dependencies = [ + "alacritty_config_derive", + "base64 0.13.0", + "bitflags", + "dirs 3.0.2", + "libc", + "log", + "mio 0.6.23", + "mio-anonymous-pipes", + "mio-extras", + "miow 0.3.7", + "nix", + "parking_lot 0.11.2", + "regex-automata", + "serde", + "serde_yaml", + "signal-hook", + "signal-hook-mio", + "unicode-width", + "vte", + "winapi 0.3.9", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -2516,6 +2555,12 @@ dependencies = [ "safemem", ] +[[package]] +name = "linked-hash-map" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + [[package]] name = "lipsum" version = "0.8.2" @@ -2725,7 +2770,7 @@ dependencies = [ "kernel32-sys", "libc", "log", - "miow", + "miow 0.2.2", "net2", "slab", "winapi 0.2.8", @@ -2743,6 +2788,42 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mio-anonymous-pipes" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bc513025fe5005a3aa561b50fdb2cda5a150b84800ae02acd8aa9ed62ca1a6b" +dependencies = [ + "mio 0.6.23", + "miow 0.3.7", + "parking_lot 0.11.2", + "spsc-buffer", + "winapi 0.3.9", +] + +[[package]] +name = "mio-extras" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" +dependencies = [ + "lazycell", + "log", + "mio 0.6.23", + "slab", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio 0.6.23", +] + [[package]] name = "miow" version = "0.2.2" @@ -2755,6 +2836,15 @@ dependencies = [ "ws2_32-sys", ] +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "multimap" version = "0.8.3" @@ -2799,6 +2889,19 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "nix" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4916f159ed8e5de0082076562152a76b7a1f64a01fd9d1e0fea002c37624faf" +dependencies = [ + "bitflags", + "cc", + "cfg-if 1.0.0", + "libc", + "memoffset", +] + [[package]] name = "nom" version = "7.1.1" @@ -4253,6 +4356,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707d15895415db6628332b737c838b88c598522e4dc70647e59b72312924aebc" +dependencies = [ + "indexmap", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "servo-fontconfig" version = "0.5.1" @@ -4365,6 +4480,18 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio 0.6.23", + "mio-uds", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -4493,6 +4620,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "spsc-buffer" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be6c3f39c37a4283ee4b43d1311c828f2e1fb0541e76ea0cb1a2abd9ef2f5b3b" + [[package]] name = "sqlformat" version = "0.1.8" @@ -4740,6 +4873,24 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal" +version = "0.1.0" +dependencies = [ + "alacritty_terminal", + "editor", + "futures", + "gpui", + "mio-extras", + "ordered-float", + "project", + "settings", + "smallvec", + "theme", + "util", + "workspace", +] + [[package]] name = "text" version = "0.1.0" @@ -5532,6 +5683,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" + [[package]] name = "util" version = "0.1.0" @@ -5617,6 +5774,26 @@ dependencies = [ "workspace", ] +[[package]] +name = "vte" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983" +dependencies = [ + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "waker-fn" version = "1.1.0" @@ -5968,6 +6145,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "zed" version = "0.42.0" @@ -6035,6 +6221,7 @@ dependencies = [ "smol", "sum_tree", "tempdir", + "terminal", "text", "theme", "theme_selector", diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 7f1c38d0b9..bea53ece45 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -403,5 +403,21 @@ "f2": "project_panel::Rename", "backspace": "project_panel::Delete" } + }, + { + "context": "Terminal", + "bindings": { + "ctrl-c": "terminal::Sigint", + "escape": "terminal::Escape", + "ctrl-d": "terminal::Quit", + "backspace": "terminal::Del", + "enter": "terminal::Return", + "left": "terminal::Left", + "right": "terminal::Right", + "up": "terminal::Up", + "down": "terminal::Down", + "tab": "terminal::Tab", + "cmd-v": "terminal::Paste" + } } ] \ No newline at end of file diff --git a/crates/gpui/src/presenter.rs b/crates/gpui/src/presenter.rs index 88e4d0a498..6285b1be99 100644 --- a/crates/gpui/src/presenter.rs +++ b/crates/gpui/src/presenter.rs @@ -703,6 +703,20 @@ impl<'a> EventContext<'a> { self.view_stack.last().copied() } + pub fn is_parent_view_focused(&self) -> bool { + if let Some(parent_view_id) = self.view_stack.last() { + self.app.focused_view_id(self.window_id) == Some(*parent_view_id) + } else { + false + } + } + + pub fn focus_parent_view(&mut self) { + if let Some(parent_view_id) = self.view_stack.last() { + self.app.focus(self.window_id, Some(*parent_view_id)) + } + } + pub fn dispatch_any_action(&mut self, action: Box) { self.dispatched_actions.push(DispatchDirective { dispatcher_view_id: self.view_stack.last().copied(), diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml new file mode 100644 index 0000000000..175c741421 --- /dev/null +++ b/crates/terminal/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "terminal" +version = "0.1.0" +edition = "2021" + +[lib] +path = "src/terminal.rs" +doctest = false + +[dependencies] +alacritty_terminal = "0.16.1" +editor = { path = "../editor" } +util = { path = "../util" } +gpui = { path = "../gpui" } +theme = { path = "../theme" } +settings = { path = "../settings" } +workspace = { path = "../workspace" } +project = { path = "../project" } +smallvec = { version = "1.6", features = ["union"] } +mio-extras = "2.0.6" +futures = "0.3" +ordered-float = "2.1.1" + +[dev-dependencies] +gpui = { path = "../gpui", features = ["test-support"] } diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs new file mode 100644 index 0000000000..6e62ce2a9f --- /dev/null +++ b/crates/terminal/src/terminal.rs @@ -0,0 +1,475 @@ +use alacritty_terminal::{ + config::{Config, Program, PtyConfig}, + event::{Event as AlacTermEvent, EventListener, Notify}, + event_loop::{EventLoop, Msg, Notifier}, + grid::Scroll, + sync::FairMutex, + term::{color::Rgb as AlacRgb, SizeInfo}, + tty, Term, +}; + +use futures::{ + channel::mpsc::{unbounded, UnboundedSender}, + StreamExt, +}; +use gpui::{ + actions, color::Color, elements::*, impl_internal_actions, platform::CursorStyle, + ClipboardItem, Entity, MutableAppContext, View, ViewContext, +}; +use project::{Project, ProjectPath}; +use settings::Settings; +use smallvec::SmallVec; +use std::{path::PathBuf, sync::Arc}; +use workspace::{Item, Workspace}; + +use crate::terminal_element::{get_color_at_index, TerminalEl}; + +//ASCII Control characters on a keyboard +//Consts -> Structs -> Impls -> Functions, Vaguely in order of importance +const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c' +const TAB_CHAR: char = 9_u8 as char; +const CARRIAGE_RETURN_CHAR: char = 13_u8 as char; +const ESC_CHAR: char = 27_u8 as char; +const DEL_CHAR: char = 127_u8 as char; +const LEFT_SEQ: &str = "\x1b[D"; +const RIGHT_SEQ: &str = "\x1b[C"; +const UP_SEQ: &str = "\x1b[A"; +const DOWN_SEQ: &str = "\x1b[B"; +const DEFAULT_TITLE: &str = "Terminal"; + +pub mod terminal_element; + +#[derive(Clone, Default, Debug, PartialEq, Eq)] +pub struct Input(pub String); + +#[derive(Clone, Debug, PartialEq)] +pub struct ScrollTerminal(pub i32); + +actions!( + terminal, + [Sigint, Escape, Del, Return, Left, Right, Up, Down, Tab, Clear, Paste, Deploy, Quit] +); +impl_internal_actions!(terminal, [Input, ScrollTerminal]); + +pub fn init(cx: &mut MutableAppContext) { + cx.add_action(Terminal::deploy); + cx.add_action(Terminal::write_to_pty); + cx.add_action(Terminal::send_sigint); + cx.add_action(Terminal::escape); + cx.add_action(Terminal::quit); + cx.add_action(Terminal::del); + cx.add_action(Terminal::carriage_return); //TODO figure out how to do this properly. Should we be checking the terminal mode? + cx.add_action(Terminal::left); + cx.add_action(Terminal::right); + cx.add_action(Terminal::up); + cx.add_action(Terminal::down); + cx.add_action(Terminal::tab); + cx.add_action(Terminal::paste); + cx.add_action(Terminal::scroll_terminal); +} + +#[derive(Clone)] +pub struct ZedListener(UnboundedSender); + +impl EventListener for ZedListener { + fn send_event(&self, event: AlacTermEvent) { + self.0.unbounded_send(event).ok(); + } +} + +///A terminal renderer. +pub struct Terminal { + pty_tx: Notifier, + term: Arc>>, + title: String, + has_new_content: bool, + has_bell: bool, //Currently using iTerm bell, show bell emoji in tab until input is received + cur_size: SizeInfo, +} + +pub enum Event { + TitleChanged, + CloseTerminal, + Activate, +} + +impl Entity for Terminal { + type Event = Event; +} + +impl Terminal { + ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices + fn new(cx: &mut ViewContext, working_directory: Option) -> Self { + //Spawn a task so the Alacritty EventLoop can communicate with us in a view context + let (events_tx, mut events_rx) = unbounded(); + cx.spawn_weak(|this, mut cx| async move { + while let Some(event) = events_rx.next().await { + match this.upgrade(&cx) { + Some(handle) => { + handle.update(&mut cx, |this, cx| { + this.process_terminal_event(event, cx); + cx.notify(); + }); + } + None => break, + } + } + }) + .detach(); + + let pty_config = PtyConfig { + shell: Some(Program::Just("zsh".to_string())), + working_directory, + hold: false, + }; + + let config = Config { + pty_config: pty_config.clone(), + ..Default::default() + }; + + //The details here don't matter, the terminal will be resized on layout + let size_info = SizeInfo::new(200., 100.0, 5., 5., 0., 0., false); + + //Set up the terminal... + let term = Term::new(&config, size_info, ZedListener(events_tx.clone())); + let term = Arc::new(FairMutex::new(term)); + + //Setup the pty... + let pty = tty::new(&pty_config, &size_info, None).expect("Could not create tty"); + + //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 = Notifier(event_loop.channel()); + let _io_thread = event_loop.spawn(); + Terminal { + title: DEFAULT_TITLE.to_string(), + term, + pty_tx, + has_new_content: false, + has_bell: false, + cur_size: size_info, + } + } + + ///Takes events from Alacritty and translates them to behavior on this view + fn process_terminal_event( + &mut self, + event: alacritty_terminal::event::Event, + cx: &mut ViewContext, + ) { + match event { + AlacTermEvent::Wakeup => { + if !cx.is_self_focused() { + //Need to figure out how to trigger a redraw when not in focus + self.has_new_content = true; //Change tab content + cx.emit(Event::TitleChanged); + } else { + cx.notify() + } + } + AlacTermEvent::PtyWrite(out) => self.write_to_pty(&Input(out), cx), + AlacTermEvent::MouseCursorDirty => { + //Calculate new cursor style. + //TODO + //Check on correctly handling mouse events for terminals + cx.platform().set_cursor_style(CursorStyle::Arrow); //??? + } + AlacTermEvent::Title(title) => { + self.title = title; + cx.emit(Event::TitleChanged); + } + AlacTermEvent::ResetTitle => { + self.title = DEFAULT_TITLE.to_string(); + cx.emit(Event::TitleChanged); + } + AlacTermEvent::ClipboardStore(_, data) => { + cx.write_to_clipboard(ClipboardItem::new(data)) + } + AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty( + &Input(format( + &cx.read_from_clipboard() + .map(|ci| ci.text().to_string()) + .unwrap_or("".to_string()), + )), + cx, + ), + AlacTermEvent::ColorRequest(index, format) => { + let color = self.term.lock().colors()[index].unwrap_or_else(|| { + let term_style = &cx.global::().theme.terminal; + match index { + 0..=255 => to_alac_rgb(get_color_at_index(&(index as u8), term_style)), + 256 => to_alac_rgb(term_style.foreground), + 257 => to_alac_rgb(term_style.background), + 258 => to_alac_rgb(term_style.cursor), + 259 => to_alac_rgb(term_style.dim_black), + 260 => to_alac_rgb(term_style.dim_red), + 261 => to_alac_rgb(term_style.dim_green), + 262 => to_alac_rgb(term_style.dim_yellow), + 263 => to_alac_rgb(term_style.dim_blue), + 264 => to_alac_rgb(term_style.dim_magenta), + 265 => to_alac_rgb(term_style.dim_cyan), + 266 => to_alac_rgb(term_style.dim_white), + 267 => to_alac_rgb(term_style.bright_foreground), + 268 => to_alac_rgb(term_style.black), //Dim Background, non-standard + _ => AlacRgb { r: 0, g: 0, b: 0 }, + } + }); + self.write_to_pty(&Input(format(color)), cx) + } + AlacTermEvent::CursorBlinkingChange => { + //So, it's our job to set a timer and cause the cursor to blink here + //Which means that I'm going to put this off until someone @ Zed looks at it + } + AlacTermEvent::Bell => { + self.has_bell = true; + cx.emit(Event::TitleChanged); + } + AlacTermEvent::Exit => self.quit(&Quit, cx), + } + } + + fn set_size(&mut self, new_size: SizeInfo) { + if new_size != self.cur_size { + self.pty_tx.0.send(Msg::Resize(new_size)).ok(); + self.term.lock().resize(new_size); + self.cur_size = new_size; + } + } + + fn scroll_terminal(&mut self, scroll: &ScrollTerminal, _: &mut ViewContext) { + self.term.lock().scroll_display(Scroll::Delta(scroll.0)); + } + + ///Create a new Terminal + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + let project = workspace.project().read(cx); + let abs_path = project + .active_entry() + .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) + .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) + .map(|wt| wt.abs_path().to_path_buf()); + + workspace.add_item(Box::new(cx.add_view(|cx| Terminal::new(cx, abs_path))), cx); + } + + ///Send the shutdown message to Alacritty + fn shutdown_pty(&mut self) { + self.pty_tx.0.send(Msg::Shutdown).ok(); + } + + fn quit(&mut self, _: &Quit, cx: &mut ViewContext) { + cx.emit(Event::CloseTerminal); + } + + fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { + if let Some(item) = cx.read_from_clipboard() { + self.write_to_pty(&Input(item.text().to_owned()), cx); + } + } + + fn write_to_pty(&mut self, input: &Input, cx: &mut ViewContext) { + //iTerm bell behavior, bell stays until terminal is interacted with + self.has_bell = false; + self.term.lock().scroll_display(Scroll::Bottom); + cx.emit(Event::TitleChanged); + self.pty_tx.notify(input.0.clone().into_bytes()); + } + + fn up(&mut self, _: &Up, cx: &mut ViewContext) { + self.write_to_pty(&Input(UP_SEQ.to_string()), cx); + } + + fn down(&mut self, _: &Down, cx: &mut ViewContext) { + self.write_to_pty(&Input(DOWN_SEQ.to_string()), cx); + } + + fn tab(&mut self, _: &Tab, cx: &mut ViewContext) { + self.write_to_pty(&Input(TAB_CHAR.to_string()), cx); + } + + fn send_sigint(&mut self, _: &Sigint, cx: &mut ViewContext) { + self.write_to_pty(&Input(ETX_CHAR.to_string()), cx); + } + + fn escape(&mut self, _: &Escape, cx: &mut ViewContext) { + self.write_to_pty(&Input(ESC_CHAR.to_string()), cx); + } + + fn del(&mut self, _: &Del, cx: &mut ViewContext) { + self.write_to_pty(&Input(DEL_CHAR.to_string()), cx); + } + + fn carriage_return(&mut self, _: &Return, cx: &mut ViewContext) { + self.write_to_pty(&Input(CARRIAGE_RETURN_CHAR.to_string()), cx); + } + + fn left(&mut self, _: &Left, cx: &mut ViewContext) { + self.write_to_pty(&Input(LEFT_SEQ.to_string()), cx); + } + + fn right(&mut self, _: &Right, cx: &mut ViewContext) { + self.write_to_pty(&Input(RIGHT_SEQ.to_string()), cx); + } +} + +impl Drop for Terminal { + fn drop(&mut self) { + self.shutdown_pty(); + } +} + +impl View for Terminal { + fn ui_name() -> &'static str { + "Terminal" + } + + fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox { + TerminalEl::new(cx.handle()) + .contained() + // .with_style(theme.terminal.container) + .boxed() + } + + fn on_focus(&mut self, cx: &mut ViewContext) { + cx.emit(Event::Activate); + self.has_new_content = false; + } +} + +impl Item for Terminal { + fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox { + let settings = cx.global::(); + let search_theme = &settings.theme.search; //TODO properly integrate themes + + let mut flex = Flex::row(); + + if self.has_bell { + flex.add_child( + Svg::new("icons/zap.svg") + .with_color(tab_theme.label.text.color) + .constrained() + .with_width(search_theme.tab_icon_width) + .aligned() + .boxed(), + ); + }; + + flex.with_child( + Label::new(self.title.clone(), tab_theme.label.clone()) + .aligned() + .contained() + .with_margin_left(if self.has_bell { + search_theme.tab_icon_spacing + } else { + 0. + }) + .boxed(), + ) + .boxed() + } + + fn project_path(&self, _cx: &gpui::AppContext) -> Option { + None + } + + fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> { + SmallVec::new() + } + + fn is_singleton(&self, _cx: &gpui::AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext) {} + + fn can_save(&self, _cx: &gpui::AppContext) -> bool { + false + } + + fn save( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save should not have been called"); + } + + fn save_as( + &mut self, + _project: gpui::ModelHandle, + _abs_path: std::path::PathBuf, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save_as should not have been called"); + } + + fn reload( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + gpui::Task::ready(Ok(())) + } + + fn is_dirty(&self, _: &gpui::AppContext) -> bool { + self.has_new_content + } + + fn should_update_tab_on_event(event: &Self::Event) -> bool { + matches!(event, &Event::TitleChanged) + } + + fn should_close_item_on_event(event: &Self::Event) -> bool { + matches!(event, &Event::CloseTerminal) + } + + fn should_activate_item_on_event(event: &Self::Event) -> bool { + matches!(event, &Event::Activate) + } +} + +fn to_alac_rgb(color: Color) -> AlacRgb { + AlacRgb { + r: color.r, + g: color.g, + b: color.g, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal_element::build_chunks; + use gpui::TestAppContext; + + #[gpui::test] + async fn test_terminal(cx: &mut TestAppContext) { + let terminal = cx.add_view(Default::default(), |cx| Terminal::new(cx, None)); + + terminal.update(cx, |terminal, cx| { + terminal.write_to_pty(&Input(("expr 3 + 4".to_string()).to_string()), cx); + terminal.carriage_return(&Return, cx); + }); + + terminal + .condition(cx, |terminal, _cx| { + let term = terminal.term.clone(); + let (chunks, _) = build_chunks( + term.lock().renderable_content().display_iter, + &Default::default(), + ); + let content = chunks.iter().map(|e| e.0.trim()).collect::(); + content.contains("7") + }) + .await; + } +} diff --git a/crates/terminal/src/terminal_element.rs b/crates/terminal/src/terminal_element.rs new file mode 100644 index 0000000000..d81292d0c2 --- /dev/null +++ b/crates/terminal/src/terminal_element.rs @@ -0,0 +1,437 @@ +use alacritty_terminal::{ + ansi::Color as AnsiColor, + grid::{GridIterator, Indexed}, + index::Point, + term::{ + cell::{Cell, Flags}, + SizeInfo, + }, +}; +use gpui::{ + color::Color, + elements::*, + fonts::{HighlightStyle, TextStyle, Underline}, + geometry::{rect::RectF, vector::vec2f}, + json::json, + text_layout::Line, + Event, MouseRegion, PaintContext, Quad, WeakViewHandle, +}; +use ordered_float::OrderedFloat; +use settings::Settings; +use std::rc::Rc; +use theme::TerminalStyle; + +use crate::{Input, ScrollTerminal, Terminal}; + +const ALACRITTY_SCROLL_MULTIPLIER: f32 = 3.; + +#[cfg(debug_assertions)] +const DEBUG_GRID: bool = false; + +pub struct TerminalEl { + view: WeakViewHandle, +} + +impl TerminalEl { + pub fn new(view: WeakViewHandle) -> TerminalEl { + TerminalEl { view } + } +} + +pub struct LayoutState { + lines: Vec, + line_height: f32, + em_width: f32, + cursor: Option<(RectF, Color)>, + cur_size: SizeInfo, + background_color: Color, +} + +impl Element for TerminalEl { + type LayoutState = LayoutState; + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + cx: &mut gpui::LayoutContext, + ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) { + let view = self.view.upgrade(cx).unwrap(); + let size = constraint.max; + let settings = cx.global::(); + let editor_theme = &settings.theme.editor; + let font_cache = cx.font_cache(); + + //Set up text rendering + let text_style = TextStyle { + color: editor_theme.text_color, + font_family_id: settings.buffer_font_family, + font_family_name: font_cache.family_name(settings.buffer_font_family).unwrap(), + font_id: font_cache + .select_font(settings.buffer_font_family, &Default::default()) + .unwrap(), + font_size: settings.buffer_font_size, + font_properties: Default::default(), + underline: Default::default(), + }; + + let line_height = font_cache.line_height(text_style.font_size); + let cell_width = font_cache.em_advance(text_style.font_id, text_style.font_size); + + let new_size = SizeInfo::new( + size.x() - cell_width, + size.y(), + cell_width, + line_height, + 0., + 0., + false, + ); + view.update(cx.app, |view, _cx| { + view.set_size(new_size); + }); + + let settings = cx.global::(); + let terminal_theme = &settings.theme.terminal; + let term = view.read(cx).term.lock(); + + let content = term.renderable_content(); + let (chunks, line_count) = build_chunks(content.display_iter, &terminal_theme); + + let shaped_lines = layout_highlighted_chunks( + chunks.iter().map(|(text, style)| (text.as_str(), *style)), + &text_style, + cx.text_layout_cache, + &cx.font_cache, + usize::MAX, + line_count, + ); + + let cursor_line = content.cursor.point.line.0 + content.display_offset as i32; + let mut cursor = None; + if let Some(layout_line) = cursor_line + .try_into() + .ok() + .and_then(|cursor_line: usize| shaped_lines.get(cursor_line)) + { + let cursor_x = layout_line.x_for_index(content.cursor.point.column.0); + cursor = Some(( + RectF::new( + vec2f(cursor_x, cursor_line as f32 * line_height), + vec2f(cell_width, line_height), + ), + terminal_theme.cursor, + )); + } + + ( + constraint.max, + LayoutState { + lines: shaped_lines, + line_height, + em_width: cell_width, + cursor, + cur_size: new_size, + background_color: terminal_theme.background, + }, + ) + } + + fn paint( + &mut self, + bounds: gpui::geometry::rect::RectF, + visible_bounds: gpui::geometry::rect::RectF, + layout: &mut Self::LayoutState, + cx: &mut gpui::PaintContext, + ) -> Self::PaintState { + cx.scene.push_layer(Some(visible_bounds)); + + cx.scene.push_mouse_region(MouseRegion { + view_id: self.view.id(), + discriminant: None, + bounds: visible_bounds, + hover: None, + mouse_down: Some(Rc::new(|_, cx| cx.focus_parent_view())), + click: None, + right_mouse_down: None, + right_click: None, + drag: None, + mouse_down_out: None, + right_mouse_down_out: None, + }); + + //Background + cx.scene.push_quad(Quad { + bounds: visible_bounds, + background: Some(layout.background_color), + border: Default::default(), + corner_radius: 0., + }); + + let origin = bounds.origin() + vec2f(layout.em_width, 0.); //Padding + + let mut line_origin = origin; + for line in &layout.lines { + let boundaries = RectF::new(line_origin, vec2f(bounds.width(), layout.line_height)); + + if boundaries.intersects(visible_bounds) { + line.paint(line_origin, visible_bounds, layout.line_height, cx); + } + + line_origin.set_y(boundaries.max_y()); + } + + if let Some((c, color)) = layout.cursor { + let new_origin = origin + c.origin(); + let new_cursor = RectF::new(new_origin, c.size()); + cx.scene.push_quad(Quad { + bounds: new_cursor, + background: Some(color), + border: Default::default(), + corner_radius: 0., + }); + } + + #[cfg(debug_assertions)] + if DEBUG_GRID { + draw_debug_grid(bounds, layout, cx); + } + + cx.scene.pop_layer(); + } + + fn dispatch_event( + &mut self, + event: &gpui::Event, + _bounds: gpui::geometry::rect::RectF, + visible_bounds: gpui::geometry::rect::RectF, + layout: &mut Self::LayoutState, + _paint: &mut Self::PaintState, + cx: &mut gpui::EventContext, + ) -> bool { + match event { + Event::ScrollWheel { + delta, position, .. + } => { + if visible_bounds.contains_point(*position) { + let vertical_scroll = + (delta.y() / layout.line_height) * ALACRITTY_SCROLL_MULTIPLIER; + cx.dispatch_action(ScrollTerminal(vertical_scroll.round() as i32)); + true + } else { + false + } + } + Event::KeyDown { + input: Some(input), .. + } => { + if cx.is_parent_view_focused() { + cx.dispatch_action(Input(input.to_string())); + true + } else { + false + } + } + _ => false, + } + } + + fn debug( + &self, + _bounds: gpui::geometry::rect::RectF, + _layout: &Self::LayoutState, + _paint: &Self::PaintState, + _cx: &gpui::DebugContext, + ) -> gpui::serde_json::Value { + json!({ + "type": "TerminalElement", + }) + } +} + +pub(crate) fn build_chunks( + grid_iterator: GridIterator, + theme: &TerminalStyle, +) -> (Vec<(String, Option)>, usize) { + let mut lines: Vec<(String, Option)> = vec![]; + let mut last_line = 0; + let mut line_count = 1; + let mut cur_chunk = String::new(); + + let mut cur_highlight = HighlightStyle { + color: Some(Color::white()), + ..Default::default() + }; + + for cell in grid_iterator { + let Indexed { + point: Point { line, .. }, + cell: Cell { + c, fg, flags, .. // TODO: Add bg and flags + }, //TODO: Learn what 'CellExtra does' + } = cell; + + let new_highlight = make_style_from_cell(fg, flags, theme); + + if line != last_line { + line_count += 1; + cur_chunk.push('\n'); + last_line = line.0; + } + + if new_highlight != cur_highlight { + lines.push((cur_chunk.clone(), Some(cur_highlight.clone()))); + cur_chunk.clear(); + cur_highlight = new_highlight; + } + cur_chunk.push(*c) + } + lines.push((cur_chunk, Some(cur_highlight))); + (lines, line_count) +} + +fn make_style_from_cell(fg: &AnsiColor, flags: &Flags, style: &TerminalStyle) -> HighlightStyle { + let fg = Some(alac_color_to_gpui_color(fg, style)); + let underline = if flags.contains(Flags::UNDERLINE) { + Some(Underline { + color: fg, + squiggly: false, + thickness: OrderedFloat(1.), + }) + } else { + None + }; + HighlightStyle { + color: fg, + underline, + ..Default::default() + } +} + +fn alac_color_to_gpui_color(allac_color: &AnsiColor, style: &TerminalStyle) -> Color { + match allac_color { + 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, + }, //Theme defined + alacritty_terminal::ansi::Color::Spec(rgb) => Color::new(rgb.r, rgb.g, rgb.b, 1), + alacritty_terminal::ansi::Color::Indexed(i) => get_color_at_index(i, style), //Color cube weirdness + } +} + +pub fn get_color_at_index(index: &u8, style: &TerminalStyle) -> Color { + match index { + 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 => { + let (r, g, b) = rgb_for_index(index); //Split the index into it's rgb components + let step = (u8::MAX as f32 / 5.).round() as u8; //Split the GPUI range into 5 chunks + Color::new(r * step, g * step, b * step, 1) //Map the rgb components to GPUI's range + } + //Grayscale from black to white, 0 to 24 + 232..=255 => { + let i = 24 - (index - 232); //Align index to 24..0 + let step = (u8::MAX as f32 / 24.).round() as u8; //Split the 256 range grayscale into 24 chunks + Color::new(i * step, i * step, i * step, 1) //Map the rgb components to GPUI's range + } + } +} + +///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!(i >= &16 && i <= &231); + 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) +} + +#[cfg(debug_assertions)] +fn draw_debug_grid(bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) { + let width = layout.cur_size.width(); + let height = layout.cur_size.height(); + //Alacritty uses 'as usize', so shall we. + for col in 0..(width / layout.em_width).round() as usize { + cx.scene.push_quad(Quad { + bounds: RectF::new( + bounds.origin() + vec2f((col + 1) as f32 * layout.em_width, 0.), + vec2f(1., height), + ), + background: Some(Color::green()), + border: Default::default(), + corner_radius: 0., + }); + } + for row in 0..((height / layout.line_height) + 1.0).round() as usize { + cx.scene.push_quad(Quad { + bounds: RectF::new( + bounds.origin() + vec2f(layout.em_width, row as f32 * layout.line_height), + vec2f(width, 1.), + ), + background: Some(Color::green()), + border: Default::default(), + corner_radius: 0., + }); + } +} + +#[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::terminal_element::rgb_for_index(&(i as u8)); + assert_eq!(i, 16 + 36 * r + 6 * g + b); + } + } +} diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index ae269c00cb..184b1880f0 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -33,6 +33,7 @@ pub struct Theme { pub contact_notification: ContactNotification, pub update_notification: UpdateNotification, pub tooltip: TooltipStyle, + pub terminal: TerminalStyle, } #[derive(Deserialize, Default)] @@ -633,3 +634,36 @@ pub struct HoverPopover { pub prose: TextStyle, pub highlight: Color, } + +#[derive(Clone, Deserialize, Default)] +pub struct TerminalStyle { + pub black: Color, + pub red: Color, + pub green: Color, + pub yellow: Color, + pub blue: Color, + pub magenta: Color, + pub cyan: Color, + pub white: Color, + pub bright_black: Color, + pub bright_red: Color, + pub bright_green: Color, + pub bright_yellow: Color, + pub bright_blue: Color, + pub bright_magenta: Color, + pub bright_cyan: Color, + pub bright_white: Color, + pub foreground: Color, + pub background: Color, + pub cursor: Color, + pub dim_black: Color, + pub dim_red: Color, + pub dim_green: Color, + pub dim_yellow: Color, + pub dim_blue: Color, + pub dim_magenta: Color, + pub dim_cyan: Color, + pub dim_white: Color, + pub bright_foreground: Color, + pub dim_foreground: Color, +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index c14dce992a..eb34539c35 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -46,6 +46,7 @@ rpc = { path = "../rpc" } settings = { path = "../settings" } sum_tree = { path = "../sum_tree" } text = { path = "../text" } +terminal = { path = "../terminal" } theme = { path = "../theme" } theme_selector = { path = "../theme_selector" } util = { path = "../util" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 10aa717c0d..c7a7e24c5a 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -36,6 +36,7 @@ use std::{ thread, time::Duration, }; +use terminal; use theme::{ThemeRegistry, DEFAULT_THEME_NAME}; use util::{ResultExt, TryFutureExt}; use workspace::{self, AppState, NewFile, OpenPaths}; @@ -181,6 +182,7 @@ fn main() { diagnostics::init(cx); search::init(cx); vim::init(cx); + terminal::init(cx); let db = cx.background().block(db); let (settings_file, keymap_file) = cx.background().block(config_files).unwrap(); diff --git a/styles/package-lock.json b/styles/package-lock.json index 49304dc2fa..2eb6d3a1bf 100644 --- a/styles/package-lock.json +++ b/styles/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "styles", "version": "1.0.0", "license": "ISC", "dependencies": { diff --git a/styles/src/styleTree/app.ts b/styles/src/styleTree/app.ts index e015895e9c..fe67cf701d 100644 --- a/styles/src/styleTree/app.ts +++ b/styles/src/styleTree/app.ts @@ -14,6 +14,7 @@ import projectDiagnostics from "./projectDiagnostics"; import contactNotification from "./contactNotification"; import updateNotification from "./updateNotification"; import tooltip from "./tooltip"; +import terminal from "./terminal"; export const panel = { padding: { top: 12, bottom: 12 }, @@ -41,5 +42,6 @@ export default function app(theme: Theme): Object { contactNotification: contactNotification(theme), updateNotification: updateNotification(theme), tooltip: tooltip(theme), + terminal: terminal(theme), }; } diff --git a/styles/src/styleTree/terminal.ts b/styles/src/styleTree/terminal.ts new file mode 100644 index 0000000000..ef9e4f93dd --- /dev/null +++ b/styles/src/styleTree/terminal.ts @@ -0,0 +1,35 @@ +import Theme from "../themes/common/theme"; + +export default function terminal(theme: Theme) { + return { + black: theme.ramps.neutral(0).hex(), + red: theme.ramps.red(0.5).hex(), + green: theme.ramps.green(0.5).hex(), + yellow: theme.ramps.yellow(0.5).hex(), + blue: theme.ramps.blue(0.5).hex(), + magenta: theme.ramps.magenta(0.5).hex(), + cyan: theme.ramps.cyan(0.5).hex(), + white: theme.ramps.neutral(7).hex(), + brightBlack: theme.ramps.neutral(2).hex(), + brightRed: theme.ramps.red(0.25).hex(), + brightGreen: theme.ramps.green(0.25).hex(), + brightYellow: theme.ramps.yellow(0.25).hex(), + brightBlue: theme.ramps.blue(0.25).hex(), + brightMagenta: theme.ramps.magenta(0.25).hex(), + brightCyan: theme.ramps.cyan(0.25).hex(), + brightWhite: theme.ramps.neutral(7).hex(), + foreground: theme.ramps.neutral(7).hex(), + background: theme.ramps.neutral(0).hex(), + cursor: theme.ramps.neutral(7).hex(), + dimBlack: theme.ramps.neutral(7).hex(), + dimRed: theme.ramps.red(0.75).hex(), + dimGreen: theme.ramps.green(0.75).hex(), + dimYellow: theme.ramps.yellow(0.75).hex(), + dimBlue: theme.ramps.blue(0.75).hex(), + dimMagenta: theme.ramps.magenta(0.75).hex(), + dimCyan: theme.ramps.cyan(0.75).hex(), + dimWhite: theme.ramps.neutral(5).hex(), + brightForeground: theme.ramps.neutral(7).hex(), + dimForeground: theme.ramps.neutral(0).hex(), + }; +} \ No newline at end of file diff --git a/styles/src/themes/cave.ts b/styles/src/themes/cave.ts index 2e66f4baf4..aab020d626 100644 --- a/styles/src/themes/cave.ts +++ b/styles/src/themes/cave.ts @@ -25,4 +25,4 @@ const ramps = { }; export const dark = createTheme(`${name}-dark`, false, ramps); -export const light = createTheme(`${name}-light`, true, ramps); +export const light = createTheme(`${name}-light`, true, ramps); \ No newline at end of file diff --git a/styles/src/themes/common/base16.ts b/styles/src/themes/common/base16.ts index 21a02cde25..729cf32ee5 100644 --- a/styles/src/themes/common/base16.ts +++ b/styles/src/themes/common/base16.ts @@ -13,15 +13,25 @@ export function colorRamp(color: Color): Scale { export function createTheme( name: string, isLight: boolean, - ramps: { [rampName: string]: Scale }, + color_ramps: { [rampName: string]: Scale }, ): Theme { + let ramps: typeof color_ramps = {}; + // Chromajs mutates the underlying ramp when you call domain. This causes problems because + // we now store the ramps object in the theme so that we can pull colors out of them. + // So instead of calling domain and storing the result, we have to construct new ramps for each + // theme so that we don't modify the passed in ramps. + // This combined with an error in the type definitions for chroma js means we have to cast the colors + // function to any in order to get the colors back out from the original ramps. if (isLight) { - for (var rampName in ramps) { - ramps[rampName] = ramps[rampName].domain([1, 0]); + for (var rampName in color_ramps) { + ramps[rampName] = chroma.scale((color_ramps[rampName].colors as any)()).domain([1, 0]); } - ramps.neutral = ramps.neutral.domain([7, 0]); + ramps.neutral = chroma.scale((color_ramps.neutral.colors as any)()).domain([7, 0]); } else { - ramps.neutral = ramps.neutral.domain([0, 7]); + for (var rampName in color_ramps) { + ramps[rampName] = chroma.scale((color_ramps[rampName].colors as any)()).domain([0, 1]); + } + ramps.neutral = chroma.scale((color_ramps.neutral.colors as any)()).domain([0, 7]); } let blend = isLight ? 0.12 : 0.24; @@ -237,6 +247,7 @@ export function createTheme( return { name, + isLight, backgroundColor, borderColor, textColor, @@ -245,5 +256,6 @@ export function createTheme( syntax, player, shadow, + ramps, }; } diff --git a/styles/src/themes/common/theme.ts b/styles/src/themes/common/theme.ts index 92b1f8eff8..7f32f48974 100644 --- a/styles/src/themes/common/theme.ts +++ b/styles/src/themes/common/theme.ts @@ -1,3 +1,4 @@ +import { Scale } from "chroma-js"; import { FontWeight } from "../../common"; import { withOpacity } from "../../utils/color"; @@ -60,6 +61,7 @@ export interface Syntax { export default interface Theme { name: string; + isLight: boolean, backgroundColor: { // Basically just Title Bar // Lowest background level @@ -155,4 +157,5 @@ export default interface Theme { 8: Player; }, shadow: string; + ramps: { [rampName: string]: Scale }; }