aboutsummaryrefslogtreecommitdiff
path: root/src/app/input.rs
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/app/input.rs
parent5f97b0c26d848ef36aed70df906893907e2d0686 (diff)
fix: align pane scrolling with tmux and ghostty
Diffstat (limited to 'src/app/input.rs')
-rw-r--r--src/app/input.rs169
1 files changed, 143 insertions, 26 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);
+ }
}