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 **line** is in the
54 /// buffer; [`Self::tracked_point`] reads it back and answers `None` once the line
55 /// has left it.
56 ///
57 /// **A line, not the characters on it, and the distinction is load-bearing.**
58 /// Erasing or overwriting the cells under a tracked point leaves it `Some` —
59 /// measured. That is deliberate rather than the marker defect one file over (#750):
60 /// a tracked point is a *positional* reference whose only consumer asks "which
61 /// occurrence was I on" and resolves by nearest position, so a point over rewritten
62 /// content is still a serviceable answer, where a command mark asserts that a
63 /// command *happened* there. The sentence above said "that content" and read as the
64 /// stronger promise.
65 ///
66 /// The coordinate is **absolute**, not a viewport row, because the positions
67 /// worth tracking are off-screen ones — a search match in scrollback is the
68 /// case this exists for, and `add_marker`'s viewport intake structurally
69 /// cannot name it.
70 ///
71 /// Out of range is bounded, not rejected, and bounded at the **read** rather
72 /// than here: the engine owns no producer for this coordinate — it is the
73 /// consumer's, like a `Match` — which is the second branch of ADR-0026 D2, the
74 /// same one `match_spans` takes.
75 pub fn track_point(&mut self, line: usize, col: usize) -> TrackedId {
76 let id = TrackedId(self.next_tracked_id);
77 self.next_tracked_id += 1;
78 self.tracked_mut().push(TrackedPoint { id, line, col });
79 id
80 }
81
82 /// Where the point registered as `id` sits now, or `None` if it has left the
83 /// buffer (or the id was never issued / already released).
84 ///
85 /// Bounded here, both ends, per ADR-0026 D2/D3: the line into the range of the
86 /// buffer the point **belongs to**, and the column to the grid width rather
87 /// than the line's text (D4). The column's domain is `[0, cols]` like a
88 /// marker's: one past the last cell is a legal *bound*, which is what a caller
89 /// pairing this with text extraction needs.
90 ///
91 /// **Only the ACTIVE buffer's points resolve.** A point registered on the other
92 /// screen answers `None` until that screen is active again — because the number
93 /// this returns *cannot carry its own frame*: the primary grid and the alt grid
94 /// occupy the **same** absolute indices `[scrollback.len(), scrollback.len() +
95 /// rows)`, so a primary grid row and an alt row are the same integer naming
96 /// different content, and no floor or ceiling can separate them. Measured: a
97 /// point on primary line 4 and the alt screen's second row both read `4`.
98 ///
99 /// Returning the stored number regardless was the first attempt, and it hands a
100 /// consumer a plausible coordinate for the wrong screen with nothing to detect
101 /// it by — the public surface has no frame tag. That is this module's own stated
102 /// failure mode arriving through the read. The sibling routes the same way for
103 /// the same reason (`markers()`), and neither reference can have the problem:
104 /// xterm's markers hang off a `Buffer`, ghostty's pins off a per-screen
105 /// `PageList`, so a cross-screen read is unconstructible there rather than
106 /// merely wrong.
107 ///
108 /// So `None` covers three cases a caller does not need to distinguish — the
109 /// content left the buffer, the id was released or never issued, or the point
110 /// belongs to the screen that is not up. All three mean *do not move anything
111 /// on account of this point*, which is the only question the one caller shape
112 /// asks.
113 pub fn tracked_point(&self, id: TrackedId) -> Option<(usize, usize)> {
114 let p = self.tracked().iter().find(|p| p.id == id)?;
115 let floor = self.abs_floor();
116 let last = (self.scrollback.len() + self.grid.rows()).saturating_sub(1);
117 Some((
118 p.line.clamp(floor, last.max(floor)),
119 p.col.min(self.grid.cols()),
120 ))
121 }
122
123 /// Release `id`. A no-op for an unknown or already-released id.
124 ///
125 /// Not optional housekeeping: the engine cannot know when a holder is done
126 /// with a position, so without this the registry only ever grows.
127 pub fn untrack_point(&mut self, id: TrackedId) {
128 // Id-based and buffer-agnostic, like `remove_marker`: ids are unique
129 // across both lists, so a point is released whichever screen it is on.
130 self.normal_tracked.retain(|p| p.id != id);
131 self.alt_tracked.retain(|p| p.id != id);
132 }
133
134 /// Shift tracked points up one absolute line from `from` down, after a
135 /// top-anchored sub-region scroll grew scrollback while the rows below the
136 /// bottom margin stayed put on screen (#449). Primary only, because the
137 /// accrual branch that needs it is. The tracked-point analogue of
138 /// `selection_shift_below_margin` / `markers_shift_below_margin`.
139 pub(super) fn tracked_shift_below_margin(&mut self, from: usize) {
140 for p in &mut self.normal_tracked {
141 if p.line >= from {
142 p.line += 1;
143 }
144 }
145 }
146
147 /// Rotate tracked points within an in-screen region scroll of absolute lines
148 /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`). A point
149 /// on the dropped edge has left the buffer and is released. The analogue of
150 /// `selection_rotate_region` / `markers_rotate_region`.
151 ///
152 /// It follows the *marker* policy, not the selection's: a marker on the edge
153 /// is disposed, while a selection clamps to keep the part of a range still in
154 /// the buffer. A tracked point is one position, not a range — there is no
155 /// surviving part to keep, and clamping would hand back a coordinate naming
156 /// content the caller never asked about.
157 pub(super) fn tracked_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
158 self.tracked_mut().retain_mut(|p| {
159 if p.line < top || p.line > bottom {
160 return true; // outside the region — unchanged
161 }
162 let dropped_edge = if up { top } else { bottom };
163 if p.line == dropped_edge {
164 false
165 } else {
166 p.line = if up { p.line - 1 } else { p.line + 1 };
167 true
168 }
169 });
170 }
171
172 /// Shift tracked points down one absolute line after the oldest history line
173 /// is evicted past the scrollback cap; a point *on* that line has left the
174 /// buffer and is dropped. The tracked-point analogue of
175 /// `selection_evict_oldest` / `markers_evict_oldest`, and the site this whole
176 /// module was filed for (#691).
177 pub(super) fn tracked_evict_oldest(&mut self) {
178 // Scrollback eviction is primary-only (the alt screen has none).
179 self.normal_tracked.retain_mut(|p| {
180 if p.line == 0 {
181 false
182 } else {
183 p.line -= 1;
184 true
185 }
186 });
187 }
188}