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};
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        let width = area.width as usize;
188        let (base_row, _) = wrap.textpos_to_row_col(TextPos::new(line, 0));
189        let rows_in_line = if width == 0 {
190            1
191        } else {
192            len.div_ceil(width).max(1)
193        };
194        // Bound the row scan to this line's overlap with the *visible*
195        // scroll window — never `0..rows_in_line` — so one enormous raw
196        // line (thousands+ of wrapped rows) still costs only what's on
197        // screen, exactly like `PanelWrap::visible_window` does.
198        let window_lo = (scroll as u32).saturating_sub(base_row);
199        let window_hi_excl =
200            ((scroll as u32).saturating_add(area.height as u32)).saturating_sub(base_row);
201        let r_lo = window_lo.min(rows_in_line as u32) as usize;
202        let r_hi = window_hi_excl.min(rows_in_line as u32) as usize;
203        for r in r_lo..r_hi {
204            let row_start = r * width.max(1);
205            let row_end = ((r + 1) * width.max(1)).min(len);
206            let seg_from = from.max(row_start);
207            let seg_to = to.min(row_end);
208            if seg_from >= seg_to {
209                continue;
210            }
211            let abs_row = base_row + r as u32;
212            if abs_row < scroll as u32 {
213                continue;
214            }
215            let local_row = abs_row - scroll as u32;
216            if local_row >= area.height as u32 {
217                continue;
218            }
219            out.push((
220                area.y + local_row as u16,
221                area.x + (seg_from - row_start) as u16,
222                area.x + (seg_to - row_start) as u16,
223            ));
224        }
225    }
226    out
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::sync::Arc;
233
234    fn rect() -> Rect {
235        Rect::new(2, 1, 20, 5) // x=2, y=1, width=20, height=5
236    }
237
238    fn wrap() -> PanelWrap {
239        PanelWrap::build(
240            Arc::from("first line here\nsecond\n\nfourth line of text\nfifth"),
241            20,
242        )
243    }
244
245    #[test]
246    fn point_to_textpos_maps_terminal_coords_into_logical_positions() {
247        let area = rect();
248        let w = wrap();
249        assert_eq!(
250            point_to_textpos((2, 1), area, 0, &w),
251            TextPos::new(0, 0),
252            "top-left of the area"
253        );
254        // row 2 (local row 1) is "second"; col 5 (local col 3) lands inside it.
255        assert_eq!(
256            point_to_textpos((5, 2), area, 0, &w),
257            TextPos::new(1, 3),
258            "interior point offsets by area origin"
259        );
260        // Above/left of the area clamps to the nearest edge, not negative.
261        assert_eq!(point_to_textpos((0, 0), area, 0, &w), TextPos::new(0, 0));
262    }
263
264    #[test]
265    fn single_row_selection_takes_only_the_selected_columns() {
266        let w = wrap();
267        let text = extract_text(TextPos::new(0, 2), TextPos::new(0, 5), &w, None).unwrap();
268        assert_eq!(text, "rst "); // chars 2..6 of "first line here"
269    }
270
271    #[test]
272    fn multi_row_selection_takes_the_rest_of_the_first_line_full_middle_lines_and_the_start_of_the_last()
273     {
274        let w = wrap();
275        let text = extract_text(TextPos::new(0, 6), TextPos::new(3, 5), &w, None).unwrap();
276        assert_eq!(text, "line here\nsecond\n\nfourth");
277    }
278
279    #[test]
280    fn dragging_backwards_still_resolves_to_the_same_selection() {
281        let w = wrap();
282        let forward = extract_text(TextPos::new(0, 2), TextPos::new(1, 4), &w, None).unwrap();
283        let backward = extract_text(TextPos::new(1, 4), TextPos::new(0, 2), &w, None).unwrap();
284        assert_eq!(forward, backward);
285    }
286
287    #[test]
288    fn a_blank_or_empty_selection_extracts_to_none() {
289        let w = wrap();
290        // A click with no drag (anchor == cursor) on a single character still
291        // yields that one character, but a selection entirely inside the
292        // blank line yields None.
293        assert_eq!(
294            extract_text(TextPos::new(2, 0), TextPos::new(2, 0), &w, None),
295            None
296        );
297        let empty = PanelWrap::build(Arc::from(""), 20);
298        assert_eq!(
299            extract_text(TextPos::new(0, 0), TextPos::new(0, 0), &empty, None),
300            None,
301            "no content"
302        );
303    }
304
305    #[test]
306    fn extract_text_excludes_only_the_positions_given() {
307        let w = wrap();
308        let mut exclude = std::collections::HashSet::new();
309        // "first line here" — drop the 'f' (col 0) but keep everything else.
310        exclude.insert(TextPos::new(0, 0));
311        let text =
312            extract_text(TextPos::new(0, 0), TextPos::new(0, 5), &w, Some(&exclude)).unwrap();
313        assert_eq!(
314            text, "irst ",
315            "the excluded column is dropped, all others are kept"
316        );
317    }
318
319    #[test]
320    fn strip_positions_removes_only_excluded_characters() {
321        let mut exclude = std::collections::HashSet::new();
322        exclude.insert(TextPos::new(0, 5)); // the '!' in "hello!world"
323        let out = strip_positions("hello!world\nsecond!line", &exclude);
324        assert_eq!(
325            out, "helloworld\nsecond!line",
326            "only the recorded position is stripped, other lines untouched"
327        );
328    }
329
330    #[test]
331    fn strip_positions_is_a_no_op_with_an_empty_exclude_set() {
332        let exclude = std::collections::HashSet::new();
333        let out = strip_positions("unchanged!text\n", &exclude);
334        assert_eq!(out, "unchanged!text\n");
335    }
336
337    #[test]
338    fn highlight_cells_skip_empty_rows_and_report_absolute_terminal_columns() {
339        let w = wrap();
340        let area = rect();
341        let cells = highlight_cells(TextPos::new(0, 6), TextPos::new(3, 5), &w, area, 0);
342        // The blank middle row (row 2) contributes nothing to highlight.
343        assert_eq!(
344            cells,
345            vec![
346                (area.y, area.x + 6, area.x + 15),
347                (area.y + 1, area.x, area.x + 6),
348                (area.y + 3, area.x, area.x + 6),
349            ]
350        );
351    }
352
353    #[test]
354    fn highlight_cells_only_scans_lines_intersecting_the_visible_window() {
355        // A huge body with a selection spanning nearly all of it: the
356        // highlight scan must still return promptly and only report rows
357        // actually within the visible scroll window.
358        let body: String = (0..100_000).map(|i| format!("line {i}\n")).collect();
359        let w = PanelWrap::build(Arc::from(body), 20);
360        let area = Rect::new(0, 0, 20, 5);
361        let cells = highlight_cells(
362            TextPos::new(0, 0),
363            TextPos::new(99_999, 3),
364            &w,
365            area,
366            50_000,
367        );
368        assert_eq!(
369            cells.len(),
370            5,
371            "exactly the 5 visible rows, not the whole selected range"
372        );
373        assert_eq!(cells[0].0, 0);
374        assert_eq!(cells[4].0, 4);
375    }
376
377    #[test]
378    fn highlight_cells_handles_a_selection_wholly_off_screen() {
379        let w = wrap();
380        let area = rect();
381        // Selection entirely above the current scroll window.
382        let cells = highlight_cells(TextPos::new(0, 0), TextPos::new(0, 3), &w, area, 10);
383        assert!(cells.is_empty());
384    }
385
386    /// Regression test: a *single* raw line that itself wraps into thousands
387    /// of rows (e.g. one enormous unbroken line in a huge response) used to
388    /// make `highlight_cells` iterate `0..rows_in_line` for that line —
389    /// tens of thousands of iterations every redraw regardless of how much
390    /// of it was actually on screen. The scan must be bounded to the rows
391    /// that intersect the visible window, exactly like `visible_window`.
392    #[test]
393    fn highlight_cells_bounds_the_scan_even_when_one_line_has_thousands_of_wrapped_rows() {
394        let body: String = "x".repeat(500_000); // one line, 500_000 / 20 = 25_000 wrapped rows
395        let w = PanelWrap::build(Arc::from(body), 20);
396        let area = Rect::new(0, 0, 20, 5);
397        // Select the whole line, but scroll deep into its middle.
398        let cells = highlight_cells(
399            TextPos::new(0, 0),
400            TextPos::new(0, 499_999),
401            &w,
402            area,
403            12_000,
404        );
405        assert_eq!(
406            cells.len(),
407            5,
408            "exactly the 5 visible rows of this one giant line, not all 25,000"
409        );
410        assert_eq!(cells[0].0, area.y);
411        assert_eq!(cells[4].0, area.y + 4);
412        // Every reported row should be a full-width row (the whole line is selected).
413        for &(_, from, to) in &cells {
414            assert_eq!(
415                to - from,
416                area.width,
417                "each visible row of a fully-selected giant line is fully highlighted"
418            );
419        }
420    }
421}