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, BufferPoint, 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    /// Select the active buffer from its first non-blank cell to its last, in absolute
75    /// coordinates — no viewport position is read and the view does not move. On the alt
76    /// screen that is the alt screen alone. A buffer with no non-blank cell leaves nothing
77    /// selected.
78    pub fn select_all(&mut self) {
79        let floor = self.abs_floor();
80        let last = self.scrollback.len() + self.grid.rows() - 1;
81        let non_blank = |cell: &crate::cell::Cell| cell.c() != ' ' || cell.is_combined();
82        let first = (floor..=last).find_map(|line| {
83            let col = self.abs_line(line).iter().position(non_blank)?;
84            Some(BufferPoint { line, col })
85        });
86        let end = (floor..=last).rev().find_map(|line| {
87            let col = self.abs_line(line).iter().rposition(non_blank)?;
88            Some(BufferPoint { line, col })
89        });
90        self.selection = first.zip(end).map(|(start, end)| Selection {
91            ty: SelectionType::Char,
92            anchor: Anchor {
93                point: start,
94                side: Side::Left,
95            },
96            focus: Anchor {
97                point: end,
98                side: Side::Right,
99            },
100        });
101    }
102
103    /// Clear the selection.
104    pub fn selection_clear(&mut self) {
105        self.selection = None;
106    }
107
108    /// Shift selection endpoints anchored at absolute line `>= from` down by
109    /// one (#449): a top-anchored sub-region scroll grew scrollback while the
110    /// rows below the margin stayed fixed on screen, so their content's
111    /// absolute index rose +1 and the anchors must follow it. Endpoints above
112    /// `from` (in-region / scrollback content, whose indices are stable) are
113    /// untouched — per endpoint, so a selection straddling the margin keeps
114    /// both ends on their content.
115    pub(super) fn selection_shift_below_margin(&mut self, from: usize) {
116        if let Some(sel) = &mut self.selection {
117            if sel.anchor.point.line >= from {
118                sel.anchor.point.line += 1;
119            }
120            if sel.focus.point.line >= from {
121                sel.focus.point.line += 1;
122            }
123        }
124    }
125
126    /// Shift the selection up by one absolute line after the oldest history line
127    /// is evicted by the scrollback cap. An endpoint on the evicted line clamps to
128    /// the start of the new top line — column 0, left side — except in a Block,
129    /// which keeps its columns (the rule `selection_rotate_region` applies at a
130    /// region top). If the whole selection was on the evicted line, it is cleared.
131    pub(super) fn selection_evict_oldest(&mut self) {
132        let Some((a, f)) = self
133            .selection
134            .as_ref()
135            .map(|s| (s.anchor.point.line, s.focus.point.line))
136        else {
137            return;
138        };
139        if a == 0 && f == 0 {
140            self.selection = None;
141            return;
142        }
143        if let Some(sel) = &mut self.selection {
144            let block = sel.ty == SelectionType::Block;
145            for end in [&mut sel.anchor, &mut sel.focus] {
146                if end.point.line == 0 {
147                    if !block {
148                        end.point.col = 0;
149                        end.side = Side::Left;
150                    }
151                } else {
152                    end.point.line -= 1;
153                }
154            }
155        }
156    }
157
158    /// Rotate the selection within an in-screen scroll of absolute lines
159    /// `[top, bottom]`. `up` = content scrolled up (a line dropped at `top`);
160    /// otherwise down (dropped at `bottom`). Called once per scrolled line (delta
161    /// 1) by linefeed/RI/SU/SD/IL/DL.
162    ///
163    /// Mirrors alacritty `Selection::rotate`: an endpoint pushed past the region
164    /// edge is *clamped* to that edge (upper → `top`/col 0/Left, lower →
165    /// `bottom`/last col/Right; columns/side kept for Block), preserving the part
166    /// of the selection still in the buffer. The whole selection clears only on a
167    /// true *overtake* — the upper endpoint crossing the bottom while the lower
168    /// stays inside, or the lower falling above the upper (a selection wholly on
169    /// the dropped line). (#174: this replaced a policy that cleared on any
170    /// endpoint touching the dropped edge, dropping still-valid content.)
171    pub(super) fn selection_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
172        let (ty, anchor, focus) = match self.selection.as_ref() {
173            Some(s) => (s.ty, s.anchor, s.focus),
174            None => return,
175        };
176        let last_col = self.grid.cols().saturating_sub(1);
177        // Order the endpoints by buffer position; the upper (`start`) clamps to
178        // the region top, the lower (`end`) to the bottom. Remember which is the
179        // anchor so the result writes back to the right field.
180        let anchor_is_start = anchor.point <= focus.point;
181        let (mut start, mut end) = if anchor_is_start {
182            (anchor, focus)
183        } else {
184            (focus, anchor)
185        };
186
187        let (top_i, bottom_i) = (top as isize, bottom as isize);
188        // The endpoint's line after the one-line scroll, or `None` if it's outside
189        // the region (untouched). The dropped-edge line shifts *past* the edge (to
190        // be clamped/overtaken below), matching alacritty's `line - delta`.
191        let shift = |line: usize| -> Option<isize> {
192            if line < top || line > bottom {
193                None
194            } else if up {
195                Some(line as isize - 1)
196            } else {
197                Some(line as isize + 1)
198            }
199        };
200
201        // Upper endpoint: clamp to the region top when pushed above it; clear if it
202        // overtook the region bottom (down-scroll) while the lower stays inside.
203        if let Some(nl) = shift(start.point.line) {
204            if nl > bottom_i && (end.point.line as isize) <= bottom_i {
205                self.selection = None;
206                return;
207            }
208            if nl < top_i {
209                start.point.line = top;
210                if ty != SelectionType::Block {
211                    start.point.col = 0;
212                    start.side = Side::Left;
213                }
214            } else {
215                start.point.line = nl as usize;
216            }
217        }
218        // Lower endpoint: clear if it fell above the (rotated) upper endpoint;
219        // else clamp to the region bottom when pushed below it.
220        if let Some(nl) = shift(end.point.line) {
221            if nl < start.point.line as isize {
222                self.selection = None;
223                return;
224            }
225            if nl > bottom_i {
226                end.point.line = bottom;
227                if ty != SelectionType::Block {
228                    end.point.col = last_col;
229                    end.side = Side::Right;
230                }
231            } else {
232                end.point.line = nl as usize;
233            }
234        }
235
236        if let Some(sel) = &mut self.selection {
237            if anchor_is_start {
238                (sel.anchor, sel.focus) = (start, end);
239            } else {
240                (sel.anchor, sel.focus) = (end, start);
241            }
242        }
243    }
244
245    /// The selection projected onto the current viewport: one inclusive-column
246    /// span per visible row. Rows scrolled off-screen (above or below) are
247    /// dropped. Empty when nothing is selected. See `SelectionSpan`.
248    pub fn selection_range(&self) -> Vec<SelectionSpan> {
249        let Some(resolved) = self.resolve() else {
250            return Vec::new();
251        };
252        let rows = self.grid.rows();
253        // Absolute index of viewport row 0.
254        let top = self.scrollback.len() - self.display_offset;
255        let mut spans = Vec::new();
256
257        // Add a span for absolute `line` with inclusive cols `left..=right`, if
258        // the line is currently visible.
259        let mut push = |line: usize, left: usize, right: usize| {
260            if line >= top {
261                let row = line - top;
262                if row < rows {
263                    spans.push(SelectionSpan { row, left, right });
264                }
265            }
266        };
267
268        match resolved {
269            Resolved::Linear {
270                start_line,
271                from,
272                end_line,
273                to,
274            } => {
275                for line in start_line..=end_line {
276                    // Bound before reading, not after (#660). `abs_line` indexes the grid
277                    // unguarded, so a line past the last visible row panics here — and the
278                    // `push` closure below, which does apply the bound, never gets to run.
279                    // The sibling projection already has this ordering right:
280                    // `Term::match_spans` (`term/search.rs`) does `if row >= rows { break }`
281                    // *before* its own `abs_line`, so this loop was the local outlier.
282                    //
283                    // This is not a clamp and truncates nothing observable: the function
284                    // already drops off-screen rows silently and says so ("Empty when … the
285                    // selection is fully scrolled off-screen"), so making the existing
286                    // filter total converts a panic into the drop the contract promises.
287                    // All three references bound at read time too — alacritty's
288                    // `Selection::to_range` goes through `grid_clamp`, xterm.js's
289                    // `translateBufferLineToString` returns `''` for a missing line, and
290                    // ghostty clamps a pin's column against its own page.
291                    //
292                    // **Unreachable as this crate stands, and that is recorded rather than
293                    // enjoyed.** With the anchor clamped at `viewport_to_abs` and the alt
294                    // drop in `resize`, no path is known that reaches this loop with a line
295                    // past the last row — measured: removing this bound reds no test in the
296                    // suite. It is kept for the reason the `right - left + 1` widening in
297                    // `serialize.rs` is kept (#582): it costs nothing, it makes the function
298                    // total on its own rather than by trusting a guard two files away, and
299                    // the walk it protects is one careless edit from an out-of-bounds index.
300                    if line < top {
301                        continue;
302                    }
303                    if line - top >= rows {
304                        break;
305                    }
306                    let len = self.abs_line(line).len();
307                    // Both ends, not one (ADR-0026 D3). `right_excl` was bounded and `left`
308                    // was not, which is not half a guard: the raw end survives into the
309                    // `right_excl > left` test below and drops the row instead of shortening
310                    // it — silently, and only ever the *start* row, so a multi-row selection
311                    // looks intact. Unreachable as this crate stands (#671 clamps the
312                    // producer, `resize` re-clamps reflowed points, alt drops the selection),
313                    // and kept for the reason the bound above it is kept: it makes the
314                    // function total on its own rather than by trusting a guard two files
315                    // away. `match_spans` is the same expression in `term/search.rs`, where
316                    // the coordinate IS the consumer's and this is the only guard there is.
317                    let left = if line == start_line { from.min(len) } else { 0 };
318                    let right_excl = if line == end_line { to.min(len) } else { len };
319                    if right_excl > left {
320                        push(line, left, right_excl - 1);
321                    }
322                }
323            }
324            Resolved::Block {
325                line0,
326                line1,
327                from,
328                to,
329            } => {
330                // The Block arm bounds against the grid rather than each line, because a
331                // rectangle is the same columns on every row by definition — `resolve`
332                // already clipped `to` with `.min(cols)`, so only `from` was open
333                // (ADR-0026 D3/D4). Same reachability as the Linear arm above.
334                let cols = self.grid.cols();
335                let from = from.min(cols);
336                if to > from {
337                    for line in line0..=line1 {
338                        // Per row, not once for the rectangle (#454): the same column range meets a
339                        // pair at a different place on every row, so the widening cannot be hoisted
340                        // out of this loop the way the rectangle's own bound can. A row where it
341                        // fires is one column wider than the rectangle — the price of never
342                        // painting half a glyph, and what all three references pay too (each
343                        // applies its pair rule with no rectangular-selection exemption).
344                        push(
345                            line,
346                            self.pair_start(line, from),
347                            self.pair_end(line, to - 1),
348                        );
349                    }
350                }
351            }
352        }
353        spans
354    }
355
356    /// Resolve the live selection into absolute-coordinate bounds per type:
357    /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
358    /// rectangle. `None` when nothing is selected. Columns are half-open
359    /// (`from..to`). Shared by `selection_text` and `selection_range`.
360    fn resolve(&self) -> Option<Resolved> {
361        let sel = self.selection.as_ref()?;
362        let (start, end) = sel.ordered();
363        let resolved = match sel.ty {
364            SelectionType::Char => {
365                // Half-open columns: each side decides if its own cell is in.
366                let from = match start.side {
367                    Side::Left => start.point.col,
368                    Side::Right => start.point.col + 1,
369                };
370                let to = match end.side {
371                    Side::Left => end.point.col,
372                    Side::Right => end.point.col + 1,
373                };
374                Resolved::Linear {
375                    start_line: start.point.line,
376                    from,
377                    end_line: end.point.line,
378                    to,
379                }
380            }
381            SelectionType::Word => {
382                // Snap both ends to word boundaries (side is ignored).
383                let ws = self.word_start(start.point);
384                let we = self.word_end(end.point);
385                Resolved::Linear {
386                    start_line: ws.line,
387                    from: ws.col,
388                    end_line: we.line,
389                    to: we.col + 1,
390                }
391            }
392            SelectionType::Line => Resolved::Linear {
393                start_line: start.point.line,
394                from: 0,
395                end_line: end.point.line,
396                to: self.grid.cols(),
397            },
398            SelectionType::Block => {
399                // Rectangular: the same column range on every row. Columns come
400                // from the two anchors (min/max, with each edge's side).
401                let cols = self.grid.cols();
402                let (a, b) = (sel.anchor, sel.focus);
403                let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
404                    (a.point.col, a.side, b.point.col, b.side)
405                } else {
406                    (b.point.col, b.side, a.point.col, a.side)
407                };
408                let from = match lside {
409                    Side::Left => lcol,
410                    Side::Right => lcol + 1,
411                };
412                let to = match rside {
413                    Side::Left => rcol,
414                    Side::Right => rcol + 1,
415                };
416                Resolved::Block {
417                    line0: a.point.line.min(b.point.line),
418                    line1: a.point.line.max(b.point.line),
419                    from,
420                    to: to.min(cols).max(from),
421                }
422            }
423        };
424        Some(match resolved {
425            Resolved::Linear {
426                start_line,
427                from,
428                end_line,
429                to,
430            } => {
431                // An empty run stays empty. The pair rule widens a range that *includes* half a
432                // pair, and a zero-width one includes no cell — but its single column can still sit
433                // between a lead and its spacer, where widening would conjure the glyph out of a
434                // press that selected nothing (#914). Tested before any pair handling, from the
435                // endpoints alone, as every reference decides it.
436                if start_line == end_line && from >= to {
437                    return Some(Resolved::Linear {
438                        start_line,
439                        from,
440                        end_line,
441                        to,
442                    });
443                }
444                // Both ends move OUTWARD, each on its own line: a Linear run's two ends can sit
445                // on different rows, and a pair never spans rows.
446                let from = self.pair_start(start_line, from);
447                let to = if to > 0 {
448                    self.pair_end(end_line, to - 1) + 1
449                } else {
450                    to
451                };
452                Resolved::Linear {
453                    start_line,
454                    from,
455                    end_line,
456                    to,
457                }
458            }
459            block => block,
460        })
461    }
462
463    /// Pull a range's **first** column left when it lands on a wide glyph's trailing spacer, so a
464    /// range can never start inside a pair (#454). Returns `col` unchanged otherwise.
465    ///
466    /// A width-2 glyph is one thing, and this crate's other answers already said so before this one
467    /// existed: a spacer extracts as nothing, so `selection_text` can only ever return the whole
468    /// glyph or none of it. A range that stopped between the halves therefore made the *highlight*
469    /// and the *copy* describe different text — measured on `"漢ab"`, anchoring on the spacer and
470    /// dragging right highlighted two cells of the glyph plus `a` while copying `a` alone.
471    ///
472    /// The pair must **agree**: a spacer is only pulled onto a cell that is actually its lead. That
473    /// is what keeps the degenerate shapes safe rather than merely unlikely — `Row::resize` narrows
474    /// straight through a pair and leaves a lead with no spacer, which ADR-0025 D4's scope records as
475    /// a *legal* buffer state and not a repair site.
476    ///
477    /// `is_wide_spacer` and not `is_spacer`: the latter also matches the wide-wrap artefact
478    /// (`C_LEADING_SPACER`), which marks a row's last column when a lead did not fit — a different
479    /// fact, and one whose partner is on another row.
480    pub(super) fn pair_start(&self, line: usize, col: usize) -> usize {
481        let cell = |c: usize| self.abs_line(line).get(c).copied();
482        if col > 0
483            && cell(col).is_some_and(|c| c.is_wide_spacer())
484            && cell(col - 1).is_some_and(|c| c.is_wide())
485        {
486            col - 1
487        } else {
488            col
489        }
490    }
491
492    /// The mirror of [`pair_start`](Self::pair_start): push a range's **last** column right when it
493    /// lands on a wide glyph's lead, so a range can never end inside a pair (#454). `col` is
494    /// *inclusive*, and the same agreement rule applies — a lead whose spacer was truncated away
495    /// ends the range where it sits.
496    ///
497    /// **The agreement half of both predicates is unobservable on this side, and that is recorded
498    /// rather than tested.** Weakening either one — `pair_end` accepting any right neighbour, or
499    /// `pair_start` any left one — reds no test in the suite, measured across all four call sites
500    /// (Linear, both Block arms, `match_spans`). Not because the tests are weak: the states they
501    /// exclude are a lead beside a non-spacer mid-row and a spacer with no lead, which are exactly
502    /// what #529 stopped the engine from producing, and the row-end case is already covered by the
503    /// bound. They are kept because the same rule in `justerm-renderer` (`pair::partner_at`) *is*
504    /// observable — it reads consumer-authored decoration rects and preedit-patched flags — and one
505    /// rule with two spellings is how two layers drift apart. Valid while core keeps both halves of a
506    /// pair in step (ADR-0025 D4).
507    pub(super) fn pair_end(&self, line: usize, col: usize) -> usize {
508        let cell = |c: usize| self.abs_line(line).get(c).copied();
509        if cell(col).is_some_and(|c| c.is_wide())
510            && cell(col + 1).is_some_and(|c| c.is_wide_spacer())
511        {
512            col + 1
513        } else {
514            col
515        }
516    }
517
518    /// The selected text (for copy), or `None` when nothing is selected.
519    pub fn selection_text(&self) -> Option<String> {
520        match self.resolve()? {
521            Resolved::Linear {
522                start_line,
523                from,
524                end_line,
525                to,
526            } => Some(self.extract_lines(&self.grid, start_line, from, end_line, to)),
527            Resolved::Block {
528                line0,
529                line1,
530                from,
531                to,
532            } => {
533                // Each row independently — no soft-wrap joining.
534                let mut out = String::new();
535                for line in line0..=line1 {
536                    let hi = to.min(self.abs_line(line).len());
537                    // The same per-row widening `selection_range` applies, and for the same reason:
538                    // these two are the pair of observables #454 exists to keep in agreement, so a
539                    // rule applied to one of them alone would rebuild the defect on the other.
540                    // `to > from` is the guard `selection_range`'s Block arm already had; without it
541                    // here an empty rectangle painted nothing and copied the glyph under it (#914).
542                    let (lo, hi) = if hi > 0 && to > from {
543                        (self.pair_start(line, from), self.pair_end(line, hi - 1) + 1)
544                    } else {
545                        (from, hi)
546                    };
547                    let mut seg = String::new();
548                    for col in lo..hi {
549                        self.append_cell(&self.grid, &mut seg, line, col);
550                    }
551                    out.push_str(seg.trim_end_matches(' '));
552                    if line != line1 {
553                        out.push('\n');
554                    }
555                }
556                Some(out)
557            }
558        }
559    }
560
561    /// The whole buffer as one text document (#150): scrollback + screen assembled
562    /// into logical lines (soft-wrap joined, wide-spacers skipped, trailing blanks
563    /// trimmed at the logical end) — the accessible-view a screen reader reads as
564    /// a document, distinct from the viewport row tree (#119). Reuses the
565    /// selection extraction (`extract_lines`) over the full
566    /// range. On the alt screen only the alt buffer is shown — its "scrollback" is
567    /// the *primary* buffer's, not this app's — mirroring `viewport_logical_lines`'
568    /// alt floor.
569    pub fn accessible_text(&self) -> String {
570        let total = self.scrollback.len() + self.grid.rows();
571        if total == 0 {
572            return String::new();
573        }
574        let start = self.abs_floor();
575        let mut doc = self.extract_lines(&self.grid, start, 0, total - 1, usize::MAX);
576        // Trim *trailing* empty lines (blank screen rows below the content) — pure
577        // noise to a listener, and what a fresh screen would otherwise emit. Keep
578        // *internal* blank lines (paragraph breaks between command outputs) — a
579        // document wants those, unlike the viewport tree which drops all empties.
580        doc.truncate(doc.trim_end_matches('\n').len());
581        doc
582    }
583}