fix: align pane scrolling with tmux and ghostty
This commit is contained in:
parent
5f97b0c26d
commit
e857710d73
169
src/app/input.rs
169
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
@ -142,6 +143,32 @@ fn legacy_alt_char_still_esc_prefix() {
|
|||
assert_eq!(encode_key(key, KeyboardProtocol::Legacy), b"\x1ba");
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
|
|
|||
71
src/pane.rs
71
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), 1);
|
||||
assert_eq!(recent_text_from_parser(&mut parser, 3), "1\n2\n3\n");
|
||||
assert_eq!(max_scrollback(&mut parser), 0);
|
||||
assert_eq!(recent_text_from_parser(&mut parser, 4), "2\n3\n4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_anchored_scroll_regions_feed_scrollback() {
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -251,6 +289,32 @@ mod tests {
|
|||
assert_eq!(r.take(), b"\x1b[?25;2$y"); // "reset" (2)
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
|
|
|||
89
src/ui.rs
89
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() {
|
||||
fn scrollbar_stays_hidden_without_scrollback() {
|
||||
let metrics = crate::pane::ScrollMetrics {
|
||||
offset_from_bottom: 0,
|
||||
max_offset_from_bottom: 0,
|
||||
viewport_rows: 5,
|
||||
};
|
||||
|
||||
assert!(!should_show_scrollbar(metrics));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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, true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_screen_scrollbar_shows_for_agents_at_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,
|
||||
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,
|
||||
viewport_rows: 5,
|
||||
};
|
||||
|
||||
assert!(should_show_scrollbar(metrics, true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_screen_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]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
|
@ -18,6 +18,7 @@ fn unique_test_dir() -> PathBuf {
|
|||
|
||||
struct SpawnedHerdr {
|
||||
_master: Box<dyn MasterPty + Send>,
|
||||
_drain_thread: thread::JoinHandle<()>,
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
|
|
@ -76,10 +77,22 @@ fn spawn_herdr_with_path(
|
|||
cmd.env("PATH", path);
|
||||
}
|
||||
|
||||
let mut reader = pair.master.try_clone_reader().unwrap();
|
||||
let drain_thread = thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
|
||||
SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
_drain_thread: drain_thread,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use std::fs;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
|
@ -18,6 +18,7 @@ fn unique_test_dir() -> PathBuf {
|
|||
|
||||
struct SpawnedHerdr {
|
||||
_master: Box<dyn MasterPty + Send>,
|
||||
_drain_thread: thread::JoinHandle<()>,
|
||||
child: Box<dyn Child + Send + Sync>,
|
||||
}
|
||||
|
||||
|
|
@ -57,9 +58,21 @@ fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> Sp
|
|||
cmd.env("HERDR_SOCKET_PATH", socket_path);
|
||||
cmd.env_remove("HERDR_ENV");
|
||||
|
||||
let mut reader = pair.master.try_clone_reader().unwrap();
|
||||
let drain_thread = thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
_drain_thread: drain_thread,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
|
@ -515,9 +528,20 @@ fn wait_agent_state_exits_when_state_matches() {
|
|||
std::env::var("PATH").unwrap_or_default()
|
||||
),
|
||||
);
|
||||
let mut reader = pair.master.try_clone_reader().unwrap();
|
||||
let drain_thread = thread::spawn(move || {
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let child = pair.slave.spawn_command(cmd).unwrap();
|
||||
let mut herdr = SpawnedHerdr {
|
||||
_master: pair.master,
|
||||
_drain_thread: drain_thread,
|
||||
child,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -59,11 +59,6 @@ impl Grid {
|
|||
self.saved_origin_mode = false;
|
||||
}
|
||||
|
||||
pub fn clear_scrollback(&mut self) {
|
||||
self.scrollback.clear();
|
||||
self.scrollback_offset = 0;
|
||||
}
|
||||
|
||||
pub fn size(&self) -> Size {
|
||||
self.size
|
||||
}
|
||||
|
|
@ -568,6 +563,8 @@ impl Grid {
|
|||
self.rows
|
||||
.insert(usize::from(self.scroll_bottom) + 1, self.new_row());
|
||||
let removed = self.rows.remove(usize::from(self.scroll_top));
|
||||
// Top-anchored scroll regions still represent terminal output moving
|
||||
// off the visible transcript and should feed host scrollback.
|
||||
if self.scrollback_len > 0 && self.scroll_top == 0 {
|
||||
self.scrollback.push_back(removed);
|
||||
while self.scrollback.len() > self.scrollback_len {
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ impl Screen {
|
|||
grid.allocate_rows();
|
||||
Self {
|
||||
grid,
|
||||
alternate_grid: crate::grid::Grid::new(size, scrollback_len),
|
||||
alternate_grid: crate::grid::Grid::new(size, 0),
|
||||
|
||||
attrs: crate::attrs::Attrs::default(),
|
||||
saved_attrs: crate::attrs::Attrs::default(),
|
||||
|
|
@ -652,7 +652,6 @@ impl Screen {
|
|||
self.grid_mut().set_scrollback(0);
|
||||
self.set_mode(MODE_ALTERNATE_SCREEN);
|
||||
self.alternate_grid.allocate_rows();
|
||||
self.grid_mut().set_scrollback(0);
|
||||
}
|
||||
|
||||
fn exit_alternate_grid(&mut self) {
|
||||
|
|
@ -1165,7 +1164,6 @@ impl Screen {
|
|||
[1049] => {
|
||||
self.decsc();
|
||||
self.alternate_grid.clear();
|
||||
self.alternate_grid.clear_scrollback();
|
||||
self.enter_alternate_grid();
|
||||
}
|
||||
[2004] => self.set_mode(MODE_BRACKETED_PASTE),
|
||||
|
|
|
|||
Loading…
Reference in New Issue