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
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
//! Text selection operations for WindowState.
//!
//! This module contains methods for selecting text in the terminal,
//! including word selection, line selection, and text extraction.
//!
//! Supports:
//! - Smart selection: Regex-based patterns (URLs, emails, paths) checked first
//! - Configurable word characters: User-defined characters considered part of a word
use crate::selection::{Selection, SelectionMode};
use crate::smart_selection::find_word_boundaries;
use crate::terminal::TerminalManager;
use std::sync::Arc;
use tokio::sync::RwLock;
use super::WindowState;
impl WindowState {
/// Get the terminal and scroll offset for text selection operations.
///
/// In split-pane mode, returns the focused pane's terminal and scroll offset.
/// Otherwise, returns the tab's terminal and scroll offset.
fn selection_terminal_and_offset(&self) -> Option<(Arc<RwLock<TerminalManager>>, usize)> {
let tab = self.tab_manager.active_tab()?;
if let Some(ref pm) = tab.pane_manager
&& let Some(focused_pane) = pm.focused_pane()
{
Some((
Arc::clone(&focused_pane.terminal),
focused_pane.scroll_state.offset,
))
} else {
Some((Arc::clone(&tab.terminal), tab.active_scroll_state().offset))
}
}
/// Select word at the given position using smart selection and word boundary rules.
///
/// Selection priority:
/// 1. If smart_selection_enabled, try pattern-based selection (URLs, emails, etc.)
/// 2. Fall back to word boundary selection using configurable word_characters
pub(crate) fn select_word_at(&mut self, col: usize, row: usize) {
let (terminal_arc, scroll_offset) = if let Some(v) = self.selection_terminal_and_offset() {
v
} else {
return;
};
// blocking_write: user-initiated double-click selection — must succeed
// for the word to be highlighted.
let term = terminal_arc.blocking_write();
let (cols, _rows) = term.dimensions();
let visible_cells = term.get_cells_with_scrollback(scroll_offset, None, false, None);
drop(term); // Release lock before accessing self fields
if visible_cells.is_empty() || cols == 0 {
return;
}
let cell_idx = row * cols + col;
if cell_idx >= visible_cells.len() {
return;
}
// Build the line string for this row
let row_start_idx = row * cols;
let row_end_idx = (row_start_idx + cols).min(visible_cells.len());
let line: String = visible_cells[row_start_idx..row_end_idx]
.iter()
.map(|c| c.grapheme.as_str())
.collect();
// Get config values
let smart_selection_enabled = self.config.smart_selection_enabled;
let word_characters = self.config.word_characters.clone();
let smart_selection_rules = self.config.smart_selection_rules.clone();
// Try smart selection first if enabled
let (start_col, end_col) = if smart_selection_enabled {
// Get or create the smart selection matcher
let matcher = self
.smart_selection_cache
.get_matcher(&smart_selection_rules);
if let Some((start, end)) = matcher.find_match_at(&line, col) {
(start, end)
} else {
// Fall back to word boundary selection
find_word_boundaries(&line, col, &word_characters)
}
} else {
// Smart selection disabled, use word boundary selection
find_word_boundaries(&line, col, &word_characters)
};
// Now update per-pane selection state
if let Some(tab) = self.tab_manager.active_tab_mut() {
let so = tab.active_scroll_state().offset;
tab.selection_mouse_mut().selection = Some(Selection::new(
(start_col, row),
(end_col, row),
SelectionMode::Normal,
so,
));
}
}
/// Select entire line at the given row (used for triple-click)
pub(crate) fn select_line_at(&mut self, row: usize) {
let (terminal_arc, _scroll_offset) = if let Some(v) = self.selection_terminal_and_offset() {
v
} else {
return;
};
// blocking_write: user-initiated triple-click selection — must succeed
// for the line to be highlighted.
let term = terminal_arc.blocking_write();
let (cols, _rows) = term.dimensions();
drop(term);
if cols == 0 {
return;
}
// Store the row in start/end - Line mode uses rows only
if let Some(tab) = self.tab_manager.active_tab_mut() {
let so = tab.active_scroll_state().offset;
tab.selection_mouse_mut().selection = Some(Selection::new(
(0, row),
(cols.saturating_sub(1), row),
SelectionMode::Line,
so,
));
}
}
/// Extend line selection to include rows from anchor to current row
pub(crate) fn extend_line_selection(&mut self, current_row: usize) {
// Get cols from terminal and click_position from mouse
let (cols, anchor_row) = {
let (terminal_arc, _scroll_offset) =
if let Some(v) = self.selection_terminal_and_offset() {
v
} else {
return;
};
// try_write: intentional — triple-click drag extension runs on every
// mouse-move frame. On miss: selection is not extended this frame;
// the user sees a brief lag. High-frequency; acceptable loss.
let cols = if let Ok(term) = terminal_arc.try_write() {
let (cols, _rows) = term.dimensions();
if cols == 0 {
return;
}
cols
} else {
return;
};
// Use click_position as the anchor row (the originally triple-clicked row)
let anchor_row = self
.tab_manager
.active_tab()
.and_then(|t| t.selection_mouse().click_position)
.map(|(_, r)| r)
.unwrap_or(current_row);
(cols, anchor_row)
};
// Now update per-pane selection
if let Some(tab) = self.tab_manager.active_tab_mut() {
let so = tab.active_scroll_state().offset;
if let Some(ref mut selection) = tab.selection_mouse_mut().selection
&& selection.mode == SelectionMode::Line
{
// For line selection, always ensure full lines are selected
// by setting columns appropriately based on drag direction
selection.scroll_offset = so;
if current_row >= anchor_row {
// Dragging down or same row: start at col 0, end at last col
selection.start = (0, anchor_row);
selection.end = (cols.saturating_sub(1), current_row);
} else {
// Dragging up: start at last col (anchor row), end at col 0 (current row)
// After normalization, this becomes: start=(0, current_row), end=(cols-1, anchor_row)
selection.start = (cols.saturating_sub(1), anchor_row);
selection.end = (0, current_row);
}
}
}
}
/// Extract selected text from terminal.
///
/// Uses `blocking_write()` because this is called on mouse release (user-initiated)
/// and must succeed to copy the selection to the clipboard. In split-pane mode,
/// reads from the focused pane's terminal rather than the tab's gateway terminal.
pub(crate) fn get_selected_text(&self) -> Option<String> {
let tab = self.tab_manager.active_tab()?;
let selection = tab.selection_mouse().selection.as_ref()?;
// Get the correct terminal and scroll offset (pane-aware)
let (terminal_arc, scroll_offset) = self.selection_terminal_and_offset()?;
// blocking_write: user-initiated copy on mouse release — must succeed to
// avoid silently dropping the selection. This is an infrequent operation
// (once per mouse release) so the brief lock wait is acceptable.
let term = terminal_arc.blocking_write();
let (start, end) = selection.normalized();
let (start_col, start_row) = start;
let (end_col, end_row) = end;
let (cols, rows) = term.dimensions();
let visible_cells = term.get_cells_with_scrollback(scroll_offset, None, false, None);
if visible_cells.is_empty() || cols == 0 {
return None;
}
let mut visible_lines = Vec::with_capacity(rows);
for row in 0..rows {
let start_idx = row * cols;
let end_idx = start_idx.saturating_add(cols);
if end_idx > visible_cells.len() {
break;
}
let mut line = String::with_capacity(cols);
for cell in &visible_cells[start_idx..end_idx] {
line.push_str(&cell.grapheme);
}
visible_lines.push(line);
}
if visible_lines.is_empty() {
return None;
}
let mut selected_text = String::new();
let max_row = visible_lines.len().saturating_sub(1);
let start_row = start_row.min(max_row);
let end_row = end_row.min(max_row);
if selection.mode == SelectionMode::Line {
// Line selection: extract full lines
#[allow(clippy::needless_range_loop)]
for row in start_row..=end_row {
if row > start_row {
selected_text.push('\n');
}
let line = &visible_lines[row];
// Trim trailing spaces from each line but keep the content
selected_text.push_str(line.trim_end());
}
} else if selection.mode == SelectionMode::Rectangular {
// Rectangular selection: extract same columns from each row
let min_col = start_col.min(end_col);
let max_col = start_col.max(end_col);
#[allow(clippy::needless_range_loop)]
for row in start_row..=end_row {
if row > start_row {
selected_text.push('\n');
}
let line = &visible_lines[row];
selected_text.push_str(&Self::extract_columns(line, min_col, Some(max_col)));
}
} else if start_row == end_row {
// Normal single-line selection
let line = &visible_lines[start_row];
selected_text = Self::extract_columns(line, start_col, Some(end_col));
} else {
// Normal multi-line selection
for (idx, row) in (start_row..=end_row).enumerate() {
let line = &visible_lines[row];
if idx == 0 {
selected_text.push_str(&Self::extract_columns(line, start_col, None));
} else if row == end_row {
selected_text.push('\n');
selected_text.push_str(&Self::extract_columns(line, 0, Some(end_col)));
} else {
selected_text.push('\n');
selected_text.push_str(line);
}
}
}
Some(selected_text)
}
/// Extract selected text and normalize it for clipboard copy operations.
///
/// Applies the `copy_trailing_newline` setting and drops selections that become
/// empty after normalization to avoid clobbering an existing clipboard payload
/// (for example an image clipboard) with an empty text write.
pub(crate) fn get_selected_text_for_copy(&self) -> Option<String> {
let mut selected_text = self.get_selected_text()?;
if selected_text.is_empty() {
return None;
}
// Inverted config logic: false means strip trailing line endings.
if !self.config.copy_trailing_newline {
while selected_text.ends_with('\n') || selected_text.ends_with('\r') {
selected_text.pop();
}
}
if selected_text.is_empty() {
log::debug!(
"Skipping clipboard copy: selection became empty after newline normalization"
);
return None;
}
Some(selected_text)
}
/// Get copy text from the prettifier pipeline if the selection overlaps a prettified block.
///
/// Returns the rendered or source text from the block based on the clipboard config's
/// `default_copy` setting. Returns `None` if no prettifier is active or the selection
/// doesn't overlap a prettified block.
pub(crate) fn get_prettifier_copy_text(&self) -> Option<String> {
let tab = self.tab_manager.active_tab()?;
let pipeline = tab.prettifier.as_ref()?;
if !pipeline.is_enabled() {
return None;
}
let selection = tab.selection_mouse().selection.as_ref()?;
let (start, _end) = selection.normalized();
let start_row = start.1 + tab.active_scroll_state().offset;
let block = pipeline.block_at_row(start_row)?;
// Use the clipboard default_copy config to decide what to return.
let default_copy = &self.config.content_prettifier.clipboard.default_copy;
if default_copy == "source" {
Some(block.buffer.source_text())
} else {
// "rendered" (default): prefer rendered text, fall back to source.
block
.buffer
.rendered_text()
.or_else(|| Some(block.buffer.source_text()))
}
}
}