The rail showed a frozen attention count over a worktree list backed by a crate that does not exist. What replaces it starts from a question rather than from a list of things we happened to know: what needs me, what am I running, what did I set aside, where does it run and what will stop me. Layout is data. sidebar/model.rs holds no renderer types, so which sections exist, what collapsing hides, what the filter keeps, and where the keyboard cursor lands are ordinary tests in CI; sidebar/view.rs renders and decides nothing. Eleven model tests, none of which need a window. The cursor is a RowKey rather than an index, because an index is wrong the moment a row above it disappears and silently pointing at a different row is worse than losing the cursor. Every header renders even when its section is empty, so positions never move under the pointer. The filter's empty state does not quote what was typed — the sidebar is the part of the window people screenshot. One selection language everywhere: before this, attention cards darkened on hover while worktree rows lightened, so the same gesture meant two different things a hundred pixels apart. Two defects the screenshots caught that review had not. Flexbox shrinks proportionally, so the longer string wins: the attention row rendered as "Te… Exited with code 7 · observed", having discarded the one word that says which pane to look at. And three quota rows all read "CLAUDE CODE" with the scope truncated away, naming the same thing three times and identifying none of them. Titles now have a floor and the harness name prints once per group. WORKSPACE is deliberately flat: a Repository → Worktree → Pane tree would need lumbridge-git, and every level above Pane would be a second fixture. The depth field and disclosure column are reserved for when it is real. HOSTS has two states, live or not — connecting and unreachable are unbuildable until lumbridge-remote exists, and shipping them would be the Buzz card again in a Rust enum. The rail drags between 200 and 480 px, applied live so the workspace reflows under the pointer; decision 0009 measures pane thresholds after the sidebar, so widening really can drop three panes to one. PTYs are resized on release only, or every mouse-move is a SIGWINCH storm through the runtime's bounded queues. While the sidebar owns the keyboard, on_key_down returns before encoding anything. Without that guard a bare `j` would be written into whatever pane happened to be selected while the user believed they were walking a list. Not persisted yet, not virtualised, and describe() has nothing to attach to until the accessibility adapter from decision 0017 lands. Recorded in 0021. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
194 lines
7.7 KiB
Rust
194 lines
7.7 KiB
Rust
//! 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, FocusSidebar, FocusUp,
|
|
JumpToAttention, OpenPalette, PasteIntoPane, RestartPane, SelectPane1, SelectPane2,
|
|
SelectPane3, SelectPane4, SelectPane5, SelectPane6, TerminalNarrower, TerminalShorter,
|
|
TerminalTaller, TerminalWider, TerminatePane, ToggleSidebar,
|
|
};
|
|
|
|
/// 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",
|
|
"secondary-alt-b",
|
|
"secondary-alt-s",
|
|
"secondary-alt-a",
|
|
];
|
|
|
|
/// 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)),
|
|
KeyBinding::new(BINDINGS[20], ToggleSidebar, Some(CONTEXT)),
|
|
KeyBinding::new(BINDINGS[21], FocusSidebar, Some(CONTEXT)),
|
|
KeyBinding::new(BINDINGS[22], JumpToAttention, 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"));
|
|
}
|
|
}
|