Skip to main content

denise_text/
engine.rs

1//! The object an application holds: fonts, a cache, measurement and drawing.
2
3use alloc::boxed::Box;
4use alloc::vec::Vec;
5
6use denise::{Color, Point, Rect, Size};
7use denise_render::Pen;
8
9use crate::atlas::{AtlasStats, GlyphAtlas, GlyphKey};
10use crate::bitmap::BitmapSource;
11use crate::source::{FontId, FontMetrics, GlyphId, GlyphSource, ShapedGlyph};
12
13/// A font and a size, together, because neither is much use alone.
14#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
15pub struct TextStyle {
16    /// Which registered font.
17    pub font: FontId,
18    /// Requested size in pixels. A source may snap it; see
19    /// [`GlyphSource::snap_size`].
20    pub size_px: u16,
21}
22
23impl TextStyle {
24    /// This style at `scale`, never rounding to nothing.
25    ///
26    /// The text half of the one-multiply DPI pattern: the application scales
27    /// its theme, its rectangles and its text styles in the same place. Rounds
28    /// to nearest; a text size never scales below one pixel.
29    pub fn scaled(self, scale: f32) -> Self {
30        let size = (self.size_px as f32 * scale + 0.5) as u16;
31        Self {
32            size_px: if size == 0 { 1 } else { size },
33            ..self
34        }
35    }
36
37    /// The built-in font at `size_px`.
38    pub const fn built_in(size_px: u16) -> Self {
39        Self {
40            font: FontId(0),
41            size_px,
42        }
43    }
44
45    /// The same style at a different size.
46    pub const fn with_size(mut self, size_px: u16) -> Self {
47        self.size_px = size_px;
48        self
49    }
50}
51
52/// Where a laid-out glyph goes.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct PositionedGlyph {
55    /// Which glyph. Not a character — see [`GlyphId`].
56    pub glyph: GlyphId,
57    /// Pen position before this glyph, relative to the start of the line.
58    pub pen_x: i32,
59    /// Where the ink goes, relative to the line's origin and baseline.
60    pub bounds: Rect,
61}
62
63/// Fonts, a bounded glyph cache, and everything that needs both.
64///
65/// One of these per application. It is `&mut` for measurement as well as drawing,
66/// because measuring is what populates the cache: a label measured during layout
67/// and drawn a moment later rasterises its glyphs once, and a label measured on
68/// every layout pass and never redrawn pays a cache lookup rather than an outline
69/// computation each time.
70pub struct TextEngine {
71    atlas: GlyphAtlas,
72    sources: Vec<Box<dyn GlyphSource>>,
73    /// Which registered face [`FontId::DEFAULT`] stands for.
74    ///
75    /// The whole of the default-face mechanism: one indirection, applied where a
76    /// style is turned into glyphs, so no widget and no `TextStyle` has to know
77    /// about it. See [`TextEngine::set_default_font`].
78    default: FontId,
79    /// Reused across calls so laying out a line allocates nothing after the
80    /// first one, which matters because measurement happens every layout pass.
81    run: Vec<ShapedGlyph>,
82}
83
84/// Each word in `line`, with its byte offset.
85///
86/// Runs of spaces collapse: a double space is not an empty word, because a line
87/// beginning with a space is a line indented by a typo.
88fn word_starts(line: &str) -> impl Iterator<Item = (usize, &str)> {
89    line.split(' ')
90        .scan(0usize, |offset, word| {
91            let start = *offset;
92            // The separator is one byte, and it is ASCII, so this stays on a
93            // character boundary however many of them are multi-byte.
94            *offset += word.len() + 1;
95            Some((start, word))
96        })
97        .filter(|(_, word)| !word.is_empty())
98}
99
100impl TextEngine {
101    /// An engine with the built-in bitmap font registered as [`FontId(0)`](FontId), and a
102    /// 64 KB glyph cache.
103    ///
104    /// `FontId(0)` is always the built-in font, in every configuration, so a
105    /// widget that names no font gets something that certainly exists.
106    pub fn new() -> Self {
107        Self::with_atlas(GlyphAtlas::with_default_size())
108    }
109
110    /// As [`TextEngine::new`], with a cache of a chosen size.
111    pub fn with_atlas(atlas: GlyphAtlas) -> Self {
112        let mut engine = Self {
113            atlas,
114            sources: Vec::new(),
115            default: FontId::DEFAULT,
116            run: Vec::new(),
117        };
118        engine.add_font(Box::new(BitmapSource::new()));
119        engine
120    }
121
122    /// Registers a font and returns its id.
123    pub fn add_font(&mut self, source: Box<dyn GlyphSource>) -> FontId {
124        let id = FontId(self.sources.len() as u16);
125        self.sources.push(source);
126        id
127    }
128
129    /// Draws every style that names no font in this face.
130    ///
131    /// Without this, `FontId(0)` is the built-in 5x7 bitmap and there is no way
132    /// to ask for anything else: every widget in this workspace carries
133    /// [`TextStyle::built_in`] or [`TextStyle::default`], both of which name
134    /// [`FontId::DEFAULT`], so registering a face with
135    /// [`add_font`](TextEngine::add_font) alone registers something nothing
136    /// refers to. That was [#130].
137    ///
138    /// One indirection, resolved when a style becomes glyphs — so no widget, no
139    /// `TextStyle` and no form file changes, and an application that wants a
140    /// real face says two lines instead of threading a style through everything
141    /// it builds.
142    ///
143    /// An id that was never registered is ignored, because the alternative is a
144    /// panel that draws nothing.
145    ///
146    /// ```
147    /// # use denise_text::{FontId, TextEngine, TextStyle};
148    /// let mut engine = TextEngine::new();
149    /// // Nothing registered but the built-in, so the default is the built-in.
150    /// assert_eq!(engine.default_font(), FontId::DEFAULT);
151    ///
152    /// // An id nobody registered changes nothing.
153    /// engine.set_default_font(FontId(9));
154    /// assert_eq!(engine.default_font(), FontId::DEFAULT);
155    /// ```
156    ///
157    /// [#130]: https://github.com/bisand/denise/issues/130
158    pub fn set_default_font(&mut self, font: FontId) {
159        if (font.0 as usize) < self.sources.len() {
160            self.default = font;
161        }
162    }
163
164    /// Which face [`FontId::DEFAULT`] currently stands for.
165    #[inline]
166    pub const fn default_font(&self) -> FontId {
167        self.default
168    }
169
170    /// The face a style is really drawn in.
171    ///
172    /// [`FontId::DEFAULT`] is a redirection rather than a face; everything else
173    /// is itself. **Every lookup goes through here, and so does every glyph
174    /// cache key** — a key built from the unresolved id would serve the old
175    /// face's glyphs after the default changed.
176    #[inline]
177    const fn resolve(&self, font: FontId) -> FontId {
178        if font.0 == FontId::DEFAULT.0 {
179            self.default
180        } else {
181            font
182        }
183    }
184
185    /// Number of registered fonts.
186    #[inline]
187    pub fn font_count(&self) -> usize {
188        self.sources.len()
189    }
190
191    /// Name of a registered font.
192    pub fn font_name(&self, font: FontId) -> Option<&str> {
193        self.sources
194            .get(self.resolve(font).0 as usize)
195            .map(|s| s.name())
196    }
197
198    /// Whether a registered font has a glyph of its own for `ch`.
199    ///
200    /// The question an application asks before *choosing* what to draw: a
201    /// keyboard that would like `⌫` on its Backspace key, a status line that
202    /// would like `°`. Drawing a character the font lacks is not an error — it
203    /// comes out as the missing-character box — so this is what turns a silent
204    /// row of tofu into a legible fallback the author picked.
205    ///
206    /// `false` for an unregistered id, and for a font that can only render `ch`
207    /// through shaping — see [`GlyphSource::glyph_id`].
208    pub fn font_contains(&self, font: FontId, ch: char) -> bool {
209        self.sources
210            .get(self.resolve(font).0 as usize)
211            .is_some_and(|s| s.contains(ch))
212    }
213
214    /// The glyph cache.
215    #[inline]
216    pub const fn atlas(&self) -> &GlyphAtlas {
217        &self.atlas
218    }
219
220    /// Cache statistics.
221    #[inline]
222    pub const fn stats(&self) -> AtlasStats {
223        self.atlas.stats()
224    }
225
226    /// Empties the glyph cache. Needed after nothing; useful in benches.
227    pub fn clear_cache(&mut self) {
228        self.atlas.clear();
229    }
230
231    /// Vertical metrics for a style.
232    pub fn metrics(&self, style: TextStyle) -> FontMetrics {
233        self.sources
234            .get(self.resolve(style.font).0 as usize)
235            .map(|s| s.metrics(style.size_px))
236            .unwrap_or_default()
237    }
238
239    /// The size this style will actually be drawn at.
240    pub fn snap_size(&self, style: TextStyle) -> u16 {
241        self.sources
242            .get(self.resolve(style.font).0 as usize)
243            .map(|s| s.snap_size(style.size_px))
244            .unwrap_or(style.size_px)
245    }
246
247    /// Baseline-to-baseline distance for a style.
248    pub fn line_height(&self, style: TextStyle) -> i32 {
249        self.metrics(style).line_height()
250    }
251
252    /// Lays out one line, calling `f` for each glyph that has ink.
253    ///
254    /// Returns the total advance. Positions are relative to the line's start, with
255    /// `bounds.y` measured from the baseline — so a caller places the line by
256    /// translating, and never has to know how the font was measured.
257    pub fn layout_line(
258        &mut self,
259        style: TextStyle,
260        text: &str,
261        mut f: impl FnMut(PositionedGlyph),
262    ) -> i32 {
263        let width = self.shape_into_run(style, text);
264        for index in 0..self.run.len() {
265            let glyph = self.run[index];
266            let Some(placed) = self.placed(style, glyph.id) else {
267                continue;
268            };
269            if placed.metrics.is_blank() {
270                continue;
271            }
272            f(PositionedGlyph {
273                glyph: glyph.id,
274                pen_x: glyph.x,
275                bounds: Rect::new(
276                    glyph.x + placed.metrics.bearing_x,
277                    glyph.y - placed.metrics.bearing_y,
278                    placed.metrics.size.width as i32,
279                    placed.metrics.size.height as i32,
280                ),
281            });
282        }
283        width
284    }
285
286    /// Fills `self.run` with the glyphs of `text`, and returns the run's width.
287    ///
288    /// A source that shapes does its own layout. Everything else is laid out here,
289    /// taking each advance from the glyph cache — which is what makes measuring
290    /// the same label on every layout pass cost a cache lookup rather than an
291    /// outline computation.
292    fn shape_into_run(&mut self, style: TextStyle, text: &str) -> i32 {
293        self.run.clear();
294        let font = self.resolve(style.font);
295        let Some(source) = self.sources.get_mut(font.0 as usize) else {
296            return 0;
297        };
298        if source.can_shape() {
299            return source.shape(text, style.size_px, &mut self.run);
300        }
301
302        let mut pen = 0;
303        for ch in text.chars() {
304            let Some(id) = source.glyph_id(ch).or_else(|| source.fallback_id(ch)) else {
305                continue;
306            };
307            let key = GlyphKey {
308                font,
309                size_px: style.size_px,
310                glyph: id,
311            };
312            let Some(placed) = self.atlas.get_or_insert(key, source.as_mut()) else {
313                continue;
314            };
315            self.run.push(ShapedGlyph { id, x: pen, y: 0 });
316            pen += placed.metrics.advance;
317        }
318        pen
319    }
320
321    /// The cached placement of one glyph, rasterising it if need be.
322    fn placed(&mut self, style: TextStyle, glyph: crate::GlyphId) -> Option<crate::Placed> {
323        let font = self.resolve(style.font);
324        let source = self.sources.get_mut(font.0 as usize)?;
325        let key = GlyphKey {
326            font,
327            size_px: style.size_px,
328            glyph,
329        };
330        self.atlas.get_or_insert(key, source.as_mut())
331    }
332
333    /// Width of one line, ignoring `\n`.
334    pub fn measure_line(&mut self, style: TextStyle, text: &str) -> i32 {
335        self.layout_line(style, text, |_| {})
336    }
337
338    /// Extent of `text`, honouring `\n`.
339    ///
340    /// The height is `lines * line_height`, not the ink's bounding box: a label
341    /// that changes from `Ok` to `Ogg` must not change height, or a form would
342    /// reflow every time a reading gained a descender.
343    pub fn measure(&mut self, style: TextStyle, text: &str) -> Size {
344        let line_height = self.line_height(style);
345        let mut widest = 0;
346        let mut lines = 0;
347        for line in text.split('\n') {
348            widest = widest.max(self.measure_line(style, line));
349            lines += 1;
350        }
351        Size::new(widest.max(0) as u32, (lines * line_height).max(0) as u32)
352    }
353
354    /// The lines `text` becomes when broken to fit `max_width`.
355    ///
356    /// Greedy: words are added to a line until the next one would not fit. That
357    /// is what every text editor does, it is one measuring pass, and the
358    /// alternative — balancing lines by minimising raggedness — is a
359    /// dynamic-programming problem this toolkit has no reason to solve.
360    ///
361    /// Explicit `\n` always breaks, so a caller who has already decided where
362    /// the lines go keeps that decision.
363    ///
364    /// Slices borrow from `text`; nothing is copied. Words are separated by ASCII
365    /// spaces, which is the boundary the built-in font can render and the one the
366    /// languages this toolkit ships keyboard layouts for use.
367    ///
368    /// # A word wider than the line
369    ///
370    /// Goes on a line of its own and overflows, rather than being broken between
371    /// characters. Breaking mid-word needs to know where a grapheme ends, and
372    /// getting that wrong turns `æ` into two bytes of nothing — so an honest
373    /// overflow the caller can see beats a corruption they cannot. A
374    /// `max_width` of zero or less disables wrapping entirely for the same
375    /// reason: there is no width that any word fits in.
376    pub fn wrap<'a>(
377        &mut self,
378        style: TextStyle,
379        text: &'a str,
380        max_width: i32,
381    ) -> alloc::vec::Vec<&'a str> {
382        let mut lines = alloc::vec::Vec::new();
383        for paragraph in text.split('\n') {
384            if max_width <= 0 || paragraph.is_empty() {
385                lines.push(paragraph);
386                continue;
387            }
388            // Byte offsets into `paragraph`: `start` where the current line
389            // begins, `end` where it currently ends. Both land on space
390            // boundaries, which are ASCII and so always character boundaries.
391            let mut start = 0;
392            let mut end = 0;
393            for (offset, word) in word_starts(paragraph) {
394                let candidate = &paragraph[start..offset + word.len()];
395                if end > start && self.measure_line(style, candidate) > max_width {
396                    lines.push(&paragraph[start..end]);
397                    start = offset;
398                }
399                end = offset + word.len();
400            }
401            lines.push(&paragraph[start..]);
402        }
403        lines
404    }
405
406    /// Height of `text` once wrapped to `max_width`.
407    pub fn wrapped_height(&mut self, style: TextStyle, text: &str, max_width: i32) -> i32 {
408        let lines = self.wrap(style, text, max_width).len() as i32;
409        (lines * self.line_height(style)).max(0)
410    }
411
412    /// Draws one line with its baseline at `origin`.
413    ///
414    /// Returns the total advance, of the whole line and not of the part drawn:
415    /// a caller measuring a line to know how far it scrolls needs all of it.
416    ///
417    /// Only the glyphs the canvas would keep are rasterised and handed over.
418    /// The painter clips the rest away to nothing, so the pixels are the same
419    /// either way, but handing them over is not free: a line a megabyte long
420    /// is a million glyphs, and a painter that builds geometry per glyph turns
421    /// that into hundreds of megabytes for the few hundred that are on screen
422    /// — more, on some, than a GPU will take in one buffer. Laying the line
423    /// out is still the whole of it, which is where `width` comes from; it is
424    /// cheap beside rasterising, being an advance apiece from the cache.
425    pub fn draw_line(
426        &mut self,
427        canvas: &mut Pen<'_>,
428        style: TextStyle,
429        origin: Point,
430        text: &str,
431        color: Color,
432    ) -> i32 {
433        let width = self.shape_into_run(style, text);
434        let clip = canvas.clip();
435        // What a glyph may reach outside its own advance: an italic's
436        // overhang, an accent, a bearing that starts left of the pen. Four ems
437        // is more than any face asks for, and still leaves nothing but the
438        // screenful.
439        let margin = i32::from(style.size_px).saturating_mul(4);
440        let line_h = self.line_height(style).saturating_add(margin);
441        if origin.y + line_h < clip.y || origin.y - line_h > clip.bottom() {
442            return width;
443        }
444        for index in 0..self.run.len() {
445            let glyph = self.run[index];
446            let x = origin.x + glyph.x;
447            if x + margin < clip.x || x - margin > clip.right() {
448                continue;
449            }
450            let Some(placed) = self.placed(style, glyph.id) else {
451                continue;
452            };
453            if !placed.rect.is_empty() {
454                let at = Point::new(
455                    x + placed.metrics.bearing_x,
456                    origin.y + glyph.y - placed.metrics.bearing_y,
457                );
458                // The page with its identity, not a mask cut from it: a painter
459                // that keeps textures uploads the page once and draws
460                // rectangles of it; the rasteriser cuts the rectangle itself.
461                canvas.blit_glyph(at, &self.atlas.page(), placed.rect, color);
462            }
463        }
464        width
465    }
466
467    /// Draws `text` with the top-left corner of its first line at `origin`,
468    /// honouring `\n`. Returns the extent laid out.
469    ///
470    /// Top-left rather than baseline, because a widget positions text in a box and
471    /// should not have to know where the baseline of a font it did not choose
472    /// happens to fall.
473    pub fn draw(
474        &mut self,
475        canvas: &mut Pen<'_>,
476        style: TextStyle,
477        origin: Point,
478        text: &str,
479        color: Color,
480    ) -> Size {
481        let metrics = self.metrics(style);
482        let line_height = metrics.line_height();
483        let mut widest = 0;
484        let mut lines = 0;
485        for line in text.split('\n') {
486            let baseline = Point::new(origin.x, origin.y + metrics.ascent + lines * line_height);
487            widest = widest.max(self.draw_line(canvas, style, baseline, line, color));
488            lines += 1;
489        }
490        Size::new(widest.max(0) as u32, (lines * line_height).max(0) as u32)
491    }
492}
493
494impl Default for TextEngine {
495    fn default() -> Self {
496        Self::new()
497    }
498}
499
500impl core::fmt::Debug for TextEngine {
501    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
502        f.debug_struct("TextEngine")
503            .field("fonts", &self.sources.len())
504            .field("atlas", &self.atlas)
505            .finish()
506    }
507}
508
509#[cfg(test)]
510mod wrap_tests {
511    use super::*;
512    use alloc::string::String;
513    use alloc::vec;
514    use alloc::vec::Vec;
515
516    /// The built-in font is 8 px per character at 16 px, which makes every width
517    /// in these tests a character count and the assertions readable.
518    fn engine() -> (TextEngine, TextStyle) {
519        (TextEngine::new(), TextStyle::built_in(16))
520    }
521
522    #[test]
523    fn words_are_found_with_their_offsets_and_runs_of_spaces_collapse() {
524        let words: Vec<_> = word_starts("ab cd  ef").collect();
525        assert_eq!(words, vec![(0, "ab"), (3, "cd"), (7, "ef")]);
526        assert_eq!(word_starts("").count(), 0);
527        assert_eq!(word_starts("   ").count(), 0);
528        let single: Vec<_> = word_starts("  x").collect();
529        assert_eq!(single, vec![(2, "x")]);
530    }
531
532    /// The ordinary case: greedy fill, breaking where the next word would not
533    /// fit.
534    #[test]
535    fn a_line_breaks_where_the_next_word_would_not_fit() {
536        let (mut engine, style) = engine();
537        let lines = engine.wrap(style, "en to tre fire fem", 80);
538        let widths: Vec<i32> = lines
539            .iter()
540            .map(|l| engine.measure_line(style, l))
541            .collect();
542        for (line, width) in lines.iter().zip(&widths) {
543            assert!(
544                *width <= 80 || !line.contains(' '),
545                "{line:?} is {width} wide and could have been broken"
546            );
547        }
548        assert!(lines.len() > 1, "nothing wrapped at all");
549        assert_eq!(lines.concat().replace(' ', ""), "entotrefirefem");
550    }
551
552    /// Explicit breaks are a decision the caller already made.
553    #[test]
554    fn an_explicit_newline_always_breaks() {
555        let (mut engine, style) = engine();
556        assert_eq!(engine.wrap(style, "a\nb\nc", 10_000), vec!["a", "b", "c"]);
557    }
558
559    /// A word wider than the line overflows on its own line rather than being
560    /// cut between bytes — `æ` is two of them, and half of it is nothing.
561    #[test]
562    fn a_word_wider_than_the_line_gets_its_own_line_and_overflows() {
563        let (mut engine, style) = engine();
564        let input = "kort kjempelangtordherinne kort";
565        let lines = engine.wrap(style, input, 40);
566        assert!(
567            lines.contains(&"kjempelangtordherinne"),
568            "the long word was broken or lost: {lines:?}"
569        );
570        // Against the input rather than a number I worked out by hand, which is
571        // how this assertion was wrong the first time.
572        assert_eq!(lines.concat().replace(' ', ""), input.replace(' ', ""));
573    }
574
575    /// No width is not a width every word fails to fit; it is no wrapping.
576    #[test]
577    fn a_width_of_zero_or_less_does_not_wrap() {
578        let (mut engine, style) = engine();
579        assert_eq!(engine.wrap(style, "en to tre", 0), vec!["en to tre"]);
580        assert_eq!(engine.wrap(style, "en to tre", -5), vec!["en to tre"]);
581    }
582
583    /// Empty input is one empty line, not no lines — a blank paragraph still
584    /// occupies a line's height.
585    #[test]
586    fn empty_text_is_one_empty_line() {
587        let (mut engine, style) = engine();
588        assert_eq!(engine.wrap(style, "", 100), vec![""]);
589        assert_eq!(engine.wrap(style, "\n", 100), vec!["", ""]);
590    }
591
592    /// Nothing is dropped and nothing is duplicated, at any width. The property
593    /// that matters: wrapping rearranges, it does not edit.
594    #[test]
595    fn wrapping_never_loses_or_duplicates_a_character() {
596        let (mut engine, style) = engine();
597        let text = "Kjærlighet på Øy er en lang setning med æøå i seg";
598        let stripped: String = text.chars().filter(|c| *c != ' ').collect();
599        for width in [1, 8, 17, 40, 101, 500, 5000] {
600            let joined = engine.wrap(style, text, width).concat();
601            let got: String = joined.chars().filter(|c| *c != ' ').collect();
602            assert_eq!(got, stripped, "width {width} changed the text");
603        }
604    }
605
606    /// The height follows the line count, which is what a widget sizes itself
607    /// from.
608    #[test]
609    fn the_wrapped_height_is_the_line_count_times_the_line_height() {
610        let (mut engine, style) = engine();
611        let one = engine.wrapped_height(style, "kort", 10_000);
612        assert_eq!(one, engine.line_height(style));
613        let many = engine.wrapped_height(style, "en to tre fire fem seks sju", 40);
614        assert!(many > one, "wrapped text should be taller");
615        assert_eq!(many % engine.line_height(style), 0);
616    }
617}