Skip to main content

lattice_cells/
virtual_rows.rs

1//! Virtual rows: a sibling lane to [`crate::CellMatrix`] for
2//! rows that **displace** content vertically without belonging
3//! to the source rope.
4//!
5//! Examples of virtual rows: diff deletion blocks (D.3),
6//! multibuffer excerpt headers and separators (M.2), LSP inlay
7//! hints that occupy a row of their own, code-lens summaries
8//! above a function declaration. Each is a row of [`Cell`]s
9//! that appears at a specific anchor source line, either
10//! immediately *above* or *below* that line.
11//!
12//! D.0a (this module) ships the primitive's data layer + an
13//! interleaving [`crate::DisplaySliceIter`] over
14//! [`CellMatrix`]. The first production consumer is D.3
15//! (inline diff overlay) per
16//! `docs/dev/architecture/diff-system.md`; the multibuffer
17//! consumer is M.2 per `multibuffer-views.md`. D.0a itself
18//! has no production renderer caller -- the iterator is
19//! validated end-to-end by tests + bench against real
20//! [`CellMatrix`] inputs.
21//!
22//! Design anchor:
23//! `docs/dev/architecture/virtual-rows.md`.
24
25use std::sync::Arc;
26
27use crate::cell::Cell;
28
29/// Where a virtual row sits relative to its anchor source
30/// line.
31#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
32pub enum AnchorPosition {
33    /// The virtual row paints immediately above the anchor
34    /// source line. Multiple `Above` rows at the same anchor
35    /// paint in their `VirtualRowMatrix.rows` insertion
36    /// order.
37    Above,
38    /// The virtual row paints immediately below the anchor
39    /// source line. Multiple `Below` rows at the same anchor
40    /// paint in their `VirtualRowMatrix.rows` insertion
41    /// order.
42    Below,
43}
44
45/// D.6.i (2026-05-31): which kind of virtual row this is,
46/// for renderer-side backdrop / decoration discrimination.
47///
48/// Two production kinds today:
49/// - `DeletionBlock` — a diff deletion-block row (D.3 inline
50///   overlay) carrying baseline content that's gone from
51///   the current side. Painted with the
52///   `host_theme.diff_deletion_block_bg` backdrop (default:
53///   faint dark red) so the user sees "this content
54///   existed in baseline but is gone in current".
55/// - `Filler` — a blank padding row (D.4.c / D.6.b
56///   side-by-side alignment) on the shorter side of a hunk
57///   so parallel rows line up across panes. Should paint
58///   with **no backdrop** (or a neutral one) — fillers are
59///   visual padding, not content; the deletion-block red
60///   would mis-read them as "deleted lines."
61///
62/// `Generic` is the default for any other virtual-row
63/// source (future code-lens, inlay-line, multibuffer
64/// excerpt header). Renderers treat it like a deletion
65/// block for backdrop purposes today; the variant exists
66/// so future kinds can join the discriminator without a
67/// breaking change.
68#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
69pub enum VirtualRowKind {
70    /// Default — any virtual row not explicitly tagged.
71    /// Painted with the deletion-block backdrop today.
72    #[default]
73    Generic,
74    /// Diff deletion-block row (D.3). Baseline content
75    /// that was removed from the current side; paints
76    /// with the deletion-block backdrop.
77    DeletionBlock,
78    /// Side-by-side alignment filler (D.4.c / D.6.b).
79    /// Blank padding; no backdrop.
80    Filler,
81    /// Sticky header row — always rendered at the top of the pane
82    /// regardless of scroll position. Excluded from the per-line
83    /// `virtual_rows_at` pass so it is never double-painted when its
84    /// anchor line is in the viewport. Use `VirtualRow::bg` to supply
85    /// a background colour; falls back to no backdrop if `bg` is `None`.
86    Sticky,
87    /// MG.26b: an annotation row — content that scrolls with its
88    /// anchor and paints **no backdrop** of its own.
89    ///
90    /// Distinct from [`Generic`](Self::Generic), which carries the
91    /// diff deletion-block backdrop: a blame chunk heading painted
92    /// with that would read as a removed line. Distinct from
93    /// [`Filler`](Self::Filler), which is blank alignment padding
94    /// rather than something to read, and from
95    /// [`Sticky`](Self::Sticky), which pins to the top of the pane —
96    /// an annotation belongs *with* the lines it describes and has to
97    /// scroll away with them. `VirtualRow::bg` still supplies a
98    /// background where one is wanted.
99    Annotation,
100    /// Dashboard branding block (DB.4-gpui). A contiguous group of
101    /// these rows carries the mark's block cells + the wordmark/tagline
102    /// text; the **GPUI peer** intercepts the group and paints a 2-D
103    /// composition instead of the flat cells — the mark as crisp square
104    /// quads (corner cuts preserved) and the "Lattice" wordmark shaped
105    /// large, vertically centred beside the mark. The **TUI peer** paints
106    /// the cells normally (its terminal-art treatment). A *paint*
107    /// discriminant only — never a motion/scroll/cursor branch.
108    BrandingBlock,
109    /// IM.3: an inline media block — an image drawn where it appears in the
110    /// buffer. A contiguous group of these rows carries the alt text as
111    /// ordinary cells; the **GPUI peer** intercepts the group and paints the
112    /// image over the region, the **TUI peer** paints the cells it was given
113    /// and needs no code of its own. The `BrandingBlock` treatment exactly,
114    /// which is what keeps the TUI a first-class peer for a feature it
115    /// cannot render.
116    ///
117    /// Scrolls with its anchor (an image belongs *with* the line that
118    /// references it), so unlike `BrandingBlock` it is NOT pinned. A *paint*
119    /// discriminant plus a height contribution — never a motion or cursor
120    /// branch.
121    MediaBlock,
122}
123
124impl VirtualRowKind {
125    /// Whether rows of this kind are *pinned* to the top of the pane
126    /// (rendered in the sticky pre-pass, excluded from the scrolling
127    /// per-line pass, and reserved out of the visible window) rather than
128    /// scrolling with the document.
129    ///
130    /// `Sticky` is the general headerline (multibuffer excerpt headers,
131    /// async-status HUD). `BrandingBlock` — the dashboard logo — is pinned
132    /// too: it is a masthead that should stay put while the sections
133    /// beneath it scroll, and it keeps its 2-D paint treatment either way.
134    pub fn is_pinned(self) -> bool {
135        matches!(self, VirtualRowKind::Sticky | VirtualRowKind::BrandingBlock)
136    }
137}
138
139/// One virtual row's anchor + content.
140///
141/// `anchor_line` is the 0-based source line this row attaches
142/// to. `position` selects Above or Below the anchor.
143/// `cells` is the rendered row content -- same `Arc<[Cell]>`
144/// shape that backs a document [`crate::CellRow`], so
145/// renderers can paint virtual rows through the same fast
146/// path with no special casing.
147///
148/// `height` is the row's vertical span in matrix-row units
149/// (`1` for the common case; values > 1 reserved for
150/// multi-line code-lens / signature-preview blocks that paint
151/// taller than one cell row).
152///
153/// `kind` (D.6.i) tags the row's provenance so renderers
154/// pick the right backdrop / decoration treatment without
155/// guessing from cell content.
156///
157/// `bg` overrides the kind-based default background when
158/// `Some(rgb_u32)` (`0xRRGGBB`). `None` → renderer picks from
159/// `kind` (deletion-block red for `DeletionBlock`/`Generic`,
160/// transparent for `Filler`/`Sticky`).
161#[derive(Clone, Debug)]
162pub struct VirtualRow {
163    pub anchor_line: u32,
164    pub position: AnchorPosition,
165    pub cells: Arc<[Cell]>,
166    pub height: u16,
167    pub kind: VirtualRowKind,
168    pub bg: Option<u32>,
169    /// F.3 (Thread F): per-display-column font scale in
170    /// **hundredths** (`100` = 1.0×, the base size), parallel to
171    /// [`Self::cells`] by display column. `None` ⇒ the whole row
172    /// is base size (the common case — zero cost, no allocation).
173    ///
174    /// This extends the variable-font commitment (per-token
175    /// scaling, the emacs markdown-heading model — only the title
176    /// scales, not the leading markers) from document rows to
177    /// virtual rows. A renderer coalesces contiguous equal scales
178    /// into runs (mirroring how it coalesces per-cell `fg`), shapes
179    /// each run at `font_size × scale/100` on a **shared baseline**,
180    /// and grows the row height to the tallest run. The dashboard
181    /// branding block (DB.4-gpui) is the first consumer: the
182    /// "Lattice" wordmark scales while the mark blocks stay base.
183    ///
184    /// The GPUI peer honors it; the TUI peer ignores it (a terminal
185    /// cell grid cannot vary font size). When present, its length
186    /// matches `cells`; a shorter/absent entry defaults to `100`.
187    pub scales: Option<Arc<[u16]>>,
188    /// IM.3: the media block this row belongs to, when
189    /// [`kind`](Self::kind) is
190    /// [`MediaBlock`](VirtualRowKind::MediaBlock).
191    ///
192    /// Shared across every row of a group, so a renderer that meets any row
193    /// of the block can paint the whole thing without reassembling it from
194    /// the cells. `None` for every other kind — one `Option<Arc>` per row,
195    /// which is what `scales` already costs.
196    pub media: Option<crate::media::MediaBlockRef>,
197    /// TC.8: the SOURCE line this row should show in its gutter (0-based;
198    /// renderers add one, as they do for document rows). `None` — the default
199    /// for every kind — paints the blank gutter virtual rows have always had.
200    ///
201    /// Deliberately separate from [`Self::anchor_line`]. Anchoring answers
202    /// "where does this row sit"; this answers "what number does it show", and
203    /// for most virtual rows the honest answer is *nothing*: a deletion block
204    /// has no current-side line, and a filler row has no line at all. A sticky
205    /// context row is the case where the two differ in the other direction —
206    /// it is anchored above the viewport but shows its own place in the file.
207    ///
208    /// It is a field rather than a renderer-side `match vrow.kind` because the
209    /// renderers must not branch on kind: any producer that knows a real line
210    /// number can set this and get the document gutter for free.
211    pub gutter_line: Option<u32>,
212    /// TC.11: foreground for [`Self::gutter_line`]'s digits (`0xRRGGBB`).
213    /// `None` — the default — leaves the renderer's own gutter colour, which
214    /// is what document rows use.
215    pub gutter_fg: Option<u32>,
216}
217
218/// F.3 (Thread F): one contiguous run of display columns rendered
219/// at a single font scale, produced by [`coalesce_scales`]. The
220/// renderer shapes each run at `font_size × scale/100`.
221#[derive(Copy, Clone, Debug, PartialEq, Eq)]
222pub struct ScaleRun {
223    /// First display column of the run (0-based, into the row's
224    /// cells).
225    pub start_col: u32,
226    /// Number of display columns the run spans.
227    pub cols: u32,
228    /// Font scale in hundredths (`100` = 1.0×).
229    pub scale: u16,
230}
231
232/// Base font scale in hundredths (`100` = 1.0×). A column with this
233/// scale (or no scale entry) renders at the base font size.
234pub const BASE_SCALE: u16 = 100;
235
236/// F.3 (Thread F): coalesce a per-column `scales` slice into
237/// contiguous same-scale [`ScaleRun`]s across `total_cols` columns,
238/// exactly as a renderer coalesces per-cell `fg` into text runs.
239///
240/// Columns beyond `scales.len()` (or when `scales` is empty) default
241/// to [`BASE_SCALE`]. The returned runs cover `0..total_cols`
242/// contiguously, in column order, and never split two adjacent
243/// columns that share a scale. `total_cols == 0` ⇒ empty. Pure and
244/// allocation-light (O(total_cols)); unit-testable without a
245/// renderer.
246pub fn coalesce_scales(scales: &[u16], total_cols: u32) -> Vec<ScaleRun> {
247    let mut runs: Vec<ScaleRun> = Vec::new();
248    if total_cols == 0 {
249        return runs;
250    }
251    let scale_at = |col: u32| -> u16 {
252        scales
253            .get(col as usize)
254            .copied()
255            .filter(|s| *s != 0)
256            .unwrap_or(BASE_SCALE)
257    };
258    let mut start = 0u32;
259    let mut cur = scale_at(0);
260    for col in 1..total_cols {
261        let s = scale_at(col);
262        if s != cur {
263            runs.push(ScaleRun {
264                start_col: start,
265                cols: col - start,
266                scale: cur,
267            });
268            start = col;
269            cur = s;
270        }
271    }
272    runs.push(ScaleRun {
273        start_col: start,
274        cols: total_cols - start,
275        scale: cur,
276    });
277    runs
278}
279
280/// A monotonically-increasing counter; bumped by the
281/// publisher whenever the [`VirtualRowMatrix`] is replaced.
282///
283/// Consumers compare versions across frames to invalidate
284/// caches. A single `u64` is sufficient because virtual rows
285/// have only one source of change (provider mutation); unlike
286/// [`crate::MatrixVersion`], they don't need multiple axes.
287#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
288pub struct VirtualRowVersion(pub u64);
289
290impl VirtualRowVersion {
291    pub const ZERO: Self = Self(0);
292
293    /// Returns the next version (wrapping on overflow, which
294    /// won't happen in any realistic session: 1 publish per
295    /// frame at 240Hz for 2 billion years would be needed).
296    pub fn next(self) -> Self {
297        Self(self.0.wrapping_add(1))
298    }
299}
300
301/// The published virtual-row lane for one document.
302///
303/// Immutable once built; the publisher replaces the `Arc<…>`
304/// when providers mutate. Cheap to clone (Arc bump).
305///
306/// `rows` is sorted by `(anchor_line, position)` with
307/// `Above` < `Below` at the same line. `line_index[i]` is the
308/// index of the first row in `rows` whose `anchor_line >= i`;
309/// length is `source_line_count + 1`. The line index turns
310/// "how many virtual rows are anchored before line L" into a
311/// constant-time array lookup, which the
312/// [`crate::DisplaySliceIter`] uses to fast-forward past
313/// scrolled-off virtual rows in O(1) instead of O(V).
314#[derive(Clone, Debug)]
315pub struct VirtualRowMatrix {
316    pub rows: Arc<[VirtualRow]>,
317    pub line_index: Arc<[u32]>,
318    pub source_line_count: u32,
319    pub version: VirtualRowVersion,
320}
321
322impl Default for VirtualRowMatrix {
323    fn default() -> Self {
324        Self::empty()
325    }
326}
327
328impl VirtualRowMatrix {
329    /// The empty matrix. The initial published value before
330    /// any provider has emitted.
331    pub fn empty() -> Self {
332        Self {
333            rows: Arc::from([] as [VirtualRow; 0]),
334            line_index: Arc::from([0u32]),
335            source_line_count: 0,
336            version: VirtualRowVersion::ZERO,
337        }
338    }
339
340    /// `true` when no virtual rows are present. The
341    /// [`crate::CellMatrix::display_slice`] fast path detects
342    /// this to skip interleaver overhead entirely.
343    pub fn is_empty(&self) -> bool {
344        self.rows.is_empty()
345    }
346
347    pub fn len(&self) -> usize {
348        self.rows.len()
349    }
350
351    /// Build a `VirtualRowMatrix` from an unsorted list. The
352    /// rows are sorted by `(anchor_line, position)` and the
353    /// `line_index` is computed.
354    ///
355    /// `source_line_count` should match the document's line
356    /// count (so the line-index sentinel covers EOF). If a
357    /// virtual row anchors past `source_line_count`, it is
358    /// clamped to anchor at `source_line_count` (treated as
359    /// "past EOF" by the interleaver, which emits it after
360    /// the last document row).
361    pub fn build(
362        mut rows: Vec<VirtualRow>,
363        source_line_count: u32,
364        version: VirtualRowVersion,
365    ) -> Self {
366        for row in &mut rows {
367            if row.anchor_line > source_line_count {
368                row.anchor_line = source_line_count;
369            }
370        }
371        rows.sort_by(|a, b| {
372            a.anchor_line
373                .cmp(&b.anchor_line)
374                .then_with(|| position_rank(a.position).cmp(&position_rank(b.position)))
375        });
376
377        let line_index_len = source_line_count.saturating_add(1) as usize;
378        let mut line_index = Vec::with_capacity(line_index_len);
379        let mut row_idx: u32 = 0;
380        for line in 0..line_index_len as u32 {
381            while (row_idx as usize) < rows.len() && rows[row_idx as usize].anchor_line < line {
382                row_idx += 1;
383            }
384            line_index.push(row_idx);
385        }
386
387        Self {
388            rows: Arc::from(rows),
389            line_index: Arc::from(line_index),
390            source_line_count,
391            version,
392        }
393    }
394
395    /// Index of the first row in `rows` whose `anchor_line >=
396    /// line`. Returns `rows.len() as u32` when every row
397    /// anchors strictly below `line`.
398    ///
399    /// O(1) array lookup when `line <= source_line_count`;
400    /// returns `rows.len()` for queries past EOF.
401    pub fn first_row_at_or_after(&self, line: u32) -> u32 {
402        let idx = (line as usize).min(self.line_index.len().saturating_sub(1));
403        self.line_index[idx]
404    }
405
406    /// Number of virtual rows whose anchor sits in the inclusive
407    /// document-line range `[lo, hi]`, regardless of
408    /// [`AnchorPosition`]. Returns `0` when `lo > hi`.
409    ///
410    /// O(1) — two [`Self::first_row_at_or_after`] lookups. This is
411    /// the geometry primitive the host's scroll model uses to
412    /// answer "how many *display* rows does the document-line span
413    /// `[lo, hi]` occupy", since each interleaved virtual row
414    /// consumes a display row without being a document line. The
415    /// count is position-agnostic on purpose: a bottom-anchored
416    /// scroll over-reserves by at most the cursor line's own
417    /// `Below` rows, which is the safe direction (the last line is
418    /// guaranteed clear of the modeline rather than flush against
419    /// it).
420    ///
421    /// [`VirtualRowKind::Sticky`] rows are excluded — they are
422    /// rendered at the pane top outside the scroll window and do
423    /// not displace content rows.
424    pub fn virtual_rows_in_line_range(&self, lo: u32, hi: u32) -> u32 {
425        if lo > hi {
426            return 0;
427        }
428        let end = self.first_row_at_or_after(hi.saturating_add(1));
429        let start = self.first_row_at_or_after(lo);
430        let total = end.saturating_sub(start);
431        let sticky = self.rows[start as usize..end as usize]
432            .iter()
433            .filter(|r| r.kind.is_pinned())
434            .count() as u32;
435        total.saturating_sub(sticky)
436    }
437
438    /// Iterator over all pinned rows in the matrix (see
439    /// [`VirtualRowKind::is_pinned`] — `Sticky` headerlines + the
440    /// `BrandingBlock` masthead). Used by renderers to paint the fixed top
441    /// strip before the scrollable content window.
442    /// IM.5: the [`RowWeights`](crate::RowWeights) implied by this matrix's
443    /// media blocks.
444    ///
445    /// A source line carrying a media block costs its own row plus the
446    /// block's DRAWN height, which is fractional and generally differs from
447    /// the rows the block reserved. Everything else is absent from the map
448    /// and therefore costs exactly what it always did.
449    ///
450    /// `draws_media` is the renderer's answer to "do I paint pixels". The TUI
451    /// passes `false` and gets a uniform map, so its scroll arithmetic is
452    /// untouched — it renders the block's alt-text cells in the reserved rows
453    /// and those rows are one line tall, which is the truth for a terminal.
454    /// Only one renderer runs in a process, so the two answers never collide.
455    pub fn media_row_weights(&self, draws_media: bool) -> crate::RowWeights {
456        let mut weights = crate::RowWeights::uniform();
457        if !draws_media {
458            return weights;
459        }
460        // Group by anchor line: a block's rows all share one anchor, and the
461        // line's cost is its own row plus the block's drawn height.
462        let mut seen: std::collections::HashMap<u32, (f32, u32)> = std::collections::HashMap::new();
463        for row in self.rows.iter() {
464            if row.kind != VirtualRowKind::MediaBlock {
465                continue;
466            }
467            let Some(block) = row.media.as_ref() else {
468                continue;
469            };
470            let entry = seen.entry(row.anchor_line).or_insert((0.0, 0));
471            entry.1 += 1;
472            // Every row of a group carries the same descriptor, so the drawn
473            // height is recorded once rather than accumulated per row.
474            entry.0 = block.line_heights(0);
475        }
476        for (line, (drawn, rows)) in seen {
477            // `line_heights(0)` returns 0.0 when the block has no resolved
478            // height yet — before its size is known it costs its reserved
479            // rows, which is what stops a resolving image from reflowing the
480            // document.
481            let cost = if drawn > 0.0 { drawn } else { rows as f32 };
482            weights.set(line, 1.0 + cost);
483        }
484        weights
485    }
486
487    pub fn sticky_rows(&self) -> impl Iterator<Item = &VirtualRow> {
488        self.rows.iter().filter(|r| r.kind.is_pinned())
489    }
490}
491
492/// Sort-order helper: `Above` < `Below` at the same anchor
493/// line.
494const fn position_rank(p: AnchorPosition) -> u8 {
495    match p {
496        AnchorPosition::Above => 0,
497        AnchorPosition::Below => 1,
498    }
499}
500
501/// Stable identity for a [`VirtualRowProvider`]. Issued by
502/// the worker / subsystem that owns the provider registry.
503pub type ProviderId = u64;
504
505/// A producer of virtual rows.
506///
507/// Producers are registered with the (future) virtual-rows
508/// worker, which calls [`Self::collect`] when rebuilding the
509/// published [`VirtualRowMatrix`]. The worker merges the
510/// outputs of all registered providers, sorts, and publishes
511/// via `ArcSwap`.
512///
513/// D.0a ships the trait; the worker itself lands in D.0a.1
514/// (or as part of D.3 when the first production consumer
515/// appears, whichever ships first). Tests build
516/// `VirtualRowMatrix` directly via [`VirtualRowMatrix::build`]
517/// rather than through a provider registry.
518pub trait VirtualRowProvider: Send + Sync + std::fmt::Debug {
519    /// A stable id for this provider. Used by the worker to
520    /// deduplicate registration + route mutation
521    /// notifications.
522    fn id(&self) -> ProviderId;
523
524    /// Monotonic version counter — the provider bumps it
525    /// whenever the rows [`Self::collect`] would emit have
526    /// changed. The worker uses the combined fingerprint of all
527    /// providers' versions to short-circuit on the cache-hit
528    /// path without paying for the (potentially expensive)
529    /// `collect` calls.
530    ///
531    /// D.0a.1 introduces this. Implementations whose row set is
532    /// truly static may return `0` forever — the worker will
533    /// then cache-hit unless some other provider's version
534    /// changes or the document's line count changes.
535    fn version(&self) -> u64;
536
537    /// Emit the current set of virtual rows for the
538    /// associated document.
539    ///
540    /// Called by the worker on its rebuild path. Providers
541    /// must not block; non-trivial computation belongs in the
542    /// provider's own background task with the result cached
543    /// here.
544    fn collect(&self) -> Vec<VirtualRow>;
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    fn row(anchor: u32, pos: AnchorPosition) -> VirtualRow {
552        VirtualRow {
553            media: None,
554            anchor_line: anchor,
555            position: pos,
556            cells: Arc::from([] as [Cell; 0]),
557            height: 1,
558            kind: VirtualRowKind::Generic,
559            bg: None,
560            scales: None,
561            gutter_line: None,
562            gutter_fg: None,
563        }
564    }
565
566    #[test]
567    fn coalesce_scales_empty_is_empty() {
568        assert!(coalesce_scales(&[], 0).is_empty());
569        assert!(coalesce_scales(&[150, 150], 0).is_empty());
570    }
571
572    #[test]
573    fn coalesce_scales_no_scales_is_one_base_run() {
574        // No per-column scales ⇒ one base-size run spanning the row.
575        let runs = coalesce_scales(&[], 5);
576        assert_eq!(
577            runs,
578            vec![ScaleRun {
579                start_col: 0,
580                cols: 5,
581                scale: BASE_SCALE
582            }]
583        );
584    }
585
586    #[test]
587    fn coalesce_scales_splits_only_on_transition() {
588        // The markdown-heading shape: base markers, scaled title —
589        // "## " at base, the rest at 1.6×. Two runs, split at col 3.
590        let scales = [100, 100, 100, 160, 160, 160];
591        let runs = coalesce_scales(&scales, 6);
592        assert_eq!(
593            runs,
594            vec![
595                ScaleRun {
596                    start_col: 0,
597                    cols: 3,
598                    scale: 100
599                },
600                ScaleRun {
601                    start_col: 3,
602                    cols: 3,
603                    scale: 160
604                },
605            ]
606        );
607    }
608
609    #[test]
610    fn coalesce_scales_handles_multiple_runs_and_zero_sentinel() {
611        // A general per-token row: base, scaled, base again — plus a
612        // `0` sentinel column that defaults to base, and a trailing
613        // column past `scales.len()` that also defaults to base.
614        let scales = [100, 250, 250, 0];
615        let runs = coalesce_scales(&scales, 6);
616        assert_eq!(
617            runs,
618            vec![
619                ScaleRun {
620                    start_col: 0,
621                    cols: 1,
622                    scale: 100
623                },
624                ScaleRun {
625                    start_col: 1,
626                    cols: 2,
627                    scale: 250
628                },
629                ScaleRun {
630                    start_col: 3,
631                    cols: 3,
632                    scale: 100
633                },
634            ]
635        );
636    }
637
638    #[test]
639    fn empty_matrix_basics() {
640        let m = VirtualRowMatrix::empty();
641        assert!(m.is_empty());
642        assert_eq!(m.len(), 0);
643        assert_eq!(m.source_line_count, 0);
644        assert_eq!(m.version, VirtualRowVersion::ZERO);
645        // line_index has one sentinel entry.
646        assert_eq!(m.line_index.len(), 1);
647        assert_eq!(m.first_row_at_or_after(0), 0);
648        assert_eq!(m.first_row_at_or_after(100), 0);
649    }
650
651    #[test]
652    fn build_sorts_by_anchor_and_position() {
653        // Insertion order: (5, Below), (3, Above), (5, Above), (3, Below).
654        // Sorted: (3, Above), (3, Below), (5, Above), (5, Below).
655        let rows = vec![
656            row(5, AnchorPosition::Below),
657            row(3, AnchorPosition::Above),
658            row(5, AnchorPosition::Above),
659            row(3, AnchorPosition::Below),
660        ];
661        let m = VirtualRowMatrix::build(rows, 10, VirtualRowVersion(1));
662        assert_eq!(m.len(), 4);
663        assert_eq!(m.rows[0].anchor_line, 3);
664        assert_eq!(m.rows[0].position, AnchorPosition::Above);
665        assert_eq!(m.rows[1].anchor_line, 3);
666        assert_eq!(m.rows[1].position, AnchorPosition::Below);
667        assert_eq!(m.rows[2].anchor_line, 5);
668        assert_eq!(m.rows[2].position, AnchorPosition::Above);
669        assert_eq!(m.rows[3].anchor_line, 5);
670        assert_eq!(m.rows[3].position, AnchorPosition::Below);
671    }
672
673    #[test]
674    fn line_index_locates_rows() {
675        let rows = vec![
676            row(2, AnchorPosition::Above),
677            row(2, AnchorPosition::Below),
678            row(5, AnchorPosition::Above),
679            row(7, AnchorPosition::Below),
680        ];
681        let m = VirtualRowMatrix::build(rows, 10, VirtualRowVersion(1));
682
683        // No rows anchor before line 0..2 ⇒ index 0 (first row
684        // is at line 2).
685        assert_eq!(m.first_row_at_or_after(0), 0);
686        assert_eq!(m.first_row_at_or_after(2), 0);
687        // Past line 2's two rows ⇒ index 2 (next is line 5).
688        assert_eq!(m.first_row_at_or_after(3), 2);
689        assert_eq!(m.first_row_at_or_after(5), 2);
690        // Past line 5's row ⇒ index 3 (next is line 7).
691        assert_eq!(m.first_row_at_or_after(6), 3);
692        assert_eq!(m.first_row_at_or_after(7), 3);
693        // Past everything ⇒ index 4 (= rows.len()).
694        assert_eq!(m.first_row_at_or_after(8), 4);
695    }
696
697    #[test]
698    fn anchor_past_eof_clamps_to_line_count() {
699        let rows = vec![
700            row(100, AnchorPosition::Above),
701            row(5, AnchorPosition::Above),
702        ];
703        let m = VirtualRowMatrix::build(rows, 10, VirtualRowVersion(1));
704        assert_eq!(m.len(), 2);
705        // (100) clamped to 10; (5) stays. After sort: (5),
706        // (10).
707        assert_eq!(m.rows[0].anchor_line, 5);
708        assert_eq!(m.rows[1].anchor_line, 10);
709    }
710
711    #[test]
712    fn virtual_rows_in_line_range_counts_inclusive() {
713        // anchors at lines 2 (x2), 5, 7.
714        let rows = vec![
715            row(2, AnchorPosition::Above),
716            row(2, AnchorPosition::Below),
717            row(5, AnchorPosition::Above),
718            row(7, AnchorPosition::Below),
719        ];
720        let m = VirtualRowMatrix::build(rows, 10, VirtualRowVersion(1));
721
722        // Empty / inverted ranges.
723        assert_eq!(m.virtual_rows_in_line_range(3, 2), 0);
724        // Range below every anchor.
725        assert_eq!(m.virtual_rows_in_line_range(0, 1), 0);
726        // Inclusive of both endpoints: [2, 7] covers all four.
727        assert_eq!(m.virtual_rows_in_line_range(2, 7), 4);
728        // Endpoint inclusivity: [2, 2] captures both line-2 rows.
729        assert_eq!(m.virtual_rows_in_line_range(2, 2), 2);
730        // Mid-range: [3, 5] captures only the line-5 row.
731        assert_eq!(m.virtual_rows_in_line_range(3, 5), 1);
732        // [6, 7] captures only the line-7 row.
733        assert_eq!(m.virtual_rows_in_line_range(6, 7), 1);
734        // Range past EOF is clamped, never panics.
735        assert_eq!(m.virtual_rows_in_line_range(8, u32::MAX), 0);
736        // The empty matrix reports zero for any range.
737        assert_eq!(
738            VirtualRowMatrix::empty().virtual_rows_in_line_range(0, u32::MAX),
739            0
740        );
741    }
742
743    #[test]
744    fn version_next_increments() {
745        let v = VirtualRowVersion::ZERO;
746        assert_eq!(v.next(), VirtualRowVersion(1));
747        assert_eq!(v.next().next(), VirtualRowVersion(2));
748    }
749}