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}