Skip to main content

justerm_core/term/
markers.rs

1//! The decoration-marker surface: engine-owned marks bound to absolute buffer lines,
2//! the OSC 133 semantic-command queries built on them, and the three fixups that keep a
3//! mark on its content while the buffer moves under it.
4//!
5//! A marker is the same kind of thing as a selection anchor — an absolute
6//! `[scrollback ++ screen]` line index that survives an ordinary scroll and has to be
7//! repaired wherever it does not. Three of those repairs are *calls*, and they are the
8//! ones here: `markers_shift_below_margin`, `markers_evict_oldest` and
9//! `markers_rotate_region` are `pub(super)` because the write path in `term.rs` invokes
10//! them, mostly on the line beside their selection counterparts. #584 weighed merging the
11//! two surfaces into one module on the strength of that pairing and rejected it; the
12//! grounds are recorded there.
13//!
14//! **What a marker's line does *not* go through is this module.** Four sites outside it
15//! also move or drop a marker, and a reader who comes here for "everywhere a marker's
16//! coordinate changes" will find none of them: primary reflow rewrites `m.line` in place,
17//! alt reflow rewrites *and* disposes, alt-leave drains the alt list, and RIS disposes
18//! both. All four live in `term.rs` because #584 put reflow and the write path out of
19//! scope, which is a boundary of the epic rather than a property of markers.
20//!
21//! Two declarations stay in `term.rs`, and neither is forced. `Marker` is the element type
22//! of the `normal_markers` / `alt_markers` fields, so it sits with the fields it describes.
23//! `CommandLine` could have travelled — `mod term` is private, so `pub use
24//! term::markers::CommandLine` would keep `justerm_core::CommandLine` byte-identical — but
25//! that edits `lib.rs`, which this slice holds untouched, and the ticket does not name it.
26//! A child module reads both without any widening.
27//!
28//! `primary_grid` *did* travel, though the ticket does not name it either: after
29//! `command_lines` moved, nothing in `term.rs` called it. It belongs here because command
30//! marks anchor **primary** content — on the alt screen their text must be read from the
31//! swapped-out grid, not the active one — which is a marker rule, not a general accessor.
32//!
33//! Visibility follows the callers. Six items are `pub(super)` because the write path and
34//! `frame()` invoke them from `term.rs`; that is not a widening, since an item private to
35//! `term` was already visible to `term` and all of its descendants. Six are private —
36//! every caller travelled with them. The four entry points are public API and keep
37//! `pub fn`: an inherent impl's methods are reached through the type, not the module path,
38//! so a private child module does not hide them.
39
40use std::collections::VecDeque;
41
42use crate::cell::Cell;
43use crate::event::TermEvent;
44use crate::grid::Grid;
45use crate::serialize::{MarkerId, MarkerKind, MarkerLine, MarkerPosition};
46
47use super::{CommandLine, MAX_MARKERS, Marker, MarkerEntry, MarkerIndex, Term};
48
49impl Term {
50    /// The primary-screen grid, wherever it currently lives — swapped into
51    /// `alt_grid` while on the alt screen (#192). Command marks anchor *primary*
52    /// content, so extracting their text must read this, not the active grid.
53    fn primary_grid(&self) -> &Grid {
54        if self.on_alt {
55            &self.alt_grid
56        } else {
57            &self.grid
58        }
59    }
60
61    /// The active buffer's marker list (#177 S0) — alt while on the alt screen,
62    /// else normal. Add/rotate/project operate on this; primary-scoped queries
63    /// (`command_marks`/`command_lines`) and scrollback eviction read
64    /// `normal_markers` directly.
65    fn markers(&self) -> &VecDeque<Marker> {
66        if self.on_alt {
67            &self.alt_markers
68        } else {
69            &self.normal_markers
70        }
71    }
72
73    /// Mutable [`Self::markers`].
74    fn markers_mut(&mut self) -> &mut VecDeque<Marker> {
75        if self.on_alt {
76            &mut self.alt_markers
77        } else {
78            &mut self.normal_markers
79        }
80    }
81
82    /// Register a decoration marker at viewport `row`, returning its stable id
83    /// (#118). The row is resolved to an absolute buffer line (like a selection
84    /// anchor), so the marker tracks that content through scroll/eviction/reflow.
85    pub fn add_marker(&mut self, row: usize) -> MarkerId {
86        // On the alt screen this anchors an *alt-scoped* marker (#187): per-buffer
87        // storage (#186) keeps it out of the primary list, and it is disposed on
88        // alt-leave — xterm's per-buffer `addMarker` + `clearAllMarkers`. No dead
89        // sentinel is needed anymore; `markers_mut` routes to the active buffer.
90        let line = self.viewport_to_abs(row, 0).line;
91        self.push_marker(line, 0, MarkerKind::Plain)
92    }
93
94    /// Push a marker anchored at absolute `(line, col)` with `kind`, returning its
95    /// id. The shared core of `add_marker` (viewport row, `col = 0`) and OSC-133
96    /// command marks (cursor line + column) — one place owns id allocation + the
97    /// `markers` list.
98    fn push_marker(&mut self, line: usize, col: usize, kind: MarkerKind) -> MarkerId {
99        let id = MarkerId(self.next_marker_id);
100        self.next_marker_id += 1;
101        // #721: this population is allocated by the *stream* — `add_command_mark` appends
102        // per OSC 133 sequence, several marks share a line, and eviction only drops one
103        // whose line reached abs 0 — so a stream that never emits a newline grows it
104        // without bound. Bounded at `MAX_MARKERS`, which the wire's own `u16` group counts
105        // derive (the same argument `MAX_COLUMNS` is written from).
106        //
107        // Overflow retires the **oldest**, not the newest. Refusing the newest is cheaper
108        // but permanently kills shell integration for the session: once a pile fills the
109        // cap on a line nothing can evict, every later mark would be refused forever. The
110        // oldest is also the one already destined to die, and `MarkerDisposed` is the
111        // channel scrollback eviction announces that on — so the consumer contract is
112        // unchanged rather than extended.
113        let mut disposed = Vec::new();
114        let markers = self.markers_mut();
115        while markers.len() >= MAX_MARKERS {
116            // `VecDeque`, not `Vec`, for this line: `remove(0)` would memmove the whole
117            // population on *every* push once the cap is reached, turning a memory defect
118            // into a throughput one.
119            let Some(m) = markers.pop_front() else {
120                // Not reachable while `MAX_MARKERS > 0`, and written so that it stays
121                // unreachable rather than becoming an infinite loop if it ever is not:
122                // an empty deque satisfies `len() >= 0` forever.
123                break;
124            };
125            disposed.push(m.id);
126        }
127        markers.push_back(Marker {
128            id,
129            line,
130            col,
131            kind,
132        });
133        for id in disposed {
134            self.events.push(TermEvent::MarkerDisposed(id));
135        }
136        // Birth is an occurrence, so it rides the event queue (ADR-0020 R1) — the mirror
137        // of the disposal above (#490). A consumer holding a pulled index has no other
138        // way to learn of a marker the *stream* created, and without it the index can
139        // only ever shrink. Not an epoch bump: that would cost an O(M) re-pull for O(1)
140        // information, four times per shell command.
141        self.events.push(TermEvent::MarkerCreated {
142            id,
143            line: line as u32,
144            kind,
145        });
146        id
147    }
148
149    /// Record an OSC 133 command-boundary mark at the cursor's current line
150    /// (#158). Ignored on the alt screen: unlike the decoration guards that
151    /// per-buffer storage retired (#187), this one stands on a *semantic* — OSC
152    /// 133 is shell integration, which only runs on the primary screen, so an alt
153    /// 133 is meaningless (there is no command to bound). Command nav/announce read
154    /// the *normal* buffer's marks (`command_marks`/`command_lines`, primary-scoped
155    /// since #186), so even a stray alt 133 could not reach them — but there is no
156    /// value in creating an alt-scoped command mark nothing consumes (#188). The
157    /// cursor line is `scrollback ++ screen`-absolute, independent of
158    /// `display_offset` (the cursor is always in the grid, never scrollback).
159    pub(super) fn add_command_mark(&mut self, kind: MarkerKind) {
160        if self.on_alt {
161            return;
162        }
163        let line = self.scrollback.len() + self.cursor.row;
164        // `cursor.col` alone is one column short whenever the command exactly filled the row: the
165        // cursor that has just written the last cell is held *at* `cols - 1` with `pending_wrap`
166        // set, because "one past the last column" is not a column (#562). A command mark's column
167        // is an **exclusive** bound on the command text, so it wants precisely that unrepresentable
168        // value — `extract_lines` clips `[b_col, c_col)` and `.min(cells.len())` absorbs it.
169        //
170        // Without this, `$ ` + `abcd` at 6 columns recorded `abc`: no resize involved, so this half
171        // of #562 was reachable on a screen that never changed size.
172        let col = self.cursor.col + usize::from(self.cursor.pending_wrap);
173        self.push_marker(line, col, kind);
174    }
175
176    /// The OSC 133 command-boundary marks in buffer order — `(id, absolute line,
177    /// kind)` (#158). Plain decoration markers (#118) are excluded. The consumer
178    /// pairs prompt/command/finished marks and drives navigation/announce policy
179    /// (#160); core only parses and anchors them.
180    pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
181        // Primary-scoped: OSC-133 shell integration marks live on the normal
182        // buffer, so command nav/announce read it even while on the alt screen.
183        self.normal_markers
184            .iter()
185            .filter(|m| m.kind != MarkerKind::Plain)
186            .map(|m| (m.id, m.line, m.kind))
187            .collect()
188    }
189
190    /// The executed shell commands recovered from OSC-133 marks, in buffer order
191    /// (#166) — the data behind screen-reader command navigation. Each
192    /// [`CommandLine`] pairs a CommandStart(B) with the following OutputStart(C)
193    /// to extract the *typed command* (the prompt before B and the output after C
194    /// excluded via the captured columns, VSCode `extractCommandLine` parity), and
195    /// attaches the trailing CommandFinished(D) exit. A command still being typed
196    /// (B with no C yet) is not navigable — its text has no bound — so it is
197    /// omitted until output starts.
198    pub fn command_lines(&self) -> Vec<CommandLine> {
199        let mut out: Vec<CommandLine> = Vec::new();
200        // (B line, B col) awaiting its matching C. Marks arrive in buffer order.
201        let mut pending: Option<(usize, usize)> = None;
202        // Primary-scoped (see `command_marks`): the normal buffer's marks.
203        for m in &self.normal_markers {
204            match m.kind {
205                MarkerKind::CommandStart => pending = Some((m.line, m.col)),
206                MarkerKind::OutputStart => {
207                    if let Some((b_line, b_col)) = pending.take() {
208                        // Columns bound the command precisely even though output was
209                        // written after C — `extract_lines` reads current cells but
210                        // clips to `[b_col, c_col)`, excluding both prompt and output.
211                        // Command marks anchor primary content — read the primary
212                        // grid so the text is right even while on the alt screen (#192).
213                        // Where the *typed* command begins, which is not always where B was
214                        // emitted: a prompt that ends its row leaves B past that row's content, and
215                        // the command really starts on the next line. Normalised **here** rather
216                        // than inside `extract_lines`, because the two answers differ by caller —
217                        // a selection that starts in a line's trailing blanks does contain the
218                        // break that follows, a command does not — and because `doc_line_of` needs
219                        // the same value. Feeding it the raw `b_line` reported the command one
220                        // document line early, which is the a11y "jump to previous command" target.
221                        let (b_line, b_col) =
222                            self.command_start(self.primary_grid(), b_line, b_col, m.line);
223                        let command =
224                            self.extract_lines(self.primary_grid(), b_line, b_col, m.line, m.col);
225                        out.push(CommandLine {
226                            line: self.doc_line_of(self.primary_grid(), b_line),
227                            command,
228                            exit: None,
229                        });
230                    }
231                }
232                MarkerKind::CommandFinished(exit) => {
233                    // The exit belongs to the most recent command not yet closed;
234                    // the `is_none` guard stops a stray D from clobbering a code.
235                    if let Some(last) = out.last_mut()
236                        && last.exit.is_none()
237                    {
238                        last.exit = exit;
239                    }
240                }
241                MarkerKind::Plain | MarkerKind::PromptStart => {}
242            }
243        }
244        out
245    }
246
247    /// Advance an OSC-133 `CommandStart` position past any hard-ended line that holds no command
248    /// text at or after it, stopping before `end` (the matching `OutputStart`).
249    ///
250    /// B is emitted at the cursor, so a prompt that fills — or merely ends — its row leaves the mark
251    /// in that row's trailing blanks (#562). Two things then go wrong if the raw position is used:
252    /// `extract_lines` selects an empty run and, because the row is hard-ended, flushes it with a
253    /// `\n` the command never contained; and `doc_line_of` names the prompt's line rather than the
254    /// command's. Both were reachable **without any resize** — the row only has to end before its
255    /// width, which an 8-column row holding a 6-column prompt does.
256    ///
257    /// Only hard-ended rows advance. On a soft-wrapped row the continuation is the same logical
258    /// line, its trailing blanks are real content (a space at a wrap boundary was typed), and no
259    /// `\n` is flushed there anyway.
260    fn command_start(&self, grid: &Grid, line: usize, col: usize, end: usize) -> (usize, usize) {
261        let (mut line, mut col) = (line, col);
262        while line < end && !self.row_in(grid, line).is_wrapped() {
263            let cells = self.line_in(grid, line);
264            if col < cells.len() && !cells[col..].iter().all(Cell::is_blank) {
265                break;
266            }
267            line += 1;
268            col = 0;
269        }
270        (line, col)
271    }
272
273    /// The document (logical) line index that absolute buffer line `abs` renders
274    /// into within [`Term::accessible_text`] — the number of hard line-ends before
275    /// it (soft-wrapped rows share one logical line). Primary-screen coordinates,
276    /// matching `accessible_text`'s `start = 0` for the primary screen; command
277    /// marks are primary-only. O(abs) per call — fine for an on-demand query over
278    /// the handful of commands in a session.
279    fn doc_line_of(&self, grid: &Grid, abs: usize) -> usize {
280        (0..abs)
281            .filter(|&l| !self.row_in(grid, l).is_wrapped())
282            .count()
283    }
284
285    /// Remove a marker by id (#118). Disposing it fires `MarkerDisposed` so the
286    /// consumer's cleanup is one path whether the marker left by eviction or by
287    /// this explicit call (xterm's `dispose()` likewise always fires onDispose).
288    /// A no-op for an unknown/already-disposed id.
289    pub fn remove_marker(&mut self, id: MarkerId) {
290        // Id-based, buffer-agnostic: search both lists (ids are unique across
291        // buffers) so a marker is removed whichever screen it lives on (#177 S0).
292        let before = self.normal_markers.len() + self.alt_markers.len();
293        self.normal_markers.retain(|m| m.id != id);
294        self.alt_markers.retain(|m| m.id != id);
295        if self.normal_markers.len() + self.alt_markers.len() != before {
296            self.events.push(TermEvent::MarkerDisposed(id));
297        }
298    }
299
300    /// Every live marker of the active buffer, with the basis that keeps the answer
301    /// usable (#490). The pull half of the marker surface: a consumer asks once and
302    /// rebases per frame rather than being handed every marker in every frame.
303    ///
304    /// Ordering is the engine's own, which is the precedence a consumer joins
305    /// decorations by (#458/#461) — the same reason `marker_positions` does not sort.
306    pub fn marker_index(&self) -> MarkerIndex {
307        MarkerIndex {
308            markers: self
309                .markers()
310                .iter()
311                .map(|m| MarkerEntry {
312                    id: m.id,
313                    line: m.line as u32,
314                    kind: m.kind,
315                })
316                .collect(),
317            evicted_total: self.evicted_total,
318            epoch: self.marker_epoch,
319        }
320    }
321
322    /// Declare that a held marker line has gone stale for a reason the
323    /// `evicted_total` delta cannot express (#490).
324    ///
325    /// Every caller is a site that moves marker lines **non-uniformly** — a region
326    /// rotate touches only the markers inside the region, a reflow rewrites them
327    /// outright, an alt switch changes which buffer the answer even describes. The
328    /// bump is deliberately *not* placed on disposal: a consumer hears that on
329    /// `MarkerDisposed` and drops the entry without asking for the rest again.
330    pub(super) fn bump_marker_epoch(&mut self) {
331        self.marker_epoch = self.marker_epoch.wrapping_add(1);
332    }
333
334    /// The marker analogue of `selection_shift_below_margin` (#449) — primary
335    /// only, because the accrual branch that needs it is primary-only.
336    pub(super) fn markers_shift_below_margin(&mut self, from: usize) {
337        let mut moved = false;
338        for m in &mut self.normal_markers {
339            if m.line >= from {
340                m.line += 1;
341                moved = true;
342            }
343        }
344        if moved {
345            self.bump_marker_epoch();
346        }
347    }
348
349    /// Shift markers down one absolute line after the oldest history line is
350    /// evicted; a marker *on* that line (abs 0) has left the buffer, so it is
351    /// disposed and announced (#118) — the marker analogue of
352    /// `selection_evict_oldest`, but a list with per-marker disposal.
353    pub(super) fn markers_evict_oldest(&mut self) {
354        // Scrollback eviction is primary-only (the alt screen has none).
355        let mut disposed = Vec::new();
356        self.normal_markers.retain_mut(|m| {
357            if m.line == 0 {
358                disposed.push(m.id);
359                false
360            } else {
361                m.line -= 1;
362                true
363            }
364        });
365        for id in disposed {
366            self.events.push(TermEvent::MarkerDisposed(id));
367        }
368    }
369
370    /// Rotate markers within an in-screen region scroll of absolute lines
371    /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`) — the
372    /// marker analogue of `selection_rotate_region`. A marker on the dropped edge
373    /// has left the buffer, so it is disposed and announced (#118).
374    pub(super) fn markers_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
375        let mut disposed = Vec::new();
376        let mut moved = false;
377        self.markers_mut().retain_mut(|m| {
378            if m.line < top || m.line > bottom {
379                return true; // outside the region — unchanged
380            }
381            let dropped_edge = if up { top } else { bottom };
382            if m.line == dropped_edge {
383                disposed.push(m.id);
384                false
385            } else {
386                m.line = if up { m.line - 1 } else { m.line + 1 };
387                moved = true;
388                true
389            }
390        });
391        for id in disposed {
392            self.events.push(TermEvent::MarkerDisposed(id));
393        }
394        // Only a *surviving* marker that moved invalidates a held index (#490). A
395        // rotate that merely disposed the edge marker, or found none inside the
396        // region at all, leaves every held line correct — and gating on that is what
397        // keeps a TUI scrolling a region from forcing a re-pull per line.
398        if moved {
399            self.bump_marker_epoch();
400        }
401    }
402
403    /// The active buffer's markers projected onto the current viewport — one
404    /// `MarkerPosition` per marker whose line is visible, off-screen markers
405    /// omitted. The alt screen projects its own (alt-scoped) markers now (#187);
406    /// they are disposed on alt-leave, so a primary frame never shows them.
407    pub(super) fn marker_positions(&self) -> Vec<MarkerPosition> {
408        let top = self.scrollback.len() - self.display_offset;
409        let rows = self.grid.rows();
410        self.markers()
411            .iter()
412            .filter_map(|m| {
413                let row = m.line.checked_sub(top)?;
414                (row < rows).then_some(MarkerPosition {
415                    id: m.id,
416                    row,
417                    kind: m.kind,
418                })
419            })
420            .collect()
421    }
422
423    /// Every live marker's absolute buffer line (#120 S3) — the off-viewport
424    /// superset of `marker_positions`, for the overview ruler. No viewport filter:
425    /// a marker scrolled out of view is still reported (that is the ruler's job),
426    /// its `line` in the same `[0, scrollback + rows)` frame as the header's
427    /// `scrollback_len`/`display_offset`.
428    pub(super) fn all_marker_lines(&self) -> Vec<MarkerLine> {
429        self.markers()
430            .iter()
431            .map(|m| MarkerLine {
432                id: m.id,
433                line: m.line as u32,
434            })
435            .collect()
436    }
437}