1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
use crate::app::window_state::WindowState;
use std::sync::Arc;
use winit::event::MouseScrollDelta;
impl WindowState {
pub(crate) fn handle_mouse_wheel(&mut self, delta: MouseScrollDelta) {
// Check if profile drawer is open - let egui handle scroll events
if self.overlay_ui.profile_drawer_ui.expanded {
self.request_redraw();
return;
}
// --- 1. Mouse Tracking Protocol ---
// Check if the terminal application (e.g., vim, htop) has requested mouse tracking.
// If enabled, we forward wheel events to the PTY instead of scrolling locally.
// In split pane mode, check and route to the focused pane's terminal.
//
// IMPORTANT: On try_lock miss, return `(terminal, None)` so the caller can
// distinguish "lock failed" from "lock succeeded, tracking is off". When the
// lock fails we must NOT fall through to local scrolling — doing so scrolls
// par-term's own scrollback while tmux (which has mouse tracking on) expects
// the events, causing a brief flash of old scrollback content.
let (terminal_for_tracking, mouse_tracking_state): (Option<Arc<_>>, Option<bool>) =
if let Some(tab) = self.tab_manager.active_tab() {
if let Some(ref pm) = tab.pane_manager
&& let Some(focused_pane) = pm.focused_pane()
{
// try_lock: intentional — scroll wheel handler in sync event loop.
// On miss: returns None so the event is skipped entirely.
let tracking = focused_pane
.terminal
.try_write()
.ok()
.map(|term| term.is_mouse_tracking_enabled());
(Some(Arc::clone(&focused_pane.terminal)), tracking)
} else {
// try_lock: intentional — same rationale as focused_pane path above.
let tracking = tab
.terminal
.try_write()
.ok()
.map(|term| term.is_mouse_tracking_enabled());
(Some(Arc::clone(&tab.terminal)), tracking)
}
} else {
(None, Some(false))
};
// Lock contention — skip this scroll event entirely. The next scroll tick
// will re-check. This prevents local scrollback jumps when tmux (or another
// app with mouse tracking) is running and the PTY reader holds the lock.
if mouse_tracking_state.is_none() {
return;
}
let is_mouse_tracking = mouse_tracking_state.unwrap_or(false);
if is_mouse_tracking && let Some(terminal_arc) = terminal_for_tracking {
// Calculate scroll amounts based on delta type (Line vs Pixel)
let (scroll_x, scroll_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (x as i32, y as i32),
MouseScrollDelta::PixelDelta(pos) => ((pos.x / 20.0) as i32, (pos.y / 20.0) as i32),
};
// Get mouse position from active tab
let mouse_position = self
.tab_manager
.active_tab()
.map(|t| t.active_mouse().position)
.unwrap_or((0.0, 0.0));
// Map pixel position to cell coordinates (pane-relative if split panes exist)
// For scroll events, fall back to (0, 0) if outside pane bounds — scroll
// should still reach the focused pane even when mouse drifts onto a divider.
let (col, row) = if let Some(tab) = self.tab_manager.active_tab()
&& let Some(ref pm) = tab.pane_manager
&& let Some(focused_pane) = pm.focused_pane()
{
self.pixel_to_pane_cell(mouse_position.0, mouse_position.1, &focused_pane.bounds)
.unwrap_or((0, 0))
} else {
self.pixel_to_cell(mouse_position.0, mouse_position.1)
.unwrap_or((0, 0))
};
let mut all_encoded = Vec::new();
// --- 1a. Vertical scroll events ---
// XTerm mouse protocol buttons: 64 = scroll up, 65 = scroll down
if scroll_y != 0 {
let button = if scroll_y > 0 { 64 } else { 65 };
// Limit burst to 10 events to avoid flooding the PTY
let count = scroll_y.unsigned_abs().min(10);
// try_lock: intentional — scroll wheel encoding in sync event loop.
// On miss: the scroll events are not encoded for this wheel tick.
// The next wheel tick will succeed. Terminal apps may notice skipped ticks.
if let Ok(term) = terminal_arc.try_write() {
for _ in 0..count {
let encoded = term.encode_mouse_event(button, col, row, true, 0);
if !encoded.is_empty() {
all_encoded.extend(encoded);
}
}
}
}
// --- 1b. Horizontal scroll events (if enabled) ---
// XTerm mouse protocol buttons: 66 = scroll left, 67 = scroll right
if self.config.report_horizontal_scroll && scroll_x != 0 {
let button = if scroll_x > 0 { 67 } else { 66 };
// Limit burst to 10 events to avoid flooding the PTY
let count = scroll_x.unsigned_abs().min(10);
// try_lock: intentional — horizontal scroll encoding, same as vertical above.
if let Ok(term) = terminal_arc.try_write() {
for _ in 0..count {
let encoded = term.encode_mouse_event(button, col, row, true, 0);
if !encoded.is_empty() {
all_encoded.extend(encoded);
}
}
}
}
// Send all encoded events to terminal
if !all_encoded.is_empty() {
let terminal_clone = Arc::clone(&terminal_arc);
let runtime = Arc::clone(&self.runtime);
runtime.spawn(async move {
let t = terminal_clone.write().await;
let _ = t.write(&all_encoded);
});
}
return; // Exit early: terminal app handled the input
}
// --- 2. Local Scrolling ---
// Normal behavior: scroll through the local scrollback buffer.
let scroll_lines = match delta {
MouseScrollDelta::LineDelta(_x, y) => (y * self.config.mouse_scroll_speed) as i32,
MouseScrollDelta::PixelDelta(pos) => (pos.y / 20.0) as i32,
};
let scrollback_len = self.get_active_scrollback_len();
// Calculate new scroll target (positive delta = scroll up = increase offset)
let new_target = if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_scroll_state_mut()
.apply_scroll(scroll_lines, scrollback_len)
} else {
return;
};
// Update target and trigger interpolation animation
self.set_scroll_target(new_target);
}
/// Set scroll target and initiate smooth interpolation animation.
pub(crate) fn set_scroll_target(&mut self, new_offset: usize) {
let target_set = if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_scroll_state_mut().set_target(new_offset)
} else {
false
};
if target_set {
// Request redraw to start the animation loop
self.request_redraw();
}
}
pub(crate) fn drag_scrollbar_to(&mut self, mouse_y: f32) {
let drag_offset = self
.tab_manager
.active_tab()
.map(|t| t.active_scroll_state().drag_offset)
.unwrap_or(0.0);
let current_offset = self
.tab_manager
.active_tab()
.map(|t| t.active_scroll_state().offset)
.unwrap_or(0);
if let Some(renderer) = &self.renderer {
let adjusted_y = mouse_y - drag_offset;
if let Some(new_offset) = renderer.scrollbar_mouse_y_to_scroll_offset(adjusted_y)
&& current_offset != new_offset
{
// Instant update for dragging (no animation)
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_scroll_state_mut().offset = new_offset;
tab.active_scroll_state_mut().target_offset = new_offset;
tab.active_scroll_state_mut().animated_offset = new_offset as f64;
tab.active_scroll_state_mut().animation_start = None;
}
self.request_redraw();
}
}
}
}