fix(input): scroll pane scrollback with pageup/pagedown and parse enhanced tilde sequences

This commit is contained in:
Ogulcan Celik
2026-05-14 22:57:09 +03:00
parent 5c82623dbf
commit c2e58974f4
3 changed files with 204 additions and 2 deletions
+152
View File
@@ -65,6 +65,35 @@ impl App {
let ws = self.state.workspaces.get(ws_idx)?;
let pane_id = ws.focused_pane_id()?;
let rt = ws.runtimes.get(&pane_id)?;
// Intercept PageUp/PageDown for pane scrollback when the focused pane
// doesn't handle its own scrolling (e.g., a plain shell with mouse off).
// Only intercept when we know the pane state; if input_state is unknown,
// fail-open and forward the key to the pane.
if matches!(key_event.code, KeyCode::PageUp | KeyCode::PageDown) {
if let Some(input_state) = rt.input_state() {
if !input_state.alternate_screen && !input_state.mouse_reporting_enabled() {
let lines = self
.state
.pane_info_by_id(pane_id)
.map(|info| info.inner_rect.height as usize)
.unwrap_or(10)
.max(1);
if key_event.code == KeyCode::PageUp {
self.state.scroll_pane_up(pane_id, lines);
} else {
self.state.scroll_pane_down(pane_id, lines);
}
debug!(
code = ?key_event.code,
lines,
"intercepted page key for pane scrollback"
);
return None;
}
}
}
rt.scroll_reset();
let protocol = rt.keyboard_protocol();
let bytes = rt.encode_terminal_key(key);
@@ -438,4 +467,127 @@ mod tests {
assert_ne!(app.state.workspaces[0].layout.focused(), focused_before);
assert_eq!(app.state.mode, Mode::Terminal);
}
#[tokio::test]
async fn page_up_scrolls_plain_shell_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
crate::pane::PaneRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
),
);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
let start_metrics = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("initial scroll metrics");
assert_eq!(start_metrics.offset_from_bottom, 0);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let end_metrics = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert_eq!(
end_metrics.offset_from_bottom,
info.inner_rect.height as usize
);
}
#[tokio::test]
async fn page_down_returns_to_bottom_after_page_up() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
ws.tabs[0].runtimes.insert(
pane_id,
crate::pane::PaneRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&numbered_lines_bytes(64),
),
);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let after_up = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
assert!(after_up.offset_from_bottom > 0);
app.handle_terminal_key_headless(TerminalKey::new(
KeyCode::PageDown,
KeyModifiers::empty(),
));
let after_down = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("scroll metrics after PageDown");
assert_eq!(after_down.offset_from_bottom, 0);
}
#[tokio::test]
async fn page_up_forwarded_to_mouse_reporting_pane() {
let mut app = app_for_mouse_test();
let mut ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
let pane_infos = ws.tabs[0].layout.panes(Rect::new(26, 2, 80, 18));
let info = pane_infos[0].clone();
let mut bytes = b"\x1b[?1002h".to_vec();
bytes.extend_from_slice(&numbered_lines_bytes(64));
ws.tabs[0].runtimes.insert(
pane_id,
crate::pane::PaneRuntime::test_with_scrollback_bytes(
info.inner_rect.width,
info.inner_rect.height,
16 * 1024,
&bytes,
),
);
app.state.workspaces = vec![ws];
app.state.active = Some(0);
app.state.selected = 0;
app.state.mode = Mode::Terminal;
app.state.view.pane_infos = pane_infos;
let start_metrics = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("initial scroll metrics");
assert_eq!(start_metrics.offset_from_bottom, 0);
app.handle_terminal_key_headless(TerminalKey::new(KeyCode::PageUp, KeyModifiers::empty()));
let end_metrics = app.state.workspaces[0]
.runtime(pane_id)
.and_then(crate::pane::PaneRuntime::scroll_metrics)
.expect("scroll metrics after PageUp");
// Forwarded to pane, so test runtime doesn't process it — scroll stays at bottom.
assert_eq!(end_metrics.offset_from_bottom, 0);
}
}
+30 -2
View File
@@ -181,7 +181,8 @@ fn parse_xterm_modified_special_sequence(data: &str) -> Option<TerminalKey> {
let tilde_body = body.strip_suffix('~')?;
let (code_part, modifier_part) = tilde_body.split_once(';')?;
let mod_value = modifier_part.parse::<u8>().ok()?.checked_sub(1)?;
let (modifier_text, event_type) = split_modifier_and_event(modifier_part);
let mod_value = modifier_text.parse::<u8>().ok()?.checked_sub(1)?;
let code = match code_part {
"2" => KeyCode::Insert,
"3" => KeyCode::Delete,
@@ -197,7 +198,10 @@ fn parse_xterm_modified_special_sequence(data: &str) -> Option<TerminalKey> {
"24" => KeyCode::F(12),
_ => return None,
};
Some(TerminalKey::new(code, key_modifiers_from_u8(mod_value)))
Some(
TerminalKey::new(code, key_modifiers_from_u8(mod_value))
.with_kind(parse_kitty_event_type(event_type)?),
)
}
fn split_modifier_and_event(input: &str) -> (&str, Option<&str>) {
@@ -484,6 +488,30 @@ mod tests {
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
}
#[test]
fn parse_ghostty_enhanced_pageup_press_sequence() {
let key = parse_terminal_key_sequence("\x1b[5;1:1~").unwrap();
assert_eq!(key.code, KeyCode::PageUp);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, crossterm::event::KeyEventKind::Press);
}
#[test]
fn parse_ghostty_enhanced_pagedown_release_sequence() {
let key = parse_terminal_key_sequence("\x1b[6;1:3~").unwrap();
assert_eq!(key.code, KeyCode::PageDown);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, crossterm::event::KeyEventKind::Release);
}
#[test]
fn parse_ghostty_enhanced_delete_repeat_sequence() {
let key = parse_terminal_key_sequence("\x1b[3;1:2~").unwrap();
assert_eq!(key.code, KeyCode::Delete);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, crossterm::event::KeyEventKind::Repeat);
}
#[test]
fn parse_xterm_alt_up_arrow_sequence() {
let key = parse_terminal_key_sequence("\x1b[1;3A").unwrap();
+22
View File
@@ -658,6 +658,28 @@ mod tests {
assert_eq!(key.modifiers, KeyModifiers::ALT);
}
#[test]
fn parses_enhanced_pageup_press() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[5;1:1~").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 8);
assert_eq!(key.code, KeyCode::PageUp);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, KeyEventKind::Press);
}
#[test]
fn parses_enhanced_pagedown_release() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[6;1:3~").unwrap() else {
panic!("expected key");
};
assert_eq!(consumed, 8);
assert_eq!(key.code, KeyCode::PageDown);
assert_eq!(key.modifiers, KeyModifiers::empty());
assert_eq!(key.kind, KeyEventKind::Release);
}
#[test]
fn raw_input_family_matrix_is_covered() {
let cases: &[(&[u8], KeyCode, KeyModifiers)] = &[