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)
This commit is contained in:
Cody 2024-10-10 01:50:12 -04:00 committed by GitHub
parent 5cf4ac16d6
commit fe1078ef68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 164 additions and 3 deletions

View file

@ -664,7 +664,8 @@
"shift-up": "terminal::ScrollLineUp", "shift-up": "terminal::ScrollLineUp",
"shift-down": "terminal::ScrollLineDown", "shift-down": "terminal::ScrollLineDown",
"shift-home": "terminal::ScrollToTop", "shift-home": "terminal::ScrollToTop",
"shift-end": "terminal::ScrollToBottom" "shift-end": "terminal::ScrollToBottom",
"ctrl-shift-space": "terminal::ToggleViMode"
} }
}, },
{ {

View file

@ -678,7 +678,8 @@
"cmd-home": "terminal::ScrollToTop", "cmd-home": "terminal::ScrollToTop",
"cmd-end": "terminal::ScrollToBottom", "cmd-end": "terminal::ScrollToBottom",
"shift-home": "terminal::ScrollToTop", "shift-home": "terminal::ScrollToTop",
"shift-end": "terminal::ScrollToBottom" "shift-end": "terminal::ScrollToBottom",
"ctrl-shift-space": "terminal::ToggleViMode"
} }
} }
] ]

View file

@ -18,6 +18,7 @@ use alacritty_terminal::{
Config, RenderableCursor, TermMode, Config, RenderableCursor, TermMode,
}, },
tty::{self}, tty::{self},
vi_mode::{ViModeCursor, ViMotion},
vte::ansi::{ vte::ansi::{
ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode, ClearMode, CursorStyle as AlacCursorStyle, Handler, NamedPrivateMode, PrivateMode,
}, },
@ -78,6 +79,7 @@ actions!(
ScrollPageDown, ScrollPageDown,
ScrollToTop, ScrollToTop,
ScrollToBottom, ScrollToBottom,
ToggleViMode,
] ]
); );
@ -139,6 +141,9 @@ enum InternalEvent {
// Adjusted mouse position, should open // Adjusted mouse position, should open
FindHyperlink(Point<Pixels>, bool), FindHyperlink(Point<Pixels>, bool),
Copy, Copy,
// Vi mode events
ToggleViMode,
ViMotion(ViMotion),
} }
///A translation struct for Alacritty to communicate with us from their event loop ///A translation struct for Alacritty to communicate with us from their event loop
@ -447,6 +452,7 @@ impl TerminalBuilder {
hovered_word: false, hovered_word: false,
url_regex, url_regex,
word_regex, word_regex,
vi_mode_enabled: false,
}; };
Ok(TerminalBuilder { Ok(TerminalBuilder {
@ -602,6 +608,7 @@ pub struct Terminal {
url_regex: RegexSearch, url_regex: RegexSearch,
word_regex: RegexSearch, word_regex: RegexSearch,
task: Option<TaskState>, task: Option<TaskState>,
vi_mode_enabled: bool,
} }
pub struct TaskState { pub struct TaskState {
@ -767,6 +774,43 @@ impl Terminal {
InternalEvent::Scroll(scroll) => { InternalEvent::Scroll(scroll) => {
term.scroll_display(*scroll); term.scroll_display(*scroll);
self.refresh_hovered_word(); 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) => { InternalEvent::SetSelection(selection) => {
term.selection = selection.as_ref().map(|(sel, _)| sel.clone()); term.selection = selection.as_ref().map(|(sel, _)| sel.clone());
@ -811,6 +855,13 @@ impl Terminal {
term.scroll_to_point(*point); term.scroll_to_point(*point);
self.refresh_hovered_word(); 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) => { InternalEvent::FindHyperlink(position, open) => {
let prev_hovered_word = self.last_content.last_hovered_word.take(); let prev_hovered_word = self.last_content.last_hovered_word.take();
@ -1092,7 +1143,109 @@ impl Terminal {
self.write_bytes_to_pty(input); 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<ViMotion> = 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 { 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); let esc = to_esc_str(keystroke, &self.last_content.mode, alt_is_meta);
if let Some(esc) = esc { if let Some(esc) = esc {
self.input(esc); self.input(esc);

View file

@ -22,7 +22,7 @@ use terminal::{
terminal_settings::{CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory}, terminal_settings::{CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory},
Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown, Clear, Copy, Event, MaybeNavigationTarget, Paste, ScrollLineDown, ScrollLineUp, ScrollPageDown,
ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal, ScrollPageUp, ScrollToBottom, ScrollToTop, ShowCharacterPalette, TaskStatus, Terminal,
TerminalSize, TerminalSize, ToggleViMode,
}; };
use terminal_element::{is_blank, TerminalElement}; use terminal_element::{is_blank, TerminalElement};
use terminal_panel::TerminalPanel; use terminal_panel::TerminalPanel;
@ -431,6 +431,11 @@ impl TerminalView {
cx.notify(); cx.notify();
} }
fn toggle_vi_mode(&mut self, _: &ToggleViMode, cx: &mut ViewContext<Self>) {
self.terminal.update(cx, |term, _| term.toggle_vi_mode());
cx.notify();
}
pub fn should_show_cursor(&self, focused: bool, cx: &mut gpui::ViewContext<Self>) -> bool { pub fn should_show_cursor(&self, focused: bool, cx: &mut gpui::ViewContext<Self>) -> bool {
//Don't blink the cursor when not focused, blinking is disabled, or paused //Don't blink the cursor when not focused, blinking is disabled, or paused
if !focused if !focused
@ -968,6 +973,7 @@ impl Render for TerminalView {
.on_action(cx.listener(TerminalView::scroll_page_down)) .on_action(cx.listener(TerminalView::scroll_page_down))
.on_action(cx.listener(TerminalView::scroll_to_top)) .on_action(cx.listener(TerminalView::scroll_to_top))
.on_action(cx.listener(TerminalView::scroll_to_bottom)) .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::show_character_palette))
.on_action(cx.listener(TerminalView::select_all)) .on_action(cx.listener(TerminalView::select_all))
.on_key_down(cx.listener(Self::key_down)) .on_key_down(cx.listener(Self::key_down))