Skip to main content

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}