Skip to main content

leaf_raster/
text.rs

1//! Shaping oversized heading text into an RGBA frame, with the editing UI —
2//! caret and selection — painted into the pixels.
3//!
4//! The frame is drawn over a transparent background, so whatever surface it
5//! lands on shows through around the glyphs. The caret and selection have to be
6//! *in* the raster rather than composited over it because the terminal — the
7//! first consumer — cannot draw cells over a graphics-protocol image at all:
8//! carrying its own editing UI is what lets a rasterized heading stay editable
9//! instead of collapsing back to plain text the moment the caret enters it.
10//!
11//! One layout answers everything. Rasterizing, caret placement, selection
12//! rectangles, and hit-testing all read the same shaped [`Buffer`], cached per
13//! [`HeadingSpec`], so a click cannot be answered by a different layout than
14//! the one on screen — and so caret motion and pointer sweeps (which arrive
15//! dozens of times a second) reuse the shaping instead of repeating it.
16
17use std::collections::HashMap;
18
19use cosmic_text::{
20    Attrs, Buffer, Color, Cursor, FontSystem, Metrics, Shaping, SwashCache, Weight, Wrap,
21};
22
23/// Everything a heading's *geometry* depends on: the text, the level (which
24/// picks the type scale), and the pixel box the raster fills. Deliberately free
25/// of colors and caret state so the same spec drives rasterization,
26/// hit-testing, and the fits check — a click must be answered by exactly the
27/// layout that was drawn.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct HeadingSpec<'a> {
30    pub text: &'a str,
31    /// Heading level, 1 or 2 — the only levels big enough to rasterize.
32    pub level: u8,
33    pub width_px: u32,
34    pub height_px: u32,
35}
36
37impl HeadingSpec<'_> {
38    /// One layout line filling the whole box: the line height *is* the box
39    /// height, and the font size is the fraction of it that optically balances
40    /// each level (an H1 carries more of its block than an H2 does).
41    fn metrics(&self) -> Metrics {
42        let line_height = self.height_px.max(1) as f32;
43        let font_size = if self.level == 1 {
44            line_height * 0.72
45        } else {
46            line_height * 0.68
47        };
48        Metrics::new(font_size, line_height)
49    }
50
51    fn key(&self) -> LayoutKey {
52        LayoutKey {
53            text: self.text.to_owned(),
54            level: self.level,
55            width: self.width_px,
56            height: self.height_px,
57        }
58    }
59}
60
61/// The editing UI to paint into a raster. Offsets are bytes into the spec's
62/// `text`, on `char` boundaries; anything out of range or misaligned is dropped
63/// rather than panicking, since a stale frontend offset is not worth refusing
64/// to draw the heading over.
65#[derive(Clone, Copy, Debug, Default, PartialEq)]
66pub struct EditingUi {
67    /// Byte offset the caret bar is drawn at, or `None` for no caret.
68    pub caret: Option<usize>,
69    /// Selected byte range `(start, end)`, or `None` for no selection.
70    pub selection: Option<(usize, usize)>,
71    /// The fill behind selected glyphs. The terminal's selection is reverse
72    /// video, so its caller passes the heading's own ink here…
73    pub selection_bg: (u8, u8, u8),
74    /// …and something that reads on that fill here, for the glyphs on it (and
75    /// for the caret whenever it stands inside the selection, where a bar in
76    /// the ink would sink into a fill of the same color).
77    pub selection_fg: (u8, u8, u8),
78}
79
80/// What a shaped layout depends on — [`HeadingSpec`] by value, for the cache.
81#[derive(Clone, PartialEq, Eq, Hash)]
82struct LayoutKey {
83    text: String,
84    level: u8,
85    width: u32,
86    height: u32,
87}
88
89/// How many shaped layouts to hold before the cache is emptied. One entry per
90/// distinct on-screen heading is the steady state (editing state is *not* in
91/// the key), so the cap only bounds a session that scrolls through many.
92const LAYOUT_CACHE_MAX: usize = 16;
93
94/// The shaping and glyph-raster state — a font database, a glyph cache, and
95/// the per-heading layout cache — shared across every raster so fonts load
96/// once per session and a heading shapes once per edit, not once per frame.
97pub struct Rasterizer {
98    fonts: FontSystem,
99    swash: SwashCache,
100    layouts: HashMap<LayoutKey, Buffer>,
101}
102
103impl Default for Rasterizer {
104    fn default() -> Self {
105        Rasterizer {
106            fonts: FontSystem::new(),
107            swash: SwashCache::new(),
108            layouts: HashMap::new(),
109        }
110    }
111}
112
113impl Rasterizer {
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Rasterize a heading to an RGBA frame: bold glyphs in `ink` over a
119    /// transparent ground, the selection's fill and re-inked glyphs under and
120    /// among them, and the caret bar on top.
121    pub fn heading(
122        &mut self,
123        spec: &HeadingSpec,
124        ink: (u8, u8, u8),
125        ui: &EditingUi,
126    ) -> image::RgbaImage {
127        let width = spec.width_px.max(1);
128        let height = spec.height_px.max(1);
129        let selection = selection_range(ui, spec.text);
130        self.shape(spec);
131        // Split borrows: the draw below needs the cached buffer, the font
132        // system, and the glyph cache all at once.
133        let Rasterizer {
134            fonts,
135            swash,
136            layouts,
137        } = self;
138        let buffer = layouts.get_mut(&spec.key()).expect("just shaped");
139        let mut pixels = image::RgbaImage::new(width, height);
140
141        // The selection's fill first, so the glyphs land on top of it. The
142        // rectangles are kept: they are also what decides which glyph pixels
143        // get the selection ink below.
144        let mut sel_rects: Vec<(i32, i32, i32, i32)> = Vec::new();
145        if let Some((s, e)) = selection {
146            let (cs, ce) = (Cursor::new(0, s), Cursor::new(0, e));
147            for run in buffer.layout_runs() {
148                let top = run.line_top.max(0.0) as i32;
149                let bottom = top + run.line_height.ceil() as i32;
150                for (x, w) in run.highlight(cs, ce) {
151                    sel_rects.push((x as i32, top, x as i32 + w.ceil() as i32, bottom));
152                }
153            }
154            let (br, bg, bb) = ui.selection_bg;
155            for &(x0, y0, x1, y1) in &sel_rects {
156                blend_rect(
157                    &mut pixels,
158                    x0,
159                    y0,
160                    (x1 - x0) as u32,
161                    (y1 - y0) as u32,
162                    [br, bg, bb, 255],
163                );
164            }
165        }
166
167        // The glyphs, composited src-over so their antialiased edges read
168        // correctly both on the transparent ground and on the selection fill.
169        // A glyph pixel inside a selection rectangle swaps the ink for the
170        // selection's — by clipping against the fill rather than by re-shaping
171        // the text in colored spans, which would split the shaping runs at the
172        // selection boundary and lay the glyphs out differently than the
173        // hit-test's single run. Only pixels carrying the ink are swapped, so
174        // color glyphs (an emoji in a title) keep their own pixels, as they do
175        // in every other selection.
176        let (ir, ig, ib) = ink;
177        let (sr, sg, sb) = ui.selection_fg;
178        buffer.draw(fonts, swash, Color::rgb(ir, ig, ib), |x, y, w, h, c| {
179            let mut px = [c.r(), c.g(), c.b(), c.a()];
180            if !sel_rects.is_empty()
181                && px[..3] == [ir, ig, ib]
182                && sel_rects
183                    .iter()
184                    .any(|&(x0, y0, x1, y1)| x >= x0 && x < x1 && y >= y0 && y < y1)
185            {
186                (px[0], px[1], px[2]) = (sr, sg, sb);
187            }
188            blend_rect(&mut pixels, x, y, w, h, px);
189        });
190
191        // The caret bar last — over the glyph it sits against, like every
192        // other leaf frontend draws it. Solid rather than blinking: a
193        // graphics-protocol image is retransmitted whole on every change, and
194        // a blink is not worth two frames a second of that. Inside the
195        // selection the bar takes the selection ink, since the fill it stands
196        // on *is* the caret's usual color.
197        if let Some(caret) = ui.caret {
198            let caret = caret.min(spec.text.len());
199            if spec.text.is_char_boundary(caret) {
200                let bar = (height / 30).max(2);
201                let cursor = Cursor::new(0, caret);
202                // Fall back to the box's left edge when there's no run to ask —
203                // an empty heading still shows where typing will land.
204                let (mut x, mut top, mut h) = (0.0f32, 0i32, height);
205                for run in buffer.layout_runs() {
206                    if let Some(cx) = run.cursor_position(&cursor) {
207                        x = cx;
208                        top = run.line_top.max(0.0) as i32;
209                        h = run.line_height.ceil() as u32;
210                        break;
211                    }
212                }
213                let x = (x as i32).clamp(0, width.saturating_sub(bar) as i32);
214                let (r, g, b) = match selection {
215                    Some((s, e)) if caret >= s && caret < e => ui.selection_fg,
216                    _ => ink,
217                };
218                blend_rect(&mut pixels, x, top, bar, h, [r, g, b, 255]);
219            }
220        }
221        pixels
222    }
223
224    /// The byte offset (a `char` boundary in the spec's text) a pixel position
225    /// hits — the reverse of [`Rasterizer::heading`], answered by the same
226    /// cached layout, so a click on the raster lands on the glyph it visually
227    /// struck rather than on whatever happens to share its character cell.
228    pub fn heading_hit(&mut self, spec: &HeadingSpec, x: f32, y: f32) -> usize {
229        let buffer = self.shape(spec);
230        match buffer.hit(x, y) {
231            Some(cursor) => cursor.index.min(spec.text.len()),
232            // No run to hit (an empty heading): before-or-after is all that's
233            // left to say.
234            None if x <= 0.0 => 0,
235            None => spec.text.len(),
236        }
237    }
238
239    /// Whether the heading lays out on the single line its box has room for.
240    /// A longer title wraps onto a second layout line that the box height
241    /// culls — its tail would be neither drawn nor clickable, and a caret in
242    /// it would have nowhere truthful to stand — so the caller keeps such a
243    /// heading as ordinary text instead of rasterizing it.
244    pub fn heading_fits(&mut self, spec: &HeadingSpec) -> bool {
245        self.shape(spec)
246            .lines
247            .first()
248            .and_then(|line| line.layout_opt())
249            .is_none_or(|lines| lines.len() <= 1)
250    }
251
252    /// The shaped layout for a spec: one bold line, wrapped only as a last
253    /// resort, sized to the spec's box. Cached, since caret motion, pointer
254    /// sweeps, and steady frames all ask for the same layout over and over.
255    fn shape(&mut self, spec: &HeadingSpec) -> &mut Buffer {
256        let key = spec.key();
257        if !self.layouts.contains_key(&key) {
258            if self.layouts.len() >= LAYOUT_CACHE_MAX {
259                self.layouts.clear();
260            }
261            let mut buffer = Buffer::new(&mut self.fonts, spec.metrics());
262            buffer.set_wrap(Wrap::WordOrGlyph);
263            buffer.set_size(
264                Some(spec.width_px.max(1) as f32),
265                Some(spec.height_px.max(1) as f32),
266            );
267            buffer.set_text(
268                spec.text,
269                &Attrs::new().weight(Weight::BOLD),
270                Shaping::Advanced,
271                None,
272            );
273            buffer.shape_until_scroll(&mut self.fonts, false);
274            self.layouts.insert(key.clone(), buffer);
275        }
276        self.layouts.get_mut(&key).expect("just inserted")
277    }
278}
279
280/// The selection clamped into the text and checked for `char` alignment —
281/// `None` (draw no selection) rather than a panic on a stale offset.
282fn selection_range(ui: &EditingUi, text: &str) -> Option<(usize, usize)> {
283    let (s, e) = ui.selection?;
284    let (s, e) = (s.min(text.len()), e.min(text.len()));
285    (s < e && text.is_char_boundary(s) && text.is_char_boundary(e)).then_some((s, e))
286}
287
288/// Composite a solid rect src-over into the frame, clipped to it. Straight
289/// (non-premultiplied) alpha throughout, which is what `image` speaks and what
290/// the graphics protocols expect.
291fn blend_rect(pixels: &mut image::RgbaImage, x: i32, y: i32, w: u32, h: u32, src: [u8; 4]) {
292    if src[3] == 0 || w == 0 || h == 0 {
293        return;
294    }
295    let (iw, ih) = (pixels.width() as i32, pixels.height() as i32);
296    let (x0, y0) = (x.max(0), y.max(0));
297    let x1 = x.saturating_add(w.min(i32::MAX as u32) as i32).min(iw);
298    let y1 = y.saturating_add(h.min(i32::MAX as u32) as i32).min(ih);
299    if x1 <= x0 || y1 <= y0 {
300        return;
301    }
302    // A fully opaque fill is a straight write, done a row at a time — a
303    // selection fill can cover most of the frame, and per-pixel blending it
304    // would be the slowest thing in the raster.
305    if src[3] == 255 {
306        let stride = pixels.width() as usize * 4;
307        let buf: &mut [u8] = pixels;
308        for py in y0 as usize..y1 as usize {
309            let row = &mut buf[py * stride + x0 as usize * 4..py * stride + x1 as usize * 4];
310            for chunk in row.chunks_exact_mut(4) {
311                chunk.copy_from_slice(&src);
312            }
313        }
314        return;
315    }
316    for py in y0..y1 {
317        for px in x0..x1 {
318            blend(pixels.get_pixel_mut(px as u32, py as u32), src);
319        }
320    }
321}
322
323/// One pixel of straight-alpha src-over.
324fn blend(dst: &mut image::Rgba<u8>, src: [u8; 4]) {
325    let sa = src[3] as u32;
326    let da = dst.0[3] as u32;
327    // out_a scaled by 255 so the color divide below stays in integers.
328    let out_a = sa * 255 + da * (255 - sa);
329    if out_a == 0 {
330        return;
331    }
332    for (d, s) in dst.0.iter_mut().zip(src).take(3) {
333        let (sc, dc) = (s as u32, *d as u32);
334        *d = ((sc * sa * 255 + dc * da * (255 - sa)) / out_a) as u8;
335    }
336    dst.0[3] = (out_a / 255) as u8;
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn spec(text: &str) -> HeadingSpec<'_> {
344        HeadingSpec {
345            text,
346            level: 1,
347            width_px: 400,
348            height_px: 60,
349        }
350    }
351
352    /// Ink coverage of a column strip, to locate where something was painted
353    /// without depending on which fonts this machine resolves.
354    fn column_alpha(img: &image::RgbaImage, x: u32) -> u32 {
355        (0..img.height())
356            .map(|y| img.get_pixel(x, y).0[3] as u32)
357            .sum()
358    }
359
360    #[test]
361    fn the_caret_is_painted_into_the_pixels() {
362        let mut r = Rasterizer::new();
363        let s = spec("Hi");
364        let plain = r.heading(&s, (200, 200, 200), &EditingUi::default());
365        let with_caret = r.heading(
366            &s,
367            (200, 200, 200),
368            &EditingUi {
369                caret: Some(0),
370                ..Default::default()
371            },
372        );
373        // The bar at offset 0 runs the full line height at the left edge —
374        // taller than any glyph column of the plain raster there.
375        assert!(
376            column_alpha(&with_caret, 0) > column_alpha(&plain, 0),
377            "no caret ink at the left edge"
378        );
379    }
380
381    #[test]
382    fn an_empty_heading_still_shows_the_caret() {
383        let mut r = Rasterizer::new();
384        let s = spec("");
385        let img = r.heading(
386            &s,
387            (200, 200, 200),
388            &EditingUi {
389                caret: Some(0),
390                ..Default::default()
391            },
392        );
393        assert!(column_alpha(&img, 0) > 0, "empty heading lost its caret");
394    }
395
396    #[test]
397    fn the_selection_fills_behind_the_glyphs_and_reinks_them() {
398        let mut r = Rasterizer::new();
399        let s = spec("Hello");
400        let img = r.heading(
401            &s,
402            (220, 220, 220),
403            &EditingUi {
404                selection: Some((0, 5)),
405                selection_bg: (255, 0, 0),
406                selection_fg: (10, 10, 10),
407                ..Default::default()
408            },
409        );
410        // Between glyph strokes there is a pure-fill pixel at the selection
411        // color, and inside a stroke the glyph carries the selection ink.
412        assert!(
413            img.pixels().any(|p| p.0 == [255, 0, 0, 255]),
414            "no selection fill painted"
415        );
416        assert!(
417            img.pixels().any(|p| p.0 == [10, 10, 10, 255]),
418            "selected glyphs kept the page ink"
419        );
420    }
421
422    /// The empirically-found invisible caret: ink and selection fill are the
423    /// same color in the terminal (reverse video), so a caret standing at the
424    /// *start* of a selection must not be drawn in ink.
425    #[test]
426    fn a_caret_inside_the_selection_stays_visible() {
427        let mut r = Rasterizer::new();
428        let s = spec("Hello world");
429        let ink = (100, 160, 240);
430        let ui = EditingUi {
431            selection: Some((0, s.text.len())),
432            selection_bg: ink,
433            selection_fg: (250, 250, 250),
434            ..Default::default()
435        };
436        let without = r.heading(&s, ink, &ui);
437        let with = r.heading(
438            &s,
439            ink,
440            &EditingUi {
441                caret: Some(0),
442                ..ui
443            },
444        );
445        assert_ne!(
446            without.as_raw(),
447            with.as_raw(),
448            "the caret vanished into the selection fill"
449        );
450    }
451
452    #[test]
453    fn a_misaligned_selection_is_dropped_not_fatal() {
454        let mut r = Rasterizer::new();
455        let s = spec("héllo"); // 'é' is two bytes; offset 2 splits it
456        let img = r.heading(
457            &s,
458            (220, 220, 220),
459            &EditingUi {
460                selection: Some((2, 4)),
461                selection_bg: (255, 0, 0),
462                ..Default::default()
463            },
464        );
465        assert!(!img.pixels().any(|p| p.0 == [255, 0, 0, 255]));
466    }
467
468    #[test]
469    fn hits_map_the_edges_to_the_ends_and_stay_monotonic() {
470        let mut r = Rasterizer::new();
471        let s = spec("Hello");
472        assert_eq!(r.heading_hit(&s, -5.0, 30.0), 0);
473        assert_eq!(r.heading_hit(&s, 399.0, 30.0), 5);
474        let mut last = 0;
475        for x in (0..400).step_by(20) {
476            let hit = r.heading_hit(&s, x as f32, 30.0);
477            assert!(hit >= last, "hit went backwards at x={x}");
478            assert!(s.text.is_char_boundary(hit));
479            last = hit;
480        }
481    }
482
483    /// The box holds exactly one layout line, so a title that wraps would lose
484    /// its tail from the raster — the fits check is what routes it back to
485    /// ordinary text.
486    #[test]
487    fn an_overflowing_title_reports_that_it_does_not_fit() {
488        let mut r = Rasterizer::new();
489        assert!(r.heading_fits(&spec("Short")));
490        assert!(!r.heading_fits(&spec(
491            "A rather long chapter title that certainly cannot fit on one line"
492        )));
493        assert!(r.heading_fits(&spec("")), "empty always fits");
494    }
495}