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 fixups that keep a mark
3//! on its content — three for the buffer moving under it, and one for the content dying
4//! where it stands.
5//!
6//! **That second kind arrived late and is the shape to remember (#750).** The three
7//! movers below repair a *coordinate*, and for a long time they read as the whole job,
8//! because every way a mark could stop describing its content moved the buffer. An
9//! in-place erase does not: the row stays exactly where it is while everything the mark
10//! was about stops existing, so no verb fired, no `MarkerDisposed` was announced, and
11//! `command_lines` answered with commands that were not there. `dispose_markers_on_row`
12//! is the repair for that class, and the two are not interchangeable — a mover cannot
13//! see a destruction and a destroyer cannot see a move.
14//!
15//! A marker is the same kind of thing as a selection anchor — an absolute
16//! `[scrollback ++ screen]` line index that survives an ordinary scroll and has to be
17//! repaired wherever it does not. Three of those repairs are *calls*, and they are the
18//! ones here: `markers_shift_below_margin`, `markers_evict_oldest` and
19//! `markers_rotate_region` are `pub(super)` because the write path in `term.rs` invokes
20//! them, mostly on the line beside their selection counterparts. #584 weighed merging the
21//! two surfaces into one module on the strength of that pairing and rejected it; the
22//! grounds are recorded there.
23//!
24//! **What a marker's line does *not* go through is this module.** Four sites outside it
25//! also move or drop a marker, and a reader who comes here for "everywhere a marker's
26//! coordinate changes" will find none of them: primary reflow rewrites `m.line` in place,
27//! alt reflow rewrites *and* disposes, alt-leave drains the alt list, and RIS disposes
28//! both. All four live in `term.rs` because #584 put reflow and the write path out of
29//! scope, which is a boundary of the epic rather than a property of markers.
30//!
31//! Two declarations stay in `term.rs`, and neither is forced. `Marker` is the element type
32//! of the `normal_markers` / `alt_markers` fields, so it sits with the fields it describes.
33//! `CommandLine` could have travelled — `mod term` is private, so `pub use
34//! term::markers::CommandLine` would keep `justerm_core::CommandLine` byte-identical — but
35//! that edits `lib.rs`, which this slice holds untouched, and the ticket does not name it.
36//! A child module reads both without any widening.
37//!
38//! `primary_grid` *did* travel, though the ticket does not name it either: after
39//! `command_lines` moved, nothing in `term.rs` called it. It belongs here because command
40//! marks anchor **primary** content — on the alt screen their text must be read from the
41//! swapped-out grid, not the active one — which is a marker rule, not a general accessor.
42//!
43//! Visibility follows the callers. Six items are `pub(super)` because the write path and
44//! `frame()` invoke them from `term.rs`; that is not a widening, since an item private to
45//! `term` was already visible to `term` and all of its descendants. Six are private —
46//! every caller travelled with them. The four entry points are public API and keep
47//! `pub fn`: an inherent impl's methods are reached through the type, not the module path,
48//! so a private child module does not hide them.
49
50use std::collections::VecDeque;
51
52use crate::cell::Cell;
53use crate::event::TermEvent;
54use crate::grid::Grid;
55use crate::serialize::{MarkerId, MarkerKind, MarkerPosition};
56
57use super::{
58 CommandLine, CommandRecord, MAX_COMMAND_TEXT, MAX_MARKERS, Marker, MarkerEntry, MarkerIndex,
59 Term,
60};
61
62impl Term {
63 /// The primary-screen grid, wherever it currently lives — swapped into
64 /// `alt_grid` while on the alt screen (#192). Command marks anchor *primary*
65 /// content, so extracting their text must read this, not the active grid.
66 fn primary_grid(&self) -> &Grid {
67 if self.on_alt {
68 &self.alt_grid
69 } else {
70 &self.grid
71 }
72 }
73
74 /// The active buffer's marker list (#177 S0) — alt while on the alt screen,
75 /// else normal. Add/rotate/project operate on this; primary-scoped queries
76 /// (`command_marks`/`command_lines`) and scrollback eviction read
77 /// `normal_markers` directly.
78 pub(super) fn markers(&self) -> &VecDeque<Marker> {
79 if self.on_alt {
80 &self.alt_markers
81 } else {
82 &self.normal_markers
83 }
84 }
85
86 /// Mutable [`Self::markers`].
87 fn markers_mut(&mut self) -> &mut VecDeque<Marker> {
88 if self.on_alt {
89 &mut self.alt_markers
90 } else {
91 &mut self.normal_markers
92 }
93 }
94
95 /// Register a decoration marker at viewport `row`, returning its stable id
96 /// (#118). The row is resolved to an absolute buffer line (like a selection
97 /// anchor), so the marker tracks that content through scroll/eviction/reflow.
98 pub fn add_marker(&mut self, row: usize) -> MarkerId {
99 // On the alt screen this anchors an *alt-scoped* marker (#187): per-buffer
100 // storage (#186) keeps it out of the primary list, and it is disposed on
101 // alt-leave — xterm's per-buffer `addMarker` + `clearAllMarkers`. No dead
102 // sentinel is needed anymore; `markers_mut` routes to the active buffer.
103 let line = self.viewport_to_abs(row, 0).line;
104 self.push_marker(line, 0, MarkerKind::Plain)
105 }
106
107 /// Push a marker anchored at absolute `(line, col)` with `kind`, returning its
108 /// id. The shared core of `add_marker` (viewport row, `col = 0`) and OSC-133
109 /// command marks (cursor line + column) — one place owns id allocation + the
110 /// `markers` list.
111 fn push_marker(&mut self, line: usize, col: usize, kind: MarkerKind) -> MarkerId {
112 let id = MarkerId(self.next_marker_id);
113 self.next_marker_id += 1;
114 // #721: this population is allocated by the *stream* — `add_command_mark` appends
115 // per OSC 133 sequence, several marks share a line, and eviction only drops one
116 // whose line reached abs 0 — so a stream that never emits a newline grows it
117 // without bound. Bounded at `MAX_MARKERS`, which the wire's own `u16` group counts
118 // derive (the same argument `MAX_COLUMNS` is written from).
119 //
120 // Overflow retires the **oldest**, not the newest. Refusing the newest is cheaper
121 // but permanently kills shell integration for the session: once a pile fills the
122 // cap on a line nothing can evict, every later mark would be refused forever. The
123 // oldest is also the one already destined to die, and `MarkerDisposed` is the
124 // channel scrollback eviction announces that on — so the consumer contract is
125 // unchanged rather than extended.
126 let mut disposed = Vec::new();
127 let markers = self.markers_mut();
128 while markers.len() >= MAX_MARKERS {
129 // `VecDeque`, not `Vec`, for this line: `remove(0)` would memmove the whole
130 // population on *every* push once the cap is reached, turning a memory defect
131 // into a throughput one.
132 let Some(m) = markers.pop_front() else {
133 // Not reachable while `MAX_MARKERS > 0`, and written so that it stays
134 // unreachable rather than becoming an infinite loop if it ever is not:
135 // an empty deque satisfies `len() >= 0` forever.
136 break;
137 };
138 disposed.push(m.id);
139 }
140 markers.push_back(Marker {
141 id,
142 line,
143 col,
144 kind,
145 command: None,
146 });
147 for id in disposed {
148 self.events.push(TermEvent::MarkerDisposed(id));
149 }
150 // Birth is an occurrence, so it rides the event queue (ADR-0020 R1) — the mirror
151 // of the disposal above (#490). A consumer holding a pulled index has no other
152 // way to learn of a marker the *stream* created, and without it the index can
153 // only ever shrink. Not an epoch bump: that would cost an O(M) re-pull for O(1)
154 // information, four times per shell command.
155 //
156 // The basis rides with the line, exactly as `marker_index` pairs them (#737):
157 // `line` is absolute *now*, and the rest of this same `feed` can still evict —
158 // which moves every marker, this one included, without touching the epoch. Read
159 // against the frame's end-of-batch basis instead, the line is short by whatever
160 // the batch evicted after this point.
161 //
162 // And the epoch rides with it for the same reason one axis up (#741): the basis
163 // dates a *uniform* move, and a reflow between this birth and the consumer's drain
164 // is not one. `marker_index` answers with all three, so its incremental mirror
165 // carries all three — a line whose generation is unstated is one the receiver
166 // cannot tell from a current one.
167 self.events.push(TermEvent::MarkerCreated {
168 id,
169 line: line as u32,
170 kind,
171 evicted_total: self.evicted_total,
172 epoch: self.marker_epoch,
173 });
174 id
175 }
176
177 /// Record an OSC 133 command-boundary mark at the cursor's current line
178 /// (#158). Ignored on the alt screen: unlike the decoration guards that
179 /// per-buffer storage retired (#187), this one stands on a *semantic* — OSC
180 /// 133 is shell integration, which only runs on the primary screen, so an alt
181 /// 133 is meaningless (there is no command to bound). Command nav/announce read
182 /// the *normal* buffer's marks (`command_marks`/`command_lines`, primary-scoped
183 /// since #186), so even a stray alt 133 could not reach them — but there is no
184 /// value in creating an alt-scoped command mark nothing consumes (#188). The
185 /// cursor line is `scrollback ++ screen`-absolute, independent of
186 /// `display_offset` (the cursor is always in the grid, never scrollback).
187 pub(super) fn add_command_mark(&mut self, kind: MarkerKind) {
188 if self.on_alt {
189 return;
190 }
191 let line = self.scrollback.len() + self.cursor.row;
192 // `cursor.col` alone is one column short whenever the command exactly filled the row: the
193 // cursor that has just written the last cell is held *at* `cols - 1` with `pending_wrap`
194 // set, because "one past the last column" is not a column (#562). A command mark's column
195 // is an **exclusive** bound on the command text, so it wants precisely that unrepresentable
196 // value — `extract_lines` clips `[b_col, c_col)` and `.min(cells.len())` absorbs it.
197 //
198 // Without this, `$ ` + `abcd` at 6 columns recorded `abc`: no resize involved, so this half
199 // of #562 was reachable on a screen that never changed size.
200 let col = self.cursor.col + usize::from(self.cursor.pending_wrap);
201 self.push_marker(line, col, kind);
202 // Both halves of a command that are *not* in the buffer are resolved here, at
203 // the mark that reveals them, rather than by a query walking survivors later
204 // (#750). See `CommandRecord`.
205 match kind {
206 MarkerKind::OutputStart => self.capture_command_text(line, col),
207 MarkerKind::CommandFinished(exit) => self.attach_exit(exit),
208 _ => {}
209 }
210 }
211
212 /// Freeze the command text on the `OutputStart` mark just pushed (#750).
213 ///
214 /// `C` is the instant the text is complete and on screen, so this runs the *same*
215 /// extraction `command_lines` used to run on demand — `command_start`'s
216 /// normalisation and the `[b_col, c_col)` clip — against cells that still hold the
217 /// command. Nothing else in this crate can say what the command was: after this
218 /// returns, any verb may write those columns.
219 ///
220 /// A `C` with no open `B` captures nothing, which is the same shape as
221 /// `command_lines`'s `pending` taking `None` — a stray `C` bounds no command.
222 fn capture_command_text(&mut self, c_line: usize, c_col: usize) {
223 let Some((b_line, b_col)) = self.open_command_start() else {
224 return;
225 };
226 let grid = self.primary_grid();
227 let (b_line, b_col) = self.command_start(grid, b_line, b_col, c_line);
228 let mut text = self.extract_lines(grid, b_line, b_col, c_line, c_col);
229 // Bounded for `MAX_MARKERS`' reason: the stream chose the distance between `B`
230 // and `C`. Truncated at a `char` boundary so the answer stays valid text.
231 if text.chars().count() > MAX_COMMAND_TEXT {
232 let end = text
233 .char_indices()
234 .nth(MAX_COMMAND_TEXT)
235 .map_or(text.len(), |(i, _)| i);
236 text.truncate(end);
237 }
238 if let Some(m) = self.normal_markers.back_mut() {
239 m.command = Some(Box::new(CommandRecord {
240 text: text.into_boxed_str(),
241 exit: None,
242 }));
243 }
244 }
245
246 /// The `(line, col)` of the `CommandStart` this `OutputStart` closes, or `None` if
247 /// no command is open (#750).
248 ///
249 /// Walks back to the most recent `B` and stops at the first `C` before it — the
250 /// scanning form of `command_lines`'s forward `pending`, and it must stay that way
251 /// or the two disagree about which command a `C` bounds. The `OutputStart` just
252 /// pushed is skipped.
253 fn open_command_start(&self) -> Option<(usize, usize)> {
254 self.normal_markers
255 .iter()
256 .rev()
257 .skip(1)
258 .find_map(|m| match m.kind {
259 MarkerKind::CommandStart => Some(Some((m.line, m.col))),
260 MarkerKind::OutputStart => Some(None),
261 _ => None,
262 })
263 .flatten()
264 }
265
266 /// Write `D`'s exit code onto the `OutputStart` mark of the command it closes
267 /// (#750) — the open one, i.e. the most recent `C` with no `B` after it.
268 ///
269 /// The `is_none` guard is the one `command_lines` used to carry: a stray second `D`
270 /// must not clobber a code that is already recorded.
271 fn attach_exit(&mut self, exit: Option<i32>) {
272 let open = self
273 .normal_markers
274 .iter_mut()
275 .rev()
276 .find_map(|m| match m.kind {
277 MarkerKind::OutputStart => Some(Some(m)),
278 MarkerKind::CommandStart => Some(None),
279 _ => None,
280 })
281 .flatten();
282 if let Some(rec) = open.and_then(|m| m.command.as_mut())
283 && rec.exit.is_none()
284 {
285 rec.exit = exit;
286 }
287 }
288
289 /// Retire every marker anchored to the **screen row** `row`, announcing each
290 /// through `TermEvent::MarkerDisposed` (#750).
291 ///
292 /// Called where a verb blanks a **whole row in place**, which the three anchor
293 /// fixups beside this one cannot see: they repair a marker when the buffer *moves*,
294 /// and here the row stays exactly where it is while everything the mark was about
295 /// stops existing. Without it `command_lines` answers with commands that are not
296 /// there, at document lines that resolve onto blank rows.
297 ///
298 /// **Takes a screen row and converts it here**, once, because both halves of that
299 /// conversion are traps. A marker's `line` is `[scrollback ++ screen]`-absolute
300 /// while every erase verb speaks in grid rows; and on the alt screen the same
301 /// integers name *primary* lines, so the routing below is what keeps them apart.
302 ///
303 /// Routes through `markers_mut()`, so an alt-screen erase retires alt markers and
304 /// leaves the primary command history alone. Copying `command_marks`' deliberate
305 /// `self.normal_markers` here instead would make a `vim` starting up delete the
306 /// shell's history.
307 ///
308 /// **No epoch bump**, by the rule `bump_marker_epoch` states: the epoch dates a
309 /// *move* no offset repairs, and this moves nothing. A consumer hears disposal on
310 /// its own channel and drops the entry — the same shape as `markers_evict_oldest`.
311 pub(super) fn dispose_markers_on_row(&mut self, row: usize) {
312 // `display_offset` is deliberately absent: a write always lands in the grid,
313 // whatever the viewport is scrolled to (the same expression `add_command_mark`
314 // uses for the cursor).
315 let line = self.scrollback.len() + row;
316 let mut disposed = Vec::new();
317 self.markers_mut().retain(|m| {
318 if m.line == line {
319 disposed.push(m.id);
320 false
321 } else {
322 true
323 }
324 });
325 for id in disposed {
326 self.events.push(TermEvent::MarkerDisposed(id));
327 }
328 }
329
330 /// The OSC 133 command-boundary marks in buffer order — `(id, absolute line,
331 /// kind)` (#158). Plain decoration markers (#118) are excluded. The consumer
332 /// pairs prompt/command/finished marks and drives navigation/announce policy
333 /// (#160); core only parses and anchors them.
334 ///
335 /// **Instantaneous, deliberately (#742).** The lines are undated and move on both
336 /// of `marker_index`'s axes, so the contract is *re-ask*, not *rebase* — see
337 /// `Engine::command_marks` for the derivation, which is the docs.rs surface a
338 /// consumer actually reads. Two properties keep that honest and are facts about
339 /// *this* function rather than preferences: the scope below is constant, so a
340 /// re-ask always answers and an empty answer can only mean disposal; and the lines
341 /// are primary even on alt, so they are not in the active buffer's space.
342 ///
343 /// Changing either — routing this through `markers()`, or filtering by anything
344 /// buffer-dependent — invalidates the contract above, not just this line.
345 pub fn command_marks(&self) -> Vec<(MarkerId, usize, MarkerKind)> {
346 // Primary-scoped: OSC-133 shell integration marks live on the normal
347 // buffer, so command nav/announce read it even while on the alt screen.
348 self.normal_markers
349 .iter()
350 .filter(|m| m.kind != MarkerKind::Plain)
351 .map(|m| (m.id, m.line, m.kind))
352 .collect()
353 }
354
355 /// The executed shell commands recovered from OSC-133 marks, in buffer order
356 /// (#166) — the data behind screen-reader command navigation. Each
357 /// [`CommandLine`] pairs a CommandStart(B) with the following OutputStart(C)
358 /// to extract the *typed command* (the prompt before B and the output after C
359 /// excluded via the captured columns, VSCode `extractCommandLine` parity), and
360 /// attaches the trailing CommandFinished(D) exit. A command still being typed
361 /// (B with no C yet) is not navigable — its text has no bound — so it is
362 /// omitted until output starts.
363 ///
364 /// The answer is instantaneous, and its lines are document lines into
365 /// [`Term::accessible_text`] *on the primary screen* — the contract a caller reads
366 /// is on `Engine::command_lines`, pinned by `tests/command_lines_document.rs`
367 /// (#743, ADR-0029 D6). Note the omission above is why absence here means *gone or
368 /// not yet complete* rather than the flat "disposed" that holds for
369 /// [`Self::command_marks`]; both are absences a re-ask resolves, which is what
370 /// D3.2 needs of them.
371 pub fn command_lines(&self) -> Vec<CommandLine> {
372 let mut out: Vec<CommandLine> = Vec::new();
373 // (B line, B col) awaiting its matching C. Marks arrive in buffer order.
374 let mut pending: Option<(usize, usize)> = None;
375 // Primary-scoped (see `command_marks`): the normal buffer's marks.
376 for m in &self.normal_markers {
377 match m.kind {
378 MarkerKind::CommandStart => pending = Some((m.line, m.col)),
379 MarkerKind::OutputStart => {
380 if let Some((b_line, b_col)) = pending.take() {
381 // The text and the exit are read off the mark, frozen when this
382 // `C` and its `D` arrived (#750) — see `CommandRecord`. A `C`
383 // with no record bounds no command (a stray one, or a mark that
384 // predates nothing this crate can produce), so it is skipped
385 // rather than reported empty.
386 let Some(rec) = m.command.as_deref() else {
387 continue;
388 };
389 // The *line* is still derived, and deliberately: it is the one
390 // half a fixup does maintain, so freezing it would break the
391 // thing that already works. Where the typed command begins is
392 // not always where B was emitted — a prompt that ends its row
393 // leaves B past that row's content, and the command really
394 // starts on the next line. Command marks anchor primary
395 // content, so this reads the primary grid even on alt (#192).
396 let (b_line, _) =
397 self.command_start(self.primary_grid(), b_line, b_col, m.line);
398 out.push(CommandLine {
399 line: self.doc_line_of(self.primary_grid(), b_line),
400 command: rec.text.to_string(),
401 exit: rec.exit,
402 });
403 }
404 }
405 MarkerKind::CommandFinished(_) => {
406 // Nothing: `D`'s code was written onto its `OutputStart` mark when
407 // it arrived (#750). Pairing here meant pairing over *survivors*,
408 // and a disposal that broke the run re-parented the next code onto
409 // the previous command — measured, `a0` inheriting `a1`'s `Some(2)`.
410 }
411 MarkerKind::Plain | MarkerKind::PromptStart => {}
412 }
413 }
414 out
415 }
416
417 /// Advance an OSC-133 `CommandStart` position past any hard-ended line that holds no command
418 /// text at or after it, stopping before `end` (the matching `OutputStart`).
419 ///
420 /// B is emitted at the cursor, so a prompt that fills — or merely ends — its row leaves the mark
421 /// in that row's trailing blanks (#562). Two things then go wrong if the raw position is used:
422 /// `extract_lines` selects an empty run and, because the row is hard-ended, flushes it with a
423 /// `\n` the command never contained; and `doc_line_of` names the prompt's line rather than the
424 /// command's. Both were reachable **without any resize** — the row only has to end before its
425 /// width, which an 8-column row holding a 6-column prompt does.
426 ///
427 /// Only hard-ended rows advance. On a soft-wrapped row the continuation is the same logical
428 /// line, its trailing blanks are real content (a space at a wrap boundary was typed), and no
429 /// `\n` is flushed there anyway.
430 fn command_start(&self, grid: &Grid, line: usize, col: usize, end: usize) -> (usize, usize) {
431 let (mut line, mut col) = (line, col);
432 while line < end && !self.row_in(grid, line).is_wrapped() {
433 let cells = self.line_in(grid, line);
434 if col < cells.len() && !cells[col..].iter().all(Cell::is_blank) {
435 break;
436 }
437 line += 1;
438 col = 0;
439 }
440 (line, col)
441 }
442
443 /// The document (logical) line index that absolute buffer line `abs` renders
444 /// into within [`Term::accessible_text`] — the number of hard line-ends before
445 /// it (soft-wrapped rows share one logical line). Primary-screen coordinates,
446 /// matching `accessible_text`'s `start = 0` for the primary screen; command
447 /// marks are primary-only. O(abs) per call — fine for an on-demand query over
448 /// the handful of commands in a session.
449 fn doc_line_of(&self, grid: &Grid, abs: usize) -> usize {
450 (0..abs)
451 .filter(|&l| !self.row_in(grid, l).is_wrapped())
452 .count()
453 }
454
455 /// Remove a marker by id (#118). Disposing it fires `MarkerDisposed` so the
456 /// consumer's cleanup is one path whether the marker left by eviction or by
457 /// this explicit call (xterm's `dispose()` likewise always fires onDispose).
458 /// A no-op for an unknown/already-disposed id.
459 pub fn remove_marker(&mut self, id: MarkerId) {
460 // Id-based, buffer-agnostic: search both lists (ids are unique across
461 // buffers) so a marker is removed whichever screen it lives on (#177 S0).
462 let before = self.normal_markers.len() + self.alt_markers.len();
463 self.normal_markers.retain(|m| m.id != id);
464 self.alt_markers.retain(|m| m.id != id);
465 if self.normal_markers.len() + self.alt_markers.len() != before {
466 self.events.push(TermEvent::MarkerDisposed(id));
467 }
468 }
469
470 /// Every live marker of the active buffer, with the basis that keeps the answer
471 /// usable (#490). The pull half of the marker surface: a consumer asks once and
472 /// rebases per frame rather than being handed every marker in every frame.
473 ///
474 /// Ordering is the engine's own, which is the precedence a consumer joins
475 /// decorations by (#458/#461) — the same reason `marker_positions` does not sort.
476 pub fn marker_index(&self) -> MarkerIndex {
477 MarkerIndex {
478 markers: self
479 .markers()
480 .iter()
481 .map(|m| MarkerEntry {
482 id: m.id,
483 line: m.line as u32,
484 kind: m.kind,
485 })
486 .collect(),
487 evicted_total: self.evicted_total,
488 epoch: self.marker_epoch,
489 }
490 }
491
492 /// Declare that a held marker line has gone stale for a reason the
493 /// `evicted_total` delta cannot express (#490).
494 ///
495 /// Every caller is a site that moves marker lines **non-uniformly** — a region
496 /// rotate touches only the markers inside the region, a reflow rewrites them
497 /// outright, an alt switch changes which buffer the answer even describes. The
498 /// bump is deliberately *not* placed on disposal: a consumer hears that on
499 /// `MarkerDisposed` and drops the entry without asking for the rest again.
500 pub(super) fn bump_marker_epoch(&mut self) {
501 self.marker_epoch = self.marker_epoch.wrapping_add(1);
502 }
503
504 /// The marker analogue of `selection_shift_below_margin` (#449) — primary
505 /// only, because the accrual branch that needs it is primary-only.
506 pub(super) fn markers_shift_below_margin(&mut self, from: usize) {
507 let mut moved = false;
508 for m in &mut self.normal_markers {
509 if m.line >= from {
510 m.line += 1;
511 moved = true;
512 }
513 }
514 if moved {
515 self.bump_marker_epoch();
516 }
517 }
518
519 /// Shift markers down one absolute line after the oldest history line is
520 /// evicted; a marker *on* that line (abs 0) has left the buffer, so it is
521 /// disposed and announced (#118) — the marker analogue of
522 /// `selection_evict_oldest`, but a list with per-marker disposal.
523 pub(super) fn markers_evict_oldest(&mut self) {
524 // Scrollback eviction is primary-only (the alt screen has none).
525 let mut disposed = Vec::new();
526 self.normal_markers.retain_mut(|m| {
527 if m.line == 0 {
528 disposed.push(m.id);
529 false
530 } else {
531 m.line -= 1;
532 true
533 }
534 });
535 for id in disposed {
536 self.events.push(TermEvent::MarkerDisposed(id));
537 }
538 }
539
540 /// Rotate markers within an in-screen region scroll of absolute lines
541 /// `[top, bottom]` (`up` = a line dropped at `top`, else at `bottom`) — the
542 /// marker analogue of `selection_rotate_region`. A marker on the dropped edge
543 /// has left the buffer, so it is disposed and announced (#118).
544 pub(super) fn markers_rotate_region(&mut self, top: usize, bottom: usize, up: bool) {
545 let mut disposed = Vec::new();
546 let mut moved = false;
547 self.markers_mut().retain_mut(|m| {
548 if m.line < top || m.line > bottom {
549 return true; // outside the region — unchanged
550 }
551 let dropped_edge = if up { top } else { bottom };
552 if m.line == dropped_edge {
553 disposed.push(m.id);
554 false
555 } else {
556 m.line = if up { m.line - 1 } else { m.line + 1 };
557 moved = true;
558 true
559 }
560 });
561 for id in disposed {
562 self.events.push(TermEvent::MarkerDisposed(id));
563 }
564 // Only a *surviving* marker that moved invalidates a held index (#490). A
565 // rotate that merely disposed the edge marker, or found none inside the
566 // region at all, leaves every held line correct — and gating on that is what
567 // keeps a TUI scrolling a region from forcing a re-pull per line.
568 if moved {
569 self.bump_marker_epoch();
570 }
571 }
572
573 /// The active buffer's markers projected onto the current viewport — one
574 /// `MarkerPosition` per marker whose line is visible, off-screen markers
575 /// omitted. The alt screen projects its own (alt-scoped) markers now (#187);
576 /// they are disposed on alt-leave, so a primary frame never shows them.
577 pub(super) fn marker_positions(&self) -> Vec<MarkerPosition> {
578 let top = self.scrollback.len() - self.display_offset;
579 let rows = self.grid.rows();
580 self.markers()
581 .iter()
582 .filter_map(|m| {
583 let row = m.line.checked_sub(top)?;
584 (row < rows).then_some(MarkerPosition {
585 id: m.id,
586 row,
587 kind: m.kind,
588 })
589 })
590 .collect()
591 }
592}