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
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
//! Left-mouse press and release handlers.
//!
//! Extracted from `mouse_button` to keep that file under 500 lines.
//!
//! Contains:
//! - `handle_left_mouse_press` — scrollbar, divider, pane-focus, gutter, selection anchoring
//! - `handle_left_mouse_release` — end drag (scrollbar/divider), copy selection to clipboard
use crate::app::window_state::WindowState;
use crate::terminal::ClipboardSlot;
impl WindowState {
pub(super) fn handle_left_mouse_press(&mut self, mouse_position: (f64, f64)) {
// --- 5. Scrollbar Interaction ---
// Check if clicking/dragging the scrollbar track or thumb
let mouse_x = mouse_position.0 as f32;
let mouse_y = mouse_position.1 as f32;
if let Some(renderer) = &self.renderer
&& renderer.scrollbar_track_contains_x(mouse_x)
{
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_scroll_state_mut().dragging = true;
tab.active_scroll_state_mut().last_activity = std::time::Instant::now();
let thumb_bounds = renderer.scrollbar_thumb_bounds();
if renderer.scrollbar_contains_point(mouse_x, mouse_y) {
// Clicked on thumb: track offset from thumb top for precise dragging
tab.active_scroll_state_mut().drag_offset = thumb_bounds
.map(|(thumb_top, thumb_height)| {
(mouse_y - thumb_top).clamp(0.0, thumb_height)
})
.unwrap_or(0.0);
} else {
// Clicked on track: center thumb on mouse position
tab.active_scroll_state_mut().drag_offset = thumb_bounds
.map(|(_, thumb_height)| thumb_height / 2.0)
.unwrap_or(0.0);
}
}
self.drag_scrollbar_to(mouse_y);
return; // Exit early: scrollbar handling takes precedence over selection
}
// --- 5b. Divider Click ---
// Check if clicking on a pane divider to start resize
if let Some(tab) = self.tab_manager.active_tab_mut()
&& let Some(divider_idx) = tab.find_divider_at(mouse_x, mouse_y)
{
// Start divider drag
tab.active_mouse_mut().dragging_divider = Some(divider_idx);
log::debug!("Started dragging divider {}", divider_idx);
return; // Exit early: divider drag started
}
// --- 5c. Pane Focus ---
// If tab has multiple panes, focus the clicked pane.
// Return early to prevent falling through to selection anchoring —
// without this, slight mouse movement during the click creates an
// accidental micro-selection that overwrites clipboard contents.
if let Some(tab) = self.tab_manager.active_tab_mut()
&& tab.has_multiple_panes()
{
// End any active drag on the OLD focused pane before switching focus.
// The selection itself persists (visible but inactive), matching iTerm2 behavior.
tab.selection_mouse_mut().is_selecting = false;
if let Some(pane_id) = tab.focus_pane_at(mouse_x, mouse_y) {
log::debug!("Focused pane {} via mouse click", pane_id);
// Also update tmux focused pane for correct input routing
self.set_tmux_focused_pane_from_native(pane_id);
// Reset scroll to bottom when switching pane focus so the
// newly-focused pane doesn't inherit the previous pane's scroll offset.
self.set_scroll_target(0);
self.focus_state.needs_redraw = true;
return;
}
}
// --- 5d. Prettifier Gutter Click ---
// Check if clicking in the gutter area to toggle a prettified block
if let Some((col, row)) = self.pixel_to_cell(mouse_position.0, mouse_position.1) {
let viewport_rows = self
.renderer
.as_ref()
.map(|r| r.grid_size().1)
.unwrap_or(24);
let handled = if let Some(tab) = self.tab_manager.active_tab_mut() {
if let Some(ref pipeline) = tab.prettifier {
let scroll_offset = tab.active_scroll_state().offset;
let indicators = tab.gutter_manager.indicators_for_viewport(
pipeline,
scroll_offset,
viewport_rows,
);
if let Some(block_id) = tab.gutter_manager.hit_test(col, row, &indicators) {
if let Some(ref mut p) = tab.prettifier {
p.toggle_block(block_id);
}
self.focus_state.needs_redraw = true;
true
} else {
false
}
} else {
false
}
} else {
false
};
if handled {
return;
}
}
// --- 6. Selection Anchoring & Click Counting ---
// Handle complex selection modes based on click sequence
// Use pane-relative coordinates in split-pane mode so selections
// are stored relative to the focused pane's terminal buffer.
if let Some((col, row)) = self.pixel_to_selection_cell(mouse_position.0, mouse_position.1) {
let now = std::time::Instant::now();
// Read current click state from per-pane selection mouse
let (same_position, click_count, last_click_time) = self
.tab_manager
.active_tab()
.map(|t| {
let sm = t.selection_mouse();
(
sm.click_position == Some((col, row)),
sm.click_count,
sm.last_click_time,
)
})
.unwrap_or((false, 0, None));
// Thresholds for sequential clicks (double/triple)
let threshold_ms = if click_count == 1 {
self.config.mouse_double_click_threshold
} else {
self.config.mouse_triple_click_threshold
};
let click_threshold = std::time::Duration::from_millis(threshold_ms);
// Determine new click count
let new_click_count = if same_position
&& last_click_time.is_some_and(|t| now.duration_since(t) < click_threshold)
{
(click_count + 1).min(3)
} else {
1
};
// Update selection mouse state (per-pane in split mode)
if let Some(tab) = self.tab_manager.active_tab_mut() {
let sm = tab.selection_mouse_mut();
if new_click_count == 1 {
// Clear previous selection on new single click
sm.selection = None;
}
sm.click_count = new_click_count;
sm.last_click_time = Some(now);
sm.click_position = Some((col, row));
sm.click_pixel_position = Some(mouse_position);
}
// Apply immediate selection based on click count
if new_click_count == 2 {
// Double-click: Anchor word selection
self.select_word_at(col, row);
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.selection_mouse_mut().is_selecting = false; // Word selection is static until drag starts
}
self.request_redraw();
} else if new_click_count == 3 {
// Triple-click: Anchor full-line selection
self.select_line_at(row);
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.selection_mouse_mut().is_selecting = true; // Triple-click usually implies immediate drag intent
}
self.request_redraw();
} else {
// Single click: Reset state and wait for drag to start Normal/Rectangular selection
if let Some(tab) = self.tab_manager.active_tab_mut() {
let sm = tab.selection_mouse_mut();
sm.is_selecting = false;
sm.selection = None;
}
self.request_redraw();
}
}
}
pub(super) fn handle_left_mouse_release(&mut self) {
// End scrollbar drag
let is_dragging = self
.tab_manager
.active_tab()
.map(|t| t.active_scroll_state().dragging)
.unwrap_or(false);
if is_dragging && let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_scroll_state_mut().dragging = false;
tab.active_scroll_state_mut().drag_offset = 0.0;
return;
}
// End divider drag
let divider_info = self.tab_manager.active_tab().and_then(|t| {
let idx = t.active_mouse().dragging_divider?;
let divider = t.get_divider(idx)?;
Some((idx, divider.is_horizontal))
});
if let Some((_divider_idx, is_horizontal)) = divider_info {
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_mouse_mut().dragging_divider = None;
log::debug!("Ended divider drag");
}
// Sync pane resize to tmux if gateway is active
// Pass whether this was a horizontal divider (affects height) or vertical (affects width)
self.sync_pane_resize_to_tmux(is_horizontal);
self.focus_state.needs_redraw = true;
self.request_redraw();
return;
} else if self
.tab_manager
.active_tab()
.and_then(|t| t.active_mouse().dragging_divider)
.is_some()
{
// Fallback: divider was being dragged but we couldn't get info
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.active_mouse_mut().dragging_divider = None;
log::debug!("Ended divider drag (no info)");
}
self.focus_state.needs_redraw = true;
self.request_redraw();
return;
}
// End selection and optionally copy to clipboard/primary selection
if let Some(tab) = self.tab_manager.active_tab_mut() {
tab.selection_mouse_mut().is_selecting = false;
}
if let Some(selected_text) = self.get_selected_text_for_copy() {
// Always copy to primary selection (Linux X11 - no-op on other platforms)
if let Err(e) = self.input_handler.copy_to_primary_selection(&selected_text) {
log::debug!("Failed to copy to primary selection: {}", e);
} else {
log::debug!("Copied {} chars to primary selection", selected_text.len());
}
// Copy to clipboard if auto_copy is enabled
if self.config.auto_copy_selection {
if let Err(e) = self.input_handler.copy_to_clipboard(&selected_text) {
log::error!("Failed to copy to clipboard: {}", e);
} else {
log::debug!("Copied {} chars to clipboard", selected_text.len());
// Sync to tmux paste buffer if connected
self.sync_clipboard_to_tmux(&selected_text);
}
}
// Add to clipboard history (once, regardless of which clipboard was used)
// try_lock: intentional — called from mouse release handler in sync loop.
// On miss: this selection is not added to clipboard history. The clipboard
// content itself was already copied above (separate operation).
if let Some(tab) = self.tab_manager.active_tab()
&& let Ok(term) = tab.terminal.try_write()
{
term.add_to_clipboard_history(
ClipboardSlot::Clipboard,
selected_text.clone(),
None,
);
}
}
}
}