mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
fix(input): scroll pane scrollback with pageup/pagedown and parse enhanced tilde sequences
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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)] = &[
|
||||
|
||||
Reference in New Issue
Block a user