Skip to main content

lattice_host/
wasm_media.rs

1//! IM.7 — WASM inline media: producer → per-buffer blocks → virtual rows.
2//!
3//! The media twin of [`wasm_decorations`](crate::wasm_decorations), and the
4//! same shape for the same reason: a media plugin's producer runs OFF the
5//! render path (paramount goal #1), and the renderer reads only a native cache.
6//!
7//! What is different is what the cache feeds. Decorations end up as gutter
8//! marks; media blocks end up as **virtual rows**, which means they change the
9//! document's display-row count and therefore its scroll arithmetic. The
10//! reservation is built here, host-side, from a size the host resolves — the
11//! guest never says how tall anything is.
12
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, Ordering};
16
17use lattice_core::BufferId;
18use lattice_mode::{MediaBlockRequest, MediaSourceRegistryHandle};
19
20use crate::editor::Editor;
21use crate::per_buffer_cache::{PerBufferCache, PerBufferCacheExt};
22
23/// `(line_height_px, pane_width_px)` — what sizing a block needs, and the
24/// only pixel geometry the host holds. `None` means no peer that draws
25/// images has published its cell metrics.
26pub type MediaGeometry = (f32, f32);
27
28/// What a refresh is single-flighted on: which buffer, at which document
29/// version, measured against which geometry. The geometry is part of the key
30/// because a resize changes neither of the other two.
31type RefreshKey = (BufferId, u64, Option<MediaGeometry>);
32
33/// Per-buffer cache of a media plugin's blocks, resolved and sized.
34#[derive(Debug, Clone, Default)]
35pub struct WasmMediaCache {
36    /// Document version the blocks were produced against — the staleness key.
37    pub document_version: u64,
38    /// IM.7a: the geometry the blocks were SIZED against, so a window resize
39    /// re-measures. Without this a block keeps the row count it earned at the
40    /// old pane width: widen the window and a `Contain` image is drawn larger
41    /// inside a box still reserved for the smaller one.
42    pub geometry: Option<MediaGeometry>,
43    /// One entry per block: the descriptor plus the rows it reserves.
44    pub blocks: Vec<(Arc<lattice_cells::MediaBlock>, u32, u16)>,
45}
46
47/// The [`Editor`]'s cohesive WASM-media wiring. Defaults to inert, so
48/// `Editor::default()` test fixtures get no media seam at all.
49#[derive(Debug, Default)]
50pub struct WasmMediaState {
51    pub cache: PerBufferCache<WasmMediaCache>,
52    pub registry: Option<MediaSourceRegistryHandle>,
53    /// Off-keystroke paint gate, bumped on every cache write.
54    pub generation: Arc<AtomicU64>,
55    /// Single-flight guard for a refresh already in flight.
56    ///
57    /// Keyed on the GEOMETRY as well as the buffer and version: a resize
58    /// changes neither of the other two, so a version-only key made the
59    /// re-measure unreachable — the staleness check let it through and this
60    /// guard turned it straight back, and an image kept the row count it
61    /// earned at the old pane width for the rest of the session.
62    pending: Option<RefreshKey>,
63    /// Buffers this state has registered a [`MediaVirtualRowProvider`] for, so
64    /// registration happens once per buffer and can be undone when the last
65    /// producer goes away.
66    registered: std::collections::HashSet<BufferId>,
67    /// Pointer identity of the last registry snapshot driven — a change means
68    /// producers were added or removed, forcing an immediate refresh.
69    last_registry_epoch: usize,
70}
71
72impl WasmMediaState {
73    pub fn with_registry(registry: MediaSourceRegistryHandle) -> Self {
74        Self {
75            registry: Some(registry),
76            ..Default::default()
77        }
78    }
79}
80
81/// How tall a block is, in display rows, before its file has been measured.
82///
83/// A provisional reservation, replaced once the header read lands. It is not
84/// zero and not one: zero would make the block invisible while still holding a
85/// matrix slot, and one would make every image visibly jump from a single line
86/// to its real height as the reads complete — the reflow the whole design is
87/// arranged to avoid. Eight rows is roughly a small figure, so the common case
88/// settles with little or no movement.
89pub const PROVISIONAL_ROWS: u16 = 8;
90
91/// Rows reserved for a block whose file could not be measured.
92///
93/// One, not [`PROVISIONAL_ROWS`]: a header read that failed is not a pending
94/// answer, it IS the answer — the file is missing, unreadable or not an image
95/// this build decodes, and no later frame will improve on it. The alt text
96/// stands in, and it needs one row. Eight blank rows around it would reserve
97/// most of a screen for a picture that is never coming.
98pub const UNREADABLE_ROWS: u16 = 1;
99
100impl Editor {
101    /// IM.7 per-tick media refresh pump.
102    ///
103    /// Version- and registry-gated, single-flight, spawns producers off the
104    /// actor thread, and writes the resolved blocks into the per-buffer cache.
105    /// No per-frame WASM: the renderer reads only what this fills.
106    ///
107    /// Graceful: a producer that errs contributes nothing and the cache is
108    /// overwritten only when at least one producer answered, so an all-error
109    /// refresh keeps the prior blocks. That is what stops every image in a
110    /// document blinking out on a transient failure mid-edit.
111    pub fn maybe_refresh_wasm_media(&mut self) {
112        let Some(registry) = self.wasm_media.registry.clone() else {
113            return;
114        };
115        let snapshot_reg = registry.load_full();
116        let epoch = Arc::as_ptr(&snapshot_reg) as usize;
117        let registry_changed = epoch != self.wasm_media.last_registry_epoch;
118        let sources = snapshot_reg.sources();
119
120        if sources.is_empty() {
121            if registry_changed {
122                self.wasm_media
123                    .cache
124                    .store(Arc::new(std::collections::HashMap::<
125                        BufferId,
126                        Arc<WasmMediaCache>,
127                    >::new()));
128                self.wasm_media.generation.fetch_add(1, Ordering::Relaxed);
129                self.wasm_media.last_registry_epoch = epoch;
130                self.wasm_media.pending = None;
131                // The cache is empty, so the providers would now draw nothing.
132                // Unregister rather than leaving them: a provider that answers
133                // `collect() -> []` still costs the worker a wake and a call,
134                // and a `:plugin-unload` should leave no trace.
135                for buffer in self.wasm_media.registered.drain().collect::<Vec<_>>() {
136                    self.virtual_row_providers
137                        .unregister(buffer, media_virtual_row_provider_id(buffer));
138                }
139            }
140            return;
141        }
142
143        let buffer_id = self.document_buffer_id;
144        let snapshot = self.document.snapshot();
145        let version = snapshot.version;
146        let line_count = snapshot.buffer.content_line_count();
147
148        let geometry = self.media_geometry();
149        let cache_current = self
150            .wasm_media
151            .cache
152            .get_for(buffer_id)
153            .map(|c| c.document_version == version && c.geometry == geometry)
154            .unwrap_or(false);
155        if !registry_changed && cache_current {
156            return;
157        }
158        if !registry_changed && self.wasm_media.pending == Some((buffer_id, version, geometry)) {
159            return;
160        }
161
162        self.wasm_media.last_registry_epoch = epoch;
163        self.wasm_media.pending = Some((buffer_id, version, geometry));
164
165        // Measurements already taken, keyed by path. The pump refreshes on
166        // every document version — that is, on every keystroke in the buffer
167        // — so without this an org file with twenty images would open twenty
168        // files per keypress. A header read is cheap; doing it per keystroke
169        // per image is not, and it is I/O nobody asked for.
170        //
171        // Carried across a RESIZE too: an intrinsic size does not depend on
172        // the pane, so a resize re-runs `block_geometry`, which is
173        // arithmetic, and reads nothing.
174        let known: std::collections::HashMap<PathBuf, (u32, u32)> = self
175            .wasm_media
176            .cache
177            .get_for(buffer_id)
178            .map(|c| {
179                c.blocks
180                    .iter()
181                    .filter_map(|(b, _, _)| Some((b.path()?.to_path_buf(), b.intrinsic?)))
182                    .collect()
183            })
184            .unwrap_or_default();
185
186        self.ensure_media_virtual_rows(buffer_id);
187
188        let path = self.buffers.document_path(buffer_id);
189        // One copy of the buffer per refresh. A media scan reads every line, so
190        // a per-line handle would cost one boundary crossing per line; this
191        // runs on open / edit, not per frame, so the copy is the cheaper side.
192        let text = snapshot.text().to_string();
193        let cache_slot = self.wasm_media.cache.clone();
194        let async_landed = self.async_landed.clone();
195        let generation = self.wasm_media.generation.clone();
196
197        lattice_runtime::runtime::spawn_on_lsp_runtime(async move {
198            let mut merged: Vec<MediaBlockRequest> = Vec::new();
199            let mut any_ok = false;
200            for source in sources {
201                match source
202                    .produce(buffer_id.0 as u64, path.clone(), line_count, text.clone())
203                    .await
204                {
205                    Ok(blocks) => {
206                        any_ok = true;
207                        merged.extend(blocks);
208                    }
209                    Err(reason) => {
210                        tracing::debug!(
211                            source = source.source_id(),
212                            error = %reason,
213                            "media producer errored; keeping prior blocks"
214                        );
215                    }
216                }
217            }
218            if !any_ok {
219                return;
220            }
221            // IM.7a — measure each block, then size it. `inline-media.md` §7:
222            // the HOST resolves the intrinsic size and computes rows +
223            // `height_lh`, so sizing policy lives in one place and both peers
224            // reserve the same rows.
225            //
226            // On `spawn_blocking` because a probe is a FILE READ. This task
227            // runs on the LSP runtime beside other async work, and a batch of
228            // header reads parked on one of its threads is the pattern the
229            // provider rules exist to forbid.
230            let blocks = tokio::task::spawn_blocking(move || size_blocks(merged, geometry, &known))
231                .await
232                .unwrap_or_default();
233            // Did anything actually change? The pump runs on every document
234            // version — that is, on every keystroke — and a buffer's blocks
235            // are the same after almost all of them. Writing the cache is
236            // cheap and has to happen (the version stamp is what stops the
237            // next tick re-running), but the WAKE is not: bumping the
238            // generation moves the provider's fingerprint, which rebuilds the
239            // virtual rows, and `notify_one` publishes render state and asks
240            // for a paint. Doing that per keystroke for an unchanged picture
241            // is exactly the per-keystroke work paramount #1 forbids.
242            let unchanged = cache_slot
243                .get_for(buffer_id)
244                .is_some_and(|prior| same_blocks(&prior.blocks, &blocks));
245            cache_slot.insert_for(
246                buffer_id,
247                WasmMediaCache {
248                    document_version: version,
249                    geometry,
250                    blocks,
251                },
252            );
253            if !unchanged {
254                generation.fetch_add(1, Ordering::Relaxed);
255                async_landed.notify_one();
256            }
257        });
258    }
259}
260
261/// Are two sized block lists the same picture in the same place?
262///
263/// Compared by VALUE, not by `Arc` identity: every refresh builds fresh
264/// `MediaBlock`s, so pointer equality would report "changed" every time and
265/// defeat the whole check.
266fn same_blocks(
267    a: &[(Arc<lattice_cells::MediaBlock>, u32, u16)],
268    b: &[(Arc<lattice_cells::MediaBlock>, u32, u16)],
269) -> bool {
270    a.len() == b.len()
271        && a.iter()
272            .zip(b)
273            .all(|((ab, aa, ar), (bb, ba, br))| aa == ba && ar == br && **ab == **bb)
274}
275
276/// IM.7a — measure each request and turn it into a sized block.
277///
278/// Off the actor thread and off the LSP runtime's async threads (the caller
279/// puts this on `spawn_blocking`), because every `probe` is a file read.
280///
281/// `geometry` is `(line_height_px, pane_width_px)` from the drawing peer.
282/// `None` — no peer published cell metrics, which is the TUI — means the
283/// block keeps its provisional reservation and **no file is read at all**:
284/// a renderer that draws alt text has nothing to learn from an image header.
285fn size_blocks(
286    requests: Vec<MediaBlockRequest>,
287    geometry: Option<MediaGeometry>,
288    known: &std::collections::HashMap<PathBuf, (u32, u32)>,
289) -> Vec<(Arc<lattice_cells::MediaBlock>, u32, u16)> {
290    requests
291        .into_iter()
292        .map(|req| {
293            let mut block = lattice_cells::MediaBlock::new(req.path.clone(), req.alt);
294            block.fit = req.fit;
295            let rows = match geometry {
296                None => PROVISIONAL_ROWS,
297                Some((line_height_px, pane_width_px)) => match known
298                    .get(&req.path)
299                    .copied()
300                    .map(Ok)
301                    .unwrap_or_else(|| lattice_media::probe(&req.path))
302                {
303                    Ok(intrinsic) => {
304                        let (rows, height_lh) = lattice_media::block_geometry(
305                            intrinsic,
306                            req.fit,
307                            line_height_px,
308                            pane_width_px,
309                        );
310                        block.intrinsic = Some(intrinsic);
311                        block.height_lh = Some(height_lh);
312                        rows
313                    }
314                    Err(err) => {
315                        // `debug!`, not `warn!`: a buffer full of links to
316                        // images that are not there would otherwise log on
317                        // every refresh forever. The alt text is the visible
318                        // report, and it names the file.
319                        tracing::debug!(
320                            path = %req.path.display(),
321                            error = %err,
322                            "inline media could not be measured; alt text stands in"
323                        );
324                        UNREADABLE_ROWS
325                    }
326                },
327            };
328            (Arc::new(block), req.anchor_line, rows)
329        })
330        .collect()
331}
332
333impl Editor {
334    /// IM.7a — `(line_height_px, pane_width_px)` for the active pane, if a
335    /// peer that draws images has published its cell metrics.
336    ///
337    /// The pane's width comes from the column count it already publishes,
338    /// multiplied by the column advance — which is why the metric channel is
339    /// two scalars rather than a per-pane pixel rectangle.
340    fn media_geometry(&self) -> Option<MediaGeometry> {
341        let m = self.cell_metrics?;
342        let cols = match self.pane_tree.active().viewport_width {
343            0 => u32::from(self.terminal_width?),
344            w => w,
345        };
346        let pane_width_px = cols as f32 * m.col_px;
347        (pane_width_px > 0.0).then_some((m.row_px, pane_width_px))
348    }
349}
350
351/// Namespace prefix for inline-media [`ProviderId`]s, with the buffer's id
352/// mixed into the low bits — the same scheme the diff overlay uses, and for the
353/// same reason: `:plugin-unload` has to be able to unregister without holding
354/// the provider.
355const MEDIA_PROVIDER_NAMESPACE: u64 = 0xED1A_0000_0000_0000;
356
357/// The [`lattice_cells::ProviderId`] of `buffer_id`'s media provider.
358pub fn media_virtual_row_provider_id(buffer_id: BufferId) -> lattice_cells::ProviderId {
359    MEDIA_PROVIDER_NAMESPACE | u64::from(buffer_id.0)
360}
361
362impl Editor {
363    /// Register `buffer_id`'s [`MediaVirtualRowProvider`], once.
364    ///
365    /// IM.7 shipped the producer pump and the provider and never connected
366    /// them: nothing outside the provider's own tests ever constructed one, so
367    /// the cache the pump fills had no reader and no image has ever reached a
368    /// frame. This is that wire.
369    ///
370    /// Per buffer, not global, because the registry is buffer-scoped and the
371    /// provider reads one buffer's cache. Called from the pump, which is
372    /// already version- and registry-gated, so this runs on the ticks where a
373    /// buffer's blocks are (re)produced rather than every tick.
374    ///
375    /// The width is the pane's, resolved once and then held: it only decides
376    /// where the alt-text caption centres, so a stale value after a resize
377    /// mis-centres a caption until the next produce — not worth a provider
378    /// rebuild on every resize.
379    fn ensure_media_virtual_rows(&mut self, buffer_id: BufferId) {
380        if self.wasm_media.registered.contains(&buffer_id) {
381            return;
382        }
383        // Prune buffers that have since been closed. Cheap here (this runs
384        // once per buffer that gains media) and it keeps a long session from
385        // accumulating providers for buffers nobody can look at.
386        let closed: Vec<BufferId> = self
387            .wasm_media
388            .registered
389            .iter()
390            .copied()
391            .filter(|b| !self.buffers.contains(*b))
392            .collect();
393        for buffer in closed {
394            self.virtual_row_providers
395                .unregister(buffer, media_virtual_row_provider_id(buffer));
396            self.wasm_media.registered.remove(&buffer);
397        }
398
399        let pane = self.pane_tree.active();
400        let width_cols = match (pane.viewport_width, self.terminal_width) {
401            (w, _) if w > 0 => w as usize,
402            (_, Some(w)) if w > 0 => w as usize,
403            _ => 80,
404        };
405        let provider: Arc<dyn lattice_cells::VirtualRowProvider> =
406            Arc::new(MediaVirtualRowProvider::new(
407                media_virtual_row_provider_id(buffer_id),
408                buffer_id,
409                self.wasm_media.cache.clone(),
410                self.wasm_media.generation.clone(),
411                width_cols,
412            ));
413        self.virtual_row_providers.register(buffer_id, provider);
414        self.wasm_media.registered.insert(buffer_id);
415    }
416}
417
418/// IM.7 — the virtual-row provider that turns cached media blocks into rows.
419///
420/// Reads only the cache the pump above fills; `collect` never blocks and never
421/// touches WASM, per the provider contract. `version` is the paint generation,
422/// so a landed produce invalidates the worker's fingerprint and the rows are
423/// rebuilt without a keystroke.
424#[derive(Debug)]
425pub struct MediaVirtualRowProvider {
426    id: lattice_cells::virtual_rows::ProviderId,
427    buffer_id: BufferId,
428    cache: PerBufferCache<WasmMediaCache>,
429    generation: Arc<AtomicU64>,
430    /// Pane width in columns, for centring the alt text.
431    width_cols: usize,
432}
433
434impl MediaVirtualRowProvider {
435    pub fn new(
436        id: lattice_cells::virtual_rows::ProviderId,
437        buffer_id: BufferId,
438        cache: PerBufferCache<WasmMediaCache>,
439        generation: Arc<AtomicU64>,
440        width_cols: usize,
441    ) -> Self {
442        Self {
443            id,
444            buffer_id,
445            cache,
446            generation,
447            width_cols,
448        }
449    }
450}
451
452impl lattice_cells::virtual_rows::VirtualRowProvider for MediaVirtualRowProvider {
453    fn id(&self) -> lattice_cells::virtual_rows::ProviderId {
454        self.id
455    }
456
457    fn version(&self) -> u64 {
458        self.generation.load(Ordering::Relaxed)
459    }
460
461    fn collect(&self) -> Vec<lattice_cells::virtual_rows::VirtualRow> {
462        let Some(cached) = self.cache.get_for(self.buffer_id) else {
463            return Vec::new();
464        };
465        cached
466            .blocks
467            .iter()
468            .flat_map(|(block, anchor, rows)| {
469                lattice_cells::media::media_block_rows(
470                    block.clone(),
471                    *anchor,
472                    *rows,
473                    self.width_cols,
474                )
475            })
476            .collect()
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    use lattice_cells::virtual_rows::VirtualRowProvider;
484
485    fn provider(
486        blocks: Vec<(Arc<lattice_cells::MediaBlock>, u32, u16)>,
487    ) -> MediaVirtualRowProvider {
488        let cache: PerBufferCache<WasmMediaCache> = Default::default();
489        cache.insert_for(
490            BufferId(1),
491            WasmMediaCache {
492                document_version: 1,
493                geometry: None,
494                blocks,
495            },
496        );
497        MediaVirtualRowProvider::new(99, BufferId(1), cache, Arc::new(AtomicU64::new(7)), 40)
498    }
499
500    /// One block of N rows becomes N virtual rows anchored to its line, each
501    /// carrying the shared descriptor.
502    #[test]
503    fn a_cached_block_becomes_its_reserved_rows() {
504        let block = Arc::new(lattice_cells::MediaBlock::new("/x.png", None));
505        let p = provider(vec![(block.clone(), 4, 5)]);
506        let rows = p.collect();
507        assert_eq!(rows.len(), 5);
508        assert!(rows.iter().all(|r| r.anchor_line == 4
509            && r.kind == lattice_cells::VirtualRowKind::MediaBlock
510            && r.media.is_some()));
511    }
512
513    /// A buffer with nothing cached emits nothing — the overwhelmingly common
514    /// case, and it must not allocate or block.
515    #[test]
516    fn an_uncached_buffer_emits_no_rows() {
517        let cache: PerBufferCache<WasmMediaCache> = Default::default();
518        let p =
519            MediaVirtualRowProvider::new(99, BufferId(2), cache, Arc::new(AtomicU64::new(0)), 40);
520        assert!(p.collect().is_empty());
521    }
522
523    /// `version` tracks the paint generation, so a produce that lands with no
524    /// keystroke in flight still invalidates the worker's fingerprint and the
525    /// rows get rebuilt.
526    #[test]
527    fn version_follows_the_paint_generation() {
528        let generation = Arc::new(AtomicU64::new(3));
529        let p = MediaVirtualRowProvider::new(
530            1,
531            BufferId(1),
532            Default::default(),
533            generation.clone(),
534            40,
535        );
536        assert_eq!(p.version(), 3);
537        generation.fetch_add(1, Ordering::Relaxed);
538        assert_eq!(p.version(), 4, "a landed produce moves the fingerprint");
539    }
540}