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}