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