Skip to main content

rich_micro/
render.rs

1//! Placeholder cells and the renderer seam (#577, #578).
2//!
3//! # The placeholder representation
4//!
5//! An asset in a [`Text`] is **exactly `cols` cells of ordinary text**, under
6//! a span whose style carries [`MICRO_META_KEY`] in its metadata (the way
7//! core's regions tag segments with `rich.region`). Its value is a list:
8//! `[name, cols, rows, id]`, where `id` is unique per occurrence, so two
9//! adjacent copies of one asset stay distinguishable (see [`MicroMeta`]).
10//!
11//! That is the asset's first row. An asset two rows high (#711) also has a
12//! second row, which [`MicroRows`](crate::MicroRows) reserves below the line
13//! after layout: [`PAD_CELL`] blanks at the same columns, tagged
14//! `[name, cols, rows, id, 1]`, the fifth item being the row.
15//!
16//! The cells hold the asset's **fallback**: its emoji, or its text, padded to
17//! `cols` with [`PAD_CELL`] (U+2800, a blank that is not whitespace, so
18//! wrapping never splits an asset); failing both, its alt text cut to fit.
19//! Printed as is — to a pipe, a log, an export — that fallback is what
20//! appears. Metadata never renders, so the bytes are plain text.
21//!
22//! Because the cells are ordinary text, every layout (measure, wrap, crop,
23//! tables, panels) sizes an asset at exactly `cols`, whatever draws it.
24//!
25//! # Drawing images
26//!
27//! A [`MicroRenderer`] turns one complete placement into segments that take
28//! the same `cols` cells, for example a zero-width control escape followed
29//! by placeholder cells. [`MicroView`] wraps any renderable, finds each
30//! placement in its rendered segments, and offers it to its renderers in
31//! order; the first answer of the right width wins, and without one the
32//! fallback cells stay. A placement that was cut (cropped, or split across
33//! lines) is never offered: its fallback stays. A two-row asset is offered
34//! only when both its rows are there, the second directly below the first
35//! at the same column; its rows are offered top first, and the first
36//! renderer that draws both at the right width draws it. Otherwise its
37//! fallback stays, so an image never spills onto a line that did not make
38//! room for it. [`FallbackRenderer`] redraws the fallback; the terminal
39//! protocols (Kitty, iTerm2, Sixel) and block cells plug in here through
40//! [`crate::graphics::MicroGraphics::renderer`].
41
42use std::collections::HashMap;
43use std::sync::Arc;
44use std::sync::atomic::{AtomicU64, Ordering};
45
46use rich::cells::cell_len;
47use rich::measure::Measurement;
48use rich::style::{Meta, MetaValue};
49use rich::{Console, ConsoleOptions, Renderable, Segment, Style, Text};
50
51use crate::model::MicroAsset;
52use crate::registry::MicroRegistry;
53use crate::rows::runs;
54
55/// The style [`Meta`] key that marks a placeholder's cells.
56pub const MICRO_META_KEY: &str = "rich.micro";
57
58/// Pads a fallback to its asset's width: U+2800 BRAILLE PATTERN BLANK, one
59/// cell, blank in every font, and not whitespace, so the asset stays one
60/// word.
61pub const PAD_CELL: char = '\u{2800}';
62
63static NEXT_ID: AtomicU64 = AtomicU64::new(1);
64
65/// The markup pre-pass stands in for tokens with characters from Supplementary
66/// Private Use Area-B while core parses the rest.
67pub(crate) const SENTINEL_BASE: u32 = 0x10_0000;
68pub(crate) const SENTINEL_LAST: u32 = 0x10_FFFD;
69
70pub(crate) fn is_sentinel(c: char) -> bool {
71    (SENTINEL_BASE..=SENTINEL_LAST).contains(&(c as u32))
72}
73
74/// What a placeholder's metadata says: which asset, its size, which
75/// occurrence, and which of its rows these cells are.
76#[derive(Clone, Debug, PartialEq, Eq, Hash)]
77pub struct MicroMeta {
78    pub name: String,
79    pub cols: usize,
80    pub rows: usize,
81    /// Unique per occurrence (per process).
82    pub id: u64,
83    /// Which row of the asset these cells are: 0, the row in the text, or
84    /// 1, the row a two-row asset reserves below it.
85    pub row: usize,
86}
87
88impl MicroMeta {
89    /// A new occurrence of `asset`: its first row.
90    pub fn new(asset: &MicroAsset) -> Self {
91        MicroMeta {
92            name: asset.name().to_string(),
93            cols: asset.cols(),
94            rows: asset.size().rows(),
95            id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
96            row: 0,
97        }
98    }
99
100    /// The same occurrence's row `row`.
101    #[must_use]
102    pub fn with_row(&self, row: usize) -> Self {
103        MicroMeta {
104            row,
105            ..self.clone()
106        }
107    }
108
109    /// As a style metadata value: `[name, cols, rows, id]` for the first
110    /// row, `[name, cols, rows, id, row]` for a row below it.
111    pub fn to_value(&self) -> MetaValue {
112        let mut items = vec![
113            MetaValue::Str(self.name.clone()),
114            MetaValue::Int(self.cols as i64),
115            MetaValue::Int(self.rows as i64),
116            MetaValue::Int(self.id as i64),
117        ];
118        if self.row > 0 {
119            items.push(MetaValue::Int(self.row as i64));
120        }
121        MetaValue::List(items)
122    }
123
124    /// A style holding only this tag.
125    pub fn to_style(&self) -> Style {
126        let mut meta = Meta::new();
127        meta.insert(MICRO_META_KEY, self.to_value());
128        Style::from_meta(meta)
129    }
130
131    /// The tag on `style`, if any.
132    pub fn from_style(style: &Style) -> Option<Self> {
133        let MetaValue::List(items) = style.meta_ref()?.get(MICRO_META_KEY)? else {
134            return None;
135        };
136        let (head, row) = match items.as_slice() {
137            [head @ .., MetaValue::Int(row)] if items.len() == 5 => (head, *row),
138            head if items.len() == 4 => (head, 0),
139            _ => return None,
140        };
141        match head {
142            [
143                MetaValue::Str(name),
144                MetaValue::Int(cols),
145                MetaValue::Int(rows),
146                MetaValue::Int(id),
147            ] if *cols > 0 && *rows > 0 && (0..*rows).contains(&row) => Some(MicroMeta {
148                name: name.clone(),
149                cols: *cols as usize,
150                rows: *rows as usize,
151                id: *id as u64,
152                row: row as usize,
153            }),
154            _ => None,
155        }
156    }
157
158    /// The tag on `segment`, if any. Control segments are never tagged.
159    pub fn from_segment(segment: &Segment) -> Option<Self> {
160        if segment.control {
161            return None;
162        }
163        segment.style.as_ref().and_then(Self::from_style)
164    }
165}
166
167/// Which fallback to prefer where no image is drawn.
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
169pub enum FallbackPreference {
170    /// The emoji, else the text, else the alt text.
171    #[default]
172    Emoji,
173    /// The text, else the alt text: for consoles that cannot show emoji.
174    Text,
175    /// The alt text, cut to fit.
176    Alt,
177}
178
179/// `value` padded with [`PAD_CELL`] to exactly `cols` cells.
180fn pad(value: &str, cols: usize) -> String {
181    let width = cell_len(value);
182    let mut out = value.to_string();
183    out.extend(std::iter::repeat_n(PAD_CELL, cols.saturating_sub(width)));
184    out
185}
186
187/// The alt text cut to `cols` cells, whitespace and controls replaced with
188/// [`PAD_CELL`], padded to exactly `cols`.
189fn alt_cells(alt: &str, cols: usize) -> String {
190    let mut out = String::new();
191    let mut width = 0;
192    for c in alt.chars() {
193        let c = if c.is_whitespace() || c.is_control() || is_sentinel(c) {
194            PAD_CELL
195        } else {
196            c
197        };
198        let w = cell_len(c.encode_utf8(&mut [0; 4]));
199        if width + w > cols {
200            break;
201        }
202        out.push(c);
203        width += w;
204    }
205    pad(&out, cols)
206}
207
208/// The fallback cells for `asset`'s first row: exactly `asset.cols()` cells.
209pub fn fallback_cells(asset: &MicroAsset, preference: FallbackPreference) -> String {
210    let cols = asset.cols();
211    let fallback = asset.fallback();
212    let choice = match preference {
213        FallbackPreference::Emoji => fallback.emoji.as_ref().or(fallback.text.as_ref()),
214        FallbackPreference::Text => fallback.text.as_ref(),
215        FallbackPreference::Alt => None,
216    };
217    match choice {
218        // Checked on the way in, but a manifest may be edited under us.
219        Some(value) if cell_len(value) <= cols && cell_len(value) > 0 => pad(value, cols),
220        _ => alt_cells(asset.alt(), cols),
221    }
222}
223
224/// The fallback cells of `asset`'s row `row`: its [first
225/// row's](fallback_cells), and [`PAD_CELL`] blanks below it. Exactly
226/// `asset.cols()` cells either way.
227pub fn fallback_row(asset: &MicroAsset, preference: FallbackPreference, row: usize) -> String {
228    if row == 0 {
229        fallback_cells(asset, preference)
230    } else {
231        PAD_CELL.to_string().repeat(asset.cols())
232    }
233}
234
235/// One occurrence of `asset` as a [`Text`]: its fallback cells, tagged. A
236/// two-row asset's second row is not in the text:
237/// [`MicroRows`](crate::MicroRows) reserves it below the line after layout.
238pub fn placeholder(asset: &MicroAsset, preference: FallbackPreference) -> Text {
239    placeholder_with(asset, preference, MicroMeta::new(asset))
240}
241
242pub(crate) fn placeholder_with(
243    asset: &MicroAsset,
244    preference: FallbackPreference,
245    meta: MicroMeta,
246) -> Text {
247    let cells = fallback_cells(asset, preference);
248    let mut text = Text::new("");
249    text.append(&cells, Some(meta.to_style().into()));
250    text
251}
252
253/// One complete placement found in rendered output: one row of an asset
254/// ([`MicroMeta::row`]).
255#[derive(Clone, Debug)]
256pub struct Placement<'a> {
257    pub asset: &'a MicroAsset,
258    pub meta: MicroMeta,
259    /// The style of the placeholder's first cell (colours around it, and the
260    /// tag itself).
261    pub style: Option<Style>,
262    /// The fallback text that is there now.
263    pub fallback: String,
264}
265
266/// Draws a placement. Implementations must return segments whose visible
267/// width (control segments count zero) is exactly `placement.meta.cols`, or
268/// `None` to pass. Anything else is ignored and the fallback stays.
269///
270/// A two-row asset is offered one row at a time, top first
271/// (`placement.meta.row` is 0, then 1), and drawn only when the same
272/// renderer draws both: an image drawn at the cursor goes with the last row,
273/// moving the cursor up to the first. The rows are printed on consecutive
274/// lines at the same column.
275///
276/// Return only what the target can show: a renderer is picked per console,
277/// and the console's capabilities are the renderer's to check. Keep the tag
278/// on visible cells ([`Placement::style`]) so later passes can still find
279/// the asset.
280pub trait MicroRenderer: Send + Sync {
281    /// A short name, for diagnostics (`fallback`, `kitty`, ...).
282    fn name(&self) -> &str;
283
284    /// Draw `placement`, or pass.
285    fn render(&self, placement: &Placement<'_>, console: &Console) -> Option<Vec<Segment>>;
286}
287
288impl<T: MicroRenderer + ?Sized> MicroRenderer for Arc<T> {
289    fn name(&self) -> &str {
290        (**self).name()
291    }
292    fn render(&self, placement: &Placement<'_>, console: &Console) -> Option<Vec<Segment>> {
293        (**self).render(placement, console)
294    }
295}
296
297/// Redraws each placement with a chosen fallback: for example
298/// [`FallbackPreference::Text`] on a console that cannot show emoji. Never
299/// emits control sequences.
300#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
301pub struct FallbackRenderer {
302    pub preference: FallbackPreference,
303}
304
305impl FallbackRenderer {
306    pub fn new(preference: FallbackPreference) -> Self {
307        FallbackRenderer { preference }
308    }
309}
310
311impl MicroRenderer for FallbackRenderer {
312    fn name(&self) -> &str {
313        "fallback"
314    }
315
316    fn render(&self, placement: &Placement<'_>, _console: &Console) -> Option<Vec<Segment>> {
317        Some(vec![Segment::new(
318            fallback_row(placement.asset, self.preference, placement.meta.row),
319            placement.style.clone(),
320        )])
321    }
322}
323
324/// Wraps a renderable and offers every complete placement in its output to
325/// `renderers`, in order. Layout is the inner renderable's: this only
326/// replaces cells with cells of the same width.
327#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
328pub struct MicroView<R> {
329    inner: R,
330    registry: Arc<MicroRegistry>,
331    renderers: Vec<Arc<dyn MicroRenderer>>,
332}
333
334impl<R: Renderable> MicroView<R> {
335    pub fn new(inner: R, registry: Arc<MicroRegistry>) -> Self {
336        MicroView {
337            inner,
338            registry,
339            renderers: Vec::new(),
340        }
341    }
342
343    /// Add a renderer after the ones added so far.
344    pub fn renderer(mut self, renderer: Arc<dyn MicroRenderer>) -> Self {
345        self.renderers.push(renderer);
346        self
347    }
348}
349
350impl<R: Renderable> Renderable for MicroView<R> {
351    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
352        let segments = self.inner.rich_render(console, options);
353        if self.renderers.is_empty() {
354            return segments;
355        }
356        rewrite(segments, &self.registry, &self.renderers, console)
357    }
358
359    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
360        self.inner.measure(console, options)
361    }
362}
363
364/// Find each run of segments tagged with one occurrence and, when the run is
365/// the asset's whole width (and, for a two-row asset, its second row is
366/// right below it), offer it to `renderers`.
367pub fn rewrite(
368    segments: Vec<Segment>,
369    registry: &MicroRegistry,
370    renderers: &[Arc<dyn MicroRenderer>],
371    console: &Console,
372) -> Vec<Segment> {
373    rewrite_rows(segments, registry, renderers, console, false)
374}
375
376/// The width of `drawn`, control segments counting zero.
377fn width_of(drawn: &[Segment]) -> usize {
378    drawn.iter().map(Segment::cell_length).sum()
379}
380
381/// [`rewrite`]; with `each_row`, every row of a two-row asset is offered on
382/// its own, for one line of a frame, where the rows around it are not known
383/// (renderers then only swap cells, never draw at the cursor).
384pub(crate) fn rewrite_rows(
385    segments: Vec<Segment>,
386    registry: &MicroRegistry,
387    renderers: &[Arc<dyn MicroRenderer>],
388    console: &Console,
389    each_row: bool,
390) -> Vec<Segment> {
391    let found = runs(&segments);
392    if found.is_empty() {
393        return segments;
394    }
395    // Runs by occurrence and row, for finding a first row's second.
396    let mut by_row: HashMap<(u64, usize), Vec<usize>> = HashMap::new();
397    for (index, run) in found.iter().enumerate() {
398        by_row
399            .entry((run.meta.id, run.meta.row))
400            .or_default()
401            .push(index);
402    }
403    let placement = |index: usize, asset| {
404        let run = &found[index];
405        Placement {
406            asset,
407            meta: run.meta.clone(),
408            style: segments[run.start].style.clone(),
409            fallback: segments[run.start..run.end]
410                .iter()
411                .map(|s| s.text.as_str())
412                .collect(),
413        }
414    };
415    let mut drawn: Vec<Option<Vec<Segment>>> = vec![None; found.len()];
416    for (index, run) in found.iter().enumerate() {
417        let meta = &run.meta;
418        let asset = registry
419            .resolve(&meta.name)
420            .filter(|asset| asset.cols() == meta.cols && asset.rows() == meta.rows);
421        let Some(asset) = asset.filter(|_| run.width == meta.cols) else {
422            continue;
423        };
424        if meta.rows == 1 || each_row {
425            let placement = placement(index, asset);
426            drawn[index] = renderers.iter().find_map(|renderer| {
427                renderer
428                    .render(&placement, console)
429                    .filter(|cells| width_of(cells) == meta.cols)
430            });
431            continue;
432        }
433        if meta.row != 0 {
434            // A lower row: drawn with the first, or not at all.
435            continue;
436        }
437        // Every row, each right below the one before, at the same column.
438        let mut group = vec![index];
439        for row in 1..meta.rows {
440            let below = by_row.get(&(meta.id, row)).and_then(|candidates| {
441                candidates.iter().copied().find(|&other| {
442                    let other = &found[other];
443                    other.line == run.line + row
444                        && other.column == run.column
445                        && other.width == meta.cols
446                })
447            });
448            match below {
449                Some(other) => group.push(other),
450                None => break,
451            }
452        }
453        if group.len() != meta.rows {
454            continue;
455        }
456        let placements: Vec<Placement<'_>> = group.iter().map(|&i| placement(i, asset)).collect();
457        let rows = renderers.iter().find_map(|renderer| {
458            placements
459                .iter()
460                .map(|placement| {
461                    renderer
462                        .render(placement, console)
463                        .filter(|cells| width_of(cells) == meta.cols)
464                })
465                .collect::<Option<Vec<_>>>()
466        });
467        if let Some(rows) = rows {
468            for (&other, cells) in group.iter().zip(rows) {
469                drawn[other] = Some(cells);
470            }
471        }
472    }
473    let mut out = Vec::with_capacity(segments.len());
474    let mut rest = segments.into_iter();
475    let mut at = 0;
476    for (run, cells) in found.iter().zip(drawn) {
477        out.extend(rest.by_ref().take(run.start - at));
478        let original: Vec<Segment> = rest.by_ref().take(run.end - run.start).collect();
479        match cells {
480            Some(cells) => out.extend(cells),
481            None => out.extend(original),
482        }
483        at = run.end;
484    }
485    out.extend(rest);
486    out
487}