Skip to main content

justerm_core/term/
selection.rs

1//! The selection surface: the gesture entry points, the three fixups that keep an
2//! anchor pointing at its content while the buffer moves under it, and two text
3//! extractors — `selection_text` for the selected run, and `accessible_text`, which
4//! reads the *active* buffer as one document (floored on the alt screen, like every
5//! other absolute walk) and lives here only because it reuses the same extraction path,
6//! not because it is a selection.
7//!
8//! The coordinate model — absolute `[scrollback ++ screen]` line indices, why they
9//! survive an ordinary scroll, and the three places they do not — is stated in
10//! [`crate::selection`]. Read it there; this module is the `Term` half of it.
11//!
12//! What is local to this site is the shape of that `Term` half. Three fixups
13//! (`selection_shift_below_margin`, `selection_evict_oldest`, `selection_rotate_region`)
14//! are `pub(super)` because the write path calls them from `term.rs` — each one beside
15//! its decoration-marker counterpart, since both are absolute anchors and a buffer
16//! motion moves them together. That pairing was weighed as a reason to merge the two
17//! surfaces into one module and rejected in #584; the grounds and the counter-evidence
18//! are recorded there, not re-argued here.
19//!
20//! `resolve` and `Resolved` stay private: every caller travelled into this module with
21//! them. The remaining entry points are public API and keep `pub fn` — an inherent
22//! impl's methods are reached through the type, not the module path, so a private child
23//! module does not hide them.
24
25use crate::selection::{Anchor, Selection, SelectionSpan, SelectionType, Side};
26
27use super::Term;
28
29/// A selection resolved to absolute-coordinate bounds, ready for text extraction
30/// or viewport-span projection. Columns are half-open (`from..to`).
31enum Resolved {
32    /// Char/Word/Line: a run that joins soft-wrapped rows. Columns apply to the
33    /// first/last line; middle lines are whole.
34    Linear {
35        start_line: usize,
36        from: usize,
37        end_line: usize,
38        to: usize,
39    },
40    /// Block: a rectangle — the same `from..to` columns on every row.
41    Block {
42        line0: usize,
43        line1: usize,
44        from: usize,
45        to: usize,
46    },
47}
48
49impl Term {
50    /// Begin a selection of `ty` at viewport `(row, col)`, `side`.
51    pub fn selection_begin(&mut self, row: usize, col: usize, side: Side, ty: SelectionType) {
52        let anchor = Anchor {
53            point: self.viewport_to_abs(row, col),
54            side,
55        };
56        self.selection = Some(Selection {
57            ty,
58            anchor,
59            focus: anchor,
60        });
61    }
62
63    /// Extend the live selection's focus to viewport `(row, col)`, `side`.
64    pub fn selection_extend(&mut self, row: usize, col: usize, side: Side) {
65        let focus = Anchor {
66            point: self.viewport_to_abs(row, col),
67            side,
68        };
69        if let Some(sel) = &mut self.selection {
70            sel.focus = focus;
71        }
72    }
73
74    /// Clear the selection.
75    pub fn selection_clear(&mut self) {
76        self.selection = None;
77    }
78
79    /// Shift selection endpoints anchored at absolute line `>= from` down by
80    /// one (#449): a top-anchored sub-region scroll grew scrollback while the
81    /// rows below the margin stayed fixed on screen, so their content's
82    /// absolute index rose +1 and the anchors must follow it. Endpoints above
83    /// `from` (in-region / scrollback content, whose indices are stable) are
84    /// untouched — per endpoint, so a selection straddling the margin keeps
85    /// both ends on their content.
86    pub(super) fn selection_shift_below_margin(&mut self, from: usize) {
87        if let Some(sel) = &mut self.selection {
88            if sel.anchor.point.line >= from {
89                sel.anchor.point.line += 1;
90            }
91            if sel.focus.point.line >= from {
92                sel.focus.point.line += 1;
93            }
94        }
95    }
96
97    /// Shift the selection up by one absolute line after the oldest history line
98    /// is evicted by the scrollback cap. An endpoint clamps to the new top; if
99    /// the whole selection was on the evicted line, it is cleared.
100    pub(super) fn selection_evict_oldest(&mut self) {
101        let Some((a, f)) = self
102            .selection
103            .as_ref()
104            .map(|s| (s.anchor.point.line, s.focus.point.line))
105        else {
106            return;
107        };
108        if a == 0 && f == 0 {
109            self.selection = None;
110            return;
111        }
112        if let Some(sel) = &mut self.selection {
113            sel.anchor.point.line = a.saturating_sub(1);
114            sel.focus.point.line = f.saturating_sub(1);
115        }
116    }
117
118    /// Rotate the selection within an in-screen scroll of absolute lines
119    /// `[top, bottom]`. `up` = content scrolled up (a line dropped at `top`);
120    /// otherwise down (dropped at `bottom`). Called once per scrolled line (delta
121    /// 1) by linefeed/RI/SU/SD/IL/DL.
122    ///
123    /// Mirrors alacritty `Selection::rotate`: an endpoint pushed past the region
124    /// edge is *clamped* to that edge (upper → `top`/col 0/Left, lower →
125    /// `bottom`/last col/Right; columns/side kept for Block), preserving the part
126    /// of the selection still in the buffer. The whole selection clears only on a
127    /// true *overtake* — the upper endpoint crossing the bottom while the lower
128    /// stays inside, or the lower falling above the upper (a selection wholly on
129    /// the dropped line). (#174: this replaced a policy that cleared on any
130    /// endpoint touching the dropped edge, dropping still-valid content.)
131    pub(super) fn selection_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
132        let (ty, anchor, focus) = match self.selection.as_ref() {
133            Some(s) => (s.ty, s.anchor, s.focus),
134            None => return,
135        };
136        let last_col = self.grid.cols().saturating_sub(1);
137        // Order the endpoints by buffer position; the upper (`start`) clamps to
138        // the region top, the lower (`end`) to the bottom. Remember which is the
139        // anchor so the result writes back to the right field.
140        let anchor_is_start = anchor.point <= focus.point;
141        let (mut start, mut end) = if anchor_is_start {
142            (anchor, focus)
143        } else {
144            (focus, anchor)
145        };
146
147        let (top_i, bottom_i) = (top as isize, bottom as isize);
148        // The endpoint's line after the one-line scroll, or `None` if it's outside
149        // the region (untouched). The dropped-edge line shifts *past* the edge (to
150        // be clamped/overtaken below), matching alacritty's `line - delta`.
151        let shift = |line: usize| -> Option<isize> {
152            if line < top || line > bottom {
153                None
154            } else if up {
155                Some(line as isize - 1)
156            } else {
157                Some(line as isize + 1)
158            }
159        };
160
161        // Upper endpoint: clamp to the region top when pushed above it; clear if it
162        // overtook the region bottom (down-scroll) while the lower stays inside.
163        if let Some(nl) = shift(start.point.line) {
164            if nl > bottom_i && (end.point.line as isize) <= bottom_i {
165                self.selection = None;
166                return;
167            }
168            if nl < top_i {
169                start.point.line = top;
170                if ty != SelectionType::Block {
171                    start.point.col = 0;
172                    start.side = Side::Left;
173                }
174            } else {
175                start.point.line = nl as usize;
176            }
177        }
178        // Lower endpoint: clear if it fell above the (rotated) upper endpoint;
179        // else clamp to the region bottom when pushed below it.
180        if let Some(nl) = shift(end.point.line) {
181            if nl < start.point.line as isize {
182                self.selection = None;
183                return;
184            }
185            if nl > bottom_i {
186                end.point.line = bottom;
187                if ty != SelectionType::Block {
188                    end.point.col = last_col;
189                    end.side = Side::Right;
190                }
191            } else {
192                end.point.line = nl as usize;
193            }
194        }
195
196        if let Some(sel) = &mut self.selection {
197            if anchor_is_start {
198                (sel.anchor, sel.focus) = (start, end);
199            } else {
200                (sel.anchor, sel.focus) = (end, start);
201            }
202        }
203    }
204
205    /// The selection projected onto the current viewport: one inclusive-column
206    /// span per visible row. Rows scrolled off-screen (above or below) are
207    /// dropped. Empty when nothing is selected. See `SelectionSpan`.
208    pub fn selection_range(&self) -> Vec<SelectionSpan> {
209        let Some(resolved) = self.resolve() else {
210            return Vec::new();
211        };
212        let rows = self.grid.rows();
213        // Absolute index of viewport row 0.
214        let top = self.scrollback.len() - self.display_offset;
215        let mut spans = Vec::new();
216
217        // Add a span for absolute `line` with inclusive cols `left..=right`, if
218        // the line is currently visible.
219        let mut push = |line: usize, left: usize, right: usize| {
220            if line >= top {
221                let row = line - top;
222                if row < rows {
223                    spans.push(SelectionSpan { row, left, right });
224                }
225            }
226        };
227
228        match resolved {
229            Resolved::Linear {
230                start_line,
231                from,
232                end_line,
233                to,
234            } => {
235                for line in start_line..=end_line {
236                    // Bound before reading, not after (#660). `abs_line` indexes the grid
237                    // unguarded, so a line past the last visible row panics here — and the
238                    // `push` closure below, which does apply the bound, never gets to run.
239                    // The sibling projection already has this ordering right:
240                    // `Term::match_spans` (`term/search.rs`) does `if row >= rows { break }`
241                    // *before* its own `abs_line`, so this loop was the local outlier.
242                    //
243                    // This is not a clamp and truncates nothing observable: the function
244                    // already drops off-screen rows silently and says so ("Empty when … the
245                    // selection is fully scrolled off-screen"), so making the existing
246                    // filter total converts a panic into the drop the contract promises.
247                    // All three references bound at read time too — alacritty's
248                    // `Selection::to_range` goes through `grid_clamp`, xterm.js's
249                    // `translateBufferLineToString` returns `''` for a missing line, and
250                    // ghostty clamps a pin's column against its own page.
251                    //
252                    // **Unreachable as this crate stands, and that is recorded rather than
253                    // enjoyed.** With the anchor clamped at `viewport_to_abs` and the alt
254                    // drop in `resize`, no path is known that reaches this loop with a line
255                    // past the last row — measured: removing this bound reds no test in the
256                    // suite. It is kept for the reason the `right - left + 1` widening in
257                    // `serialize.rs` is kept (#582): it costs nothing, it makes the function
258                    // total on its own rather than by trusting a guard two files away, and
259                    // the walk it protects is one careless edit from an out-of-bounds index.
260                    if line < top {
261                        continue;
262                    }
263                    if line - top >= rows {
264                        break;
265                    }
266                    let len = self.abs_line(line).len();
267                    // Both ends, not one (ADR-0026 D3). `right_excl` was bounded and `left`
268                    // was not, which is not half a guard: the raw end survives into the
269                    // `right_excl > left` test below and drops the row instead of shortening
270                    // it — silently, and only ever the *start* row, so a multi-row selection
271                    // looks intact. Unreachable as this crate stands (#671 clamps the
272                    // producer, `resize` re-clamps reflowed points, alt drops the selection),
273                    // and kept for the reason the bound above it is kept: it makes the
274                    // function total on its own rather than by trusting a guard two files
275                    // away. `match_spans` is the same expression in `term/search.rs`, where
276                    // the coordinate IS the consumer's and this is the only guard there is.
277                    let left = if line == start_line { from.min(len) } else { 0 };
278                    let right_excl = if line == end_line { to.min(len) } else { len };
279                    if right_excl > left {
280                        push(line, left, right_excl - 1);
281                    }
282                }
283            }
284            Resolved::Block {
285                line0,
286                line1,
287                from,
288                to,
289            } => {
290                // The Block arm bounds against the grid rather than each line, because a
291                // rectangle is the same columns on every row by definition — `resolve`
292                // already clipped `to` with `.min(cols)`, so only `from` was open
293                // (ADR-0026 D3/D4). Same reachability as the Linear arm above.
294                let cols = self.grid.cols();
295                let from = from.min(cols);
296                if to > from {
297                    for line in line0..=line1 {
298                        push(line, from, to - 1);
299                    }
300                }
301            }
302        }
303        spans
304    }
305
306    /// Resolve the live selection into absolute-coordinate bounds per type:
307    /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
308    /// rectangle. `None` when nothing is selected. Columns are half-open
309    /// (`from..to`). Shared by `selection_text` and `selection_range`.
310    fn resolve(&self) -> Option<Resolved> {
311        let sel = self.selection.as_ref()?;
312        let (start, end) = sel.ordered();
313        Some(match sel.ty {
314            SelectionType::Char => {
315                // Half-open columns: each side decides if its own cell is in.
316                let from = match start.side {
317                    Side::Left => start.point.col,
318                    Side::Right => start.point.col + 1,
319                };
320                let to = match end.side {
321                    Side::Left => end.point.col,
322                    Side::Right => end.point.col + 1,
323                };
324                Resolved::Linear {
325                    start_line: start.point.line,
326                    from,
327                    end_line: end.point.line,
328                    to,
329                }
330            }
331            SelectionType::Word => {
332                // Snap both ends to word boundaries (side is ignored).
333                let ws = self.word_start(start.point);
334                let we = self.word_end(end.point);
335                Resolved::Linear {
336                    start_line: ws.line,
337                    from: ws.col,
338                    end_line: we.line,
339                    to: we.col + 1,
340                }
341            }
342            SelectionType::Line => Resolved::Linear {
343                start_line: start.point.line,
344                from: 0,
345                end_line: end.point.line,
346                to: self.grid.cols(),
347            },
348            SelectionType::Block => {
349                // Rectangular: the same column range on every row. Columns come
350                // from the two anchors (min/max, with each edge's side).
351                let cols = self.grid.cols();
352                let (a, b) = (sel.anchor, sel.focus);
353                let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
354                    (a.point.col, a.side, b.point.col, b.side)
355                } else {
356                    (b.point.col, b.side, a.point.col, a.side)
357                };
358                let from = match lside {
359                    Side::Left => lcol,
360                    Side::Right => lcol + 1,
361                };
362                let to = match rside {
363                    Side::Left => rcol,
364                    Side::Right => rcol + 1,
365                };
366                Resolved::Block {
367                    line0: a.point.line.min(b.point.line),
368                    line1: a.point.line.max(b.point.line),
369                    from,
370                    to: to.min(cols).max(from),
371                }
372            }
373        })
374    }
375
376    /// The selected text (for copy), or `None` when nothing is selected.
377    pub fn selection_text(&self) -> Option<String> {
378        match self.resolve()? {
379            Resolved::Linear {
380                start_line,
381                from,
382                end_line,
383                to,
384            } => Some(self.extract_lines(&self.grid, start_line, from, end_line, to)),
385            Resolved::Block {
386                line0,
387                line1,
388                from,
389                to,
390            } => {
391                // Each row independently — no soft-wrap joining.
392                let mut out = String::new();
393                for line in line0..=line1 {
394                    let hi = to.min(self.abs_line(line).len());
395                    let mut seg = String::new();
396                    for col in from..hi {
397                        self.append_cell(&self.grid, &mut seg, line, col);
398                    }
399                    out.push_str(seg.trim_end_matches(' '));
400                    if line != line1 {
401                        out.push('\n');
402                    }
403                }
404                Some(out)
405            }
406        }
407    }
408
409    /// The whole buffer as one text document (#150): scrollback + screen assembled
410    /// into logical lines (soft-wrap joined, wide-spacers skipped, trailing blanks
411    /// trimmed at the logical end) — the accessible-view a screen reader reads as
412    /// a document, distinct from the viewport row tree (#119). Reuses the
413    /// selection extraction (`extract_lines`) over the full
414    /// range. On the alt screen only the alt buffer is shown — its "scrollback" is
415    /// the *primary* buffer's, not this app's — mirroring `viewport_logical_lines`'
416    /// alt floor.
417    pub fn accessible_text(&self) -> String {
418        let total = self.scrollback.len() + self.grid.rows();
419        if total == 0 {
420            return String::new();
421        }
422        let start = self.abs_floor();
423        let mut doc = self.extract_lines(&self.grid, start, 0, total - 1, usize::MAX);
424        // Trim *trailing* empty lines (blank screen rows below the content) — pure
425        // noise to a listener, and what a fresh screen would otherwise emit. Keep
426        // *internal* blank lines (paragraph breaks between command outputs) — a
427        // document wants those, unlike the viewport tree which drops all empties.
428        doc.truncate(doc.trim_end_matches('\n').len());
429        doc
430    }
431}