diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index bbfcd323..37174273 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -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); + } } diff --git a/src/input/parse.rs b/src/input/parse.rs index 79f11e23..a7d10ab2 100644 --- a/src/input/parse.rs +++ b/src/input/parse.rs @@ -181,7 +181,8 @@ fn parse_xterm_modified_special_sequence(data: &str) -> Option { let tilde_body = body.strip_suffix('~')?; let (code_part, modifier_part) = tilde_body.split_once(';')?; - let mod_value = modifier_part.parse::().ok()?.checked_sub(1)?; + let (modifier_text, event_type) = split_modifier_and_event(modifier_part); + let mod_value = modifier_text.parse::().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 { "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(); diff --git a/src/raw_input.rs b/src/raw_input.rs index aa231595..3ebd9554 100644 --- a/src/raw_input.rs +++ b/src/raw_input.rs @@ -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)] = &[