Make the terminal usable: control keys, paste, scroll, restart, terminate
The largest defect was not the one the plan named. Every control character was being dropped before it reached the PTY — ctrl-c, ctrl-d, ctrl-a, ctrl-r, not just ctrl-k — because terminal_key_from_parts required a key_char and GPUI reports none for a control chord, since ctrl-k produces no printable character. Verified with `cat -v`, which now prints ^K^A^R; ctrl-c interrupts a sleep and ctrl-d ends a heredoc. The engine had always encoded these correctly; nothing ever handed them to it. The binding shadowing was real too. OpenPalette was on secondary-k, which is ctrl-k on Linux, and GPUI stops dispatching once a binding claims an event, so readline's kill-line was unreachable in every pane. Pane selection sat on alt-1..6, which readline reads as a digit argument, and focus movement on alt-arrows, which is word motion in most terminals. Bindings now live in keymap.rs with the rule written down and tested: no binding may be a bare control character or a bare Meta sequence, because those are what a terminal application actually receives. A leader chord was considered and rejected — GPUI parks a chord prefix for a second and drops it if focus moves. Also in this pass: - Paste on secondary-shift-v, through the engine's bracketed-paste path so a shell that asked for bracketed paste is told this is a paste. secondary-v would have been ctrl-v, which readline reads as quoted-insert. There is no matching copy: the engine has no selection yet, and a key that copied the whole screen would not be the same feature under the same name. - A scroll wheel on the terminal surface. Shift+PageUp was the only route to scrollback, which is not something anyone guesses. - Restart and Terminate. RuntimeRegistry::shutdown existed and was called only from its own crate's tests, so nothing in the application could ever stop a PTY. Terminate is the literal words with a confirmation naming the pid, per decision 0010, never a close icon; restart keeps the pane and replaces the process, per decision 0011. - A dead or faulted pane now says so over its stale screen instead of looking idle, and typing into a pane with no terminal explains where the keystroke went instead of silently discarding it. - The twelve reachable .expect panics on live-terminal state are gone. A pane can outlive its runtime — failed spawn, terminate, restored snapshot — and every one of those paths used to be a panic in the middle of a paint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1556b87f37
commit
d76da3babb
@@ -0,0 +1,186 @@
|
||||
//! Every key Lumbridge claims, and the rule for which keys it may claim.
|
||||
//!
|
||||
//! A terminal multiplexer is a program whose main job is to *not* intercept
|
||||
//! keys. Everything it takes is taken from the shell, the editor, and the
|
||||
//! reader running inside it. So the bindings live in one table with a test that
|
||||
//! enforces the rule, rather than being scattered through the shell where the
|
||||
//! next one gets added without anyone checking what it costs.
|
||||
//!
|
||||
//! **The rule: no binding may be a bare control character or a bare Meta
|
||||
//! sequence.** Those are what a terminal application actually receives.
|
||||
//!
|
||||
//! - `ctrl-<letter>` is a C0 control byte. `ctrl-k` is kill-line, `ctrl-a`
|
||||
//! start-of-line, `ctrl-r` reverse search, `ctrl-c` interrupt. Binding one at
|
||||
//! the window level makes it unreachable in every pane, because GPUI stops
|
||||
//! dispatching once a binding claims the event — `finish_dispatch_key_event`
|
||||
//! never runs, so there is no fallthrough to the terminal.
|
||||
//! - `alt-<key>` is an ESC-prefixed Meta sequence. `alt-f`/`alt-b` are word
|
||||
//! motion, `alt-1` is readline's digit argument, and `alt-<arrow>` is word
|
||||
//! motion in most terminals. tmux and zellij leave these alone too.
|
||||
//!
|
||||
//! What is left is `secondary-alt-…` (ctrl+alt on Linux, cmd+alt on macOS) and
|
||||
//! `secondary-shift-…`, neither of which a terminal program can receive as a
|
||||
//! distinct sequence anyway.
|
||||
//!
|
||||
//! A leader chord — tmux's `ctrl-b` — was considered and rejected: GPUI parks a
|
||||
//! pending chord prefix for a full second and discards it if focus moves, so the
|
||||
//! second key of a chord is unreliable in a multi-pane window.
|
||||
|
||||
use gpui::KeyBinding;
|
||||
|
||||
use crate::{
|
||||
AddPanel, DetachSelectedPanel, FocusDown, FocusLeft, FocusRight, FocusUp, OpenPalette,
|
||||
PasteIntoPane, RestartPane, SelectPane1, SelectPane2, SelectPane3, SelectPane4, SelectPane5,
|
||||
SelectPane6, TerminalNarrower, TerminalShorter, TerminalTaller, TerminalWider, TerminatePane,
|
||||
};
|
||||
|
||||
/// The context every binding is scoped to.
|
||||
const CONTEXT: &str = "LumbridgeShell";
|
||||
|
||||
/// Every keystroke Lumbridge claims, as written for `KeyBinding::new`.
|
||||
///
|
||||
/// Kept as strings so the test below can inspect them. A binding that is not in
|
||||
/// this table does not exist.
|
||||
pub(crate) const BINDINGS: &[&str] = &[
|
||||
"secondary-shift-p",
|
||||
"secondary-alt-left",
|
||||
"secondary-alt-right",
|
||||
"secondary-alt-up",
|
||||
"secondary-alt-down",
|
||||
"secondary-alt-1",
|
||||
"secondary-alt-2",
|
||||
"secondary-alt-3",
|
||||
"secondary-alt-4",
|
||||
"secondary-alt-5",
|
||||
"secondary-alt-6",
|
||||
"secondary-alt-n",
|
||||
"secondary-alt-w",
|
||||
"secondary-alt-shift-up",
|
||||
"secondary-alt-shift-down",
|
||||
"secondary-alt-shift-right",
|
||||
"secondary-alt-shift-left",
|
||||
"secondary-alt-r",
|
||||
"secondary-alt-x",
|
||||
// The terminal convention, and the only safe spelling: `secondary-v` is
|
||||
// ctrl-v on Linux, which readline reads as quoted-insert.
|
||||
"secondary-shift-v",
|
||||
];
|
||||
|
||||
/// Builds the bindings in the same order as [`BINDINGS`].
|
||||
pub(crate) fn bindings() -> Vec<KeyBinding> {
|
||||
vec![
|
||||
// Was `secondary-k`, which on Linux is ctrl-k — readline's kill-line,
|
||||
// unreachable in every pane for as long as that binding existed.
|
||||
KeyBinding::new(BINDINGS[0], OpenPalette, Some(CONTEXT)),
|
||||
// Was `alt-<arrow>`: word motion in most terminals.
|
||||
KeyBinding::new(BINDINGS[1], FocusLeft, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[2], FocusRight, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[3], FocusUp, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[4], FocusDown, Some(CONTEXT)),
|
||||
// Was `alt-1..6`: readline reads those as a digit argument.
|
||||
KeyBinding::new(BINDINGS[5], SelectPane1, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[6], SelectPane2, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[7], SelectPane3, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[8], SelectPane4, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[9], SelectPane5, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[10], SelectPane6, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[11], AddPanel, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[12], DetachSelectedPanel, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[13], TerminalTaller, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[14], TerminalShorter, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[15], TerminalWider, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[16], TerminalNarrower, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[17], RestartPane, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[18], TerminatePane, Some(CONTEXT)),
|
||||
KeyBinding::new(BINDINGS[19], PasteIntoPane, Some(CONTEXT)),
|
||||
]
|
||||
}
|
||||
|
||||
/// Splits a binding into its modifiers and its key.
|
||||
#[cfg(test)]
|
||||
fn parts(binding: &str) -> (Vec<&str>, &str) {
|
||||
let mut segments: Vec<&str> = binding.split('-').collect();
|
||||
// A trailing empty segment means the key itself is `-`.
|
||||
let key = if segments.last() == Some(&"") {
|
||||
segments.pop();
|
||||
segments.pop();
|
||||
"-"
|
||||
} else {
|
||||
segments.pop().unwrap_or("")
|
||||
};
|
||||
(segments, key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BINDINGS, bindings, parts};
|
||||
|
||||
#[test]
|
||||
fn no_binding_shadows_a_control_character() {
|
||||
// `secondary` is ctrl on Linux. secondary+letter with no other modifier
|
||||
// is a C0 control byte the terminal needs: ctrl-k kill-line, ctrl-a
|
||||
// start-of-line, ctrl-r reverse-search, ctrl-w kill-word, ctrl-c.
|
||||
for binding in BINDINGS {
|
||||
let (modifiers, key) = parts(binding);
|
||||
let bare_secondary = modifiers == ["secondary"];
|
||||
assert!(
|
||||
!(bare_secondary && key.len() == 1 && key.chars().all(|c| c.is_ascii_alphabetic())),
|
||||
"{binding} shadows a control character the terminal needs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_binding_shadows_a_meta_sequence() {
|
||||
// A bare alt+key is ESC-prefixed Meta: readline word motion, digit
|
||||
// arguments, and vim's own Meta bindings all arrive this way.
|
||||
for binding in BINDINGS {
|
||||
let (modifiers, _) = parts(binding);
|
||||
assert_ne!(
|
||||
modifiers,
|
||||
["alt"],
|
||||
"{binding} shadows an ESC-prefixed Meta sequence"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_binding_claims_a_key_a_terminal_cannot_live_without() {
|
||||
// Even with modifiers, these must never be claimed at window level.
|
||||
for binding in BINDINGS {
|
||||
let (_, key) = parts(binding);
|
||||
assert!(
|
||||
!matches!(key, "escape" | "tab" | "enter" | "backspace"),
|
||||
"{binding} claims {key}, which every terminal application needs"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_binding_is_unique() {
|
||||
let mut seen = Vec::new();
|
||||
for binding in BINDINGS {
|
||||
assert!(!seen.contains(binding), "{binding} is bound twice");
|
||||
seen.push(binding);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_table_and_the_builder_stay_in_step() {
|
||||
assert_eq!(
|
||||
bindings().len(),
|
||||
BINDINGS.len(),
|
||||
"every entry in BINDINGS must build exactly one binding"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parts_splits_modifiers_from_the_key() {
|
||||
assert_eq!(
|
||||
parts("secondary-shift-p"),
|
||||
(vec!["secondary", "shift"], "p")
|
||||
);
|
||||
assert_eq!(parts("alt-left"), (vec!["alt"], "left"));
|
||||
assert_eq!(parts("escape"), (Vec::new(), "escape"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user