Skip to main content

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}