Skip to main content

tui_panel_select/
selection.rs

1//! Mouse (and keyboard-extended) text selection scoped to a single panel
2//! (Request JSON / Response).
3//!
4//! The terminal's own click-drag selection can't be confined to one panel —
5//! it always spans the full terminal row, sweeping up whatever's to the left
6//! (other panels, borders, etc.). To let users copy a long response body or
7//! URL cleanly, the app captures the mouse itself and implements its own
8//! selection: dragging inside a panel selects text using ordinary "stream"
9//! semantics (first line from the start column to its own end, full lines in
10//! between, last line from its own start to the end column) — never a
11//! rectangular block — and never anything outside that panel's own Rect.
12//!
13//! Selections are stored as [`TextPos`] (logical line/char-offset)
14//! positions, not terminal (row, col) cells — see `wrapcache` — so the exact
15//! same characters stay selected across a rewrap/rescroll/resize instead of
16//! silently re-interpreting stale screen coordinates against new content.
17
18use ratatui::layout::Rect;
19
20use crate::wrapcache::{PanelWrap, TextPos, WrapMode};
21
22/// Order two positions so the first is not after the second (a selection
23/// dragged "backwards" — up or left — still resolves correctly).
24pub fn ordered(a: TextPos, b: TextPos) -> (TextPos, TextPos) {
25    if a <= b { (a, b) } else { (b, a) }
26}
27
28/// Map a raw terminal (column, row) point onto the [`TextPos`] it
29/// corresponds to, given the panel's Rect, its current scroll offset (in
30/// wrapped rows), and its line/wrap cache. Points outside the area clamp to
31/// its nearest edge, exactly as the on-screen content does.
32pub fn point_to_textpos(point: (u16, u16), area: Rect, scroll: u16, wrap: &PanelWrap) -> TextPos {
33    let (col, row) = point;
34    let local_row = if area.height == 0 || row < area.y {
35        0
36    } else {
37        ((row - area.y) as u32).min(area.height as u32 - 1)
38    };
39    let local_col = if area.width == 0 || col < area.x {
40        0
41    } else {
42        (col - area.x) as usize
43    };
44    wrap.row_col_to_textpos(scroll as u32 + local_row, local_col)
45}
46
47/// The selected char range `(from, to_exclusive)` on `line`, given the
48/// selection's ordered endpoints — "stream" semantics: the first line runs
49/// from its start column to its own end, the last line from column 0 to its
50/// end column, every line strictly between is selected in full.
51fn range_for_line(line: usize, start: TextPos, end: TextPos, wrap: &PanelWrap) -> (usize, usize) {
52    let len = wrap.line_char_len(line);
53    if start.line == end.line {
54        (start.col.min(len), (end.col + 1).min(len))
55    } else if line == start.line {
56        (start.col.min(len), len)
57    } else if line == end.line {
58        (0, (end.col + 1).min(len))
59    } else {
60        (0, len)
61    }
62}
63
64/// Per-line selected character ranges `(line, char_from, char_to_exclusive)`
65/// across the *entire* selection (which may span far more lines than are
66/// currently visible on screen — e.g. after a drag-to-autoscroll). Used only
67/// for extraction (`extract_text`), where touching every selected line is
68/// unavoidable; never for painting the on-screen highlight (see
69/// `highlight_cells`, which bounds itself to the visible window instead).
70fn selection_ranges(start: TextPos, end: TextPos, wrap: &PanelWrap) -> Vec<(usize, usize, usize)> {
71    let mut out = Vec::new();
72    for line in start.line..=end.line {
73        if line >= wrap.line_count() {
74            break;
75        }
76        let (from, to) = range_for_line(line, start, end, wrap);
77        out.push((line, from, to));
78    }
79    out
80}
81
82/// Extract the selected text (lines joined with `\n`) between two logical
83/// positions. Cost is proportional only to the selected lines themselves
84/// (via `PanelWrap::line_text`'s O(1) slicing), never the panel's total
85/// content size. `None` when there's nothing to select (no content, or a
86/// purely blank selection). `exclude`, when given, drops any character
87/// whose position is in the set — used to keep purely-visual annotations
88/// (like the Request panel's shadow-warning icon; see
89/// `TuiApp::main_shadow_icon_positions`) out of copied text even though
90/// they're part of what's shown on screen.
91pub fn extract_text(
92    anchor: TextPos,
93    cursor: TextPos,
94    wrap: &PanelWrap,
95    exclude: Option<&std::collections::HashSet<TextPos>>,
96) -> Option<String> {
97    if wrap.line_count() == 0 {
98        return None;
99    }
100    let (start, end) = ordered(anchor, cursor);
101    let ranges = selection_ranges(start, end, wrap);
102    let mut out = String::new();
103    for (i, (line, from, to)) in ranges.iter().enumerate() {
104        if i > 0 {
105            out.push('\n');
106        }
107        let text = wrap.line_text(*line);
108        let piece: String = text
109            .chars()
110            .enumerate()
111            .skip(*from)
112            .take(to.saturating_sub(*from))
113            .filter(|(col, _)| !exclude.is_some_and(|ex| ex.contains(&TextPos::new(*line, *col))))
114            .map(|(_, c)| c)
115            .collect();
116        out.push_str(&piece);
117    }
118    if out.trim().is_empty() {
119        None
120    } else {
121        Some(out)
122    }
123}
124
125/// Strip every character at a position in `exclude` from `text` (used for
126/// "copy the whole panel", which reads straight from `PanelWrap::source`
127/// rather than going through `extract_text`'s per-line ranges). Rebuilds
128/// line-by-line the same way `text`'s own trailing newline was originally
129/// appended (see `draw::draw_collection_main`), so a whole-panel copy still
130/// matches the underlying buffer exactly but for the excluded positions.
131pub fn strip_positions(text: &str, exclude: &std::collections::HashSet<TextPos>) -> String {
132    if exclude.is_empty() {
133        return text.to_string();
134    }
135    let mut out = String::with_capacity(text.len());
136    for (line_idx, line) in text.lines().enumerate() {
137        if line_idx > 0 {
138            out.push('\n');
139        }
140        for (col, ch) in line.chars().enumerate() {
141            if !exclude.contains(&TextPos::new(line_idx, col)) {
142                out.push(ch);
143            }
144        }
145    }
146    if text.ends_with('\n') {
147        out.push('\n');
148    }
149    out
150}
151
152/// Convert the selection into absolute terminal cell ranges (row, col_from,
153/// col_to_exclusive) suitable for painting a highlight, for whichever
154/// (raw) lines the selection intersects the *current visible window*
155/// (`scroll`..`scroll + area.height`). Bounded to that window — never the
156/// whole selection — so highlighting stays cheap even when the selection
157/// itself spans an enormous, mostly off-screen range.
158pub fn highlight_cells(
159    anchor: TextPos,
160    cursor: TextPos,
161    wrap: &PanelWrap,
162    area: Rect,
163    scroll: u16,
164) -> Vec<(u16, u16, u16)> {
165    if area.width == 0 || area.height == 0 || wrap.line_count() == 0 {
166        return Vec::new();
167    }
168    let (start, end) = ordered(anchor, cursor);
169    let first_visible = wrap.row_col_to_textpos(scroll as u32, 0).line;
170    let last_visible_row = (scroll as u32 + area.height as u32).saturating_sub(1);
171    let last_visible = wrap.row_col_to_textpos(last_visible_row, 0).line;
172    let lo = start.line.max(first_visible);
173    let hi = end.line.min(last_visible);
174    if lo > hi {
175        return Vec::new();
176    }
177    let mut out = Vec::new();
178    for line in lo..=hi {
179        if line >= wrap.line_count() {
180            break;
181        }
182        let (from, to) = range_for_line(line, start, end, wrap);
183        if from >= to {
184            continue;
185        }
186        let len = wrap.line_char_len(line);
187        // Use the panel's *wrap* width (which excludes any column reserved for
188        // an end-of-row wrap marker), not `area.width` — the marker column is
189        // never part of the selectable text, so highlight geometry must match
190        // where characters actually wrap.
191        let width = wrap.wrap_width();
192        let (base_row, _) = wrap.textpos_to_row_col(TextPos::new(line, 0));
193        // In clip mode each raw line is exactly one (clipped) row.
194        let rows_in_line = if wrap.mode() == WrapMode::Clip || width == 0 {
195            // Clip mode collapses each raw line to a single (clipped) row.
196            1
197        } else {
198            len.div_ceil(width).max(1)
199        };
200        // Bound the row scan to this line's overlap with the *visible*
201        // scroll window — never `0..rows_in_line` — so one enormous raw
202        // line (thousands+ of wrapped rows) still costs only what's on
203        // screen, exactly like `PanelWrap::visible_window` does.
204        let window_lo = (scroll as u32).saturating_sub(base_row);
205        let window_hi_excl =
206            ((scroll as u32).saturating_add(area.height as u32)).saturating_sub(base_row);
207        let r_lo = window_lo.min(rows_in_line as u32) as usize;
208        let r_hi = window_hi_excl.min(rows_in_line as u32) as usize;
209        for r in r_lo..r_hi {
210            let row_start = r * width.max(1);
211            let row_end = ((r + 1) * width.max(1)).min(len);
212            let seg_from = from.max(row_start);
213            let seg_to = to.min(row_end);
214            if seg_from >= seg_to {
215                continue;
216            }
217            let abs_row = base_row + r as u32;
218            if abs_row < scroll as u32 {
219                continue;
220            }
221            let local_row = abs_row - scroll as u32;
222            if local_row >= area.height as u32 {
223                continue;
224            }
225            out.push((
226                area.y + local_row as u16,
227                area.x + (seg_from - row_start) as u16,
228                area.x + (seg_to - row_start) as u16,
229            ));
230        }
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use std::sync::Arc;
239
240    fn rect() -> Rect {
241        Rect::new(2, 1, 20, 5) // x=2, y=1, width=20, height=5
242    }
243
244    fn wrap() -> PanelWrap {
245        PanelWrap::build(
246            Arc::from("first line here\nsecond\n\nfourth line of text\nfifth"),
247            20,
248        )
249    }
250
251    #[test]
252    fn point_to_textpos_maps_terminal_coords_into_logical_positions() {
253        let area = rect();
254        let w = wrap();
255        assert_eq!(
256            point_to_textpos((2, 1), area, 0, &w),
257            TextPos::new(0, 0),
258            "top-left of the area"
259        );
260        // row 2 (local row 1) is "second"; col 5 (local col 3) lands inside it.
261        assert_eq!(
262            point_to_textpos((5, 2), area, 0, &w),
263            TextPos::new(1, 3),
264            "interior point offsets by area origin"
265        );
266        // Above/left of the area clamps to the nearest edge, not negative.
267        assert_eq!(point_to_textpos((0, 0), area, 0, &w), TextPos::new(0, 0));
268    }
269
270    #[test]
271    fn single_row_selection_takes_only_the_selected_columns() {
272        let w = wrap();
273        let text = extract_text(TextPos::new(0, 2), TextPos::new(0, 5), &w, None).unwrap();
274        assert_eq!(text, "rst "); // chars 2..6 of "first line here"
275    }
276
277    #[test]
278    fn multi_row_selection_takes_the_rest_of_the_first_line_full_middle_lines_and_the_start_of_the_last()
279     {
280        let w = wrap();
281        let text = extract_text(TextPos::new(0, 6), TextPos::new(3, 5), &w, None).unwrap();
282        assert_eq!(text, "line here\nsecond\n\nfourth");
283    }
284
285    #[test]
286    fn dragging_backwards_still_resolves_to_the_same_selection() {
287        let w = wrap();
288        let forward = extract_text(TextPos::new(0, 2), TextPos::new(1, 4), &w, None).unwrap();
289        let backward = extract_text(TextPos::new(1, 4), TextPos::new(0, 2), &w, None).unwrap();
290        assert_eq!(forward, backward);
291    }
292
293    #[test]
294    fn a_blank_or_empty_selection_extracts_to_none() {
295        let w = wrap();
296        // A click with no drag (anchor == cursor) on a single character still
297        // yields that one character, but a selection entirely inside the
298        // blank line yields None.
299        assert_eq!(
300            extract_text(TextPos::new(2, 0), TextPos::new(2, 0), &w, None),
301            None
302        );
303        let empty = PanelWrap::build(Arc::from(""), 20);
304        assert_eq!(
305            extract_text(TextPos::new(0, 0), TextPos::new(0, 0), &empty, None),
306            None,
307            "no content"
308        );
309    }
310
311    #[test]
312    fn extract_text_excludes_only_the_positions_given() {
313        let w = wrap();
314        let mut exclude = std::collections::HashSet::new();
315        // "first line here" — drop the 'f' (col 0) but keep everything else.
316        exclude.insert(TextPos::new(0, 0));
317        let text =
318            extract_text(TextPos::new(0, 0), TextPos::new(0, 5), &w, Some(&exclude)).unwrap();
319        assert_eq!(
320            text, "irst ",
321            "the excluded column is dropped, all others are kept"
322        );
323    }
324
325    #[test]
326    fn strip_positions_removes_only_excluded_characters() {
327        let mut exclude = std::collections::HashSet::new();
328        exclude.insert(TextPos::new(0, 5)); // the '!' in "hello!world"
329        let out = strip_positions("hello!world\nsecond!line", &exclude);
330        assert_eq!(
331            out, "helloworld\nsecond!line",
332            "only the recorded position is stripped, other lines untouched"
333        );
334    }
335
336    #[test]
337    fn strip_positions_is_a_no_op_with_an_empty_exclude_set() {
338        let exclude = std::collections::HashSet::new();
339        let out = strip_positions("unchanged!text\n", &exclude);
340        assert_eq!(out, "unchanged!text\n");
341    }
342
343    #[test]
344    fn highlight_cells_skip_empty_rows_and_report_absolute_terminal_columns() {
345        let w = wrap();
346        let area = rect();
347        let cells = highlight_cells(TextPos::new(0, 6), TextPos::new(3, 5), &w, area, 0);
348        // The blank middle row (row 2) contributes nothing to highlight.
349        assert_eq!(
350            cells,
351            vec![
352                (area.y, area.x + 6, area.x + 15),
353                (area.y + 1, area.x, area.x + 6),
354                (area.y + 3, area.x, area.x + 6),
355            ]
356        );
357    }
358
359    #[test]
360    fn highlight_cells_only_scans_lines_intersecting_the_visible_window() {
361        // A huge body with a selection spanning nearly all of it: the
362        // highlight scan must still return promptly and only report rows
363        // actually within the visible scroll window.
364        let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
365        let w = PanelWrap::build(Arc::from(body), 20);
366        let area = Rect::new(0, 0, 20, 5);
367        let cells = highlight_cells(
368            TextPos::new(0, 0),
369            TextPos::new(99_999, 3),
370            &w,
371            area,
372            50_000,
373        );
374        assert_eq!(
375            cells.len(),
376            5,
377            "exactly the 5 visible rows, not the whole selected range"
378        );
379        assert_eq!(cells[0].0, 0);
380        assert_eq!(cells[4].0, 4);
381    }
382
383    #[test]
384    fn highlight_cells_handles_a_selection_wholly_off_screen() {
385        let w = wrap();
386        let area = rect();
387        // Selection entirely above the current scroll window.
388        let cells = highlight_cells(TextPos::new(0, 0), TextPos::new(0, 3), &w, area, 10);
389        assert!(cells.is_empty());
390    }
391
392    /// Regression test: a *single* raw line that itself wraps into thousands
393    /// of rows (e.g. one enormous unbroken line in a huge response) used to
394    /// make `highlight_cells` iterate `0..rows_in_line` for that line —
395    /// tens of thousands of iterations every redraw regardless of how much
396    /// of it was actually on screen. The scan must be bounded to the rows
397    /// that intersect the visible window, exactly like `visible_window`.
398    #[test]
399    fn highlight_cells_bounds_the_scan_even_when_one_line_has_thousands_of_wrapped_rows() {
400        let body: String = "x".repeat(500_000); // one line, 500_000 / 20 = 25_000 wrapped rows
401        let w = PanelWrap::build(Arc::from(body), 20);
402        let area = Rect::new(0, 0, 20, 5);
403        // Select the whole line, but scroll deep into its middle.
404        let cells = highlight_cells(
405            TextPos::new(0, 0),
406            TextPos::new(0, 499_999),
407            &w,
408            area,
409            12_000,
410        );
411        assert_eq!(
412            cells.len(),
413            5,
414            "exactly the 5 visible rows of this one giant line, not all 25,000"
415        );
416        assert_eq!(cells[0].0, area.y);
417        assert_eq!(cells[4].0, area.y + 4);
418        // Every reported row should be a full-width row (the whole line is selected).
419        for &(_, from, to) in &cells {
420            assert_eq!(
421                to - from,
422                area.width,
423                "each visible row of a fully-selected giant line is fully highlighted"
424            );
425        }
426    }
427}