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 crate::cell::Cell;
41use crate::event::TermEvent;
42use crate::grid::Grid;
43use crate::serialize::{MarkerId, MarkerKind, MarkerLine, MarkerPosition};
44
45use super::{CommandLine, Marker, Term};
46
47impl Term {
48    /// The primary-screen grid, wherever it currently lives — swapped into
49    /// `alt_grid` while on the alt screen (#192). Command marks anchor *primary*
50    /// content, so extracting their text must read this, not the active grid.
51    fn primary_grid(&self) -> &Grid {
52        if self.on_alt {
53            &self.alt_grid
54        } else {
55            &self.grid
56        }
57    }
58
59    /// The active buffer's marker list (#177 S0) — alt while on the alt screen,
60    /// else normal. Add/rotate/project operate on this; primary-scoped queries
61    /// (`command_marks`/`command_lines`) and scrollback eviction read
62    /// `normal_markers` directly.
63    fn markers(&self) -> &Vec<Marker> {
64        if self.on_alt {
65            &self.alt_markers
66        } else {
67            &self.normal_markers
68        }
69    }
70
71    /// Mutable [`Self::markers`].
72    fn markers_mut(&mut self) -> &mut Vec<Marker> {
73        if self.on_alt {
74            &mut self.alt_markers
75        } else {
76            &mut self.normal_markers
77        }
78    }
79
80    /// Register a decoration marker at viewport `row`, returning its stable id
81    /// (#118). The row is resolved to an absolute buffer line (like a selection
82    /// anchor), so the marker tracks that content through scroll/eviction/reflow.
83    pub fn add_marker(&mut self, row: usize) -> MarkerId {
84        // On the alt screen this anchors an *alt-scoped* marker (#187): per-buffer
85        // storage (#186) keeps it out of the primary list, and it is disposed on
86        // alt-leave — xterm's per-buffer `addMarker` + `clearAllMarkers`. No dead
87        // sentinel is needed anymore; `markers_mut` routes to the active buffer.
88        let line = self.viewport_to_abs(row, 0).line;
89        self.push_marker(line, 0, MarkerKind::Plain)
90    }
91
92    /// Push a marker anchored at absolute `(line, col)` with `kind`, returning its
93    /// id. The shared core of `add_marker` (viewport row, `col = 0`) and OSC-133
94    /// command marks (cursor line + column) — one place owns id allocation + the
95    /// `markers` list.
96    fn push_marker(&mut self, line: usize, col: usize, kind: MarkerKind) -> MarkerId {
97        let id = MarkerId(self.next_marker_id);
98        self.next_marker_id += 1;
99        self.markers_mut().push(Marker {
100            id,
101            line,
102            col,
103            kind,
104        });
105        id
106    }
107
108    /// Record an OSC 133 command-boundary mark at the cursor's current line
109    /// (#158). Ignored on the alt screen: unlike the decoration guards that
110    /// per-buffer storage retired (#187), this one stands on a *semantic* — OSC
111    /// 133 is shell integration, which only runs on the primary screen, so an alt
112    /// 133 is meaningless (there is no command to bound). Command nav/announce read
113    /// the *normal* buffer's marks (`command_marks`/`command_lines`, primary-scoped
114    /// since #186), so even a stray alt 133 could not reach them — but there is no
115    /// value in creating an alt-scoped command mark nothing consumes (#188). The
116    /// cursor line is `scrollback ++ screen`-absolute, independent of
117    /// `display_offset` (the cursor is always in the grid, never scrollback).
118    pub(super) fn add_command_mark(&mut self, kind: MarkerKind) {
119        if self.on_alt {
120            return;
121        }
122        let line = self.scrollback.len() + self.cursor.row;
123        // `cursor.col` alone is one column short whenever the command exactly filled the row: the
124        // cursor that has just written the last cell is held *at* `cols - 1` with `pending_wrap`
125        // set, because "one past the last column" is not a column (#562). A command mark's column
126        // is an **exclusive** bound on the command text, so it wants precisely that unrepresentable
127        // value — `extract_lines` clips `[b_col, c_col)` and `.min(cells.len())` absorbs it.
128        //
129        // Without this, `$ ` + `abcd` at 6 columns recorded `abc`: no resize involved, so this half
130        // of #562 was reachable on a screen that never changed size.
131        let col = self.cursor.col + usize::from(self.cursor.pending_wrap);
132        self.push_marker(line, col, kind);
133    }
134
135    /// The OSC 133 command-boundary marks in buffer order — `(id, absolute line,
136    /// kind)` (#158). Plain decoration markers (#118) are excluded. The consumer
137    /// pairs prompt/command/finished marks and drives navigation/announce policy
138    /// (#160); core only parses and anchors them.
139    pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
140        // Primary-scoped: OSC-133 shell integration marks live on the normal
141        // buffer, so command nav/announce read it even while on the alt screen.
142        self.normal_markers
143            .iter()
144            .filter(|m| m.kind != MarkerKind::Plain)
145            .map(|m| (m.id, m.line, m.kind))
146            .collect()
147    }
148
149    /// The executed shell commands recovered from OSC-133 marks, in buffer order
150    /// (#166) — the data behind screen-reader command navigation. Each
151    /// [`CommandLine`] pairs a CommandStart(B) with the following OutputStart(C)
152    /// to extract the *typed command* (the prompt before B and the output after C
153    /// excluded via the captured columns, VSCode `extractCommandLine` parity), and
154    /// attaches the trailing CommandFinished(D) exit. A command still being typed
155    /// (B with no C yet) is not navigable — its text has no bound — so it is
156    /// omitted until output starts.
157    pub fn command_lines(&self) -> Vec<CommandLine> {
158        let mut out: Vec<CommandLine> = Vec::new();
159        // (B line, B col) awaiting its matching C. Marks arrive in buffer order.
160        let mut pending: Option<(usize, usize)> = None;
161        // Primary-scoped (see `command_marks`): the normal buffer's marks.
162        for m in &self.normal_markers {
163            match m.kind {
164                MarkerKind::CommandStart => pending = Some((m.line, m.col)),
165                MarkerKind::OutputStart => {
166                    if let Some((b_line, b_col)) = pending.take() {
167                        // Columns bound the command precisely even though output was
168                        // written after C — `extract_lines` reads current cells but
169                        // clips to `[b_col, c_col)`, excluding both prompt and output.
170                        // Command marks anchor primary content — read the primary
171                        // grid so the text is right even while on the alt screen (#192).
172                        // Where the *typed* command begins, which is not always where B was
173                        // emitted: a prompt that ends its row leaves B past that row's content, and
174                        // the command really starts on the next line. Normalised **here** rather
175                        // than inside `extract_lines`, because the two answers differ by caller —
176                        // a selection that starts in a line's trailing blanks does contain the
177                        // break that follows, a command does not — and because `doc_line_of` needs
178                        // the same value. Feeding it the raw `b_line` reported the command one
179                        // document line early, which is the a11y "jump to previous command" target.
180                        let (b_line, b_col) =
181                            self.command_start(self.primary_grid(), b_line, b_col, m.line);
182                        let command =
183                            self.extract_lines(self.primary_grid(), b_line, b_col, m.line, m.col);
184                        out.push(CommandLine {
185                            line: self.doc_line_of(self.primary_grid(), b_line),
186                            command,
187                            exit: None,
188                        });
189                    }
190                }
191                MarkerKind::CommandFinished(exit) => {
192                    // The exit belongs to the most recent command not yet closed;
193                    // the `is_none` guard stops a stray D from clobbering a code.
194                    if let Some(last) = out.last_mut()
195                        && last.exit.is_none()
196                    {
197                        last.exit = exit;
198                    }
199                }
200                MarkerKind::Plain | MarkerKind::PromptStart => {}
201            }
202        }
203        out
204    }
205
206    /// Advance an OSC-133 `CommandStart` position past any hard-ended line that holds no command
207    /// text at or after it, stopping before `end` (the matching `OutputStart`).
208    ///
209    /// B is emitted at the cursor, so a prompt that fills — or merely ends — its row leaves the mark
210    /// in that row's trailing blanks (#562). Two things then go wrong if the raw position is used:
211    /// `extract_lines` selects an empty run and, because the row is hard-ended, flushes it with a
212    /// `\n` the command never contained; and `doc_line_of` names the prompt's line rather than the
213    /// command's. Both were reachable **without any resize** — the row only has to end before its
214    /// width, which an 8-column row holding a 6-column prompt does.
215    ///
216    /// Only hard-ended rows advance. On a soft-wrapped row the continuation is the same logical
217    /// line, its trailing blanks are real content (a space at a wrap boundary was typed), and no
218    /// `\n` is flushed there anyway.
219    fn command_start(&self, grid: &Grid, line: usize, col: usize, end: usize) -> (usize, usize) {
220        let (mut line, mut col) = (line, col);
221        while line < end && !self.row_in(grid, line).is_wrapped() {
222            let cells = self.line_in(grid, line);
223            if col < cells.len() && !cells[col..].iter().all(Cell::is_blank) {
224                break;
225            }
226            line += 1;
227            col = 0;
228        }
229        (line, col)
230    }
231
232    /// The document (logical) line index that absolute buffer line `abs` renders
233    /// into within [`Term::accessible_text`] — the number of hard line-ends before
234    /// it (soft-wrapped rows share one logical line). Primary-screen coordinates,
235    /// matching `accessible_text`'s `start = 0` for the primary screen; command
236    /// marks are primary-only. O(abs) per call — fine for an on-demand query over
237    /// the handful of commands in a session.
238    fn doc_line_of(&self, grid: &Grid, abs: usize) -> usize {
239        (0..abs)
240            .filter(|&l| !self.row_in(grid, l).is_wrapped())
241            .count()
242    }
243
244    /// Remove a marker by id (#118). Disposing it fires `MarkerDisposed` so the
245    /// consumer's cleanup is one path whether the marker left by eviction or by
246    /// this explicit call (xterm's `dispose()` likewise always fires onDispose).
247    /// A no-op for an unknown/already-disposed id.
248    pub fn remove_marker(&mut self, id: MarkerId) {
249        // Id-based, buffer-agnostic: search both lists (ids are unique across
250        // buffers) so a marker is removed whichever screen it lives on (#177 S0).
251        let before = self.normal_markers.len() + self.alt_markers.len();
252        self.normal_markers.retain(|m| m.id != id);
253        self.alt_markers.retain(|m| m.id != id);
254        if self.normal_markers.len() + self.alt_markers.len() != before {
255            self.events.push(TermEvent::MarkerDisposed(id));
256        }
257    }
258
259    /// The marker analogue of `selection_shift_below_margin` (#449) — primary
260    /// only, because the accrual branch that needs it is primary-only.
261    pub(super) fn markers_shift_below_margin(&mut self, from: usize) {
262        for m in &mut self.normal_markers {
263            if m.line >= from {
264                m.line += 1;
265            }
266        }
267    }
268
269    /// Shift markers down one absolute line after the oldest history line is
270    /// evicted; a marker *on* that line (abs 0) has left the buffer, so it is
271    /// disposed and announced (#118) — the marker analogue of
272    /// `selection_evict_oldest`, but a list with per-marker disposal.
273    pub(super) fn markers_evict_oldest(&mut self) {
274        // Scrollback eviction is primary-only (the alt screen has none).
275        let mut disposed = Vec::new();
276        self.normal_markers.retain_mut(|m| {
277            if m.line == 0 {
278                disposed.push(m.id);
279                false
280            } else {
281                m.line -= 1;
282                true
283            }
284        });
285        for id in disposed {
286            self.events.push(TermEvent::MarkerDisposed(id));
287        }
288    }
289
290    /// Rotate markers within an in-screen region scroll of absolute lines
291    /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`) — the
292    /// marker analogue of `selection_rotate_region`. A marker on the dropped edge
293    /// has left the buffer, so it is disposed and announced (#118).
294    pub(super) fn markers_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
295        let mut disposed = Vec::new();
296        self.markers_mut().retain_mut(|m| {
297            if m.line < top || m.line > bottom {
298                return true; // outside the region — unchanged
299            }
300            let dropped_edge = if up { top } else { bottom };
301            if m.line == dropped_edge {
302                disposed.push(m.id);
303                false
304            } else {
305                m.line = if up { m.line - 1 } else { m.line + 1 };
306                true
307            }
308        });
309        for id in disposed {
310            self.events.push(TermEvent::MarkerDisposed(id));
311        }
312    }
313
314    /// The active buffer's markers projected onto the current viewport — one
315    /// `MarkerPosition` per marker whose line is visible, off-screen markers
316    /// omitted. The alt screen projects its own (alt-scoped) markers now (#187);
317    /// they are disposed on alt-leave, so a primary frame never shows them.
318    pub(super) fn marker_positions(&self) -> Vec<MarkerPosition> {
319        let top = self.scrollback.len() - self.display_offset;
320        let rows = self.grid.rows();
321        self.markers()
322            .iter()
323            .filter_map(|m| {
324                let row = m.line.checked_sub(top)?;
325                (row < rows).then_some(MarkerPosition {
326                    id: m.id,
327                    row,
328                    kind: m.kind,
329                })
330            })
331            .collect()
332    }
333
334    /// Every live marker's absolute buffer line (#120 S3) — the off-viewport
335    /// superset of `marker_positions`, for the overview ruler. No viewport filter:
336    /// a marker scrolled out of view is still reported (that is the ruler's job),
337    /// its `line` in the same `[0, scrollback + rows)` frame as the header's
338    /// `scrollback_len`/`display_offset`.
339    pub(super) fn all_marker_lines(&self) -> Vec<MarkerLine> {
340        self.markers()
341            .iter()
342            .map(|m| MarkerLine {
343                id: m.id,
344                line: m.line as u32,
345            })
346            .collect()
347    }
348}