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 **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}