From fe1078ef685d4e34ea079f779c2ac0c87ca806c9 Mon Sep 17 00:00:00 2001 From: Cody <30978270+NukaCody@users.noreply.github.com> Date: Thu, 10 Oct 2024 01:50:12 -0400 Subject: [PATCH] Add basic vi motion support for terminal (#18715) Closes #7417 Release Notes: - Added basic support for Alacritty's [vi mode](https://github.com/alacritty/alacritty/blob/master/docs/features.md#vi-mode) to the built-in terminal (which is using Alacritty under the hood.) The vi mode can be activated with `ctrl-shift-space` and then supports some basic motions to navigate through the terminal's scrollback buffer. ## Details Leverages existing selection functionality from mouse_drag and the ViMotion API of alacritty to add basic vi motions in the terminal. Please note, this is only basic functionality (move, select, and yank to system clipboard) and not a fully functional vim environment (e.g. search, configurable keybindings, and paste). I figured this would be an interim solution to the long term, more fleshed out, solution proposed by @mrnugget. Ctrl+Shift+Space to enter Vi mode while in the terminal (Same default binding in alacritty) --- assets/keymaps/default-linux.json | 3 +- assets/keymaps/default-macos.json | 3 +- crates/terminal/src/terminal.rs | 153 ++++++++++++++++++++++ crates/terminal_view/src/terminal_view.rs | 8 +- 4 files changed, 164 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index d33df02747..fca38a45a4 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -664,7 +664,8 @@ "shift-up": "terminal::ScrollLineUp", "shift-down": "terminal::ScrollLineDown", "shift-home": "terminal::ScrollToTop", - "shift-end": "terminal::ScrollToBottom" + "shift-end": "terminal::ScrollToBottom", + "ctrl-shift-space": "terminal::ToggleViMode" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index de929b8dd1..3de9a1495e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -678,7 +678,8 @@ "cmd-home": "terminal::ScrollToTop", "cmd-end": "terminal::ScrollToBottom", "shift-home": "terminal::ScrollToTop", - "shift-end": "terminal::ScrollToBottom" + "shift-end": "terminal::ScrollToBottom", + "ctrl-shift-space": "terminal::ToggleViMode" } } ] diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 9412999cbf..76c2fa892c 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -18,6 +18,7 @@ use alacritty_terminal::{ Config, RenderableCursor, TermMode, }, tty::{self}, + vi_mode::{ViModeCursor, ViMotion}, vte::ansi::{ ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode, }, @@ -78,6 +79,7 @@ actions!( ScrollPageDown, ScrollToTop, ScrollToBottom, + ToggleViMode, ] ); @@ -139,6 +141,9 @@ enum InternalEvent { // Adjusted mouse position, should open FindHyperlink(Point, bool), Copy, + // Vi mode events + ToggleViMode, + ViMotion(ViMotion), } ///A translation struct for Alacritty to communicate with us from their event loop @@ -447,6 +452,7 @@ impl TerminalBuilder { hovered_word: false, url_regex, word_regex, + vi_mode_enabled: false, }; Ok(TerminalBuilder { @@ -602,6 +608,7 @@ pub struct Terminal { url_regex: RegexSearch, word_regex: RegexSearch, task: Option, + vi_mode_enabled: bool, } pub struct TaskState { @@ -767,6 +774,43 @@ impl Terminal { InternalEvent::Scroll(scroll) => { term.scroll_display(*scroll); self.refresh_hovered_word(); + + if self.vi_mode_enabled { + match *scroll { + AlacScroll::Delta(delta) => { + term.vi_mode_cursor = term.vi_mode_cursor.scroll(&term, delta); + } + AlacScroll::PageUp => { + let lines = term.screen_lines() as i32; + term.vi_mode_cursor = term.vi_mode_cursor.scroll(&term, lines); + } + AlacScroll::PageDown => { + let lines = -(term.screen_lines() as i32); + term.vi_mode_cursor = term.vi_mode_cursor.scroll(&term, lines); + } + AlacScroll::Top => { + let point = AlacPoint::new(term.topmost_line(), Column(0)); + term.vi_mode_cursor = ViModeCursor::new(point); + } + AlacScroll::Bottom => { + let point = AlacPoint::new(term.bottommost_line(), Column(0)); + term.vi_mode_cursor = ViModeCursor::new(point); + } + } + if let Some(mut selection) = term.selection.take() { + let point = term.vi_mode_cursor.point; + selection.update(point, AlacDirection::Right); + term.selection = Some(selection); + + #[cfg(target_os = "linux")] + if let Some(selection_text) = term.selection_to_string() { + cx.write_to_primary(ClipboardItem::new_string(selection_text)); + } + + self.selection_head = Some(point); + cx.emit(Event::SelectionsChanged) + } + } } InternalEvent::SetSelection(selection) => { term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); @@ -811,6 +855,13 @@ impl Terminal { term.scroll_to_point(*point); self.refresh_hovered_word(); } + InternalEvent::ToggleViMode => { + self.vi_mode_enabled = !self.vi_mode_enabled; + term.toggle_vi_mode(); + } + InternalEvent::ViMotion(motion) => { + term.vi_motion(*motion); + } InternalEvent::FindHyperlink(position, open) => { let prev_hovered_word = self.last_content.last_hovered_word.take(); @@ -1092,7 +1143,109 @@ impl Terminal { self.write_bytes_to_pty(input); } + pub fn toggle_vi_mode(&mut self) { + self.events.push_back(InternalEvent::ToggleViMode); + } + + pub fn vi_motion(&mut self, keystroke: &Keystroke) { + if !self.vi_mode_enabled { + return; + } + + let mut key = keystroke.key.clone(); + if keystroke.modifiers.shift { + key = key.to_uppercase(); + } + + let motion: Option = match key.as_str() { + "h" => Some(ViMotion::Left), + "j" => Some(ViMotion::Down), + "k" => Some(ViMotion::Up), + "l" => Some(ViMotion::Right), + "w" => Some(ViMotion::WordRight), + "b" if !keystroke.modifiers.control => Some(ViMotion::WordLeft), + "e" => Some(ViMotion::WordRightEnd), + "%" => Some(ViMotion::Bracket), + "$" => Some(ViMotion::Last), + "0" => Some(ViMotion::First), + "^" => Some(ViMotion::FirstOccupied), + "H" => Some(ViMotion::High), + "M" => Some(ViMotion::Middle), + "L" => Some(ViMotion::Low), + _ => None, + }; + + if let Some(motion) = motion { + let cursor = self.last_content.cursor.point; + let cursor_pos = Point { + x: cursor.column.0 as f32 * self.last_content.size.cell_width, + y: cursor.line.0 as f32 * self.last_content.size.line_height, + }; + self.events + .push_back(InternalEvent::UpdateSelection(cursor_pos)); + self.events.push_back(InternalEvent::ViMotion(motion)); + return; + } + + let scroll_motion = match key.as_str() { + "g" => Some(AlacScroll::Top), + "G" => Some(AlacScroll::Bottom), + "b" if keystroke.modifiers.control => Some(AlacScroll::PageUp), + "f" if keystroke.modifiers.control => Some(AlacScroll::PageDown), + "d" if keystroke.modifiers.control => { + let amount = self.last_content.size.line_height().to_f64() as i32 / 2; + Some(AlacScroll::Delta(-amount)) + } + "u" if keystroke.modifiers.control => { + let amount = self.last_content.size.line_height().to_f64() as i32 / 2; + Some(AlacScroll::Delta(amount)) + } + _ => None, + }; + + if let Some(scroll_motion) = scroll_motion { + self.events.push_back(InternalEvent::Scroll(scroll_motion)); + return; + } + + match key.as_str() { + "v" => { + let point = self.last_content.cursor.point; + let selection_type = SelectionType::Simple; + let side = AlacDirection::Right; + let selection = Selection::new(selection_type, point, side); + self.events + .push_back(InternalEvent::SetSelection(Some((selection, point)))); + return; + } + + "escape" => { + self.events.push_back(InternalEvent::SetSelection(None)); + return; + } + + "y" => { + self.events.push_back(InternalEvent::Copy); + self.events.push_back(InternalEvent::SetSelection(None)); + return; + } + + "i" => { + self.scroll_to_bottom(); + self.toggle_vi_mode(); + return; + } + _ => {} + } + } + pub fn try_keystroke(&mut self, keystroke: &Keystroke, alt_is_meta: bool) -> bool { + if self.vi_mode_enabled { + self.vi_motion(keystroke); + return true; + } + + // Keep default terminal behavior let esc = to_esc_str(keystroke, &self.last_content.mode, alt_is_meta); if let Some(esc) = esc { self.input(esc); diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ce65be30c6..28a3d61d65 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -22,7 +22,7 @@ use terminal::{ terminal_settings::{CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory}, Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal, - TerminalSize, + TerminalSize, ToggleViMode, }; use terminal_element::{is_blank, TerminalElement}; use terminal_panel::TerminalPanel; @@ -431,6 +431,11 @@ impl TerminalView { cx.notify(); } + fn toggle_vi_mode(&mut self, _: &ToggleViMode, cx: &mut ViewContext) { + self.terminal.update(cx, |term, _| term.toggle_vi_mode()); + cx.notify(); + } + pub fn should_show_cursor(&self, focused: bool, cx: &mut gpui::ViewContext) -> bool { //Don't blink the cursor when not focused, blinking is disabled, or paused if !focused @@ -968,6 +973,7 @@ impl Render for TerminalView { .on_action(cx.listener(TerminalView::scroll_page_down)) .on_action(cx.listener(TerminalView::scroll_to_top)) .on_action(cx.listener(TerminalView::scroll_to_bottom)) + .on_action(cx.listener(TerminalView::toggle_vi_mode)) .on_action(cx.listener(TerminalView::show_character_palette)) .on_action(cx.listener(TerminalView::select_all)) .on_key_down(cx.listener(Self::key_down))