mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge pull request #222 from l0ng-ai/feat/local-editor-parity
feat(editor): readline parity at the prompt — Ctrl-P/N, Ctrl-Y, Alt-. and shell fallthrough
This commit is contained in:
@@ -24,6 +24,11 @@ pub struct CmdEditor {
|
||||
/// shuttle states between the two.
|
||||
undo: Vec<(Vec<char>, usize)>,
|
||||
redo: Vec<(Vec<char>, usize)>,
|
||||
/// What the last *kill* removed, for [`Self::yank`] to put back — readline's
|
||||
/// kill ring, one slot deep. Only the word/line kills (⌃W, ⌃U, ⌃K, ⌥D and
|
||||
/// the arrow-key spellings of them) write here; a plain character delete is
|
||||
/// not a kill and leaves it untouched.
|
||||
kill: String,
|
||||
}
|
||||
|
||||
/// Cap on undo history, so a long editing session can't grow it without bound.
|
||||
@@ -409,6 +414,15 @@ impl CmdEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove `s..e` and stash it as the kill ring's contents. The four chords
|
||||
/// below are readline *kills*, not deletes: what they take is meant to come
|
||||
/// back out under [`Self::yank`].
|
||||
fn kill_range(&mut self, s: usize, e: usize) {
|
||||
self.kill = self.chars[s..e].iter().collect();
|
||||
self.chars.drain(s..e);
|
||||
self.shift_anchor_for_removal(s, e);
|
||||
}
|
||||
|
||||
/// Delete the word after the cursor (Alt+Delete): skip following whitespace,
|
||||
/// then the word.
|
||||
pub fn delete_word_right(&mut self) {
|
||||
@@ -421,8 +435,7 @@ impl CmdEditor {
|
||||
while e < n && !self.chars[e].is_whitespace() {
|
||||
e += 1;
|
||||
}
|
||||
self.chars.drain(self.cursor..e);
|
||||
self.shift_anchor_for_removal(self.cursor, e);
|
||||
self.kill_range(self.cursor, e);
|
||||
}
|
||||
|
||||
/// Delete the word before the cursor (Ctrl+W / Alt+Backspace).
|
||||
@@ -430,25 +443,33 @@ impl CmdEditor {
|
||||
self.checkpoint();
|
||||
let end = self.cursor;
|
||||
self.move_word_left();
|
||||
self.chars.drain(self.cursor..end);
|
||||
self.shift_anchor_for_removal(self.cursor, end);
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the start of the line (Ctrl+U / Cmd+Backspace).
|
||||
pub fn delete_to_start(&mut self) {
|
||||
self.checkpoint();
|
||||
self.chars.drain(0..self.cursor);
|
||||
let end = self.cursor;
|
||||
self.cursor = 0;
|
||||
self.shift_anchor_for_removal(0, end);
|
||||
self.kill_range(0, end);
|
||||
}
|
||||
|
||||
/// Delete from the cursor to the end of the line (Ctrl+K).
|
||||
pub fn delete_to_end(&mut self) {
|
||||
self.checkpoint();
|
||||
let end = self.chars.len();
|
||||
self.chars.drain(self.cursor..);
|
||||
self.shift_anchor_for_removal(self.cursor, end);
|
||||
self.kill_range(self.cursor, end);
|
||||
}
|
||||
|
||||
/// Reinsert the most recent kill at the cursor (Ctrl+Y). A no-op — undo
|
||||
/// checkpoint included — when nothing has been killed yet.
|
||||
pub fn yank(&mut self) {
|
||||
if self.kill.is_empty() {
|
||||
return;
|
||||
}
|
||||
let kill = std::mem::take(&mut self.kill);
|
||||
self.insert_str(&kill);
|
||||
self.kill = kill;
|
||||
}
|
||||
|
||||
/// Clear the line and reset the cursor and undo history (after submit).
|
||||
@@ -572,6 +593,58 @@ mod tests {
|
||||
assert_eq!(d.cursor(), 9);
|
||||
}
|
||||
|
||||
/// The four readline *kill* chords stash what they removed so ⌃Y can put it
|
||||
/// back; the ring holds the most recent kill only.
|
||||
#[test]
|
||||
fn kills_fill_the_kill_buffer_and_yank_puts_it_back() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
e.delete_word_left();
|
||||
assert_eq!(e.text(), "git push ");
|
||||
e.yank();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("git push origin", 15));
|
||||
|
||||
let mut k = ed("hello world", 5);
|
||||
k.delete_to_end();
|
||||
assert_eq!(k.text(), "hello");
|
||||
k.yank();
|
||||
assert_eq!(k.text(), "hello world");
|
||||
|
||||
let mut u = ed("hello world", 6);
|
||||
u.delete_to_start();
|
||||
assert_eq!(u.text(), "world");
|
||||
u.move_end();
|
||||
u.yank();
|
||||
assert_eq!(u.text(), "worldhello ");
|
||||
|
||||
let mut d = ed("git push origin", 4);
|
||||
d.delete_word_right();
|
||||
assert_eq!(d.text(), "git origin");
|
||||
d.yank();
|
||||
assert_eq!(d.text(), "git push origin");
|
||||
}
|
||||
|
||||
/// A plain character delete is not a kill — readline keeps the two apart,
|
||||
/// so backspacing must not clobber the word ⌃W stashed a moment ago.
|
||||
#[test]
|
||||
fn character_deletes_leave_the_kill_buffer_alone() {
|
||||
let mut e = ed("git push origin", 15);
|
||||
e.delete_word_left();
|
||||
e.backspace();
|
||||
e.delete();
|
||||
assert_eq!(e.text(), "git push");
|
||||
e.yank();
|
||||
assert_eq!(e.text(), "git pushorigin");
|
||||
}
|
||||
|
||||
/// Nothing killed yet: ⌃Y leaves the line and the caret exactly as they
|
||||
/// were rather than inserting an empty string.
|
||||
#[test]
|
||||
fn yank_without_a_kill_does_nothing() {
|
||||
let mut e = ed("hello", 3);
|
||||
e.yank();
|
||||
assert_eq!((e.text().as_str(), e.cursor()), ("hello", 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_to_start_and_end() {
|
||||
let mut s = ed("hello world", 6);
|
||||
|
||||
+565
-42
@@ -340,6 +340,12 @@ pub struct TerminalView {
|
||||
/// The in-progress line saved when history navigation starts, so pressing ↓
|
||||
/// past the newest entry restores what the user was typing.
|
||||
history_stash: String,
|
||||
/// Position of a run of ⌥. presses (readline's `yank-last-arg`): which
|
||||
/// `history` entry the last press took its word from, and the char span it
|
||||
/// left in the line — the next press replaces that span with the word from
|
||||
/// the entry before it. Any other key clears this, so the following ⌥.
|
||||
/// starts a fresh walk at the newest entry.
|
||||
last_word_nav: Option<LastWordWalk>,
|
||||
/// A submitted command whose history-file record is deferred until the
|
||||
/// shell reports back at its prompt, so the record can carry the command's
|
||||
/// exit code (see [`PendingHistory`]).
|
||||
@@ -448,6 +454,23 @@ struct PendingHistory {
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
/// Where a run of ⌥. presses has walked to (see
|
||||
/// [`TerminalView::last_word_nav`]).
|
||||
struct LastWordWalk {
|
||||
/// Index into `history` the last press took its word from.
|
||||
entry: usize,
|
||||
/// Char offset of the word it inserted — the next press swaps that span
|
||||
/// for the word from an older entry.
|
||||
at: usize,
|
||||
/// The word itself: both the span's length and a fingerprint. Edits that
|
||||
/// bypass `handle_editor_key` (IME-committed text, a paste, a completion
|
||||
/// pick, ⌘Z) can't clear `last_word_nav`, so before resuming, the walk
|
||||
/// checks the line still holds this word at `at` with the caret at its
|
||||
/// end — anything else means an edit intervened and the walk starts over
|
||||
/// rather than eating it.
|
||||
word: String,
|
||||
}
|
||||
|
||||
/// Seconds since the unix epoch — the timestamp history records carry.
|
||||
fn unix_now() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
@@ -1172,6 +1195,7 @@ impl TerminalView {
|
||||
ranked_cwd: None,
|
||||
history_nav: None,
|
||||
history_stash: String::new(),
|
||||
last_word_nav: None,
|
||||
pending_history: None,
|
||||
completion: None,
|
||||
completion_generation: 0,
|
||||
@@ -1655,11 +1679,7 @@ impl TerminalView {
|
||||
// Keep the cursor solid while typing (resets the blink phase).
|
||||
self.cursor_visible = true;
|
||||
// Typing clears the selection and jumps to the prompt.
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
// Consume so the key isn't also re-sent through the IME path.
|
||||
cx.stop_propagation();
|
||||
@@ -1776,11 +1796,43 @@ impl TerminalView {
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
self.cursor_visible = true;
|
||||
// The raw key path does this per keystroke; the editor owns the keyboard
|
||||
// at the prompt and every arm below returns early, so it has to happen
|
||||
// once here instead. Without it a key pressed while scrolled up edits a
|
||||
// line the viewport isn't showing (#208).
|
||||
self.jump_to_prompt();
|
||||
|
||||
// ⌃P / ⌃N are readline's spelling of ↑ / ↓ (0x10 / 0x0e on the wire, and
|
||||
// what the shell's own keymap answers when the editor isn't holding the
|
||||
// line). Rewrite them into the arrow keys here rather than giving them
|
||||
// arms of their own, so the two spellings can't drift apart — history
|
||||
// recall, multi-line steps, the completion picker and the reverse-search
|
||||
// menu all treat them identically from this point down.
|
||||
let aliased;
|
||||
let ks = if m.control && !m.platform && !m.alt && matches!(key, "p" | "n") {
|
||||
aliased = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers::default(),
|
||||
key: if key == "p" { "up" } else { "down" }.to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
&aliased
|
||||
} else {
|
||||
ks
|
||||
};
|
||||
let m = &ks.modifiers;
|
||||
let key = ks.key.as_str();
|
||||
|
||||
// Any key other than a vertical step drops the sticky goal column, so the
|
||||
// next ↑/↓ takes its column from wherever the caret ends up.
|
||||
if key != "up" && key != "down" {
|
||||
self.editor_goal_col = None;
|
||||
}
|
||||
// Likewise, only a repeat of ⌥. continues an insert-last-word walk —
|
||||
// anything else and the next press starts fresh at the newest entry
|
||||
// rather than swallowing whatever now sits left of the caret.
|
||||
if !(m.alt && key == ".") {
|
||||
self.last_word_nav = None;
|
||||
}
|
||||
|
||||
// A reverse search, when active, owns the keyboard.
|
||||
if self.reverse_search.is_some() {
|
||||
@@ -1845,8 +1897,9 @@ impl TerminalView {
|
||||
self.close_completion();
|
||||
|
||||
// Readline-style control combinations, delegated so this dispatcher stays
|
||||
// scannable. Every Ctrl chord is swallowed at the prompt (recognized or
|
||||
// not), so this always notifies and returns.
|
||||
// scannable. A chord the editor answers is consumed here; one it doesn't
|
||||
// goes on to the shell rather than dying at the prompt. Either way this
|
||||
// branch returns.
|
||||
if m.control && !m.platform && !m.alt {
|
||||
// Off macOS, word navigation and deletion live on Ctrl (the Windows /
|
||||
// Linux convention): Ctrl+←/→ move by word (Shift extends the
|
||||
@@ -1901,19 +1954,33 @@ impl TerminalView {
|
||||
self.handoff_line_to_shell(&[0x12], cx);
|
||||
return;
|
||||
}
|
||||
self.apply_readline_ctrl(key);
|
||||
cx.notify();
|
||||
if self.apply_readline_ctrl(key) {
|
||||
cx.notify();
|
||||
} else if let Some(bytes) = super::input::keystroke_to_bytes(ks, self.kitty_flags()) {
|
||||
// No local widget answers this chord. Swallowing it is the one
|
||||
// thing we mustn't do — the key worked before shell integration
|
||||
// engaged, and zle's keymap (⌃T transpose, a `bindkey` widget,
|
||||
// an fzf binding…) still knows what to do with it.
|
||||
self.handoff_line_to_shell(&bytes, cx);
|
||||
} else {
|
||||
cx.notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Readline-style Meta word chords on the edited line: M-b / M-f motions
|
||||
// and M-d delete-word, mirroring the Alt+←/→/Delete handling below. On
|
||||
// macOS these are reachable only with `macos_option_as_alt` on — with it
|
||||
// off the chord composes a character upstream and arrives here altless,
|
||||
// through the printable-text arm. Other Alt+letter chords stay swallowed
|
||||
// no-ops as before (the local editor can't mirror every zle widget).
|
||||
// Readline-style Meta chords on the edited line: M-b / M-f motions,
|
||||
// M-d delete-word (mirroring the Alt+←/→/Delete handling below) and
|
||||
// M-. insert-last-word. On macOS these are reachable only with
|
||||
// `macos_option_as_alt` on — with it off the chord composes a character
|
||||
// upstream and arrives here altless, through the printable-text arm.
|
||||
// Meta chords with no arm here reach the shell instead of dying (see
|
||||
// the fallthrough at the bottom of the dispatcher).
|
||||
if m.alt && !m.platform && !m.control {
|
||||
match key {
|
||||
"." => {
|
||||
self.insert_last_word(cx);
|
||||
return;
|
||||
}
|
||||
"b" => {
|
||||
self.editor_move_h(false, m.shift, true);
|
||||
cx.notify();
|
||||
@@ -2038,7 +2105,7 @@ impl TerminalView {
|
||||
// events carrying `key_char`; feed them through the same commit path
|
||||
// the IME would use so the local editor sees the text. Skip control /
|
||||
// Cmd chords and any non-printable char (function keys have no
|
||||
// `key_char`; Alt combos stay editor no-ops as before).
|
||||
// `key_char`).
|
||||
_ => {
|
||||
if !m.control && !m.platform && !m.alt {
|
||||
if let Some(ch) = ks.key_char.as_deref() {
|
||||
@@ -2048,6 +2115,29 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Meta chord with nothing local behind it (M-t transpose-word,
|
||||
// M-u/M-l/M-c case widgets, whatever the user bound) goes to the
|
||||
// shell rather than dying here — same reasoning as the Ctrl side
|
||||
// above. The shared encoder goes first (it knows the shifted
|
||||
// character and the Kitty form when `key_char` is there to
|
||||
// consult), but the platforms that deliver Alt chords at all
|
||||
// don't reliably carry one — then fall back to ESC + the key
|
||||
// name, uppercased under Shift, as a raw terminal would send.
|
||||
if m.alt && !m.control && !m.platform && key.chars().count() == 1 {
|
||||
let bytes = super::input::keystroke_to_bytes(ks, self.kitty_flags())
|
||||
.unwrap_or_else(|| {
|
||||
let name = if m.shift {
|
||||
key.to_uppercase()
|
||||
} else {
|
||||
key.to_string()
|
||||
};
|
||||
let mut b = vec![0x1b];
|
||||
b.extend_from_slice(name.as_bytes());
|
||||
b
|
||||
});
|
||||
self.handoff_line_to_shell(&bytes, cx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
cx.notify();
|
||||
@@ -2055,14 +2145,18 @@ impl TerminalView {
|
||||
|
||||
/// Apply a readline-style Ctrl chord to the command editor: Ctrl-A/E/B/F
|
||||
/// motions (Ctrl-F also accepts the autosuggestion), Ctrl-W/U/K/H deletions
|
||||
/// (each removing the selection first if there is one), Ctrl-L clear-screen,
|
||||
/// Ctrl-R reverse search, Ctrl-C interrupt, and Ctrl-D EOF/forward-delete.
|
||||
/// Unrecognized chords are no-ops (the caller swallows every Ctrl combo at
|
||||
/// the prompt regardless).
|
||||
/// (each removing the selection first if there is one), Ctrl-Y yanking the
|
||||
/// last kill back, Ctrl-L clear-screen, Ctrl-R reverse search, Ctrl-C
|
||||
/// interrupt, and Ctrl-D EOF/forward-delete.
|
||||
///
|
||||
/// The caller resolves Ctrl-J / Ctrl-M (accept-line) and, when the history
|
||||
/// menu is switched off, Ctrl-R before this point — neither reaches here.
|
||||
fn apply_readline_ctrl(&mut self, key: &str) {
|
||||
/// Returns whether the chord was recognized: the caller hands the ones that
|
||||
/// weren't to the shell, so a widget tty7 has no answer for still reaches
|
||||
/// the keymap that does.
|
||||
///
|
||||
/// The caller resolves Ctrl-J / Ctrl-M (accept-line), Ctrl-P / Ctrl-N (the
|
||||
/// arrow keys by another name) and, when the history menu is switched off,
|
||||
/// Ctrl-R before this point — none of them reach here.
|
||||
fn apply_readline_ctrl(&mut self, key: &str) -> bool {
|
||||
match key {
|
||||
"r" => self.start_reverse_search(),
|
||||
"a" => {
|
||||
@@ -2103,6 +2197,10 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
"h" => self.cmd.backspace(),
|
||||
// Yank: the other half of ⌃W / ⌃U / ⌃K. Answered locally rather
|
||||
// than handed to the shell — zle keeps its own kill ring, and
|
||||
// yanking from it would paste text this editor never cut.
|
||||
"y" => self.cmd.yank(),
|
||||
"l" => {
|
||||
// Clear screen belongs to the shell/readline layer: send the
|
||||
// same form-feed byte the raw terminal path emits for Ctrl+L.
|
||||
@@ -2133,8 +2231,9 @@ impl TerminalView {
|
||||
self.cmd.delete();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Horizontal caret motion in the editor with selection semantics: Shift
|
||||
@@ -2272,6 +2371,19 @@ impl TerminalView {
|
||||
super::input::tab_bytes(shift, self.kitty_flags())
|
||||
}
|
||||
|
||||
/// The housekeeping every input path shares: drop the selection the key
|
||||
/// invalidated and bring the viewport back to the live prompt, whole lines
|
||||
/// (`display_offset`) and sub-line remainder (`scroll_frac`) alike. Acting
|
||||
/// on a line the user can't see is the thing to avoid — so this runs for
|
||||
/// keys handled locally too, not only for bytes that reach the PTY.
|
||||
fn jump_to_prompt(&mut self) {
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
drop(term);
|
||||
self.scroll_frac = 0.;
|
||||
}
|
||||
|
||||
/// Write a fixed byte sequence to the PTY (for keystrokes delivered as
|
||||
/// actions rather than through `on_key_down`, e.g. Tab / Shift-Tab), applying
|
||||
/// the same cursor / selection / scroll housekeeping as normal typing.
|
||||
@@ -2281,11 +2393,7 @@ impl TerminalView {
|
||||
}
|
||||
self.terminal.write(bytes.to_vec());
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -3500,11 +3608,72 @@ impl TerminalView {
|
||||
self.terminal.write(submit_bytes(&line, bracketed));
|
||||
self.cmd.clear();
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Readline's `yank-last-arg` (⌥.): drop the last word of the previous
|
||||
/// command at the caret. Repeating the chord walks further back through the
|
||||
/// history, each press swapping out the word the one before it inserted, so
|
||||
/// a run of presses leaves exactly one word behind. Entries with no words
|
||||
/// are stepped over rather than inserting nothing.
|
||||
fn insert_last_word(&mut self, cx: &mut Context<Self>) {
|
||||
// Only trust the recorded walk while the line still shows it: its word
|
||||
// sitting at `at`, caret at the word's end, nothing selected. The keys
|
||||
// this dispatcher sees reset `last_word_nav` themselves, but edits that
|
||||
// bypass it (IME-committed text, a paste, a completion pick, ⌘Z) don't
|
||||
// — resuming over those would delete text the walk never inserted.
|
||||
let resumed = self.last_word_nav.take().filter(|walk| {
|
||||
let len = walk.word.chars().count();
|
||||
self.cmd.cursor() == walk.at + len
|
||||
&& self.cmd.selection().is_none()
|
||||
&& self
|
||||
.cmd
|
||||
.text()
|
||||
.chars()
|
||||
.skip(walk.at)
|
||||
.take(len)
|
||||
.eq(walk.word.chars())
|
||||
});
|
||||
// A repeat resumes one entry older than the last press; a fresh walk
|
||||
// starts at the newest entry.
|
||||
let start = match &resumed {
|
||||
Some(walk) => walk.entry.checked_sub(1),
|
||||
None => self.history.len().checked_sub(1),
|
||||
};
|
||||
let Some(mut entry) = start else {
|
||||
// Nothing older to reach (or no history at all) — leave the line as
|
||||
// it stands, the word the previous press inserted included.
|
||||
self.last_word_nav = resumed;
|
||||
return;
|
||||
};
|
||||
let word = loop {
|
||||
if let Some(w) = self.history[entry].split_whitespace().next_back() {
|
||||
break w.to_string();
|
||||
}
|
||||
let Some(older) = entry.checked_sub(1) else {
|
||||
self.last_word_nav = resumed;
|
||||
return;
|
||||
};
|
||||
entry = older;
|
||||
};
|
||||
|
||||
// Take back what the previous press left, so the walk swaps words in
|
||||
// place rather than piling them up.
|
||||
if let Some(walk) = resumed {
|
||||
self.cmd.clear_selection();
|
||||
self.cmd.set_cursor(walk.at);
|
||||
self.cmd.extend_to(walk.at + walk.word.chars().count());
|
||||
self.cmd.delete_selection();
|
||||
}
|
||||
self.cmd.insert_str(&word);
|
||||
// `insert_str` replaces a live selection first, which moves the caret
|
||||
// to the selection's start — so the word's position is wherever the
|
||||
// caret landed minus the word, not the pre-insert cursor.
|
||||
let at = self.cmd.cursor() - word.chars().count();
|
||||
self.last_word_nav = Some(LastWordWalk { entry, at, word });
|
||||
// The line is now the user's own edit, not a recalled entry.
|
||||
self.history_nav = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -4228,6 +4397,9 @@ impl TerminalView {
|
||||
self.cmd.insert_str(text);
|
||||
self.history_nav = None;
|
||||
self.editor_goal_col = None;
|
||||
// Typed text ends an ⌥. run: IME-committed text bypasses
|
||||
// `handle_editor_key`'s reset, so it has to happen here too.
|
||||
self.last_word_nav = None;
|
||||
self.completion_refilter();
|
||||
self.cursor_visible = true;
|
||||
cx.notify();
|
||||
@@ -4239,11 +4411,7 @@ impl TerminalView {
|
||||
self.write_gap_text(text, text.as_bytes().to_vec(), cx);
|
||||
// Keep the cursor solid while committing input (resets the blink phase).
|
||||
self.cursor_visible = true;
|
||||
let mut term = self.terminal.term.lock();
|
||||
term.selection = None;
|
||||
term.scroll_display(Scroll::Bottom);
|
||||
self.scroll_frac = 0.;
|
||||
drop(term);
|
||||
self.jump_to_prompt();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
@@ -7926,10 +8094,365 @@ mod gpui_tests {
|
||||
assert_eq!(view.cmd.cursor(), 0);
|
||||
view.handle_editor_key(&meta("f"), cx);
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
// Other Meta letters stay swallowed no-ops (line untouched).
|
||||
// Other Meta letters have no local widget, so they hand the line
|
||||
// to the shell rather than dying here — see
|
||||
// `an_unknown_meta_chord_goes_to_the_shell_with_the_line`.
|
||||
view.handle_editor_key(&meta("z"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ");
|
||||
assert_eq!(view.cmd.cursor(), 4);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Fill the scrollback and park the viewport `offset` lines up inside it,
|
||||
/// so a test can watch a keystroke snap it back to the live prompt.
|
||||
fn scroll_into_history(view: &TerminalView, offset: usize) {
|
||||
let mut parser: alacritty_terminal::vte::ansi::Processor = Default::default();
|
||||
let mut term = view.terminal.term.lock();
|
||||
parser.advance(&mut *term, &b"line\r\n".repeat(60));
|
||||
term.scroll_display(Scroll::Delta(offset as i32));
|
||||
assert_eq!(
|
||||
term.grid().display_offset(),
|
||||
offset,
|
||||
"the viewport starts parked in the scrollback"
|
||||
);
|
||||
}
|
||||
|
||||
fn display_offset(view: &TerminalView) -> usize {
|
||||
view.terminal.term.lock().grid().display_offset()
|
||||
}
|
||||
|
||||
/// Scrolled up into the scrollback, recalling history with ↑ must bring the
|
||||
/// viewport back to the live prompt (#208). The local editor owns ↑ and
|
||||
/// returns early, so it never reached the "typing jumps to the prompt"
|
||||
/// housekeeping on the raw key path — leaving the user editing a line they
|
||||
/// cannot see.
|
||||
#[gpui::test]
|
||||
fn history_recall_snaps_the_viewport_back_to_the_prompt(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = vec!["echo hello".to_string()];
|
||||
scroll_into_history(view, 10);
|
||||
view.scroll_frac = 0.5;
|
||||
|
||||
view.handle_editor_key(&key("up"), cx);
|
||||
|
||||
assert_eq!(view.cmd.text(), "echo hello", "↑ recalled the entry");
|
||||
assert_eq!(display_offset(view), 0, "and the viewport followed it down");
|
||||
assert_eq!(view.scroll_frac, 0., "the sub-line remainder reset too");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌃P / ⌃N are readline's history motions, and a raw terminal passes them
|
||||
/// to the shell as 0x10 / 0x0e. The local editor swallows every Ctrl chord
|
||||
/// at the prompt, so without arms of their own they went from "works" to
|
||||
/// "does nothing" the moment shell integration engaged.
|
||||
#[gpui::test]
|
||||
fn ctrl_p_and_ctrl_n_walk_the_history(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.history = ["git status", "cargo build", "echo hello"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
// ⌃P walks back from the newest entry.
|
||||
view.handle_editor_key(&key("ctrl-p"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello");
|
||||
view.handle_editor_key(&key("ctrl-p"), cx);
|
||||
assert_eq!(view.cmd.text(), "cargo build");
|
||||
// ⌃N walks forward again.
|
||||
view.handle_editor_key(&key("ctrl-n"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello");
|
||||
// Past the newest entry the in-progress line comes back.
|
||||
view.handle_editor_key(&key("ctrl-n"), cx);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A Ctrl chord the local editor has no widget for used to be swallowed, so
|
||||
/// engaging shell integration *removed* ⌃T, ⌥T, ⌥U and every `bindkey`
|
||||
/// widget the user had bound. Hand the line to zle instead and let its
|
||||
/// keymap answer — the same escape hatch ⌃R already uses.
|
||||
#[gpui::test]
|
||||
fn an_unknown_ctrl_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
// ⌃T is readline's transpose-chars; tty7 has no widget for it.
|
||||
view.handle_editor_key(&key("ctrl-t"), cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"",
|
||||
"the line left for the shell, so the local buffer is empty"
|
||||
);
|
||||
assert!(
|
||||
view.editor_handoff.is_some(),
|
||||
"the local editor stands down for the rest of the line"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), vec![0x14], "⌃T reached the shell");
|
||||
}
|
||||
|
||||
/// The Meta half of the same gap: ⌥U (upcase-word) and friends were dead at
|
||||
/// the prompt. Unrecognized Meta chords ship the line and the ESC-prefixed
|
||||
/// key, the way a raw terminal would have.
|
||||
#[gpui::test]
|
||||
fn an_unknown_meta_chord_goes_to_the_shell_with_the_line(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(
|
||||
&gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: "u".to_string(),
|
||||
key_char: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), b"\x1bu".to_vec());
|
||||
}
|
||||
|
||||
/// ⌃W / ⌃U / ⌃K are *kills*, and ⌃Y is what puts a kill back — without it
|
||||
/// the pair was half-implemented: the editor cut text with nowhere to paste
|
||||
/// it from. ⌃Y has to stay local rather than reaching the shell, because
|
||||
/// zle's kill ring is a different buffer and would yank unrelated text.
|
||||
#[gpui::test]
|
||||
fn ctrl_y_yanks_back_what_the_kill_chords_cut(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hello world");
|
||||
view.handle_editor_key(&key("ctrl-w"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello ");
|
||||
view.handle_editor_key(&key("ctrl-y"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo hello world");
|
||||
assert!(
|
||||
view.editor_handoff.is_none(),
|
||||
"the line never left for the shell"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌥. is readline's `yank-last-arg`: it pulls the last word of the previous
|
||||
/// command into the line, and repeating it walks further back through the
|
||||
/// history, replacing what the last press inserted. Frequent enough that
|
||||
/// paying the handoff cost (ghost text and completion gone for the rest of
|
||||
/// the line) on every press would be the wrong trade — tty7 holds the same
|
||||
/// history, so it answers locally.
|
||||
#[gpui::test]
|
||||
fn meta_dot_walks_back_through_the_last_words(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["git status", "cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.cmd.set("ls ");
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls world", "newest entry's last word");
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls --release", "repeat steps one back");
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls status");
|
||||
// Nothing older to reach: the line holds what it had.
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "ls status");
|
||||
// The caret sits after the inserted word, ready to keep typing.
|
||||
assert_eq!(view.cmd.cursor(), "ls status".chars().count());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The walk is only a walk while ⌥. repeats. Once another key edits the
|
||||
/// line, the next ⌥. starts over from the newest entry instead of eating
|
||||
/// whatever happens to sit left of the caret.
|
||||
#[gpui::test]
|
||||
fn an_intervening_key_restarts_the_last_word_walk(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "world");
|
||||
view.handle_editor_key(&key("left"), cx);
|
||||
view.handle_editor_key(&key("end"), cx);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"worldworld",
|
||||
"a fresh walk appends rather than replacing the earlier word"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Edits that bypass `handle_editor_key` — IME-committed text is the
|
||||
/// everyday one (it's how all typing arrives on macOS and Windows) — must
|
||||
/// end the walk too. Without that, the next ⌥. deletes the span the walk
|
||||
/// recorded even though the user's typing now sits inside it.
|
||||
#[gpui::test]
|
||||
fn an_intervening_ime_commit_restarts_the_last_word_walk(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
// `commit_text` edits the local line only while the editor is engaged
|
||||
// at a shell prompt; anywhere else it writes gap text to the PTY.
|
||||
DaemonMsg::Prompt {
|
||||
active: true,
|
||||
at_prompt: true,
|
||||
last_exit: None,
|
||||
}
|
||||
.encode(&mut daemon)
|
||||
.unwrap();
|
||||
wait_for_input_active(&window, cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(view.cmd.text(), "world");
|
||||
view.commit_text("x", cx);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"worldxworld",
|
||||
"the typed char survives; the walk starts over after it"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// ⌥. with a selection active: the word replaces the selection (insertion
|
||||
/// replaces selections everywhere in this editor), and the walk records
|
||||
/// where the word actually landed — the caret the selection collapsed to,
|
||||
/// not where the caret stood before the insert — so a repeat swaps the
|
||||
/// word cleanly.
|
||||
#[gpui::test]
|
||||
fn meta_dot_over_a_selection_records_where_the_word_landed(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
let meta_dot = gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: ".".to_string(),
|
||||
key_char: None,
|
||||
};
|
||||
view.history = ["cargo build --release", "echo hello world"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect();
|
||||
view.cmd.set("ls foo");
|
||||
// Select "foo" with the caret at the selection's far end.
|
||||
view.cmd.set_cursor(3);
|
||||
view.cmd.extend_to(6);
|
||||
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"ls world",
|
||||
"the word replaced the selection"
|
||||
);
|
||||
view.handle_editor_key(&meta_dot, cx);
|
||||
assert_eq!(
|
||||
view.cmd.text(),
|
||||
"ls --release",
|
||||
"the repeat swapped the word, not some other span"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A shifted Meta chord must ship the shifted character: ⌥⇧U is `ESC U`
|
||||
/// on the wire (upcase-region in zsh's keymap), not the `ESC u` of plain
|
||||
/// ⌥U — gpui reports the key name unshifted, so the handoff has to apply
|
||||
/// Shift itself when no `key_char` is there to consult.
|
||||
#[gpui::test]
|
||||
fn a_shifted_meta_chord_hands_off_the_shifted_character(cx: &mut TestAppContext) {
|
||||
let (window, mut daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(
|
||||
&gpui::Keystroke {
|
||||
modifiers: gpui::Modifiers {
|
||||
alt: true,
|
||||
shift: true,
|
||||
..Default::default()
|
||||
},
|
||||
key: "u".to_string(),
|
||||
key_char: None,
|
||||
},
|
||||
cx,
|
||||
);
|
||||
assert_eq!(view.cmd.text(), "");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(next_input(&mut daemon), b"echo hi".to_vec());
|
||||
assert_eq!(next_input(&mut daemon), b"\x1bU".to_vec());
|
||||
}
|
||||
|
||||
/// Chords the editor *does* answer stay local — handing off would forfeit
|
||||
/// ghost text and completion for the rest of the line, and ⌃A/⌃E/⌃W are
|
||||
/// exactly the keys pressed most often mid-edit.
|
||||
#[gpui::test]
|
||||
fn a_known_ctrl_chord_stays_in_the_local_editor(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
view.cmd.set("echo hi");
|
||||
view.handle_editor_key(&key("ctrl-w"), cx);
|
||||
assert_eq!(view.cmd.text(), "echo ", "⌃W cut the word locally");
|
||||
assert!(view.editor_handoff.is_none());
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user