lattice_cells/media.rs
1//! IM.3 — the descriptor for an inline media block.
2//!
3//! An image (later: a LaTeX fragment, a chart) drawn where it appears in the
4//! buffer. Design: `docs/dev/architecture/inline-media.md`.
5//!
6//! ## The shape is `BrandingBlock`'s, deliberately
7//!
8//! A media block is a contiguous group of virtual rows tagged
9//! [`VirtualRowKind::MediaBlock`](crate::virtual_rows::VirtualRowKind::MediaBlock),
10//! carrying ordinary cells that spell out the alt text. The GPUI peer
11//! intercepts the group and paints an image over the region instead; the TUI
12//! peer paints the cells it was given and needs **no code at all**.
13//!
14//! That is exactly how the dashboard's branding block already works, and it
15//! is why the TUI stays a first-class peer for a feature it cannot render:
16//! the fallback is not a special case bolted on afterwards, it is what the
17//! rows literally contain.
18//!
19//! ## Why the descriptor names a path and not bytes
20//!
21//! Three reasons, and none of them is size alone:
22//!
23//! - **The UI thread must not decode.** A descriptor is cheap to build and
24//! cheap to publish; the read + decode happens off-thread (IM.4) and lands
25//! through the inbound primitive. Handing around bytes invites decoding
26//! wherever they are needed.
27//! - **Capability gating stays host-side.** A plugin (IM.6) names a file and
28//! the *host* decides whether that plugin may read it. If the guest sent
29//! pixels, it could put anything on screen regardless of its `fs:read`
30//! grant.
31//! - **The cache has a key.** `(path, mtime, target size)` is a cache key;
32//! an opaque buffer is not.
33//!
34//! ## Why `rows` is authoritative and `intrinsic` is not
35//!
36//! `rows` — the reserved display-row count — is what the core's scroll
37//! arithmetic uses, and both peers agree on it. `intrinsic` is the image's
38//! natural pixel size, known only once something has read the file header,
39//! and only meaningful to a peer that draws pixels.
40//!
41//! Keeping the row count authoritative is what lets a document have the same
42//! number of display rows on both peers while only one shows a picture. It is
43//! also what stops a decoding image from reflowing the buffer: the block
44//! reserves its space before its pixels exist.
45
46use std::path::PathBuf;
47use std::sync::Arc;
48
49/// Where a block's pixels come from.
50///
51/// A single variant today. It is an enum rather than a bare `PathBuf` because
52/// the producers already in view — an org `[[http://…]]` link, a generated
53/// chart, a rendered LaTeX fragment — are not files on disk, and widening a
54/// struct field later is a breaking change for every construction site.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum MediaSource {
57 /// A file on disk, already resolved to an absolute path by the producer.
58 Path(PathBuf),
59}
60
61/// How the intrinsic size maps into the block's box.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum MediaFit {
64 /// Scale down to fit the box, preserving aspect ratio; never scale up.
65 /// The default because upscaling a small diagram is worse than showing
66 /// it small.
67 #[default]
68 Contain,
69 /// Scale to the box's width, preserving aspect ratio, and let the row
70 /// count follow from the result.
71 Width,
72}
73
74/// An inline media block: what to draw, how big, and what to say instead.
75#[derive(Debug, Clone, PartialEq)]
76pub struct MediaBlock {
77 pub source: MediaSource,
78 /// Natural size in pixels, once a header read has established it.
79 /// `None` until then — and a block is perfectly usable meanwhile, which
80 /// is the point: it reserves `rows` either way.
81 pub intrinsic: Option<(u32, u32)>,
82 pub fit: MediaFit,
83 /// What the TUI shows and what a screen reader reads. Never empty in
84 /// practice — [`MediaBlock::new`] falls back to the file name — because
85 /// a blank box tells the user nothing about what they are missing.
86 pub alt: String,
87 /// IM.5: the block's drawn height in **line-heights**, once geometry is
88 /// known — `None` until then.
89 ///
90 /// This is the number that makes the block genuinely variable-height
91 /// rather than snapped to whole rows, and it is deliberately allowed to
92 /// differ from the reserved row count. A 3.4-line-height image reserves
93 /// 4 rows and draws 3.4 of them.
94 ///
95 /// The HOST populates it, in the media pump's sizing pass, from the
96 /// file's header and the drawing peer's published cell metrics
97 /// (`inline-media.md` §7.1). This comment used to say the renderer did,
98 /// "through `RowWeights`" — which was the intent and never the code: no
99 /// renderer ever wrote it, so it stayed `None` on every block and the
100 /// GPUI peer skipped each one as unresolved.
101 ///
102 /// `None` still means "not measured", and a peer that draws pixels must
103 /// treat it that way: on a peer that published no metrics, and for a file
104 /// whose header could not be read, it stays `None` and the alt text is
105 /// the rendering.
106 pub height_lh: Option<f32>,
107}
108
109impl MediaBlock {
110 /// A block for `path`, with `alt` defaulting to the file's name when the
111 /// producer has nothing better.
112 pub fn new(path: impl Into<PathBuf>, alt: Option<String>) -> Self {
113 let path = path.into();
114 let alt = alt.filter(|a| !a.trim().is_empty()).unwrap_or_else(|| {
115 path.file_name()
116 .map(|n| n.to_string_lossy().into_owned())
117 .unwrap_or_else(|| "image".to_string())
118 });
119 Self {
120 source: MediaSource::Path(path),
121 intrinsic: None,
122 fit: MediaFit::default(),
123 alt,
124 height_lh: None,
125 }
126 }
127
128 /// The path, when the source is one.
129 pub fn path(&self) -> Option<&std::path::Path> {
130 match &self.source {
131 MediaSource::Path(p) => Some(p.as_path()),
132 }
133 }
134
135 /// Height in **line-heights** — the drawn height once geometry is known,
136 /// else the reserved row count.
137 ///
138 /// The fallback is what keeps a block harmless before its size is
139 /// resolved and on any peer that does not draw media: it then costs the
140 /// scroll walks exactly what its rows already cost, so introducing a
141 /// block cannot move an existing scroll position.
142 pub fn line_heights(&self, rows: u16) -> f32 {
143 self.height_lh.unwrap_or(rows as f32)
144 }
145}
146
147/// Shared handle — blocks are cloned into every row of their group.
148pub type MediaBlockRef = Arc<MediaBlock>;
149
150/// Build the virtual rows for a media block anchored below `anchor_line`.
151///
152/// The rows carry the alt text as ordinary cells, which is the whole of the
153/// TUI's rendering: it paints what it is given and needs no media code. The
154/// GPUI peer recognises the `kind` and paints an image over the region
155/// instead — the `BrandingBlock` treatment.
156///
157/// `rows` is clamped to at least 1: a zero-row block would be invisible on
158/// both peers while still occupying a slot in the matrix, which is a bug that
159/// presents as "the image silently did nothing".
160pub fn media_block_rows(
161 block: MediaBlockRef,
162 anchor_line: u32,
163 rows: u16,
164 width_cols: usize,
165) -> Vec<crate::virtual_rows::VirtualRow> {
166 use crate::virtual_rows::{AnchorPosition, VirtualRow, VirtualRowKind};
167 let rows = rows.max(1);
168 // The alt text goes on the block's middle row so it reads as a caption in
169 // a box rather than a line of text with space under it.
170 let label_row = (rows / 2) as usize;
171 (0..rows as usize)
172 .map(|i| {
173 let text = if i == label_row {
174 centred(&block.alt, width_cols)
175 } else {
176 String::new()
177 };
178 VirtualRow {
179 anchor_line,
180 position: AnchorPosition::Below,
181 cells: text
182 .chars()
183 .map(|c| crate::cell::Cell::new(c as u32, 0, 0, 0))
184 .collect::<Vec<_>>()
185 .into(),
186 height: 1,
187 kind: VirtualRowKind::MediaBlock,
188 bg: None,
189 scales: None,
190 // No gutter number: the block is not a source line, and a
191 // repeated line number down the side of an image reads as
192 // content that is not there.
193 gutter_line: None,
194 gutter_fg: None,
195 media: Some(block.clone()),
196 }
197 })
198 .collect()
199}
200
201/// Centre `text` in `width` columns, truncating rather than overflowing.
202fn centred(text: &str, width: usize) -> String {
203 let n = text.chars().count();
204 if n >= width {
205 return text.chars().take(width).collect();
206 }
207 let pad = (width - n) / 2;
208 let mut out = " ".repeat(pad);
209 out.push_str(text);
210 out
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn alt_falls_back_to_the_file_name() {
219 let b = MediaBlock::new("/tmp/docs/diagram.png", None);
220 assert_eq!(b.alt, "diagram.png");
221 assert_eq!(
222 b.path().unwrap(),
223 std::path::Path::new("/tmp/docs/diagram.png")
224 );
225 }
226
227 /// A blank alt is the same as no alt: an empty box tells the user nothing
228 /// about what they cannot see.
229 #[test]
230 fn a_blank_alt_is_refused_in_favour_of_the_file_name() {
231 assert_eq!(
232 MediaBlock::new("/a/b/c.png", Some(" ".into())).alt,
233 "c.png"
234 );
235 assert_eq!(
236 MediaBlock::new("/a/b/c.png", Some(String::new())).alt,
237 "c.png"
238 );
239 assert_eq!(
240 MediaBlock::new("/a/b/c.png", Some("a wiring diagram".into())).alt,
241 "a wiring diagram"
242 );
243 }
244
245 /// Before IM.5 a block costs the scroll walks exactly what its rows cost,
246 /// so introducing blocks cannot move any existing scroll position.
247 #[test]
248 fn a_block_costs_its_reserved_rows_until_natural_sizing_lands() {
249 let b = MediaBlock::new("/x.png", None);
250 for rows in [1u16, 4, 12] {
251 assert_eq!(b.line_heights(rows), rows as f32);
252 }
253 }
254
255 fn row_text(r: &crate::virtual_rows::VirtualRow) -> String {
256 r.cells
257 .iter()
258 .map(|c| char::from_u32(c.codepoint).unwrap_or(' '))
259 .collect()
260 }
261
262 /// The block reserves exactly the rows it was asked for, and every one of
263 /// them carries the shared descriptor — so a renderer meeting any row can
264 /// paint the whole block without reassembling it.
265 #[test]
266 fn a_block_reserves_its_rows_and_every_row_knows_the_block() {
267 let block = Arc::new(MediaBlock::new("/tmp/diagram.png", None));
268 let rows = media_block_rows(block.clone(), 7, 5, 40);
269
270 assert_eq!(rows.len(), 5, "five rows reserved");
271 assert!(
272 rows.iter().all(|r| r.anchor_line == 7
273 && r.kind == crate::virtual_rows::VirtualRowKind::MediaBlock
274 && r.media.as_deref() == Some(&*block)),
275 "every row anchors to the source line and carries the descriptor"
276 );
277 }
278
279 /// The TUI's entire rendering of a media block is the cells it is handed.
280 /// This is what keeps it a first-class peer for a feature it cannot draw:
281 /// the fallback is not a special case, it is the row content.
282 #[test]
283 fn the_alt_text_is_in_the_cells_so_the_tui_needs_no_media_code() {
284 let block = Arc::new(MediaBlock::new("/tmp/x.png", Some("wiring diagram".into())));
285 let rows = media_block_rows(block, 0, 3, 30);
286
287 let joined: Vec<String> = rows.iter().map(row_text).collect();
288 assert!(
289 joined.iter().any(|t| t.contains("wiring diagram")),
290 "alt text is painted as ordinary cells: {joined:?}"
291 );
292 // Centred on the middle row, so it reads as a caption in a box.
293 assert!(
294 joined[1].contains("wiring diagram"),
295 "on the middle row, got {joined:?}"
296 );
297 assert!(joined[0].trim().is_empty() && joined[2].trim().is_empty());
298 }
299
300 /// A zero-row block would occupy a matrix slot while being invisible on
301 /// both peers — a bug that presents as "the image silently did nothing".
302 #[test]
303 fn a_zero_row_block_is_clamped_rather_than_vanishing() {
304 let block = Arc::new(MediaBlock::new("/tmp/x.png", None));
305 assert_eq!(media_block_rows(block, 0, 0, 20).len(), 1);
306 }
307
308 /// The TUI gets a uniform map, so introducing media cannot move its
309 /// scroll positions — it paints the alt-text cells in the reserved rows,
310 /// and in a terminal those rows really are one line tall.
311 #[test]
312 fn a_non_drawing_renderer_gets_a_uniform_weight_map() {
313 use crate::virtual_rows::{VirtualRowMatrix, VirtualRowVersion};
314 let mut block = MediaBlock::new("/x.png", None);
315 block.height_lh = Some(4.25);
316 let rows = media_block_rows(Arc::new(block), 3, 5, 40);
317 let m = VirtualRowMatrix::build(rows, 20, VirtualRowVersion::default());
318
319 assert!(
320 m.media_row_weights(false).is_uniform(),
321 "a renderer that draws no pixels charges nothing extra"
322 );
323 }
324
325 /// A drawing renderer charges the block's DRAWN height, which is
326 /// fractional and differs from the rows it reserved — that is what makes
327 /// the block variable-height rather than snapped.
328 #[test]
329 fn a_drawing_renderer_charges_the_blocks_drawn_height() {
330 use crate::virtual_rows::{VirtualRowMatrix, VirtualRowVersion};
331 let mut block = MediaBlock::new("/x.png", None);
332 block.height_lh = Some(4.25);
333 let rows = media_block_rows(Arc::new(block), 3, 5, 40);
334 let m = VirtualRowMatrix::build(rows, 20, VirtualRowVersion::default());
335
336 let w = m.media_row_weights(true);
337 assert!(!w.is_uniform());
338 // The anchor line costs its own row plus the drawn height, NOT the
339 // five rows the block reserved.
340 assert!(
341 (w.cost(3, 6) - 5.25).abs() < 0.001,
342 "expected 1 + 4.25, got {}",
343 w.cost(3, 6)
344 );
345 assert_eq!(w.cost(4, 1), 1.0, "neighbouring lines are untouched");
346 }
347
348 /// Before its size is known a block costs its RESERVED rows, which is
349 /// what stops a resolving image from reflowing the document under the
350 /// reader.
351 #[test]
352 fn an_unresolved_block_costs_its_reserved_rows() {
353 use crate::virtual_rows::{VirtualRowMatrix, VirtualRowVersion};
354 let block = MediaBlock::new("/x.png", None); // height_lh: None
355 let rows = media_block_rows(Arc::new(block), 2, 6, 40);
356 let m = VirtualRowMatrix::build(rows, 20, VirtualRowVersion::default());
357
358 let w = m.media_row_weights(true);
359 assert!(
360 (w.cost(2, 7) - 7.0).abs() < 0.001,
361 "1 + 6 reserved rows, got {}",
362 w.cost(2, 7)
363 );
364 }
365
366 /// Alt text longer than the pane truncates rather than overflowing the
367 /// row, which would corrupt the cell grid.
368 #[test]
369 fn long_alt_text_truncates_to_the_width() {
370 let block = Arc::new(MediaBlock::new("/x.png", Some("a".repeat(80))));
371 let rows = media_block_rows(block, 0, 1, 20);
372 assert_eq!(row_text(&rows[0]).chars().count(), 20);
373 }
374}