justerm_core/term/tracked.rs
1//! Tracked points: absolute buffer positions the engine keeps on their **content**
2//! for a holder that lives outside it, and the fixups that do the keeping.
3//!
4//! The forcing case is a search anchor (#691). A consumer that carries an emphasis
5//! across a re-search has to remember *where* the user was, and the only name it can
6//! use is an absolute `[scrollback ++ screen]` coordinate — which the write path
7//! renumbers, at four separate sites and with two different signs. The selection and
8//! the markers each carry a fixup per site for exactly that reason; a coordinate held
9//! across the boundary carried none, so it named different text after every eviction.
10//!
11//! **This is mechanism, not policy** (ADR-0017). The engine keeps a position valid; it
12//! does not know or decide what the position *means* — which occurrence is current,
13//! and what to do once the point is gone, stay with the consumer. That split is why the
14//! answer is a point registry rather than the engine owning the search anchor itself.
15//!
16//! Two things it deliberately is **not**:
17//!
18//! - **Not a marker.** The shape is the same one `markers.rs` implements (stable id,
19//! line kept by the write path, death observable), but every live marker rides two
20//! frame groups, so registering one to remember a position would paint it on the
21//! overview ruler. Nothing here reaches a frame.
22//! - **Not an announcement.** A marker's death is a `TermEvent` because a decoration
23//! holder is push-driven and would otherwise never learn. A tracked point is *asked*
24//! — [`Term::tracked_point`] answers `None` — and the one caller shape that exists
25//! asks on every re-search anyway, so an event would be a second channel carrying
26//! what the first already says.
27
28use crate::term::{Term, TrackedId, TrackedPoint};
29
30impl Term {
31 /// The active buffer's tracked points — alt while the alt screen is up, else
32 /// primary. Mirrors `markers`/`markers_mut`, and for the same reason: an
33 /// absolute index means a different thing on each screen.
34 fn tracked_mut(&mut self) -> &mut Vec<TrackedPoint> {
35 if self.on_alt {
36 &mut self.alt_tracked
37 } else {
38 &mut self.normal_tracked
39 }
40 }
41
42 /// Read-only [`Self::tracked_mut`].
43 fn tracked(&self) -> &Vec<TrackedPoint> {
44 if self.on_alt {
45 &self.alt_tracked
46 } else {
47 &self.normal_tracked
48 }
49 }
50
51 /// Track absolute buffer `(line, col)`, returning a stable id (#691). The
52 /// engine keeps the position on the content that is there now, through
53 /// eviction, region scrolls and reflow, for as long as that content is in the
54 /// buffer; [`Self::tracked_point`] reads it back and answers `None` once it is
55 /// gone.
56 ///
57 /// The coordinate is **absolute**, not a viewport row, because the positions
58 /// worth tracking are off-screen ones — a search match in scrollback is the
59 /// case this exists for, and `add_marker`'s viewport intake structurally
60 /// cannot name it.
61 ///
62 /// Out of range is bounded, not rejected, and bounded at the **read** rather
63 /// than here: the engine owns no producer for this coordinate — it is the
64 /// consumer's, like a `Match` — which is the second branch of ADR-0026 D2, the
65 /// same one `match_spans` takes.
66 pub fn track_point(&mut self, line: usize, col: usize) -> TrackedId {
67 let id = TrackedId(self.next_tracked_id);
68 self.next_tracked_id += 1;
69 self.tracked_mut().push(TrackedPoint { id, line, col });
70 id
71 }
72
73 /// Where the point registered as `id` sits now, or `None` if it has left the
74 /// buffer (or the id was never issued / already released).
75 ///
76 /// Bounded here, both ends, per ADR-0026 D2/D3: the line into the range of the
77 /// buffer the point **belongs to**, and the column to the grid width rather
78 /// than the line's text (D4). The column's domain is `[0, cols]` like a
79 /// marker's: one past the last cell is a legal *bound*, which is what a caller
80 /// pairing this with text extraction needs.
81 ///
82 /// **Only the ACTIVE buffer's points resolve.** A point registered on the other
83 /// screen answers `None` until that screen is active again — because the number
84 /// this returns *cannot carry its own frame*: the primary grid and the alt grid
85 /// occupy the **same** absolute indices `[scrollback.len(), scrollback.len() +
86 /// rows)`, so a primary grid row and an alt row are the same integer naming
87 /// different content, and no floor or ceiling can separate them. Measured: a
88 /// point on primary line 4 and the alt screen's second row both read `4`.
89 ///
90 /// Returning the stored number regardless was the first attempt, and it hands a
91 /// consumer a plausible coordinate for the wrong screen with nothing to detect
92 /// it by — the public surface has no frame tag. That is this module's own stated
93 /// failure mode arriving through the read. The sibling routes the same way for
94 /// the same reason (`markers()`), and neither reference can have the problem:
95 /// xterm's markers hang off a `Buffer`, ghostty's pins off a per-screen
96 /// `PageList`, so a cross-screen read is unconstructible there rather than
97 /// merely wrong.
98 ///
99 /// So `None` covers three cases a caller does not need to distinguish — the
100 /// content left the buffer, the id was released or never issued, or the point
101 /// belongs to the screen that is not up. All three mean *do not move anything
102 /// on account of this point*, which is the only question the one caller shape
103 /// asks.
104 pub fn tracked_point(&self, id: TrackedId) -> Option<(usize, usize)> {
105 let p = self.tracked().iter().find(|p| p.id == id)?;
106 let floor = self.abs_floor();
107 let last = (self.scrollback.len() + self.grid.rows()).saturating_sub(1);
108 Some((
109 p.line.clamp(floor, last.max(floor)),
110 p.col.min(self.grid.cols()),
111 ))
112 }
113
114 /// Release `id`. A no-op for an unknown or already-released id.
115 ///
116 /// Not optional housekeeping: the engine cannot know when a holder is done
117 /// with a position, so without this the registry only ever grows.
118 pub fn untrack_point(&mut self, id: TrackedId) {
119 // Id-based and buffer-agnostic, like `remove_marker`: ids are unique
120 // across both lists, so a point is released whichever screen it is on.
121 self.normal_tracked.retain(|p| p.id != id);
122 self.alt_tracked.retain(|p| p.id != id);
123 }
124
125 /// Shift tracked points up one absolute line from `from` down, after a
126 /// top-anchored sub-region scroll grew scrollback while the rows below the
127 /// bottom margin stayed put on screen (#449). Primary only, because the
128 /// accrual branch that needs it is. The tracked-point analogue of
129 /// `selection_shift_below_margin` / `markers_shift_below_margin`.
130 pub(super) fn tracked_shift_below_margin(&mut self, from: usize) {
131 for p in &mut self.normal_tracked {
132 if p.line >= from {
133 p.line += 1;
134 }
135 }
136 }
137
138 /// Rotate tracked points within an in-screen region scroll of absolute lines
139 /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`). A point
140 /// on the dropped edge has left the buffer and is released. The analogue of
141 /// `selection_rotate_region` / `markers_rotate_region`.
142 ///
143 /// It follows the *marker* policy, not the selection's: a marker on the edge
144 /// is disposed, while a selection clamps to keep the part of a range still in
145 /// the buffer. A tracked point is one position, not a range — there is no
146 /// surviving part to keep, and clamping would hand back a coordinate naming
147 /// content the caller never asked about.
148 pub(super) fn tracked_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
149 self.tracked_mut().retain_mut(|p| {
150 if p.line < top || p.line > bottom {
151 return true; // outside the region — unchanged
152 }
153 let dropped_edge = if up { top } else { bottom };
154 if p.line == dropped_edge {
155 false
156 } else {
157 p.line = if up { p.line - 1 } else { p.line + 1 };
158 true
159 }
160 });
161 }
162
163 /// Shift tracked points down one absolute line after the oldest history line
164 /// is evicted past the scrollback cap; a point *on* that line has left the
165 /// buffer and is dropped. The tracked-point analogue of
166 /// `selection_evict_oldest` / `markers_evict_oldest`, and the site this whole
167 /// module was filed for (#691).
168 pub(super) fn tracked_evict_oldest(&mut self) {
169 // Scrollback eviction is primary-only (the alt screen has none).
170 self.normal_tracked.retain_mut(|p| {
171 if p.line == 0 {
172 false
173 } else {
174 p.line -= 1;
175 true
176 }
177 });
178 }
179}