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 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}