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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+355
-104
@@ -1,3 +1,4 @@
|
|||||||
|
mod keymap;
|
||||||
mod panel_registry;
|
mod panel_registry;
|
||||||
mod theme;
|
mod theme;
|
||||||
mod usage_feed;
|
mod usage_feed;
|
||||||
@@ -7,9 +8,8 @@ use std::path::PathBuf;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyDownEvent, Pixels, Rgba, Size,
|
||||||
Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb,
|
Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
||||||
size,
|
|
||||||
};
|
};
|
||||||
use lumbridge_core::UsageProvenance;
|
use lumbridge_core::UsageProvenance;
|
||||||
use lumbridge_runtime::{
|
use lumbridge_runtime::{
|
||||||
@@ -56,6 +56,9 @@ actions!(
|
|||||||
FocusDown,
|
FocusDown,
|
||||||
OpenPalette,
|
OpenPalette,
|
||||||
ClosePalette,
|
ClosePalette,
|
||||||
|
RestartPane,
|
||||||
|
TerminatePane,
|
||||||
|
PasteIntoPane,
|
||||||
SelectPane1,
|
SelectPane1,
|
||||||
SelectPane2,
|
SelectPane2,
|
||||||
SelectPane3,
|
SelectPane3,
|
||||||
@@ -88,6 +91,10 @@ struct LumbridgeShell {
|
|||||||
/// inert by design: it prepares nothing and runs nothing.
|
/// inert by design: it prepares nothing and runs nothing.
|
||||||
shelf_choice: BTreeMap<PanelId, usize>,
|
shelf_choice: BTreeMap<PanelId, usize>,
|
||||||
add_panel_chooser_open: bool,
|
add_panel_chooser_open: bool,
|
||||||
|
/// The pane a terminate has been asked for but not yet confirmed.
|
||||||
|
pending_terminate: Option<PanelId>,
|
||||||
|
/// Why the last keystroke went nowhere, if it did.
|
||||||
|
input_gap: Option<String>,
|
||||||
root_focus: FocusHandle,
|
root_focus: FocusHandle,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +710,8 @@ impl LumbridgeShell {
|
|||||||
surfaces: BTreeMap::new(),
|
surfaces: BTreeMap::new(),
|
||||||
shelf_choice: BTreeMap::new(),
|
shelf_choice: BTreeMap::new(),
|
||||||
add_panel_chooser_open: false,
|
add_panel_chooser_open: false,
|
||||||
|
pending_terminate: None,
|
||||||
|
input_gap: None,
|
||||||
root_focus,
|
root_focus,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -751,19 +760,17 @@ impl LumbridgeShell {
|
|||||||
Ok(Some(event)) => event,
|
Ok(Some(event)) => event,
|
||||||
Ok(None) => break,
|
Ok(None) => break,
|
||||||
Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
|
Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status =
|
||||||
.expect("live terminal state exists")
|
|
||||||
.status =
|
|
||||||
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
||||||
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
.expect("live terminal state exists")
|
}
|
||||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
|
||||||
changed = true;
|
changed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -774,23 +781,21 @@ impl LumbridgeShell {
|
|||||||
session_id,
|
session_id,
|
||||||
process_id,
|
process_id,
|
||||||
} => {
|
} => {
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status = LiveRuntimeStatus::Running {
|
||||||
.expect("live terminal state exists")
|
|
||||||
.status = LiveRuntimeStatus::Running {
|
|
||||||
session_id: session_id.get(),
|
session_id: session_id.get(),
|
||||||
process_id,
|
process_id,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
RuntimeEvent::Output {
|
RuntimeEvent::Output {
|
||||||
sequence, bytes, ..
|
sequence, bytes, ..
|
||||||
} => {
|
} => {
|
||||||
let responses = {
|
let responses = {
|
||||||
let terminal = self
|
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||||
.live_terminals
|
break;
|
||||||
.get_mut(&pane)
|
};
|
||||||
.expect("live terminal state exists");
|
|
||||||
if sequence <= terminal.last_runtime_sequence {
|
if sequence <= terminal.last_runtime_sequence {
|
||||||
terminal.status = LiveRuntimeStatus::Fault(format!(
|
terminal.status = LiveRuntimeStatus::Fault(format!(
|
||||||
"non-monotonic PTY output sequence {sequence}"
|
"non-monotonic PTY output sequence {sequence}"
|
||||||
@@ -805,10 +810,9 @@ impl LumbridgeShell {
|
|||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
self.send_runtime_command(pane, RuntimeCommand::Input(response))
|
self.send_runtime_command(pane, RuntimeCommand::Input(response))
|
||||||
{
|
{
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
.expect("live terminal state exists")
|
}
|
||||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -817,23 +821,22 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
RuntimeEvent::InputClosed { .. } => {}
|
RuntimeEvent::InputClosed { .. } => {}
|
||||||
RuntimeEvent::Exited { status, .. } => {
|
RuntimeEvent::Exited { status, .. } => {
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status = LiveRuntimeStatus::Exited(format!(
|
||||||
.expect("live terminal state exists")
|
|
||||||
.status = LiveRuntimeStatus::Exited(format!(
|
|
||||||
"PTY exited with code {}",
|
"PTY exited with code {}",
|
||||||
status.code
|
status.code
|
||||||
));
|
));
|
||||||
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
RuntimeEvent::Fault {
|
RuntimeEvent::Fault {
|
||||||
operation, message, ..
|
operation, message, ..
|
||||||
} => {
|
} => {
|
||||||
self.live_terminals
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
.get_mut(&pane)
|
terminal.status =
|
||||||
.expect("live terminal state exists")
|
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||||||
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -853,10 +856,9 @@ impl LumbridgeShell {
|
|||||||
|
|
||||||
fn publish_terminal_snapshot(&mut self, pane: PanelId) {
|
fn publish_terminal_snapshot(&mut self, pane: PanelId) {
|
||||||
let lines = {
|
let lines = {
|
||||||
let terminal = self
|
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||||
.live_terminals
|
return;
|
||||||
.get_mut(&pane)
|
};
|
||||||
.expect("live terminal state exists");
|
|
||||||
let snapshot = terminal.terminal.snapshot();
|
let snapshot = terminal.terminal.snapshot();
|
||||||
let lines = snapshot.plain_rows();
|
let lines = snapshot.plain_rows();
|
||||||
terminal.snapshot = snapshot;
|
terminal.snapshot = snapshot;
|
||||||
@@ -875,20 +877,18 @@ impl LumbridgeShell {
|
|||||||
fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool {
|
fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool {
|
||||||
let dimensions = TerminalDimensions::new(rows, columns)
|
let dimensions = TerminalDimensions::new(rows, columns)
|
||||||
.expect("resize actions always retain non-zero dimensions");
|
.expect("resize actions always retain non-zero dimensions");
|
||||||
let terminal = self
|
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||||
.live_terminals
|
return false;
|
||||||
.get_mut(&pane)
|
};
|
||||||
.expect("live terminal state exists");
|
|
||||||
if !terminal.terminal.resize(dimensions) {
|
if !terminal.terminal.resize(dimensions) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let pty_size = TerminalSize::new(rows, columns)
|
let pty_size = TerminalSize::new(rows, columns)
|
||||||
.expect("terminal engine dimensions are valid PTY dimensions");
|
.expect("terminal engine dimensions are valid PTY dimensions");
|
||||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) {
|
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size))
|
||||||
self.live_terminals
|
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||||
.get_mut(&pane)
|
{
|
||||||
.expect("live terminal state exists")
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
|
||||||
}
|
}
|
||||||
self.publish_terminal_snapshot(pane);
|
self.publish_terminal_snapshot(pane);
|
||||||
true
|
true
|
||||||
@@ -908,13 +908,10 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) {
|
fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) {
|
||||||
if !self
|
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||||
.live_terminals
|
return;
|
||||||
.get_mut(&pane)
|
};
|
||||||
.expect("live terminal state exists")
|
if !terminal.terminal.scroll_display(scroll) {
|
||||||
.terminal
|
|
||||||
.scroll_display(scroll)
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.publish_terminal_snapshot(pane);
|
self.publish_terminal_snapshot(pane);
|
||||||
@@ -1025,6 +1022,100 @@ impl LumbridgeShell {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restarts the selected pane's process, keeping the pane.
|
||||||
|
///
|
||||||
|
/// The pane's identity is durable and its process is not, which is the whole
|
||||||
|
/// point of decision 0011: a crashed shell should not cost you the pane, its
|
||||||
|
/// position, or its place in the workspace.
|
||||||
|
fn restart_pane(&mut self, _: &RestartPane, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let pane = self.panels.selected();
|
||||||
|
if !self.live_terminals.contains_key(&pane) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
// Stop the old one first. A failure here is reported rather than
|
||||||
|
// swallowed: a restart that silently left the old process running would
|
||||||
|
// leak a shell every time it was pressed.
|
||||||
|
if let Err(error) = self.runtimes.shutdown(&pane) {
|
||||||
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dimensions =
|
||||||
|
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count());
|
||||||
|
let mut terminal = LiveTerminalState::new(dimensions);
|
||||||
|
if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) {
|
||||||
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
|
}
|
||||||
|
self.live_terminals.insert(pane, terminal);
|
||||||
|
self.pending_terminate = None;
|
||||||
|
window.focus(&self.root_focus);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks before terminating, then terminates on a second press.
|
||||||
|
///
|
||||||
|
/// Decision 0010 requires terminate to be a distinct, named, confirmed
|
||||||
|
/// operation rather than a close icon, and the confirmation names the
|
||||||
|
/// process being killed. Detaching a pane leaves its process alive; this is
|
||||||
|
/// the only path in the application that ends one.
|
||||||
|
fn terminate_pane(&mut self, _: &TerminatePane, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let pane = self.panels.selected();
|
||||||
|
if !self.live_terminals.contains_key(&pane) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.timing.mark_dispatch();
|
||||||
|
if self.pending_terminate != Some(pane) {
|
||||||
|
self.pending_terminate = Some(pane);
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.pending_terminate = None;
|
||||||
|
let outcome = self.runtimes.shutdown(&pane);
|
||||||
|
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||||
|
terminal.status = match outcome {
|
||||||
|
Ok(()) => LiveRuntimeStatus::Exited("terminated by request".to_owned()),
|
||||||
|
Err(error) => LiveRuntimeStatus::Fault(error.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.focus(&self.root_focus);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pastes the clipboard into the selected terminal.
|
||||||
|
///
|
||||||
|
/// Through the engine's bracketed-paste path rather than as synthetic
|
||||||
|
/// keystrokes, so a shell or editor that asked for bracketed paste is told
|
||||||
|
/// this is a paste and does not run every newline as a command.
|
||||||
|
///
|
||||||
|
/// There is no matching copy: the terminal engine has no selection yet, so
|
||||||
|
/// there is nothing to copy from. Adding a key that copied the whole screen
|
||||||
|
/// would not be the same feature under the same name.
|
||||||
|
fn paste_into_pane(&mut self, _: &PasteIntoPane, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let pane = self.panels.selected();
|
||||||
|
let Some(terminal) = self.live_terminals.get(&pane) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if terminal.status.is_terminal() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if text.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let bytes = terminal.terminal.encode_paste(&text);
|
||||||
|
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
|
||||||
|
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||||
|
{
|
||||||
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.dispatch(ShellAction::CloseCommandPalette);
|
self.dispatch(ShellAction::CloseCommandPalette);
|
||||||
self.add_panel_chooser_open = !self.add_panel_chooser_open;
|
self.add_panel_chooser_open = !self.add_panel_chooser_open;
|
||||||
@@ -1156,13 +1247,28 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
if !self.model.command_palette().is_open() {
|
if !self.model.command_palette().is_open() {
|
||||||
let pane = self.panels.selected();
|
let pane = self.panels.selected();
|
||||||
if self
|
// Typing into a pane with nothing to type into used to vanish
|
||||||
.live_terminals
|
// without a word: no beep, no message, no indication that the
|
||||||
.get(&pane)
|
// keystroke had gone anywhere. Say where it went.
|
||||||
.is_none_or(|terminal| terminal.status.is_terminal())
|
match self.live_terminals.get(&pane) {
|
||||||
{
|
None => {
|
||||||
|
self.input_gap = Some(
|
||||||
|
"This pane has no terminal. Select a terminal pane to type into it."
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Some(terminal) if terminal.status.is_terminal() => {
|
||||||
|
self.input_gap =
|
||||||
|
Some("This pane's process has ended. ⌘⌥R restarts it.".to_owned());
|
||||||
|
cx.notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Some(_) => self.input_gap = None,
|
||||||
|
}
|
||||||
|
// Any key that is not the confirmation cancels a pending terminate.
|
||||||
|
self.pending_terminate = None;
|
||||||
let modifiers = key_modifiers(event);
|
let modifiers = key_modifiers(event);
|
||||||
if let Some(scroll) =
|
if let Some(scroll) =
|
||||||
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
|
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
|
||||||
@@ -1177,29 +1283,25 @@ impl LumbridgeShell {
|
|||||||
) else {
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let bytes = self
|
let Some(bytes) = self
|
||||||
.live_terminals
|
.live_terminals
|
||||||
.get(&pane)
|
.get(&pane)
|
||||||
.expect("selected live terminal exists")
|
.map(|terminal| terminal.terminal.encode_key(&event))
|
||||||
.terminal
|
else {
|
||||||
.encode_key(&event);
|
return;
|
||||||
|
};
|
||||||
if bytes.is_empty() {
|
if bytes.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) {
|
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
|
||||||
self.live_terminals
|
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||||
.get_mut(&pane)
|
{
|
||||||
.expect("selected live terminal exists")
|
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
|
||||||
}
|
}
|
||||||
let moved_to_bottom = {
|
let moved_to_bottom = self.live_terminals.get_mut(&pane).is_some_and(|terminal| {
|
||||||
let terminal = self
|
|
||||||
.live_terminals
|
|
||||||
.get_mut(&pane)
|
|
||||||
.expect("selected live terminal exists");
|
|
||||||
terminal.terminal.display_offset() > 0
|
terminal.terminal.display_offset() > 0
|
||||||
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
|
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
|
||||||
};
|
});
|
||||||
if moved_to_bottom {
|
if moved_to_bottom {
|
||||||
self.publish_terminal_snapshot(pane);
|
self.publish_terminal_snapshot(pane);
|
||||||
}
|
}
|
||||||
@@ -1339,12 +1441,23 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement {
|
fn terminal_view(&self, pane: PanelId, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||||
let theme = self.theme.colors;
|
let theme = self.theme.colors;
|
||||||
let terminal = self
|
// A pane can outlive its runtime state — a failed spawn, a terminate, a
|
||||||
.live_terminals
|
// restored snapshot whose process is gone. Saying so beats panicking in
|
||||||
.get(&pane)
|
// the middle of a paint.
|
||||||
.expect("external terminal pane has live state");
|
let Some(terminal) = self.live_terminals.get(&pane) else {
|
||||||
|
return div()
|
||||||
|
.flex()
|
||||||
|
.size_full()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.child("No runtime is attached to this pane.")
|
||||||
|
.into_any_element();
|
||||||
|
};
|
||||||
|
let scroll_pane = pane;
|
||||||
let rows = terminal_paint_rows(&terminal.snapshot, theme)
|
let rows = terminal_paint_rows(&terminal.snapshot, theme)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|runs| {
|
.map(|runs| {
|
||||||
@@ -1363,6 +1476,33 @@ impl LumbridgeShell {
|
|||||||
.bg(theme.chrome)
|
.bg(theme.chrome)
|
||||||
.font_family("monospace")
|
.font_family("monospace")
|
||||||
.text_size(px(13.0))
|
.text_size(px(13.0))
|
||||||
|
// Shift+PageUp was the only way into scrollback, which is not
|
||||||
|
// something anyone guesses. A wheel is how people scroll.
|
||||||
|
.on_scroll_wheel(
|
||||||
|
cx.listener(move |shell, event: &gpui::ScrollWheelEvent, _, cx| {
|
||||||
|
let delta = event.delta.pixel_delta(px(TERMINAL_CELL_HEIGHT));
|
||||||
|
let lines = (f32::from(delta.y) / TERMINAL_CELL_HEIGHT).clamp(-64.0, 64.0);
|
||||||
|
#[expect(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
reason = "clamped into -64..=64 on the line above"
|
||||||
|
)]
|
||||||
|
let rows = lines.trunc() as i32;
|
||||||
|
// A wheel notch shorter than one row still scrolls one row,
|
||||||
|
// otherwise a fine-grained trackpad does nothing at all.
|
||||||
|
let rows = if rows == 0 {
|
||||||
|
if lines > 0.0 {
|
||||||
|
1
|
||||||
|
} else if lines < 0.0 {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
shell.scroll_terminal(scroll_pane, TerminalScroll::Delta(rows), cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
.children(rows)
|
.children(rows)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -1381,11 +1521,11 @@ impl LumbridgeShell {
|
|||||||
let pane_id = pane.id;
|
let pane_id = pane.id;
|
||||||
let can_detach = self.panels.attached_count() > 1;
|
let can_detach = self.panels.attached_count() > 1;
|
||||||
let external = pane.output_source == OutputSource::External;
|
let external = pane.output_source == OutputSource::External;
|
||||||
let live_terminal = external.then(|| {
|
// A pane can be marked external and still have no runtime: a failed
|
||||||
self.live_terminals
|
// spawn, a terminated session, a snapshot restored past its process.
|
||||||
.get(&pane_id)
|
let live_terminal = external
|
||||||
.expect("external terminal pane has live state")
|
.then(|| self.live_terminals.get(&pane_id))
|
||||||
});
|
.flatten();
|
||||||
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
|
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
|
||||||
let detail =
|
let detail =
|
||||||
live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail());
|
live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail());
|
||||||
@@ -1629,7 +1769,71 @@ impl LumbridgeShell {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement {
|
/// What a pane shows when its process has ended.
|
||||||
|
///
|
||||||
|
/// The stale screen stays, dimmed, rather than being cleared: what the
|
||||||
|
/// process last printed is usually why it stopped. Decision 0010 wants a
|
||||||
|
/// terminated session to be visibly terminated, not silently blank.
|
||||||
|
fn exited_banner(status: &LiveRuntimeStatus, theme: ThemeColors) -> Option<gpui::AnyElement> {
|
||||||
|
let (label, tone) = match status {
|
||||||
|
LiveRuntimeStatus::Exited(detail) => (format!("Process ended · {detail}"), theme.muted),
|
||||||
|
LiveRuntimeStatus::Fault(detail) => (format!("Runtime fault · {detail}"), theme.danger),
|
||||||
|
LiveRuntimeStatus::Starting | LiveRuntimeStatus::Running { .. } => return None,
|
||||||
|
};
|
||||||
|
Some(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.text_xs()
|
||||||
|
.bg(theme.danger_container)
|
||||||
|
.text_color(tone)
|
||||||
|
.child(format!("{label} · ⌘⌥R restarts it"))
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The confirmation decision 0010 requires before a process is ended.
|
||||||
|
///
|
||||||
|
/// It names the process and says what will happen, and it is a separate
|
||||||
|
/// keystroke rather than a close icon, because detaching a pane and killing
|
||||||
|
/// its process are different operations with different consequences.
|
||||||
|
fn terminate_confirmation(
|
||||||
|
&self,
|
||||||
|
pane: PanelId,
|
||||||
|
theme: ThemeColors,
|
||||||
|
) -> Option<gpui::AnyElement> {
|
||||||
|
if self.pending_terminate != Some(pane) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let process = self.live_terminals.get(&pane).map_or_else(
|
||||||
|
|| "this pane".to_owned(),
|
||||||
|
|terminal| match terminal.status {
|
||||||
|
LiveRuntimeStatus::Running {
|
||||||
|
process_id: Some(id),
|
||||||
|
..
|
||||||
|
} => format!("pid {id} on this machine"),
|
||||||
|
_ => "this pane's process".to_owned(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Some(
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.text_xs()
|
||||||
|
.bg(theme.danger_container)
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.danger)
|
||||||
|
.text_color(theme.text)
|
||||||
|
.child(format!(
|
||||||
|
"Terminate session? {process} will be ended. ⌘⌥X again to confirm, any other key to cancel."
|
||||||
|
))
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pane_work_surface(&self, pane: &PanelView, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||||
let theme = self.theme.colors;
|
let theme = self.theme.colors;
|
||||||
let shown = self.surface_for(pane.id, pane.kind);
|
let shown = self.surface_for(pane.id, pane.kind);
|
||||||
if shown != SurfaceTab::native_for(pane.kind) {
|
if shown != SurfaceTab::native_for(pane.kind) {
|
||||||
@@ -1637,7 +1841,7 @@ impl LumbridgeShell {
|
|||||||
}
|
}
|
||||||
let external = pane.output_source == OutputSource::External;
|
let external = pane.output_source == OutputSource::External;
|
||||||
let content = if external {
|
let content = if external {
|
||||||
self.terminal_view(pane.id)
|
self.terminal_view(pane.id, cx)
|
||||||
} else {
|
} else {
|
||||||
let start = pane.lines.len().saturating_sub(18);
|
let start = pane.lines.len().saturating_sub(18);
|
||||||
div()
|
div()
|
||||||
@@ -1653,6 +1857,10 @@ impl LumbridgeShell {
|
|||||||
.into_any_element()
|
.into_any_element()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let banner = self
|
||||||
|
.live_terminals
|
||||||
|
.get(&pane.id)
|
||||||
|
.and_then(|terminal| Self::exited_banner(&terminal.status, theme));
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
@@ -1660,6 +1868,23 @@ impl LumbridgeShell {
|
|||||||
.min_w_0()
|
.min_w_0()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.children(self.terminate_confirmation(pane.id, theme))
|
||||||
|
.children(banner)
|
||||||
|
.children(
|
||||||
|
self.input_gap
|
||||||
|
.as_ref()
|
||||||
|
.filter(|_| pane.id == self.panels.selected())
|
||||||
|
.map(|reason| {
|
||||||
|
div()
|
||||||
|
.flex_none()
|
||||||
|
.px_3()
|
||||||
|
.py_1()
|
||||||
|
.text_xs()
|
||||||
|
.bg(theme.surface_raised)
|
||||||
|
.text_color(theme.attention)
|
||||||
|
.child(reason.clone())
|
||||||
|
}),
|
||||||
|
)
|
||||||
.bg(theme.chrome)
|
.bg(theme.chrome)
|
||||||
.border_y_1()
|
.border_y_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
@@ -1826,7 +2051,7 @@ impl LumbridgeShell {
|
|||||||
.flex_none()
|
.flex_none()
|
||||||
.min_h_0()
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.child(self.pane_work_surface(pane)),
|
.child(self.pane_work_surface(pane, cx)),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -2296,6 +2521,9 @@ impl LumbridgeShell {
|
|||||||
.on_action(cx.listener(Self::close_palette))
|
.on_action(cx.listener(Self::close_palette))
|
||||||
.on_action(cx.listener(Self::add_panel_action))
|
.on_action(cx.listener(Self::add_panel_action))
|
||||||
.on_action(cx.listener(Self::detach_selected_panel))
|
.on_action(cx.listener(Self::detach_selected_panel))
|
||||||
|
.on_action(cx.listener(Self::restart_pane))
|
||||||
|
.on_action(cx.listener(Self::terminate_pane))
|
||||||
|
.on_action(cx.listener(Self::paste_into_pane))
|
||||||
.on_action(cx.listener(Self::terminal_taller))
|
.on_action(cx.listener(Self::terminal_taller))
|
||||||
.on_action(cx.listener(Self::terminal_shorter))
|
.on_action(cx.listener(Self::terminal_shorter))
|
||||||
.on_action(cx.listener(Self::terminal_wider))
|
.on_action(cx.listener(Self::terminal_wider))
|
||||||
@@ -2901,7 +3129,20 @@ fn terminal_key_from_parts(
|
|||||||
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
||||||
TerminalKey::Function(number)
|
TerminalKey::Function(number)
|
||||||
}
|
}
|
||||||
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()),
|
_ if !modifiers.contains(KeyModifiers::PLATFORM) => {
|
||||||
|
// GPUI reports no `key_char` for a control chord, because ctrl-k
|
||||||
|
// produces no printable character. Taking `key_char` alone meant
|
||||||
|
// every control byte was dropped before it reached the PTY — not
|
||||||
|
// just ctrl-k, but ctrl-c, ctrl-d, ctrl-a and ctrl-r as well, which
|
||||||
|
// is most of what makes a terminal usable. Fall back to the key
|
||||||
|
// name when it names a single character; the engine applies the
|
||||||
|
// control transformation.
|
||||||
|
let text = key_char
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| (key.chars().count() == 1).then(|| key.to_owned()))?;
|
||||||
|
TerminalKey::Text(text)
|
||||||
|
}
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
Some(TerminalKeyEvent { key, modifiers })
|
Some(TerminalKeyEvent { key, modifiers })
|
||||||
@@ -2960,25 +3201,7 @@ fn spawn_live_runtime(
|
|||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
Application::new().run(|cx: &mut App| {
|
Application::new().run(|cx: &mut App| {
|
||||||
cx.bind_keys([
|
cx.bind_keys(keymap::bindings());
|
||||||
KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-down", FocusDown, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-n", AddPanel, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-w", DetachSelectedPanel, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
|
|
||||||
KeyBinding::new("alt-shift-left", TerminalNarrower, Some("LumbridgeShell")),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||||
cx.open_window(
|
cx.open_window(
|
||||||
@@ -3021,6 +3244,34 @@ mod tests {
|
|||||||
assert_eq!(composed.key, TerminalKey::Text("é".into()));
|
assert_eq!(composed.key, TerminalKey::Text("é".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The regression this test exists for: a control chord carries no
|
||||||
|
/// printable character, so requiring one dropped every control byte.
|
||||||
|
#[test]
|
||||||
|
fn a_control_chord_still_reaches_the_terminal_without_a_key_char() {
|
||||||
|
for letter in ["k", "c", "d", "a", "r"] {
|
||||||
|
let event = terminal_key_from_parts(letter, None, KeyModifiers::CONTROL)
|
||||||
|
.unwrap_or_else(|| panic!("ctrl-{letter} must reach the terminal"));
|
||||||
|
assert_eq!(event.key, TerminalKey::Text(letter.to_owned()));
|
||||||
|
assert!(event.modifiers.contains(KeyModifiers::CONTROL));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_printable_character_still_wins_over_the_key_name() {
|
||||||
|
// Shift-a arrives as key "a" with key_char "A"; the character is what
|
||||||
|
// the terminal should receive.
|
||||||
|
let event = terminal_key_from_parts("a", Some("A"), KeyModifiers::SHIFT).expect("shift-a");
|
||||||
|
assert_eq!(event.key, TerminalKey::Text("A".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_named_key_with_no_character_is_still_refused() {
|
||||||
|
// Multi-character key names that are not in the table above are not
|
||||||
|
// text and must not be sent as though they were.
|
||||||
|
assert!(terminal_key_from_parts("capslock", None, KeyModifiers::default()).is_none());
|
||||||
|
assert!(terminal_key_from_parts("shift", None, KeyModifiers::default()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
||||||
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
||||||
|
|||||||
@@ -38,8 +38,9 @@ pub(crate) struct ThemeColors {
|
|||||||
pub(crate) success: Rgba,
|
pub(crate) success: Rgba,
|
||||||
/// No surface reports failure in colour yet; a dead pane is described in
|
/// No surface reports failure in colour yet; a dead pane is described in
|
||||||
/// words. Defined here so the role exists when one does.
|
/// words. Defined here so the role exists when one does.
|
||||||
#[expect(dead_code, reason = "no failure surface paints yet")]
|
|
||||||
pub(crate) danger: Rgba,
|
pub(crate) danger: Rgba,
|
||||||
|
/// A danger fill quiet enough to sit behind text.
|
||||||
|
pub(crate) danger_container: Rgba,
|
||||||
pub(crate) attention: Rgba,
|
pub(crate) attention: Rgba,
|
||||||
/// The needs-input row tint, which arrives with the sidebar rework.
|
/// The needs-input row tint, which arrives with the sidebar rework.
|
||||||
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
|
#[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")]
|
||||||
@@ -75,6 +76,7 @@ impl From<&Palette> for ThemeColors {
|
|||||||
accent: rgba(palette.accent),
|
accent: rgba(palette.accent),
|
||||||
success: rgba(palette.success),
|
success: rgba(palette.success),
|
||||||
danger: rgba(palette.danger),
|
danger: rgba(palette.danger),
|
||||||
|
danger_container: rgba(palette.danger_container),
|
||||||
attention: rgba(palette.attention),
|
attention: rgba(palette.attention),
|
||||||
attention_wash: rgba(palette.attention_wash),
|
attention_wash: rgba(palette.attention_wash),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user