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}