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}