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 let len = self.abs_line(line).len();
237 let left = if line == start_line { from } else { 0 };
238 let right_excl = if line == end_line { to.min(len) } else { len };
239 if right_excl > left {
240 push(line, left, right_excl - 1);
241 }
242 }
243 }
244 Resolved::Block {
245 line0,
246 line1,
247 from,
248 to,
249 } => {
250 if to > from {
251 for line in line0..=line1 {
252 push(line, from, to - 1);
253 }
254 }
255 }
256 }
257 spans
258 }
259
260 /// Resolve the live selection into absolute-coordinate bounds per type:
261 /// a `Linear` run (char/word/line, which join soft wraps) or a `Block`
262 /// rectangle. `None` when nothing is selected. Columns are half-open
263 /// (`from..to`). Shared by `selection_text` and `selection_range`.
264 fn resolve(&self) -> Option<Resolved> {
265 let sel = self.selection.as_ref()?;
266 let (start, end) = sel.ordered();
267 Some(match sel.ty {
268 SelectionType::Char => {
269 // Half-open columns: each side decides if its own cell is in.
270 let from = match start.side {
271 Side::Left => start.point.col,
272 Side::Right => start.point.col + 1,
273 };
274 let to = match end.side {
275 Side::Left => end.point.col,
276 Side::Right => end.point.col + 1,
277 };
278 Resolved::Linear {
279 start_line: start.point.line,
280 from,
281 end_line: end.point.line,
282 to,
283 }
284 }
285 SelectionType::Word => {
286 // Snap both ends to word boundaries (side is ignored).
287 let ws = self.word_start(start.point);
288 let we = self.word_end(end.point);
289 Resolved::Linear {
290 start_line: ws.line,
291 from: ws.col,
292 end_line: we.line,
293 to: we.col + 1,
294 }
295 }
296 SelectionType::Line => Resolved::Linear {
297 start_line: start.point.line,
298 from: 0,
299 end_line: end.point.line,
300 to: self.grid.cols(),
301 },
302 SelectionType::Block => {
303 // Rectangular: the same column range on every row. Columns come
304 // from the two anchors (min/max, with each edge's side).
305 let cols = self.grid.cols();
306 let (a, b) = (sel.anchor, sel.focus);
307 let (lcol, lside, rcol, rside) = if a.point.col <= b.point.col {
308 (a.point.col, a.side, b.point.col, b.side)
309 } else {
310 (b.point.col, b.side, a.point.col, a.side)
311 };
312 let from = match lside {
313 Side::Left => lcol,
314 Side::Right => lcol + 1,
315 };
316 let to = match rside {
317 Side::Left => rcol,
318 Side::Right => rcol + 1,
319 };
320 Resolved::Block {
321 line0: a.point.line.min(b.point.line),
322 line1: a.point.line.max(b.point.line),
323 from,
324 to: to.min(cols).max(from),
325 }
326 }
327 })
328 }
329
330 /// The selected text (for copy), or `None` when nothing is selected.
331 pub fn selection_text(&self) -> Option<String> {
332 match self.resolve()? {
333 Resolved::Linear {
334 start_line,
335 from,
336 end_line,
337 to,
338 } => Some(self.extract_lines(&self.grid, start_line, from, end_line, to)),
339 Resolved::Block {
340 line0,
341 line1,
342 from,
343 to,
344 } => {
345 // Each row independently — no soft-wrap joining.
346 let mut out = String::new();
347 for line in line0..=line1 {
348 let hi = to.min(self.abs_line(line).len());
349 let mut seg = String::new();
350 for col in from..hi {
351 self.append_cell(&self.grid, &mut seg, line, col);
352 }
353 out.push_str(seg.trim_end());
354 if line != line1 {
355 out.push('\n');
356 }
357 }
358 Some(out)
359 }
360 }
361 }
362
363 /// The whole buffer as one text document (#150): scrollback + screen assembled
364 /// into logical lines (soft-wrap joined, wide-spacers skipped, trailing blanks
365 /// trimmed at the logical end) — the accessible-view a screen reader reads as
366 /// a document, distinct from the viewport row tree (#119). Reuses the
367 /// selection extraction (`extract_lines`) over the full
368 /// range. On the alt screen only the alt buffer is shown — its "scrollback" is
369 /// the *primary* buffer's, not this app's — mirroring `viewport_logical_lines`'
370 /// alt floor.
371 pub fn accessible_text(&self) -> String {
372 let total = self.scrollback.len() + self.grid.rows();
373 if total == 0 {
374 return String::new();
375 }
376 let start = self.abs_floor();
377 let mut doc = self.extract_lines(&self.grid, start, 0, total - 1, usize::MAX);
378 // Trim *trailing* empty lines (blank screen rows below the content) — pure
379 // noise to a listener, and what a fresh screen would otherwise emit. Keep
380 // *internal* blank lines (paragraph breaks between command outputs) — a
381 // document wants those, unlike the viewport tree which drops all empties.
382 doc.truncate(doc.trim_end_matches('\n').len());
383 doc
384 }
385}