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 = ¶graph[start..offset + word.len()];
395 if end > start && self.measure_line(style, candidate) > max_width {
396 lines.push(¶graph[start..end]);
397 start = offset;
398 }
399 end = offset + word.len();
400 }
401 lines.push(¶graph[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}