Move the keystroke table out of the renderer that reported the keystroke
Deciding what a key means to a shell needs the key name, the composed character if the platform produced one, and the modifier flags. It does not need a window, and keeping it beside one made the least forgiving table in the shell the hardest to read and the least obvious to test. It is the least forgiving because it has already failed silently. Requiring a printable character before anything was sent swallowed every control byte -- ctrl-c, ctrl-d, ctrl-a, ctrl-r and ctrl-k -- with no crash and no log, so the symptom was a shell that ignored you. Those tests move with the functions and keep their names, which describe the mistake rather than the function. key_modifiers stays in main.rs. Copying KeyDownEvent.keystroke.modifiers field by field is a statement about a gpui type and belongs where gpui types live; what it produces is a KeyModifiers, and that crosses the boundary fine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SPYebLiN2w4TqnHUYGdECq
This commit is contained in:
co-authored by
Claude Opus 5
parent
5564063aa5
commit
6daee84ca6
@@ -0,0 +1,160 @@
|
|||||||
|
//! Translating a keystroke the window system reported into bytes a PTY accepts.
|
||||||
|
//!
|
||||||
|
//! The renderer knows what was pressed and the terminal engine knows what a
|
||||||
|
//! shell expects; this is the table between them, and it is deliberately
|
||||||
|
//! written in terms neither of them owns. A key arrives here as three plain
|
||||||
|
//! values — the key name, the printable character the platform composed from
|
||||||
|
//! it if there was one, and a set of modifier flags — so the whole mapping can
|
||||||
|
//! be exercised by naming those three values, with no window open and no event
|
||||||
|
//! loop running.
|
||||||
|
//!
|
||||||
|
//! That matters more here than almost anywhere else in the shell, because this
|
||||||
|
//! is where the terminal has already been badly wrong once. Requiring a
|
||||||
|
//! printable character before anything reached the PTY silently swallowed every
|
||||||
|
//! control byte: ctrl-c, ctrl-d, ctrl-a, ctrl-r and ctrl-k, which between them
|
||||||
|
//! are most of what makes a terminal a terminal. Nothing crashed, nothing
|
||||||
|
//! logged, the shell just ignored you. Every rule below that looks like an edge
|
||||||
|
//! case is one of those failures, and each has a test named after the mistake
|
||||||
|
//! rather than after the function.
|
||||||
|
//!
|
||||||
|
//! What stays in the view layer is the one step that cannot be described
|
||||||
|
//! without gpui: reading `KeyDownEvent.keystroke.modifiers` into
|
||||||
|
//! [`KeyModifiers`]. That is a field-by-field copy of a renderer type, so it
|
||||||
|
//! lives beside the renderer and hands the result down here.
|
||||||
|
|
||||||
|
use lumbridge_terminal::{KeyModifiers, TerminalKey, TerminalKeyEvent, TerminalScroll};
|
||||||
|
|
||||||
|
pub(crate) fn terminal_key_from_parts(
|
||||||
|
key: &str,
|
||||||
|
key_char: Option<&str>,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> Option<TerminalKeyEvent> {
|
||||||
|
let key = match key {
|
||||||
|
"enter" => TerminalKey::Enter,
|
||||||
|
"backspace" => TerminalKey::Backspace,
|
||||||
|
"tab" => TerminalKey::Tab,
|
||||||
|
"escape" => TerminalKey::Escape,
|
||||||
|
"up" => TerminalKey::Up,
|
||||||
|
"down" => TerminalKey::Down,
|
||||||
|
"left" => TerminalKey::Left,
|
||||||
|
"right" => TerminalKey::Right,
|
||||||
|
"home" => TerminalKey::Home,
|
||||||
|
"end" => TerminalKey::End,
|
||||||
|
"insert" => TerminalKey::Insert,
|
||||||
|
"delete" => TerminalKey::Delete,
|
||||||
|
"pageup" => TerminalKey::PageUp,
|
||||||
|
"pagedown" => TerminalKey::PageDown,
|
||||||
|
function
|
||||||
|
if function.strip_prefix('f').is_some_and(|suffix| {
|
||||||
|
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
}) =>
|
||||||
|
{
|
||||||
|
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
||||||
|
TerminalKey::Function(number)
|
||||||
|
}
|
||||||
|
_ 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,
|
||||||
|
};
|
||||||
|
Some(TerminalKeyEvent { key, modifiers })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn terminal_scroll_from_parts(
|
||||||
|
key: &str,
|
||||||
|
modifiers: KeyModifiers,
|
||||||
|
) -> Option<TerminalScroll> {
|
||||||
|
if !modifiers.contains(KeyModifiers::SHIFT) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match key {
|
||||||
|
"pageup" => Some(TerminalScroll::PageUp),
|
||||||
|
"pagedown" => Some(TerminalScroll::PageDown),
|
||||||
|
"home" => Some(TerminalScroll::Top),
|
||||||
|
"end" => Some(TerminalScroll::Bottom),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{terminal_key_from_parts, terminal_scroll_from_parts};
|
||||||
|
use lumbridge_terminal::{KeyModifiers, TerminalKey, TerminalScroll};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_named_and_composed_gpui_keys_to_terminal_input() {
|
||||||
|
let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap();
|
||||||
|
assert_eq!(enter.key, TerminalKey::Enter);
|
||||||
|
|
||||||
|
let literal_f = terminal_key_from_parts("f", Some("f"), KeyModifiers::default()).unwrap();
|
||||||
|
assert_eq!(literal_f.key, TerminalKey::Text("f".into()));
|
||||||
|
|
||||||
|
let function = terminal_key_from_parts("f12", None, KeyModifiers::default()).unwrap();
|
||||||
|
assert_eq!(function.key, TerminalKey::Function(12));
|
||||||
|
|
||||||
|
let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap();
|
||||||
|
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]
|
||||||
|
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
||||||
|
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
||||||
|
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
|
||||||
|
assert_eq!(control.key, TerminalKey::Text("c".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shift_navigation_controls_scrollback_without_reaching_the_pty() {
|
||||||
|
assert_eq!(
|
||||||
|
terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT),
|
||||||
|
Some(TerminalScroll::PageUp)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal_scroll_from_parts("end", KeyModifiers::SHIFT),
|
||||||
|
Some(TerminalScroll::Bottom)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
terminal_scroll_from_parts("pageup", KeyModifiers::default()),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-131
@@ -1,5 +1,6 @@
|
|||||||
mod attention;
|
mod attention;
|
||||||
mod geometry;
|
mod geometry;
|
||||||
|
mod input;
|
||||||
mod keymap;
|
mod keymap;
|
||||||
mod panel_registry;
|
mod panel_registry;
|
||||||
mod settings_view;
|
mod settings_view;
|
||||||
@@ -23,8 +24,7 @@ use lumbridge_runtime::{
|
|||||||
use lumbridge_storage::Store;
|
use lumbridge_storage::Store;
|
||||||
use lumbridge_terminal::{
|
use lumbridge_terminal::{
|
||||||
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
|
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
|
||||||
TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalKeyEvent, TerminalNamedColor,
|
TerminalEngine, TerminalEngineOptions, TerminalNamedColor, TerminalScroll, TerminalSnapshot,
|
||||||
TerminalScroll, TerminalSnapshot,
|
|
||||||
};
|
};
|
||||||
use lumbridge_ui_fixture::{
|
use lumbridge_ui_fixture::{
|
||||||
ActionOutcome, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, ShellModel,
|
ActionOutcome, OutputSource, PaneId as FixturePaneId, PaneStatus, ShellAction, ShellModel,
|
||||||
@@ -32,6 +32,8 @@ use lumbridge_ui_fixture::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
|
use attention::{Attention, AttentionKind, AttentionSignal, AttentionSource};
|
||||||
|
use input::{terminal_key_from_parts, terminal_scroll_from_parts};
|
||||||
|
|
||||||
use geometry::{
|
use geometry::{
|
||||||
APP_FOOTER_HEIGHT, CellMetrics, TERMINAL_CELL_HEIGHT, TERMINAL_CELL_WIDTH, WindowSize,
|
APP_FOOTER_HEIGHT, CellMetrics, TERMINAL_CELL_HEIGHT, TERMINAL_CELL_WIDTH, WindowSize,
|
||||||
clamp_to_u16, terminal_dimensions_for_window, visible_panel_count, visible_panel_range,
|
clamp_to_u16, terminal_dimensions_for_window, visible_panel_count, visible_panel_range,
|
||||||
@@ -3499,66 +3501,6 @@ fn key_modifiers(event: &KeyDownEvent) -> KeyModifiers {
|
|||||||
modifiers
|
modifiers
|
||||||
}
|
}
|
||||||
|
|
||||||
fn terminal_key_from_parts(
|
|
||||||
key: &str,
|
|
||||||
key_char: Option<&str>,
|
|
||||||
modifiers: KeyModifiers,
|
|
||||||
) -> Option<TerminalKeyEvent> {
|
|
||||||
let key = match key {
|
|
||||||
"enter" => TerminalKey::Enter,
|
|
||||||
"backspace" => TerminalKey::Backspace,
|
|
||||||
"tab" => TerminalKey::Tab,
|
|
||||||
"escape" => TerminalKey::Escape,
|
|
||||||
"up" => TerminalKey::Up,
|
|
||||||
"down" => TerminalKey::Down,
|
|
||||||
"left" => TerminalKey::Left,
|
|
||||||
"right" => TerminalKey::Right,
|
|
||||||
"home" => TerminalKey::Home,
|
|
||||||
"end" => TerminalKey::End,
|
|
||||||
"insert" => TerminalKey::Insert,
|
|
||||||
"delete" => TerminalKey::Delete,
|
|
||||||
"pageup" => TerminalKey::PageUp,
|
|
||||||
"pagedown" => TerminalKey::PageDown,
|
|
||||||
function
|
|
||||||
if function.strip_prefix('f').is_some_and(|suffix| {
|
|
||||||
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
|
|
||||||
}) =>
|
|
||||||
{
|
|
||||||
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
|
||||||
TerminalKey::Function(number)
|
|
||||||
}
|
|
||||||
_ 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,
|
|
||||||
};
|
|
||||||
Some(TerminalKeyEvent { key, modifiers })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option<TerminalScroll> {
|
|
||||||
if !modifiers.contains(KeyModifiers::SHIFT) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
match key {
|
|
||||||
"pageup" => Some(TerminalScroll::PageUp),
|
|
||||||
"pagedown" => Some(TerminalScroll::PageDown),
|
|
||||||
"home" => Some(TerminalScroll::Top),
|
|
||||||
"end" => Some(TerminalScroll::Bottom),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn live_pty_script(seed: Option<SeedPane>) -> &'static str {
|
fn live_pty_script(seed: Option<SeedPane>) -> &'static str {
|
||||||
match seed {
|
match seed {
|
||||||
Some(SeedPane::CodexRuntime) => {
|
Some(SeedPane::CodexRuntime) => {
|
||||||
@@ -3628,77 +3570,9 @@ fn main() {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalScroll,
|
TerminalEngine, TerminalEngineOptions, indexed_terminal_color, terminal_paint_rows,
|
||||||
indexed_terminal_color, terminal_key_from_parts, terminal_paint_rows,
|
|
||||||
terminal_scroll_from_parts,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn maps_named_and_composed_gpui_keys_to_terminal_input() {
|
|
||||||
let enter = terminal_key_from_parts("enter", None, KeyModifiers::default()).unwrap();
|
|
||||||
assert_eq!(enter.key, TerminalKey::Enter);
|
|
||||||
|
|
||||||
let literal_f = terminal_key_from_parts("f", Some("f"), KeyModifiers::default()).unwrap();
|
|
||||||
assert_eq!(literal_f.key, TerminalKey::Text("f".into()));
|
|
||||||
|
|
||||||
let function = terminal_key_from_parts("f12", None, KeyModifiers::default()).unwrap();
|
|
||||||
assert_eq!(function.key, TerminalKey::Function(12));
|
|
||||||
|
|
||||||
let composed = terminal_key_from_parts("é", Some("é"), KeyModifiers::default()).unwrap();
|
|
||||||
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]
|
|
||||||
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
|
||||||
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
|
||||||
let control = terminal_key_from_parts("c", Some("c"), KeyModifiers::CONTROL).unwrap();
|
|
||||||
assert_eq!(control.key, TerminalKey::Text("c".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn shift_navigation_controls_scrollback_without_reaching_the_pty() {
|
|
||||||
assert_eq!(
|
|
||||||
terminal_scroll_from_parts("pageup", KeyModifiers::SHIFT),
|
|
||||||
Some(TerminalScroll::PageUp)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
terminal_scroll_from_parts("end", KeyModifiers::SHIFT),
|
|
||||||
Some(TerminalScroll::Bottom)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
terminal_scroll_from_parts("pageup", KeyModifiers::default()),
|
|
||||||
None
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() {
|
fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() {
|
||||||
let mut terminal = TerminalEngine::new(TerminalEngineOptions::default());
|
let mut terminal = TerminalEngine::new(TerminalEngineOptions::default());
|
||||||
|
|||||||
Reference in New Issue
Block a user