aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 23:03:11 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-31 23:03:11 +0300
commite857710d73ad660d17ce39fa3201a39aa49f6932 (patch)
tree7fa2db20fc6c4e78b9d182af449784ed48515c9e /src
parent5f97b0c26d848ef36aed70df906893907e2d0686 (diff)
fix: align pane scrolling with tmux and ghostty
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs169
-rw-r--r--src/input/encode.rs73
-rw-r--r--src/input/mod.rs2
-rw-r--r--src/input/tests.rs29
-rw-r--r--src/pane.rs69
-rw-r--r--src/pty_callbacks.rs70
-rw-r--r--src/ui.rs73
7 files changed, 377 insertions, 108 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index 05667ab9..4822a2e0 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -16,6 +16,13 @@ enum ScrollbarClickTarget {
Track { offset_from_bottom: usize },
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum WheelRouting {
+ HostScroll,
+ MouseReport,
+ AlternateScroll,
+}
+
use super::state::{
key_matches, AppState, ContextMenuKind, ContextMenuState, DragState, DragTarget, Mode,
};
@@ -1071,33 +1078,9 @@ impl AppState {
}
}
- MouseEventKind::ScrollUp if !in_sidebar => {
+ MouseEventKind::ScrollUp | MouseEventKind::ScrollDown if !in_sidebar => {
self.selection = None;
- if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- self.scroll_pane_up(info.id, 3);
- } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- self.scroll_pane_up(info.id, 3);
- } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
- rt.scroll_up(3);
- }
- }
- }
- MouseEventKind::ScrollDown if !in_sidebar => {
- self.selection = None;
- if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- self.scroll_pane_down(info.id, 3);
- } else if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
- self.focus_pane(info.id);
- self.scroll_pane_down(info.id, 3);
- } else if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if let Some(rt) = ws.focused_runtime() {
- rt.scroll_down(3);
- }
- }
+ self.handle_terminal_wheel(mouse);
}
MouseEventKind::ScrollUp if in_sidebar => {
@@ -1343,6 +1326,91 @@ impl AppState {
}
}
+ fn handle_terminal_wheel(&mut self, mouse: MouseEvent) {
+ const LINES_PER_NOTCH: usize = 3;
+
+ if let Some(info) = self.pane_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ if self.forward_pane_wheel(&info, mouse) {
+ return;
+ }
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(info) = self.pane_frame_at(mouse.column, mouse.row).cloned() {
+ self.focus_pane(info.id);
+ match mouse.kind {
+ MouseEventKind::ScrollUp => self.scroll_pane_up(info.id, LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => self.scroll_pane_down(info.id, LINES_PER_NOTCH),
+ _ => {}
+ }
+ return;
+ }
+
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
+ if let Some(rt) = ws.focused_runtime() {
+ match mouse.kind {
+ MouseEventKind::ScrollUp => rt.scroll_up(LINES_PER_NOTCH),
+ MouseEventKind::ScrollDown => rt.scroll_down(LINES_PER_NOTCH),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn forward_pane_wheel(&self, info: &PaneInfo, mouse: MouseEvent) -> bool {
+ let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) else {
+ return false;
+ };
+ let Some(rt) = ws.runtimes.get(&info.id) else {
+ return false;
+ };
+ let Some(input_state) = rt.input_state() else {
+ return false;
+ };
+
+ match wheel_routing(input_state) {
+ WheelRouting::HostScroll => false,
+ WheelRouting::MouseReport => {
+ rt.scroll_reset();
+ let column = mouse.column.saturating_sub(info.inner_rect.x);
+ let row = mouse.row.saturating_sub(info.inner_rect.y);
+ let Some(bytes) = crate::input::encode_mouse_scroll(
+ mouse.kind,
+ column,
+ row,
+ mouse.modifiers,
+ input_state.mouse_protocol_encoding,
+ ) else {
+ warn!(pane = info.id.raw(), kind = ?mouse.kind, "failed to encode mouse wheel event");
+ return true;
+ };
+ if let Err(err) = rt.sender.try_send(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward mouse wheel event");
+ }
+ true
+ }
+ WheelRouting::AlternateScroll => {
+ rt.scroll_reset();
+ let key = match mouse.kind {
+ MouseEventKind::ScrollUp => KeyCode::Up,
+ MouseEventKind::ScrollDown => KeyCode::Down,
+ _ => return true,
+ };
+ let bytes = crate::input::encode_cursor_key(key, input_state.application_cursor);
+ if let Err(err) = rt.sender.try_send(Bytes::from(bytes)) {
+ warn!(pane = info.id.raw(), err = %err, "failed to forward alternate-scroll key");
+ }
+ true
+ }
+ }
+ }
+
fn set_pane_scroll_offset(&self, pane_id: crate::layout::PaneId, offset_from_bottom: usize) {
if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
if let Some(rt) = ws.runtimes.get(&pane_id) {
@@ -1410,6 +1478,16 @@ impl AppState {
}
}
+fn wheel_routing(input_state: crate::pane::InputState) -> WheelRouting {
+ if input_state.mouse_protocol_mode != vt100::MouseProtocolMode::None {
+ WheelRouting::MouseReport
+ } else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
+ WheelRouting::AlternateScroll
+ } else {
+ WheelRouting::HostScroll
+ }
+}
+
// Note: split_pane needs runtime (event_tx for PTY spawn), so it lives on App
impl AppState {
pub(crate) fn split_pane(&mut self, direction: Direction) {
@@ -1656,4 +1734,43 @@ mod tests {
assert!(app.state.sidebar_width_auto);
assert!(app.state.drag.is_none());
}
+
+ #[test]
+ fn wheel_routing_prefers_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ mouse_protocol_mode: vt100::MouseProtocolMode::ButtonMotion,
+ mouse_protocol_encoding: vt100::MouseProtocolEncoding::Sgr,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::MouseReport);
+ }
+
+ #[test]
+ fn wheel_routing_uses_alternate_scroll_in_fullscreen_without_mouse_reporting() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: true,
+ application_cursor: false,
+ mouse_protocol_mode: vt100::MouseProtocolMode::None,
+ mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::AlternateScroll);
+ }
+
+ #[test]
+ fn wheel_routing_falls_back_to_host_scrollback() {
+ let input_state = crate::pane::InputState {
+ alternate_screen: false,
+ application_cursor: false,
+ mouse_protocol_mode: vt100::MouseProtocolMode::None,
+ mouse_protocol_encoding: vt100::MouseProtocolEncoding::Default,
+ mouse_alternate_scroll: true,
+ };
+
+ assert_eq!(wheel_routing(input_state), WheelRouting::HostScroll);
+ }
}
diff --git a/src/input/encode.rs b/src/input/encode.rs
index b114935a..ee466f42 100644
--- a/src/input/encode.rs
+++ b/src/input/encode.rs
@@ -1,4 +1,4 @@
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind};
use super::{KeyboardProtocol, TerminalKey};
@@ -23,6 +23,77 @@ pub fn encode_terminal_key(key: TerminalKey, protocol: KeyboardProtocol) -> Vec<
encode_legacy(key.as_key_event())
}
+pub fn encode_cursor_key(code: KeyCode, application_cursor: bool) -> Vec<u8> {
+ match (code, application_cursor) {
+ (KeyCode::Up, true) => b"\x1bOA".to_vec(),
+ (KeyCode::Down, true) => b"\x1bOB".to_vec(),
+ (KeyCode::Right, true) => b"\x1bOC".to_vec(),
+ (KeyCode::Left, true) => b"\x1bOD".to_vec(),
+ (KeyCode::Up, false) => b"\x1b[A".to_vec(),
+ (KeyCode::Down, false) => b"\x1b[B".to_vec(),
+ (KeyCode::Right, false) => b"\x1b[C".to_vec(),
+ (KeyCode::Left, false) => b"\x1b[D".to_vec(),
+ _ => encode_legacy(KeyEvent::new(code, KeyModifiers::empty())),
+ }
+}
+
+pub fn encode_mouse_scroll(
+ kind: MouseEventKind,
+ column: u16,
+ row: u16,
+ modifiers: KeyModifiers,
+ encoding: vt100::MouseProtocolEncoding,
+) -> Option<Vec<u8>> {
+ let button = match kind {
+ MouseEventKind::ScrollUp => 64u16,
+ MouseEventKind::ScrollDown => 65u16,
+ MouseEventKind::ScrollLeft => 66u16,
+ MouseEventKind::ScrollRight => 67u16,
+ _ => return None,
+ };
+
+ let mut cb = button;
+ if modifiers.contains(KeyModifiers::SHIFT) {
+ cb += 4;
+ }
+ if modifiers.contains(KeyModifiers::ALT) {
+ cb += 8;
+ }
+ if modifiers.contains(KeyModifiers::CONTROL) {
+ cb += 16;
+ }
+
+ let column = column as u32 + 1;
+ let row = row as u32 + 1;
+
+ match encoding {
+ vt100::MouseProtocolEncoding::Sgr => {
+ Some(format!("\x1b[<{cb};{column};{row}M").into_bytes())
+ }
+ vt100::MouseProtocolEncoding::Default => {
+ let cb = u8::try_from(cb + 32).ok()?;
+ let column = u8::try_from(column + 32).ok()?;
+ let row = u8::try_from(row + 32).ok()?;
+ Some(vec![0x1b, b'[', b'M', cb, column, row])
+ }
+ vt100::MouseProtocolEncoding::Utf8 => {
+ let mut bytes = Vec::with_capacity(16);
+ bytes.extend_from_slice(b"\x1b[M");
+ push_mouse_codepoint(&mut bytes, cb as u32 + 32)?;
+ push_mouse_codepoint(&mut bytes, column + 32)?;
+ push_mouse_codepoint(&mut bytes, row + 32)?;
+ Some(bytes)
+ }
+ }
+}
+
+fn push_mouse_codepoint(bytes: &mut Vec<u8>, value: u32) -> Option<()> {
+ let ch = char::from_u32(value)?;
+ let mut buf = [0u8; 4];
+ bytes.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
+ Some(())
+}
+
/// CSI u encoding: \e[{codepoint};{modifiers}u
/// Used when the child has pushed Kitty keyboard enhancement.
/// Returns None if the key doesn't need CSI u (unmodified basic keys).
diff --git a/src/input/mod.rs b/src/input/mod.rs
index de4330a4..b5ed99b4 100644
--- a/src/input/mod.rs
+++ b/src/input/mod.rs
@@ -2,7 +2,7 @@ mod encode;
mod model;
mod parse;
-pub use encode::{encode_key, encode_terminal_key};
+pub use encode::{encode_cursor_key, encode_key, encode_mouse_scroll, encode_terminal_key};
pub use model::{KeyboardProtocol, TerminalKey};
pub use parse::parse_terminal_key_sequence;
diff --git a/src/input/tests.rs b/src/input/tests.rs
index 0d9d2c74..e6900df7 100644
--- a/src/input/tests.rs
+++ b/src/input/tests.rs
@@ -1,7 +1,8 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, ModifierKeyCode};
use super::{
- encode_key, encode_terminal_key, parse_terminal_key_sequence, KeyboardProtocol, TerminalKey,
+ encode_cursor_key, encode_key, encode_mouse_scroll, encode_terminal_key,
+ parse_terminal_key_sequence, KeyboardProtocol, TerminalKey,
};
fn assert_terminal_key_eq(
@@ -143,6 +144,32 @@ fn legacy_alt_char_still_esc_prefix() {
}
#[test]
+fn application_cursor_keys_use_ss3_sequences() {
+ assert_eq!(encode_cursor_key(KeyCode::Up, true), b"\x1bOA");
+ assert_eq!(encode_cursor_key(KeyCode::Down, true), b"\x1bOB");
+}
+
+#[test]
+fn normal_cursor_keys_use_csi_sequences() {
+ assert_eq!(encode_cursor_key(KeyCode::Up, false), b"\x1b[A");
+ assert_eq!(encode_cursor_key(KeyCode::Down, false), b"\x1b[B");
+}
+
+#[test]
+fn sgr_mouse_scroll_encodes_wheel_button_and_coordinates() {
+ let encoded = encode_mouse_scroll(
+ crossterm::event::MouseEventKind::ScrollDown,
+ 4,
+ 6,
+ KeyModifiers::SHIFT,
+ vt100::MouseProtocolEncoding::Sgr,
+ )
+ .expect("mouse scroll should encode");
+
+ assert_eq!(encoded, b"\x1b[<69;5;7M");
+}
+
+#[test]
fn kitty_shift_enter() {
let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT);
assert_eq!(
diff --git a/src/pane.rs b/src/pane.rs
index f25c6a42..01d872a6 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -84,6 +84,7 @@ pub struct PaneRuntime {
current_size: Cell<(u16, u16)>,
child_pid: Arc<AtomicU32>,
pub kitty_keyboard_flags: Arc<AtomicU16>,
+ mouse_alternate_scroll: Arc<AtomicBool>,
/// Live screen content snapshot — updated by reader, read by detector.
/// Decouples detection from parser viewport state (scrollback).
/// Kept alive here so the Arc isn't dropped; tasks hold their own clones.
@@ -101,9 +102,12 @@ pub struct ScrollMetrics {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct ScrollState {
- pub metrics: ScrollMetrics,
+pub struct InputState {
pub alternate_screen: bool,
+ pub application_cursor: bool,
+ pub mouse_protocol_mode: vt100::MouseProtocolMode,
+ pub mouse_protocol_encoding: vt100::MouseProtocolEncoding,
+ pub mouse_alternate_scroll: bool,
}
impl Drop for PaneRuntime {
@@ -263,6 +267,7 @@ impl PaneRuntime {
let responses = PtyResponses::new();
let kitty_keyboard_flags = responses.kitty_keyboard_flags.clone();
+ let mouse_alternate_scroll = responses.mouse_alternate_scroll.clone();
let parser = Arc::new(RwLock::new(vt100::Parser::new_with_callbacks(
rows,
cols,
@@ -523,6 +528,7 @@ impl PaneRuntime {
current_size: Cell::new((rows, cols)),
child_pid,
kitty_keyboard_flags,
+ mouse_alternate_scroll,
screen_content,
detect_handle,
})
@@ -572,25 +578,32 @@ impl PaneRuntime {
}
}
- pub fn scroll_state(&self) -> Option<ScrollState> {
+ pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
let Ok(mut parser) = self.parser.write() else {
return None;
};
let max_offset_from_bottom = max_scrollback(&mut parser);
let screen = parser.screen();
let (viewport_rows, _) = screen.size();
- Some(ScrollState {
- metrics: ScrollMetrics {
- offset_from_bottom: screen.scrollback(),
- max_offset_from_bottom,
- viewport_rows: viewport_rows as usize,
- },
- alternate_screen: screen.alternate_screen(),
+ Some(ScrollMetrics {
+ offset_from_bottom: screen.scrollback(),
+ max_offset_from_bottom,
+ viewport_rows: viewport_rows as usize,
})
}
- pub fn scroll_metrics(&self) -> Option<ScrollMetrics> {
- self.scroll_state().map(|state| state.metrics)
+ pub fn input_state(&self) -> Option<InputState> {
+ let Ok(parser) = self.parser.read() else {
+ return None;
+ };
+ let screen = parser.screen();
+ Some(InputState {
+ alternate_screen: screen.alternate_screen(),
+ application_cursor: screen.application_cursor(),
+ mouse_protocol_mode: screen.mouse_protocol_mode(),
+ mouse_protocol_encoding: screen.mouse_protocol_encoding(),
+ mouse_alternate_scroll: self.mouse_alternate_scroll.load(Ordering::Relaxed),
+ })
}
pub fn visible_text(&self) -> String {
@@ -659,25 +672,45 @@ mod tests {
}
#[test]
- fn alternate_screen_accumulates_its_own_scrollback() {
+ fn alternate_screen_does_not_accumulate_host_scrollback() {
let responses = PtyResponses::new();
- let mut parser = vt100::Parser::new_with_callbacks(2, 10, 100, responses);
- parser.process(b"\x1b[?1049h1\r\n2\r\n3");
+ let mut parser = vt100::Parser::new_with_callbacks(3, 10, 100, responses);
+ parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4");
assert!(parser.screen().alternate_screen());
+ assert_eq!(max_scrollback(&mut parser), 0);
+ assert_eq!(recent_text_from_parser(&mut parser, 4), "2\n3\n4\n");
+ }
+
+ #[test]
+ fn normal_screen_top_anchored_scroll_regions_feed_scrollback() {
+ let responses = PtyResponses::new();
+ let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses);
+ parser.process(b"1\r\n2\r\n3\r\n4\r\n5");
+ parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX");
+
assert_eq!(max_scrollback(&mut parser), 1);
- assert_eq!(recent_text_from_parser(&mut parser, 3), "1\n2\n3\n");
}
#[test]
- fn top_anchored_scroll_regions_feed_scrollback() {
+ fn normal_screen_non_top_anchored_scroll_regions_do_not_feed_scrollback() {
+ let responses = PtyResponses::new();
+ let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses);
+ parser.process(b"1\r\n2\r\n3\r\n4\r\n5");
+ parser.process(b"\x1b[2;4r\x1b[4;1H\r\nX");
+
+ assert_eq!(max_scrollback(&mut parser), 0);
+ }
+
+ #[test]
+ fn alternate_screen_scroll_regions_do_not_create_host_scrollback() {
let responses = PtyResponses::new();
let mut parser = vt100::Parser::new_with_callbacks(5, 10, 100, responses);
parser.process(b"\x1b[?1049h1\r\n2\r\n3\r\n4\r\n5");
parser.process(b"\x1b[1;3r\x1b[3;1H\r\nX");
assert!(parser.screen().alternate_screen());
- assert_eq!(max_scrollback(&mut parser), 1);
+ assert_eq!(max_scrollback(&mut parser), 0);
}
#[test]
diff --git a/src/pty_callbacks.rs b/src/pty_callbacks.rs
index 44000701..f4df562a 100644
--- a/src/pty_callbacks.rs
+++ b/src/pty_callbacks.rs
@@ -14,7 +14,7 @@ use std::sync::{
/// Collects response bytes that need to be written back to the PTY.
/// Also tracks whether the child has requested the Kitty keyboard protocol.
-#[derive(Clone, Default)]
+#[derive(Clone)]
pub struct PtyResponses {
pending: Arc<Mutex<Vec<u8>>>,
/// Stack of kitty keyboard enhancement flags pushed by child programs.
@@ -27,6 +27,21 @@ pub struct PtyResponses {
pub kitty_keyboard: Arc<AtomicBool>,
/// Exact active kitty keyboard flags from the top of the stack.
pub kitty_keyboard_flags: Arc<AtomicU16>,
+ /// Tracks DECSET 1007 (alternate scroll mode).
+ /// Default on, matching Ghostty/xterm-style behavior for fullscreen apps.
+ pub mouse_alternate_scroll: Arc<AtomicBool>,
+}
+
+impl Default for PtyResponses {
+ fn default() -> Self {
+ Self {
+ pending: Arc::default(),
+ kitty_stack: Arc::default(),
+ kitty_keyboard: Arc::new(AtomicBool::new(false)),
+ kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
+ mouse_alternate_scroll: Arc::new(AtomicBool::new(true)),
+ }
+ }
}
impl PtyResponses {
@@ -43,6 +58,12 @@ impl PtyResponses {
fn push(&self, bytes: &[u8]) {
self.pending.lock().unwrap().extend_from_slice(bytes);
}
+
+ fn has_private_mode(params: &[&[u16]], mode: u16) -> bool {
+ params
+ .iter()
+ .any(|param| param.len() == 1 && param[0] == mode)
+ }
}
impl vt100::Callbacks for PtyResponses {
@@ -100,8 +121,17 @@ impl vt100::Callbacks for PtyResponses {
// DECRQM: \e[?Np → "is DEC private mode N set?"
// Response: \e[?N;Ps$y where Ps = 1 (set), 2 (reset), 0 (unknown)
(Some(b'?'), 'p') => {
- // Report all queried modes as "reset" (2) — safe default
- let response = format!("\x1b[?{param0};2$y");
+ let state = match param0 {
+ 1007 => {
+ if self.mouse_alternate_scroll.load(Ordering::Relaxed) {
+ 1
+ } else {
+ 2
+ }
+ }
+ _ => 2,
+ };
+ let response = format!("\x1b[?{param0};{state}$y");
self.push(response.as_bytes());
}
@@ -113,6 +143,14 @@ impl vt100::Callbacks for PtyResponses {
// === Keyboard Protocol ===
+ // DECSET/DECRST 1007: alternate scroll mode.
+ (Some(b'?'), 'h') if Self::has_private_mode(params, 1007) => {
+ self.mouse_alternate_scroll.store(true, Ordering::Relaxed);
+ }
+ (Some(b'?'), 'l') if Self::has_private_mode(params, 1007) => {
+ self.mouse_alternate_scroll.store(false, Ordering::Relaxed);
+ }
+
// Kitty keyboard query: \e[?u → "what keyboard flags are active?"
(Some(b'?'), 'u') => {
let stack = self.kitty_stack.lock().unwrap();
@@ -252,6 +290,32 @@ mod tests {
}
#[test]
+ fn alternate_scroll_defaults_on_and_reports_set() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ assert!(r.mouse_alternate_scroll.load(Ordering::Relaxed));
+ p.process(b"\x1b[?1007p");
+ assert_eq!(r.take(), b"\x1b[?1007;1$y");
+ }
+
+ #[test]
+ fn decset_decrst_1007_updates_alternate_scroll_mode() {
+ let r = PtyResponses::new();
+ let mut p = make_parser(r.clone());
+
+ p.process(b"\x1b[?1007l");
+ assert!(!r.mouse_alternate_scroll.load(Ordering::Relaxed));
+ p.process(b"\x1b[?1007p");
+ assert_eq!(r.take(), b"\x1b[?1007;2$y");
+
+ p.process(b"\x1b[?1007h");
+ assert!(r.mouse_alternate_scroll.load(Ordering::Relaxed));
+ p.process(b"\x1b[?1007p");
+ assert_eq!(r.take(), b"\x1b[?1007;1$y");
+ }
+
+ #[test]
fn responds_to_kitty_keyboard_query() {
let r = PtyResponses::new();
let mut p = make_parser(r.clone());
diff --git a/src/ui.rs b/src/ui.rs
index df9216a1..b3f0152e 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -121,14 +121,10 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let mut inner_rect = area;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&focused_id) {
- let detected_agent = ws
- .panes
- .get(&focused_id)
- .and_then(|pane| pane.detected_agent);
- if rt.scroll_state().is_some_and(|state| {
- should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent)
- && area.width > 1
- }) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 1)
+ {
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
area.x + area.width.saturating_sub(1),
@@ -168,11 +164,10 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let mut inner_rect = pane_inner;
let mut scrollbar_rect = None;
if let Some(rt) = ws.runtimes.get(&info.id) {
- let detected_agent = ws.panes.get(&info.id).and_then(|pane| pane.detected_agent);
- if rt.scroll_state().is_some_and(|state| {
- should_show_scrollbar(state.metrics, state.alternate_screen, detected_agent)
- && pane_inner.width > 1
- }) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 1)
+ {
inner_rect.width = inner_rect.width.saturating_sub(1);
scrollbar_rect = Some(Rect::new(
pane_inner.x + pane_inner.width.saturating_sub(1),
@@ -699,20 +694,8 @@ pub(crate) fn pane_scrollbar_rect(info: &PaneInfo) -> Option<Rect> {
info.scrollbar_rect
}
-fn should_show_scrollbar(
- metrics: crate::pane::ScrollMetrics,
- alternate_screen: bool,
- detected_agent: Option<crate::detect::Agent>,
-) -> bool {
- if metrics.max_offset_from_bottom == 0 {
- return false;
- }
-
- if !alternate_screen {
- return true;
- }
-
- metrics.offset_from_bottom > 0 || detected_agent.is_some()
+fn should_show_scrollbar(metrics: crate::pane::ScrollMetrics) -> bool {
+ metrics.max_offset_from_bottom > 0
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -1845,51 +1828,25 @@ mod tests {
}
#[test]
- fn alternate_screen_scrollbar_stays_hidden_for_unidentified_live_bottom() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 0,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
-
- assert!(!should_show_scrollbar(metrics, true, None));
- }
-
- #[test]
- fn alternate_screen_scrollbar_shows_for_agents_at_live_bottom() {
+ fn scrollbar_stays_hidden_without_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
- max_offset_from_bottom: 20,
- viewport_rows: 5,
- };
-
- assert!(should_show_scrollbar(
- metrics,
- true,
- Some(crate::detect::Agent::Codex)
- ));
- }
-
- #[test]
- fn alternate_screen_scrollbar_shows_when_user_scrolled_up() {
- let metrics = crate::pane::ScrollMetrics {
- offset_from_bottom: 3,
- max_offset_from_bottom: 20,
+ max_offset_from_bottom: 0,
viewport_rows: 5,
};
- assert!(should_show_scrollbar(metrics, true, None));
+ assert!(!should_show_scrollbar(metrics));
}
#[test]
- fn normal_screen_scrollbar_shows_with_scrollback() {
+ fn scrollbar_shows_with_scrollback() {
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: 0,
max_offset_from_bottom: 20,
viewport_rows: 5,
};
- assert!(should_show_scrollbar(metrics, false, None));
+ assert!(should_show_scrollbar(metrics));
}
#[test]