Skip to main content

lattice_host/
virtual_rows_worker.rs

1//! D.0a.1 (2026-05-29) — Virtual-rows worker.
2//!
3//! Sibling of `cells_worker`. Owns the rebuild path for the
4//! displacing-virtual-row primitive landed in D.0a
5//! ([`lattice_cells::VirtualRowMatrix`]) and publishes the
6//! result via the shared `virtual_rows_matrix_cell:
7//! Arc<ArcSwap<VirtualRowMatrix>>` on `Editor`.
8//!
9//! ## Why this exists
10//!
11//! Per paramount goal #1 (`CLAUDE.md`): UI thread does no I/O,
12//! no parsing, no shaping. Virtual rows are the renderer's
13//! input for inline diff deletion blocks (D.3), multibuffer
14//! excerpt headers (M.2), and future inlay-hint /
15//! signature-preview consumers. Each consumer registers a
16//! [`VirtualRowProvider`]; the worker polls them off-thread
17//! and publishes a fresh [`VirtualRowMatrix`] via RCU.
18//!
19//! ## D.0a.1 scope — minimal
20//!
21//! - [`VirtualRowProviderRegistry`] — `BufferId`-keyed (as of
22//!   D.4.d.2.1.a). Each visible buffer owns its own
23//!   `ProviderId → provider` map so baseline + current panes'
24//!   filler providers (D.4.c) coexist without colliding on
25//!   `ProviderId`, and the worker (post-D.4.d.2.1.b) can
26//!   iterate panes and poll each one's scope independently.
27//!   This slice keeps the worker single-pane: `recompute`
28//!   reads `active_document.document_buffer_id` and snapshots
29//!   only that buffer's providers. Today's sole producer (the
30//!   D.3.a inline diff overlay) registers against the same
31//!   buffer the worker reads, so behaviour is identical for
32//!   single-pane flows.
33//! - [`recompute`] — sync decision function. Tests call this
34//!   directly to assert each branch.
35//! - [`run`] — async loop. `wake.notified().await`s the
36//!   `VirtualRowsWake` signal, then calls `recompute`.
37//! - Cache-hit fingerprinting via a stable hash of
38//!   `[(provider_id, provider_version)]` + `source_line_count`.
39//!   The worker holds its own monotonic publish counter; the
40//!   matrix's `version` only bumps when the fingerprint
41//!   changes, so downstream consumers compare versions to
42//!   invalidate their derived state cheaply.
43//!
44//! ## What this slice does NOT do
45//!
46//! - No production provider yet. The first one lands with
47//!   D.3 (inline diff overlay's deletion-block provider) or
48//!   M.2 (multibuffer excerpt-header provider), whichever
49//!   ships first.
50//! - No per-pane matrices. v1 uses one global matrix tied to
51//!   the active document. Multi-pane diff (D.4) and
52//!   project-wide diff (multibuffer M.6) will introduce
53//!   per-pane or per-document indexing then.
54//! - No paint-debounce inside the worker. `Notify`-permit
55//!   coalescing (mirrors `cells_worker`) handles bursts —
56//!   the worker rebuilds once per quiescent burst regardless
57//!   of how many wakes arrived.
58
59use std::collections::HashMap;
60use std::collections::hash_map::DefaultHasher;
61use std::hash::{Hash, Hasher};
62use std::sync::{Arc, Mutex};
63
64use arc_swap::ArcSwap;
65use tracing::{debug, info};
66
67use lattice_cells::{
68    ProviderId, VirtualRow, VirtualRowMatrix, VirtualRowProvider, VirtualRowVersion,
69};
70use lattice_core::BufferId;
71
72use crate::editor::VirtualRowsWake;
73use crate::render_state::RenderState;
74
75/// Process-wide registry of [`VirtualRowProvider`] instances,
76/// scoped per [`BufferId`] (D.4.d.2.1.a).
77///
78/// Each visible buffer that participates in the virtual-rows
79/// pipeline owns its own `ProviderId → provider` sub-map.
80/// Scoping by `BufferId` is what lets baseline + current
81/// panes of a side-by-side diff (D.4.d.3) both register
82/// filler providers (D.4.c) without colliding on
83/// `ProviderId`, even though the filler ids use side-
84/// distinct prefixes — registering against the wrong
85/// scope would still mis-route the rows when the worker
86/// (post-D.4.d.2.1.b) iterates per-pane.
87///
88/// Mutation (`register` / `unregister`) is consumer-creation
89/// frequency — never per-frame. Read (`snapshot`) is per
90/// worker tick. Behind a `std::sync::Mutex`; the hot path
91/// holds the lock only long enough to clone out `Arc`
92/// references.
93#[derive(Debug, Default)]
94pub struct VirtualRowProviderRegistry {
95    by_buffer: Mutex<HashMap<BufferId, HashMap<ProviderId, Arc<dyn VirtualRowProvider>>>>,
96}
97
98impl VirtualRowProviderRegistry {
99    pub fn new() -> Self {
100        Self::default()
101    }
102
103    /// Register a provider against `buffer_id`. Returns `false`
104    /// if a provider with the same id was already registered in
105    /// the same buffer scope (no replacement — the caller is
106    /// expected to `unregister` first). Providers with the same
107    /// id in *different* buffer scopes do not collide.
108    pub fn register(&self, buffer_id: BufferId, provider: Arc<dyn VirtualRowProvider>) -> bool {
109        let id = provider.id();
110        let mut by_buffer = self
111            .by_buffer
112            .lock()
113            .expect("VirtualRowProviderRegistry mutex poisoned");
114        let scope = by_buffer.entry(buffer_id).or_default();
115        if scope.contains_key(&id) {
116            return false;
117        }
118        scope.insert(id, provider);
119        true
120    }
121
122    /// Remove a provider from `buffer_id`'s scope. Returns
123    /// `true` if one was removed. Empty scopes are pruned so
124    /// `is_empty()` reflects "no providers anywhere".
125    pub fn unregister(&self, buffer_id: BufferId, id: ProviderId) -> bool {
126        let mut by_buffer = self
127            .by_buffer
128            .lock()
129            .expect("VirtualRowProviderRegistry mutex poisoned");
130        let Some(scope) = by_buffer.get_mut(&buffer_id) else {
131            return false;
132        };
133        let removed = scope.remove(&id).is_some();
134        if scope.is_empty() {
135            by_buffer.remove(&buffer_id);
136        }
137        removed
138    }
139
140    /// Snapshot providers registered against `buffer_id`. Returns
141    /// fresh `Arc` clones — callers can hold them past a
142    /// concurrent `unregister` (RCU). Order is unspecified.
143    /// Returns an empty `Vec` if the buffer has no scope.
144    pub fn snapshot(&self, buffer_id: BufferId) -> Vec<Arc<dyn VirtualRowProvider>> {
145        self.by_buffer
146            .lock()
147            .expect("VirtualRowProviderRegistry mutex poisoned")
148            .get(&buffer_id)
149            .map(|scope| scope.values().cloned().collect())
150            .unwrap_or_default()
151    }
152
153    /// True iff no buffer scope holds any provider.
154    pub fn is_empty(&self) -> bool {
155        self.by_buffer
156            .lock()
157            .expect("VirtualRowProviderRegistry mutex poisoned")
158            .is_empty()
159    }
160
161    /// Total provider count across all buffer scopes.
162    pub fn len(&self) -> usize {
163        self.by_buffer
164            .lock()
165            .expect("VirtualRowProviderRegistry mutex poisoned")
166            .values()
167            .map(|scope| scope.len())
168            .sum()
169    }
170}
171
172/// AUX‑2: bridge so subsystems without access to
173/// `VirtualRowProviderRegistry` can register headerlines.
174impl lattice_mode::VirtualRowRegistrar for VirtualRowProviderRegistry {
175    fn register(&self, buffer: BufferId, provider: Arc<dyn VirtualRowProvider>) -> bool {
176        self.register(buffer, provider)
177    }
178
179    fn unregister(&self, buffer: BufferId, id: ProviderId) -> bool {
180        self.unregister(buffer, id)
181    }
182}
183
184/// Recompute decision the worker takes on a wake. Visible for
185/// testing; the production loop calls [`recompute`] directly.
186///
187/// As of D.4.d.2.1.c the worker iterates `rs.cells.panes` and
188/// produces one decision per pane; the aggregate decision
189/// returned to `run` is the highest-precedence one across
190/// panes (`Recomputed > Clear > CacheHit`).
191#[derive(Debug, PartialEq, Eq)]
192pub enum WorkerDecision {
193    /// Pane had no document snapshot (transient race during
194    /// buffer close, or non-Document leaf — though the
195    /// publisher already filters those out). Worker cleared
196    /// that pane's matrix (or noted it was already empty).
197    Clear,
198    /// Fingerprint of `(providers × versions, source_line_count)`
199    /// matches the previously observed fingerprint for this
200    /// pane's buffer. No new matrix published; renderer reads
201    /// the existing one.
202    CacheHit,
203    /// Fingerprint changed. Worker polled `collect()` on every
204    /// registered provider for the pane's buffer, built a
205    /// fresh `VirtualRowMatrix`, and stored it via
206    /// `pane.virtual_rows_matrix`.
207    Recomputed,
208}
209
210/// Worker-local state held across recompute calls.
211///
212/// `last_fingerprints` lets us short-circuit per buffer when
213/// neither the providers nor that buffer's document have
214/// changed (D.4.d.2.1.c switched this from a single
215/// `Option<u64>` to a `HashMap<BufferId, u64>` so two visible
216/// buffers cache-hit independently). `next_publish_version`
217/// is a single monotonic counter that stamps every published
218/// matrix — across buffers — so downstream consumers compare
219/// versions cheaply per matrix.
220#[derive(Debug)]
221pub struct VirtualRowsWorkerState {
222    last_fingerprints: HashMap<BufferId, u64>,
223    next_publish_version: u64,
224}
225
226impl Default for VirtualRowsWorkerState {
227    fn default() -> Self {
228        Self {
229            last_fingerprints: HashMap::new(),
230            // Start at 1; the empty matrix at construction is
231            // `VirtualRowVersion::ZERO`, so the first
232            // successful publish always carries a strictly
233            // higher version.
234            next_publish_version: 1,
235        }
236    }
237}
238
239impl VirtualRowsWorkerState {
240    pub fn new() -> Self {
241        Self::default()
242    }
243}
244
245/// Stable hash of the worker's input axes — providers
246/// (each via `id` + `version`) and the document's
247/// `source_line_count`. Order-independent over providers
248/// (sorted before hashing) so the snapshot order doesn't
249/// affect the result.
250fn compute_fingerprint(providers: &[Arc<dyn VirtualRowProvider>], source_line_count: u32) -> u64 {
251    let mut pairs: Vec<(ProviderId, u64)> =
252        providers.iter().map(|p| (p.id(), p.version())).collect();
253    pairs.sort_unstable();
254    let mut hasher = DefaultHasher::new();
255    pairs.hash(&mut hasher);
256    source_line_count.hash(&mut hasher);
257    hasher.finish()
258}
259
260/// Pure sync recompute. Iterates `rs.cells.panes`, dispatches
261/// each to [`recompute_pane`], and aggregates the per-pane
262/// decisions into a single one for the async loop. Returns the
263/// aggregate so tests can assert behaviour without driving
264/// `run`.
265///
266/// D.4.d.2.1.c (2026-05-30): switched from a single global
267/// `matrix_cell` write target to per-pane iteration over
268/// `rs.cells.panes`. Each pane carries its own
269/// `pane.virtual_rows_matrix` registry cell (D.4.d.2.1.b
270/// publish-time scaffold), which the worker writes via
271/// `pane.virtual_rows_matrix.store(...)`. Active-pane entries
272/// share Arc identity with `Editor::virtual_rows_matrix_cell`
273/// (D.4.d.2.0 boot seed) so the existing renderer read path
274/// through `RenderState.virtual_rows.matrix` is bit-identical
275/// for single-pane flows until D.4.d.2.1.d swaps the renderer
276/// to per-pane lookup.
277///
278/// Aggregate decision precedence: `Recomputed > Clear >
279/// CacheHit`. Mirrors the cells worker's precedence (minus
280/// the incremental path virtual rows don't have). `Recomputed`
281/// or `Clear` content changes; the async loop fires
282/// `paint_request` on either.
283pub fn recompute(
284    state: &mut VirtualRowsWorkerState,
285    render_state: &ArcSwap<RenderState>,
286    providers: &VirtualRowProviderRegistry,
287) -> WorkerDecision {
288    let rs = render_state.load_full();
289    // I.5.2: `cells` is an inner `ArcSwap`; load the snapshot once.
290    let cells = rs.cells.load();
291    if cells.panes.is_empty() {
292        return WorkerDecision::CacheHit;
293    }
294    let mut any_recomputed = false;
295    let mut any_cleared = false;
296    for pane in cells.panes.iter() {
297        match recompute_pane(pane, state, providers) {
298            WorkerDecision::CacheHit => {}
299            WorkerDecision::Clear => any_cleared = true,
300            WorkerDecision::Recomputed => any_recomputed = true,
301        }
302    }
303    if any_recomputed {
304        WorkerDecision::Recomputed
305    } else if any_cleared {
306        WorkerDecision::Clear
307    } else {
308        WorkerDecision::CacheHit
309    }
310}
311
312/// D.4.d.2.1.c (2026-05-30): per-pane recompute. Same shape
313/// as `cells_worker::recompute_pane`. Visible for tests that
314/// want to assert per-pane decisions without driving the
315/// aggregate.
316///
317/// Writes via `pane.virtual_rows_matrix` (the per-buffer
318/// registry cell), so two panes showing the same buffer
319/// share a single output cell — the second pane sees a
320/// `CacheHit` against the rebuild the first one already
321/// published (same `(buffer_id, providers, source_line_count)`
322/// fingerprint).
323pub fn recompute_pane(
324    pane: &crate::render_state::PaneCellsInputs,
325    state: &mut VirtualRowsWorkerState,
326    providers: &VirtualRowProviderRegistry,
327) -> WorkerDecision {
328    let Some(snapshot) = pane.snapshot.as_ref() else {
329        // No snapshot — buffer closed mid-publish, or no
330        // active document for this pane. Clear this pane's
331        // matrix if it isn't already empty; idempotent on
332        // repeat clears so the second call doesn't churn the
333        // Arc. Drop the cached fingerprint so a later
334        // re-bind doesn't false-positive cache-hit.
335        state.last_fingerprints.remove(&pane.buffer_id);
336        let existing = pane.virtual_rows_matrix.load();
337        if existing.is_empty() && existing.source_line_count == 0 {
338            return WorkerDecision::Clear;
339        }
340        pane.virtual_rows_matrix
341            .store(Arc::new(VirtualRowMatrix::empty()));
342        return WorkerDecision::Clear;
343    };
344
345    // CV.3: content space — virtual rows anchor to real source lines.
346    let source_line_count = snapshot.buffer.content_line_count();
347    let provider_snap = providers.snapshot(pane.buffer_id);
348    let fingerprint = compute_fingerprint(&provider_snap, source_line_count);
349
350    if state.last_fingerprints.get(&pane.buffer_id) == Some(&fingerprint) {
351        return WorkerDecision::CacheHit;
352    }
353    state.last_fingerprints.insert(pane.buffer_id, fingerprint);
354
355    let mut rows: Vec<VirtualRow> = Vec::new();
356    for p in &provider_snap {
357        rows.extend(p.collect());
358    }
359
360    let publish_version = state.next_publish_version;
361    state.next_publish_version = publish_version.wrapping_add(1);
362    let new_matrix =
363        VirtualRowMatrix::build(rows, source_line_count, VirtualRowVersion(publish_version));
364    pane.virtual_rows_matrix.store(Arc::new(new_matrix));
365    WorkerDecision::Recomputed
366}
367
368/// Worker entry point spawned at boot. Loops forever, awaiting
369/// the wake `Notify`. Each wake re-reads the latest
370/// `RenderState` + provider registry and calls [`recompute`].
371///
372/// `paint_request` is the shared `Notify` consumed by the
373/// renderer peer — fired on `Recomputed` / `Clear` decisions
374/// (content changed). `CacheHit` returns without waking the
375/// renderer.
376///
377/// Coalescing mirrors `cells_worker`: `Notify::notify_one`
378/// stores at most one permit, so a burst of wakes during a
379/// rebuild collapses to one tail rebuild. No explicit
380/// debounce.
381pub async fn run(
382    render_state: Arc<ArcSwap<RenderState>>,
383    wake: VirtualRowsWake,
384    providers: Arc<VirtualRowProviderRegistry>,
385    paint_request: Arc<tokio::sync::Notify>,
386) {
387    info!(
388        target: "lattice_host::virtual_rows_worker",
389        "virtual-rows worker spawned"
390    );
391    let mut state = VirtualRowsWorkerState::new();
392    let mut tick: u64 = 0;
393    // 2026-08-16: aggregated for the same reason as the cells worker's —
394    // `*messages*` is a rendered buffer fed by the log pipeline, so a log
395    // per tick closes a render→log→edit→publish→wake→render cycle. See
396    // the comment there; this worker spun ~500 times/second on the same
397    // loop because its ticks are cheap (~20 µs), so it went round faster.
398    let mut window_start = std::time::Instant::now();
399    let mut window_ticks: u64 = 0;
400    let mut window_changed: u64 = 0;
401    loop {
402        wake.0.notified().await;
403        let t0 = std::time::Instant::now();
404        let decision = recompute(&mut state, &render_state, &providers);
405        let elapsed_us = t0.elapsed().as_micros();
406        tick += 1;
407        if matches!(decision, WorkerDecision::Recomputed | WorkerDecision::Clear) {
408            paint_request.notify_one();
409        }
410        window_ticks += 1;
411        if !matches!(decision, WorkerDecision::CacheHit) {
412            window_changed += 1;
413        }
414        if window_start.elapsed() >= std::time::Duration::from_secs(1) {
415            debug!(
416                target: "lattice_host::virtual_rows_worker",
417                tick,
418                ticks_per_sec = window_ticks,
419                rebuilds = window_changed,
420                last_us = elapsed_us,
421                last = ?decision,
422                "virtual-rows worker (1s summary)"
423            );
424            window_start = std::time::Instant::now();
425            window_ticks = 0;
426            window_changed = 0;
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    use lattice_cells::AnchorPosition;
436    use lattice_core::{Buffer, BufferId};
437    use lattice_runtime::DocumentSnapshot;
438    use std::sync::atomic::{AtomicU64, Ordering};
439
440    /// The active-document buffer id used across recompute
441    /// tests. Mirrors `RenderState::default()`'s
442    /// `active_document.document_buffer_id` (also
443    /// `BufferId(0)`) so providers registered against this
444    /// scope match what `recompute` snapshots.
445    const ACTIVE: BufferId = BufferId(0);
446
447    /// Test-only provider that returns a fixed set of rows and
448    /// reports a version bumped by tests via `set_version`.
449    #[derive(Debug)]
450    struct MockProvider {
451        id: ProviderId,
452        version: AtomicU64,
453        rows: Mutex<Vec<VirtualRow>>,
454    }
455
456    impl MockProvider {
457        fn new(id: ProviderId, rows: Vec<VirtualRow>) -> Self {
458            Self {
459                id,
460                version: AtomicU64::new(1),
461                rows: Mutex::new(rows),
462            }
463        }
464
465        fn bump_version(&self) {
466            self.version.fetch_add(1, Ordering::Relaxed);
467        }
468
469        fn replace_rows(&self, rows: Vec<VirtualRow>) {
470            *self.rows.lock().unwrap() = rows;
471            self.bump_version();
472        }
473    }
474
475    impl VirtualRowProvider for MockProvider {
476        fn id(&self) -> ProviderId {
477            self.id
478        }
479
480        fn version(&self) -> u64 {
481            self.version.load(Ordering::Relaxed)
482        }
483
484        fn collect(&self) -> Vec<VirtualRow> {
485            self.rows.lock().unwrap().clone()
486        }
487    }
488
489    fn row(anchor: u32, pos: AnchorPosition) -> VirtualRow {
490        VirtualRow {
491            media: None,
492            anchor_line: anchor,
493            position: pos,
494            cells: Arc::from([] as [lattice_cells::Cell; 0]),
495            height: 1,
496            kind: lattice_cells::VirtualRowKind::Generic,
497            bg: None,
498            scales: None,
499            gutter_line: None,
500            gutter_fg: None,
501        }
502    }
503
504    /// Build a `PaneCellsInputs` carrying the fields the
505    /// virtual-rows worker reads (`buffer_id`, `snapshot`,
506    /// `virtual_rows_matrix`) — every other field stays at a
507    /// cheap default. Each call mints a fresh `PaneId`.
508    fn pane_inputs(
509        buffer_id: BufferId,
510        snapshot: Option<Arc<DocumentSnapshot>>,
511        vr_matrix: Arc<ArcSwap<VirtualRowMatrix>>,
512    ) -> crate::render_state::PaneCellsInputs {
513        use lattice_core::ui::pane::PaneId;
514        crate::render_state::PaneCellsInputs {
515            conceal_reveal_line: None,
516            // IG.2: default guide inputs — enabled with the default indent
517            // unit, which is the shape a test pane has unless it is
518            // exercising guides specifically.
519            indent_guides: Default::default(),
520            indent_unit: lattice_core::IndentUnit::default(),
521            indent_guides_enabled: true,
522            sticky_context_lines: std::sync::Arc::from([] as [u32; 0]),
523            sticky_context_line_numbers: true,
524            sticky_context_separator: None,
525            sticky_context: Default::default(),
526            pane_id: PaneId::next(),
527            buffer_id,
528            matrix: Arc::new(ArcSwap::from_pointee(lattice_cells::CellMatrix::empty())),
529            display_matrix: Arc::new(ArcSwap::from_pointee(
530                crate::display_matrix::DisplayMatrix::empty(),
531            )),
532            virtual_rows_matrix: vr_matrix,
533            version: lattice_cells::MatrixVersion::ZERO,
534            snapshot,
535            syntax_handle: None,
536            inlay_hints: Arc::from(
537                Vec::<crate::render_state::InlayHintRow>::new().into_boxed_slice(),
538            ),
539            folds: Arc::from(Vec::<lattice_core::Fold>::new().into_boxed_slice()),
540            viewport_height: 10,
541            scroll: 0,
542            viewport_width: 0,
543            wrap: false,
544            wrap_reserved_cols: 0,
545            foldenable: false,
546            last_edit: None,
547            excerpt_syntax: Arc::from([]),
548            extra_spans: Arc::from([]),
549            extra_refine: Arc::from(Vec::new().into_boxed_slice()),
550        }
551    }
552
553    /// Build an `ArcSwap<RenderState>` whose `cells.panes`
554    /// carries the supplied entries verbatim. The
555    /// virtual-rows worker now reads `rs.cells.panes` (per
556    /// D.4.d.2.1.c) so this is the canonical recompute
557    /// fixture.
558    fn rs_with_panes(
559        panes: Vec<crate::render_state::PaneCellsInputs>,
560    ) -> Arc<ArcSwap<RenderState>> {
561        let cells = crate::render_state::CellsRenderState {
562            panes: Arc::from(panes.into_boxed_slice()),
563            ..crate::render_state::CellsRenderState::default()
564        };
565        let rs = RenderState {
566            cells: Arc::new(ArcSwap::from_pointee(cells)),
567            ..RenderState::default()
568        };
569        Arc::new(ArcSwap::from_pointee(rs))
570    }
571
572    /// Shorthand: build a fresh `DocumentSnapshot` carrying
573    /// the supplied text. Used by the line-count-driven tests.
574    fn snapshot_with_text(text: &str) -> Arc<DocumentSnapshot> {
575        let snap = DocumentSnapshot {
576            buffer: Buffer::from_text(text),
577            ..Default::default()
578        };
579        Arc::new(snap)
580    }
581
582    /// Build the canonical single-pane fixture: one pane
583    /// scoped to `ACTIVE` carrying a snapshot of `text` and
584    /// writing into `cell`.
585    fn rs_with_single_pane(
586        text: &str,
587        cell: Arc<ArcSwap<VirtualRowMatrix>>,
588    ) -> Arc<ArcSwap<RenderState>> {
589        rs_with_panes(vec![pane_inputs(
590            ACTIVE,
591            Some(snapshot_with_text(text)),
592            cell,
593        )])
594    }
595
596    // ── Registry ──────────────────────────────────────────────
597
598    #[test]
599    fn registry_register_lookup_unregister() {
600        let reg = VirtualRowProviderRegistry::new();
601        assert!(reg.is_empty());
602        let p = Arc::new(MockProvider::new(1, vec![]));
603        assert!(reg.register(ACTIVE, p as Arc<dyn VirtualRowProvider>));
604        assert_eq!(reg.len(), 1);
605        assert_eq!(reg.snapshot(ACTIVE).len(), 1);
606        assert!(reg.unregister(ACTIVE, 1));
607        assert!(reg.is_empty());
608    }
609
610    #[test]
611    fn registry_duplicate_register_in_same_scope_returns_false() {
612        let reg = VirtualRowProviderRegistry::new();
613        let p1 = Arc::new(MockProvider::new(1, vec![]));
614        let p2 = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
615        assert!(reg.register(ACTIVE, p1 as Arc<dyn VirtualRowProvider>));
616        assert!(!reg.register(ACTIVE, p2 as Arc<dyn VirtualRowProvider>));
617        assert_eq!(reg.len(), 1);
618    }
619
620    /// D.4.d.2.1.a: same `ProviderId` in two distinct buffer
621    /// scopes coexist — this is what lets baseline + current
622    /// panes both run their filler providers without one
623    /// rejecting the other. Even though today's filler ids
624    /// (`0xD1FF_0001_*` vs `0xD1FF_0002_*`) are side-distinct,
625    /// the registry scoping is what makes the per-pane worker
626    /// iteration (D.4.d.2.1.b) sound when two providers
627    /// happen to share an id.
628    #[test]
629    fn registry_isolates_providers_by_buffer() {
630        let reg = VirtualRowProviderRegistry::new();
631        let baseline = BufferId(7);
632        let current = BufferId(8);
633        let p1 = Arc::new(MockProvider::new(42, vec![]));
634        let p2 = Arc::new(MockProvider::new(42, vec![row(0, AnchorPosition::Above)]));
635        assert!(reg.register(baseline, p1 as Arc<dyn VirtualRowProvider>));
636        assert!(reg.register(current, p2 as Arc<dyn VirtualRowProvider>));
637        assert_eq!(reg.len(), 2);
638        assert_eq!(reg.snapshot(baseline).len(), 1);
639        assert_eq!(reg.snapshot(current).len(), 1);
640    }
641
642    #[test]
643    fn registry_snapshot_scoped_to_buffer() {
644        let reg = VirtualRowProviderRegistry::new();
645        let bid_a = BufferId(1);
646        let bid_b = BufferId(2);
647        reg.register(
648            bid_a,
649            Arc::new(MockProvider::new(10, vec![])) as Arc<dyn VirtualRowProvider>,
650        );
651        reg.register(
652            bid_b,
653            Arc::new(MockProvider::new(20, vec![])) as Arc<dyn VirtualRowProvider>,
654        );
655        let snap_a = reg.snapshot(bid_a);
656        let snap_b = reg.snapshot(bid_b);
657        assert_eq!(snap_a.len(), 1);
658        assert_eq!(snap_a[0].id(), 10);
659        assert_eq!(snap_b.len(), 1);
660        assert_eq!(snap_b[0].id(), 20);
661        // Unknown buffer → empty snapshot, never a panic.
662        assert!(reg.snapshot(BufferId(99)).is_empty());
663    }
664
665    #[test]
666    fn registry_unregister_only_affects_its_buffer() {
667        let reg = VirtualRowProviderRegistry::new();
668        let bid_a = BufferId(1);
669        let bid_b = BufferId(2);
670        reg.register(
671            bid_a,
672            Arc::new(MockProvider::new(5, vec![])) as Arc<dyn VirtualRowProvider>,
673        );
674        reg.register(
675            bid_b,
676            Arc::new(MockProvider::new(5, vec![])) as Arc<dyn VirtualRowProvider>,
677        );
678        assert!(reg.unregister(bid_a, 5));
679        assert!(reg.snapshot(bid_a).is_empty());
680        assert_eq!(reg.snapshot(bid_b).len(), 1);
681        // Pruning: bid_a scope removed entirely → len drops to 1.
682        assert_eq!(reg.len(), 1);
683        // Idempotent: removing again is a no-op `false`, not a panic.
684        assert!(!reg.unregister(bid_a, 5));
685        // Unknown buffer unregister → false, no panic.
686        assert!(!reg.unregister(BufferId(99), 5));
687    }
688
689    // ── Fingerprint ───────────────────────────────────────────
690
691    #[test]
692    fn fingerprint_independent_of_provider_order() {
693        let p1: Arc<dyn VirtualRowProvider> = Arc::new(MockProvider::new(1, vec![]));
694        let p2: Arc<dyn VirtualRowProvider> = Arc::new(MockProvider::new(2, vec![]));
695        let fp_a = compute_fingerprint(&[p1.clone(), p2.clone()], 10);
696        let fp_b = compute_fingerprint(&[p2, p1], 10);
697        assert_eq!(fp_a, fp_b);
698    }
699
700    #[test]
701    fn fingerprint_changes_when_source_line_count_changes() {
702        let p: Arc<dyn VirtualRowProvider> = Arc::new(MockProvider::new(1, vec![]));
703        let fp_a = compute_fingerprint(std::slice::from_ref(&p), 10);
704        let fp_b = compute_fingerprint(&[p], 11);
705        assert_ne!(fp_a, fp_b);
706    }
707
708    #[test]
709    fn fingerprint_changes_when_provider_version_changes() {
710        let mock = Arc::new(MockProvider::new(1, vec![]));
711        let dyn_provider: Arc<dyn VirtualRowProvider> = mock.clone();
712        let fp_a = compute_fingerprint(std::slice::from_ref(&dyn_provider), 10);
713        mock.bump_version();
714        let fp_b = compute_fingerprint(&[dyn_provider], 10);
715        assert_ne!(fp_a, fp_b);
716    }
717
718    // ── Recompute ─────────────────────────────────────────────
719
720    /// D.4.d.2.1.c: empty `rs.cells.panes` ⇒ `CacheHit`. The
721    /// pre-D.4.d.2.1.c semantics emitted `Clear` here (off
722    /// the global `matrix_cell`); after the pane-driven cutover
723    /// there's no top-level cell to write through, and "no
724    /// panes" is observationally indistinguishable from a
725    /// quiet tick — matching the cells worker's precedent.
726    #[test]
727    fn recompute_empty_panes_is_cache_hit() {
728        let mut state = VirtualRowsWorkerState::new();
729        let rs = rs_with_panes(vec![]);
730        let reg = VirtualRowProviderRegistry::new();
731        let decision = recompute(&mut state, &rs, &reg);
732        assert_eq!(decision, WorkerDecision::CacheHit);
733    }
734
735    /// The worker must CONVERGE: repeated ticks with unchanged inputs
736    /// have to reach `CacheHit`.
737    ///
738    /// A non-CacheHit decision fires `paint_request`, which republishes
739    /// render state, which wakes this worker again. A decision that never
740    /// settles is a spin loop — reported 2026-08-16 as ~500 mutual
741    /// cells/virtual-rows ticks per second that never stopped after `G`.
742    ///
743    /// Covers the no-provider case deliberately: an ordinary buffer has no
744    /// virtual-row providers at all, so "nothing to do" is the path that
745    /// must be quietest.
746    #[test]
747    fn repeated_ticks_with_no_providers_converge_to_cache_hit() {
748        let mut state = VirtualRowsWorkerState::new();
749        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
750        let rs = rs_with_single_pane("a\nb\nc\n", cell);
751        let reg = VirtualRowProviderRegistry::new();
752        // Tick 1 legitimately publishes (an empty matrix) and seeds the
753        // fingerprint; it is every tick AFTER that which must be quiet.
754        let _ = recompute(&mut state, &rs, &reg);
755        for tick in 2..=5 {
756            let d = recompute(&mut state, &rs, &reg);
757            assert_eq!(
758                d,
759                WorkerDecision::CacheHit,
760                "tick {tick} with no providers must be quiet, got {d:?}"
761            );
762        }
763    }
764
765    /// Same requirement WITH a provider: the first tick builds, every
766    /// later one with an unchanged provider version is quiet.
767    #[test]
768    fn repeated_ticks_with_a_stable_provider_converge_to_cache_hit() {
769        let mut state = VirtualRowsWorkerState::new();
770        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
771        let rs = rs_with_single_pane("a\nb\nc\n", cell);
772        let reg = VirtualRowProviderRegistry::new();
773        reg.register(
774            ACTIVE,
775            Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]))
776                as Arc<dyn VirtualRowProvider>,
777        );
778        let _ = recompute(&mut state, &rs, &reg);
779        for tick in 2..=5 {
780            let d = recompute(&mut state, &rs, &reg);
781            assert_eq!(
782                d,
783                WorkerDecision::CacheHit,
784                "tick {tick} with a stable provider must be quiet, got {d:?}"
785            );
786        }
787    }
788
789    /// D.4.d.2.1.c: a pane whose `snapshot` is `None` clears
790    /// that pane's matrix (transient buffer-close race
791    /// behaviour). Aggregate decision is `Clear` since no
792    /// pane recomputed.
793    #[test]
794    fn recompute_pane_without_snapshot_clears_its_matrix() {
795        let mut state = VirtualRowsWorkerState::new();
796        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
797        // Seed the cell with non-empty content so the clear
798        // branch is observable (the no-op clear path returns
799        // `Clear` too, but doesn't churn the Arc).
800        cell.store(Arc::new(VirtualRowMatrix::build(
801            vec![row(0, AnchorPosition::Above)],
802            3,
803            VirtualRowVersion(7),
804        )));
805        assert_eq!(cell.load_full().len(), 1);
806        let rs = rs_with_panes(vec![pane_inputs(ACTIVE, None, cell.clone())]);
807        let reg = VirtualRowProviderRegistry::new();
808        let d = recompute(&mut state, &rs, &reg);
809        assert_eq!(d, WorkerDecision::Clear);
810        assert_eq!(cell.load_full().len(), 0, "matrix cleared");
811    }
812
813    #[test]
814    fn recompute_with_no_providers_is_cache_hit_after_first() {
815        let mut state = VirtualRowsWorkerState::new();
816        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
817        let rs = rs_with_single_pane("a\nb\nc\n", cell.clone());
818        let reg = VirtualRowProviderRegistry::new();
819        // First wake: fingerprint not yet seen → Recomputed
820        // (publishes an empty matrix tagged with the line count).
821        let d1 = recompute(&mut state, &rs, &reg);
822        assert_eq!(d1, WorkerDecision::Recomputed);
823        // Second wake: identical fingerprint → CacheHit.
824        let d2 = recompute(&mut state, &rs, &reg);
825        assert_eq!(d2, WorkerDecision::CacheHit);
826    }
827
828    #[test]
829    fn recompute_publishes_rows_from_provider() {
830        let mut state = VirtualRowsWorkerState::new();
831        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
832        let rs = rs_with_single_pane("a\nb\nc\nd\n", cell.clone());
833        let reg = VirtualRowProviderRegistry::new();
834        let provider = Arc::new(MockProvider::new(
835            1,
836            vec![row(1, AnchorPosition::Above), row(2, AnchorPosition::Below)],
837        ));
838        reg.register(ACTIVE, provider.clone() as Arc<dyn VirtualRowProvider>);
839
840        let d = recompute(&mut state, &rs, &reg);
841        assert_eq!(d, WorkerDecision::Recomputed);
842        let published = cell.load_full();
843        assert_eq!(published.len(), 2);
844        assert!(!published.is_empty());
845    }
846
847    #[test]
848    fn recompute_cache_hit_after_provider_static() {
849        let mut state = VirtualRowsWorkerState::new();
850        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
851        let rs = rs_with_single_pane("a\nb\n", cell.clone());
852        let reg = VirtualRowProviderRegistry::new();
853        let provider = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
854        reg.register(ACTIVE, provider as Arc<dyn VirtualRowProvider>);
855
856        let d1 = recompute(&mut state, &rs, &reg);
857        assert_eq!(d1, WorkerDecision::Recomputed);
858        let d2 = recompute(&mut state, &rs, &reg);
859        assert_eq!(d2, WorkerDecision::CacheHit);
860    }
861
862    #[test]
863    fn recompute_re_publishes_when_provider_version_bumps() {
864        let mut state = VirtualRowsWorkerState::new();
865        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
866        let rs = rs_with_single_pane("a\nb\n", cell.clone());
867        let reg = VirtualRowProviderRegistry::new();
868        let provider = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
869        reg.register(ACTIVE, provider.clone() as Arc<dyn VirtualRowProvider>);
870
871        recompute(&mut state, &rs, &reg);
872        let v1 = cell.load_full().version;
873
874        provider.replace_rows(vec![
875            row(0, AnchorPosition::Above),
876            row(1, AnchorPosition::Below),
877        ]);
878        let d = recompute(&mut state, &rs, &reg);
879        assert_eq!(d, WorkerDecision::Recomputed);
880        let v2 = cell.load_full().version;
881        assert!(v2.0 > v1.0, "publish version monotonically bumps");
882        assert_eq!(cell.load_full().len(), 2);
883    }
884
885    #[test]
886    fn recompute_re_publishes_when_document_line_count_changes() {
887        let mut state = VirtualRowsWorkerState::new();
888        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
889        let rs1 = rs_with_single_pane("a\nb\n", cell.clone());
890        let reg = VirtualRowProviderRegistry::new();
891
892        let d1 = recompute(&mut state, &rs1, &reg);
893        assert_eq!(d1, WorkerDecision::Recomputed);
894        let v1 = cell.load_full().version;
895
896        // Re-publish the same cell against a longer document.
897        let rs2 = rs_with_single_pane("a\nb\nc\nd\n", cell.clone());
898        let d2 = recompute(&mut state, &rs2, &reg);
899        assert_eq!(d2, WorkerDecision::Recomputed);
900        let v2 = cell.load_full().version;
901        assert!(v2.0 > v1.0);
902        // CV.3: content space — "a\nb\nc\nd\n" is a FOUR line document.
903        // This pinned ropey's raw 5, i.e. the phantom line after the
904        // terminating newline, as the virtual-row matrix's extent.
905        assert_eq!(cell.load_full().source_line_count, 4);
906    }
907
908    #[test]
909    fn recompute_merges_rows_from_multiple_providers() {
910        let mut state = VirtualRowsWorkerState::new();
911        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
912        let rs = rs_with_single_pane("a\nb\nc\nd\n", cell.clone());
913        let reg = VirtualRowProviderRegistry::new();
914        let p1 = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
915        let p2 = Arc::new(MockProvider::new(2, vec![row(2, AnchorPosition::Below)]));
916        reg.register(ACTIVE, p1 as Arc<dyn VirtualRowProvider>);
917        reg.register(ACTIVE, p2 as Arc<dyn VirtualRowProvider>);
918
919        recompute(&mut state, &rs, &reg);
920        let published = cell.load_full();
921        assert_eq!(published.len(), 2);
922    }
923
924    /// D.4.d.2.1.a: the worker scopes its provider snapshot
925    /// to each pane's buffer. A provider registered against a
926    /// *different* buffer must not contribute rows to a pane
927    /// showing a different buffer — load-bearing for
928    /// D.4.d.2.1.c's per-pane iteration, where each pane's
929    /// matrix only sees its own buffer's providers.
930    #[test]
931    fn recompute_only_polls_active_doc_providers() {
932        let mut state = VirtualRowsWorkerState::new();
933        let cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
934        let rs = rs_with_single_pane("a\nb\nc\n", cell.clone());
935        let reg = VirtualRowProviderRegistry::new();
936        // Register against a buffer that is NOT the pane's.
937        let foreign = BufferId(42);
938        assert_ne!(foreign, ACTIVE);
939        let provider = Arc::new(MockProvider::new(
940            1,
941            vec![row(0, AnchorPosition::Above), row(1, AnchorPosition::Below)],
942        ));
943        reg.register(foreign, provider as Arc<dyn VirtualRowProvider>);
944
945        let d = recompute(&mut state, &rs, &reg);
946        // Recomputed because the fingerprint of (no providers,
947        // line_count=4) is unseen for this buffer; matrix stays empty.
948        assert_eq!(d, WorkerDecision::Recomputed);
949        assert_eq!(cell.load_full().len(), 0);
950    }
951
952    // ── D.4.d.2.1.c: per-pane iteration ───────────────────────
953
954    /// Two panes for distinct buffers, each with its own
955    /// provider scoped to its buffer. Both panes recompute on
956    /// the same tick; each pane's matrix carries that pane's
957    /// provider's rows. Mirror of
958    /// `cells_worker::two_panes_distinct_buffers_both_rebuild`.
959    #[test]
960    fn two_panes_distinct_buffers_both_rebuild() {
961        let mut state = VirtualRowsWorkerState::new();
962        let bid_a = BufferId(11);
963        let bid_b = BufferId(22);
964        let cell_a = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
965        let cell_b = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
966        let rs = rs_with_panes(vec![
967            pane_inputs(bid_a, Some(snapshot_with_text("a\n")), cell_a.clone()),
968            pane_inputs(bid_b, Some(snapshot_with_text("b\nb\n")), cell_b.clone()),
969        ]);
970        let reg = VirtualRowProviderRegistry::new();
971        let pa = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
972        let pb = Arc::new(MockProvider::new(
973            2,
974            vec![row(0, AnchorPosition::Above), row(1, AnchorPosition::Below)],
975        ));
976        reg.register(bid_a, pa as Arc<dyn VirtualRowProvider>);
977        reg.register(bid_b, pb as Arc<dyn VirtualRowProvider>);
978
979        let d = recompute(&mut state, &rs, &reg);
980        assert_eq!(d, WorkerDecision::Recomputed);
981        assert_eq!(
982            cell_a.load_full().len(),
983            1,
984            "buffer A: one row from its provider"
985        );
986        assert_eq!(
987            cell_b.load_full().len(),
988            2,
989            "buffer B: two rows from its provider"
990        );
991    }
992
993    /// Two panes for distinct buffers. After the initial
994    /// publish, bump only buffer A's provider. Only pane A
995    /// should rebuild; pane B's matrix Arc must be the
996    /// identical `Arc<VirtualRowMatrix>` it carried before
997    /// (no `store` call on a cache-hit pane). Mirror of
998    /// `cells_worker::per_pane_cache_hit_skips_unchanged_pane`.
999    #[test]
1000    fn per_pane_cache_hit_skips_unchanged_pane() {
1001        let mut state = VirtualRowsWorkerState::new();
1002        let bid_a = BufferId(11);
1003        let bid_b = BufferId(22);
1004        let cell_a = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
1005        let cell_b = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
1006        let rs = rs_with_panes(vec![
1007            pane_inputs(bid_a, Some(snapshot_with_text("a\n")), cell_a.clone()),
1008            pane_inputs(bid_b, Some(snapshot_with_text("b\n")), cell_b.clone()),
1009        ]);
1010        let reg = VirtualRowProviderRegistry::new();
1011        let pa = Arc::new(MockProvider::new(1, vec![row(0, AnchorPosition::Above)]));
1012        let pb = Arc::new(MockProvider::new(2, vec![row(0, AnchorPosition::Above)]));
1013        reg.register(bid_a, pa.clone() as Arc<dyn VirtualRowProvider>);
1014        reg.register(bid_b, pb as Arc<dyn VirtualRowProvider>);
1015
1016        // First tick: both panes publish.
1017        assert_eq!(recompute(&mut state, &rs, &reg), WorkerDecision::Recomputed);
1018        let matrix_b_v1 = cell_b.load_full();
1019
1020        // Bump buffer A's provider only.
1021        pa.replace_rows(vec![
1022            row(0, AnchorPosition::Above),
1023            row(0, AnchorPosition::Below),
1024        ]);
1025        assert_eq!(recompute(&mut state, &rs, &reg), WorkerDecision::Recomputed);
1026
1027        // Pane A rebuilt (now 2 rows); pane B's Arc identity
1028        // must be unchanged (no `store` on cache-hit).
1029        assert_eq!(cell_a.load_full().len(), 2);
1030        let matrix_b_v2 = cell_b.load_full();
1031        assert!(
1032            Arc::ptr_eq(&matrix_b_v1, &matrix_b_v2),
1033            "unchanged pane's matrix Arc must survive untouched"
1034        );
1035    }
1036
1037    /// Two panes showing the *same* buffer share the same
1038    /// registry cell. The first pane processed recomputes and
1039    /// writes; the second pane's iteration sees the cached
1040    /// fingerprint and short-circuits — exactly one write
1041    /// against the shared cell per tick, no thrash. Mirror of
1042    /// `cells_worker::two_panes_sharing_buffer_share_one_matrix_write`.
1043    #[test]
1044    fn two_panes_sharing_buffer_share_one_matrix_write() {
1045        let mut state = VirtualRowsWorkerState::new();
1046        let bid = BufferId(33);
1047        let shared_cell = Arc::new(ArcSwap::from_pointee(VirtualRowMatrix::empty()));
1048        let snap = snapshot_with_text("x\ny\n");
1049        let rs = rs_with_panes(vec![
1050            pane_inputs(bid, Some(snap.clone()), shared_cell.clone()),
1051            pane_inputs(bid, Some(snap.clone()), shared_cell.clone()),
1052        ]);
1053        let reg = VirtualRowProviderRegistry::new();
1054        let provider = Arc::new(MockProvider::new(
1055            7,
1056            vec![row(0, AnchorPosition::Above), row(1, AnchorPosition::Below)],
1057        ));
1058        reg.register(bid, provider as Arc<dyn VirtualRowProvider>);
1059
1060        let d = recompute(&mut state, &rs, &reg);
1061        // Aggregate is `Recomputed` because the first pane
1062        // rebuilt; the second is a `CacheHit` against the
1063        // fingerprint already cached for `bid` and writes
1064        // nothing.
1065        assert_eq!(d, WorkerDecision::Recomputed);
1066        assert_eq!(shared_cell.load_full().len(), 2);
1067
1068        // A second tick over the same `rs` is now a full
1069        // aggregate `CacheHit` — neither pane writes.
1070        let d2 = recompute(&mut state, &rs, &reg);
1071        assert_eq!(d2, WorkerDecision::CacheHit);
1072    }
1073}