Skip to main content

rlvgl_core/
font.rs

1//! Backend-neutral font metrics, shaping, and greedy LTR wrapping.
2//!
3//! The types in this module are the LPAR-08 text substrate shared by bitmap,
4//! packed, and feature-gated dynamic font backends. They provide measurement
5//! and shaping without tying widgets to a concrete font renderer.
6
7use alloc::vec::Vec;
8use core::fmt;
9
10use crate::widget::Rect;
11
12/// Opaque identifier for a font registered with a display or platform font registry.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
14#[repr(transparent)]
15pub struct FontId(pub u16);
16
17impl FontId {
18    /// The default font registered for a display.
19    pub const DEFAULT: Self = Self(0);
20}
21
22/// Per-glyph advance and bitmap extent information.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct GlyphInfo {
25    /// Horizontal advance in 1/16 pixel units.
26    pub advance_fp16: u16,
27    /// Left bearing from the glyph origin in pixels.
28    pub bearing_x: i16,
29    /// Top bearing from the baseline in pixels.
30    pub bearing_y: i16,
31    /// Glyph bitmap width in pixels.
32    pub width: u16,
33    /// Glyph bitmap height in pixels.
34    pub height: u16,
35}
36
37/// Font-level vertical metrics.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub struct FontLineMetrics {
40    /// Full line height in pixels.
41    pub line_height: u16,
42    /// Pixels above the baseline.
43    pub ascent: i16,
44    /// Pixels below the baseline, stored as a positive value.
45    pub descent: i16,
46}
47
48/// Absolute placement of one shaped glyph.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct GlyphPlacement {
51    /// Source character represented by this placement.
52    pub ch: char,
53    /// Glyph metrics used for this placement.
54    pub info: GlyphInfo,
55    /// Absolute glyph origin on the horizontal axis.
56    pub x: i32,
57    /// Absolute glyph origin on the vertical axis; this is the baseline.
58    pub y: i32,
59}
60
61impl GlyphPlacement {
62    /// Return this glyph's tight bitmap extent in absolute coordinates.
63    pub fn extent(&self) -> Rect {
64        Rect {
65            x: self.x + self.info.bearing_x as i32,
66            y: self.y - self.info.bearing_y as i32,
67            width: self.info.width as i32,
68            height: self.info.height as i32,
69        }
70    }
71}
72
73/// Shaped text ready for measurement, wrapping, clipping, or drawing.
74#[derive(Clone)]
75pub struct ShapedText<'a> {
76    /// Glyph placements in visual draw order.
77    pub glyphs: Vec<GlyphPlacement>,
78    /// Total horizontal advance in 1/16 pixel units.
79    pub total_advance_fp16: i32,
80    /// Tight bounding box of all glyph extents.
81    pub bounds: Rect,
82    /// Paragraph bidi level. V1 shaping is LTR-only and sets this to `0`.
83    pub bidi_level: u8,
84    /// Font that produced this shaped run, when available.
85    ///
86    /// [`Renderer::draw_text_shaped`](crate::renderer::Renderer::draw_text_shaped)
87    /// uses this reference to render glyph coverage. Manually constructed
88    /// shaped runs may leave this as `None`; renderers then use a
89    /// deterministic extent-only fallback.
90    pub font: Option<&'a dyn FontMetrics>,
91}
92
93impl fmt::Debug for ShapedText<'_> {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        f.debug_struct("ShapedText")
96            .field("glyphs", &self.glyphs)
97            .field("total_advance_fp16", &self.total_advance_fp16)
98            .field("bounds", &self.bounds)
99            .field("bidi_level", &self.bidi_level)
100            .field("has_font", &self.font.is_some())
101            .finish()
102    }
103}
104
105impl PartialEq for ShapedText<'_> {
106    fn eq(&self, other: &Self) -> bool {
107        self.glyphs == other.glyphs
108            && self.total_advance_fp16 == other.total_advance_fp16
109            && self.bounds == other.bounds
110            && self.bidi_level == other.bidi_level
111    }
112}
113
114impl Eq for ShapedText<'_> {}
115
116impl<'a> ShapedText<'a> {
117    /// Return an empty shaped run anchored at `origin`.
118    pub fn empty(origin: (i32, i32)) -> Self {
119        Self {
120            glyphs: Vec::new(),
121            total_advance_fp16: 0,
122            bounds: Rect {
123                x: origin.0,
124                y: origin.1,
125                width: 0,
126                height: 0,
127            },
128            bidi_level: 0,
129            font: None,
130        }
131    }
132}
133
134/// Glyph-level metrics query and text shaping for a font backend.
135///
136/// Implementations are object-safe so widgets can use `&dyn FontMetrics`.
137pub trait FontMetrics {
138    /// Return per-glyph advance and extent information for `ch`.
139    fn glyph_metrics(&self, ch: char) -> Option<GlyphInfo>;
140
141    /// Return font-level vertical metrics.
142    fn line_metrics(&self) -> FontLineMetrics;
143
144    /// Fill one row of glyph coverage for `ch`.
145    ///
146    /// `row` and `x_offset` are in glyph bitmap coordinates, where row `0`
147    /// is the top row of [`GlyphPlacement::extent`]. Implementations must
148    /// overwrite every byte in `coverage` with `0..=255` alpha values.
149    /// Returning `false` means this backend has no coverage data for `ch`;
150    /// renderers then use their deterministic extent-only fallback.
151    fn glyph_coverage_row(
152        &self,
153        _ch: char,
154        _row: u16,
155        _x_offset: u16,
156        _coverage: &mut [u8],
157    ) -> bool {
158        false
159    }
160
161    /// Measure the advance width of `text` in 1/16 pixel units.
162    fn measure_fp16(&self, text: &str) -> i32 {
163        measure_text_fp16(self, text, 0)
164    }
165
166    /// Shape `text` into an LTR [`ShapedText`] run at `origin`.
167    ///
168    /// `origin.1` is the baseline. The v1 shaper performs no bidi reordering;
169    /// future RTL support is expected to reorder the returned glyph sequence
170    /// and set [`ShapedText::bidi_level`].
171    fn shape(&self, text: &str, origin: (i32, i32)) -> ShapedText<'_>
172    where
173        Self: Sized,
174    {
175        shape_text_ltr(self, text, origin, 0)
176    }
177}
178
179/// A font-handle slot for text widgets (FONT-00 §5).
180///
181/// Holds an optional process-lifetime font handle and resolves to the
182/// built-in [`FONT_6X10`](crate::bitmap_font::FONT_6X10) default when none is
183/// assigned. Embedding a `WidgetFont` (rather than an inline `&FONT_6X10`)
184/// gives every widget a uniform [`set_font`](WidgetFont::set) assignment point
185/// while centralizing the fallback in [`resolve`](WidgetFont::resolve).
186///
187/// The handle is `&'static dyn FontMetrics`: fonts are process-lifetime assets
188/// (`static FONT_6X10`, `static`-baked `PackedFont`s), so widgets need no
189/// lifetime parameter and the slot stays `Copy`.
190#[derive(Clone, Copy, Default)]
191pub struct WidgetFont(Option<&'static dyn FontMetrics>);
192
193impl WidgetFont {
194    /// A slot with no assigned font; [`resolve`](Self::resolve) yields the
195    /// `FONT_6X10` default.
196    pub const fn new() -> Self {
197        Self(None)
198    }
199
200    /// A slot pre-assigned to `font`.
201    pub const fn with_font(font: &'static dyn FontMetrics) -> Self {
202        Self(Some(font))
203    }
204
205    /// Assign `font`, replacing any previous assignment.
206    pub fn set(&mut self, font: &'static dyn FontMetrics) {
207        self.0 = Some(font);
208    }
209
210    /// Clear the assignment, reverting [`resolve`](Self::resolve) to the
211    /// `FONT_6X10` default.
212    pub fn clear(&mut self) {
213        self.0 = None;
214    }
215
216    /// Return `true` when a font has been explicitly assigned.
217    pub fn is_set(&self) -> bool {
218        self.0.is_some()
219    }
220
221    /// Resolve to the assigned font, or the built-in `FONT_6X10` default.
222    pub fn resolve(&self) -> &'static dyn FontMetrics {
223        match self.0 {
224            Some(font) => font,
225            None => &crate::bitmap_font::FONT_6X10,
226        }
227    }
228}
229
230/// A `FontId → &'static dyn FontMetrics` lookup (FONT-05 §5.A).
231///
232/// Bridges the LPAR-07 style cascade's [`FontId`] identity
233/// ([`TextStyle::font_id`](crate::style_cascade::TextStyle::font_id)) to the
234/// FONT-00 [`WidgetFont`] handle slot. Immutable, borrow-backed, `no_std`-clean,
235/// and **not** a global singleton: the application owns a `FontRegistry` value
236/// and passes it to [`apply_font_registry`]. Handles are `'static`
237/// (`static FONT_6X10`, `static`-baked `PackedFont`s), so the registry needs no
238/// lifetime parameter.
239#[derive(Clone, Copy)]
240pub struct FontRegistry<'a> {
241    entries: &'a [(FontId, &'static dyn FontMetrics)],
242}
243
244impl<'a> FontRegistry<'a> {
245    /// Wrap a table of `(FontId, handle)` entries.
246    ///
247    /// The handles are `'static`; the table itself is borrowed for `'a` (see
248    /// the FONT-05 §5.A rationale — `&dyn FontMetrics` is `!Sync`, so a `static`
249    /// table is rejected and rvalue promotion is blocked by the `dyn`
250    /// coercion). Keep the application's entry array in scope and build this
251    /// `Copy` registry from `&entries`. `FontId::DEFAULT` entries are ignored
252    /// by [`resolve`](Self::resolve); keep the table small (lookup is a linear
253    /// scan).
254    pub const fn new(entries: &'a [(FontId, &'static dyn FontMetrics)]) -> Self {
255        Self { entries }
256    }
257
258    /// Resolve `id` to a registered handle.
259    ///
260    /// Returns `None` for [`FontId::DEFAULT`] and for any unregistered id —
261    /// signalling "no registry override" so the widget keeps its explicit
262    /// [`set`](WidgetFont::set) assignment or the `FONT_6X10` default
263    /// (FONT-05 §5.D).
264    pub fn resolve(&self, id: FontId) -> Option<&'static dyn FontMetrics> {
265        if id == FontId::DEFAULT {
266            return None;
267        }
268        self.entries
269            .iter()
270            .find(|(fid, _)| *fid == id)
271            .map(|(_, handle)| *handle)
272    }
273}
274
275impl FontRegistry<'static> {
276    /// An empty registry: [`resolve`](Self::resolve) yields `None` for every
277    /// id, so the pass leaves every widget's explicit/default font untouched.
278    pub const EMPTY: Self = Self { entries: &[] };
279}
280
281/// Resolve each node's cascade `font_id` through `registry` and write the
282/// mapped handle into the node's [`WidgetFont`] slot (FONT-05 §5.C).
283///
284/// Walks `root` top-down via
285/// [`resolve_tree_with_text`](crate::style_cascade::resolve_tree_with_text),
286/// reusing the cascade's inheritance — so a child with no own `font_id`
287/// inherits its parent's. For each node whose resolved `font_id` the registry
288/// maps, the widget's font is set via
289/// [`Widget::widget_font_mut`](crate::widget::Widget::widget_font_mut). Nodes
290/// whose `font_id` is `DEFAULT`/unregistered, or whose widget exposes no font
291/// slot, are **left untouched** — preserving explicit
292/// [`set_font`](WidgetFont::set) assignments and the `FONT_6X10` default
293/// (FONT-05 §5.D). Idempotent for a stable tree + registry.
294///
295/// The application owns `registry` and calls this after building/mutating the
296/// tree and on any change that affects font resolution (theme swap, locale
297/// remap, registry edit). It is not on the per-frame draw path (FONT-05 §5.E).
298pub fn apply_font_registry(root: &crate::object::ObjectNode, registry: &FontRegistry<'_>) {
299    crate::style_cascade::resolve_tree_with_text(root, &mut |node, _style, text| {
300        let Some(handle) = registry.resolve(text.font_id) else {
301            return;
302        };
303        let widget = node.widget();
304        let mut w = widget.borrow_mut();
305        if let Some(slot) = w.widget_font_mut() {
306            slot.set(handle);
307        }
308    });
309}
310
311/// One line produced by greedy wrapping.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct WrappedLine {
314    /// Byte offset where the line starts in the original string.
315    pub start: usize,
316    /// Byte offset where the line ends in the original string.
317    pub end: usize,
318    /// Measured line advance in 1/16 pixel units.
319    pub advance_fp16: i32,
320}
321
322/// Result of greedy LTR wrapping.
323#[derive(Debug, Clone, PartialEq, Eq)]
324pub struct WrappedText {
325    /// Wrapped line byte ranges into the original string.
326    pub lines: Vec<WrappedLine>,
327    /// Total vertical space used by the wrapped lines in pixels.
328    pub used_height: i32,
329}
330
331/// Measure `text` with additional per-glyph letter spacing.
332///
333/// `letter_spacing_px` is applied between adjacent visible glyphs. It may be
334/// negative; the measured width never drops below zero.
335pub fn measure_text_fp16<F: FontMetrics + ?Sized>(
336    font: &F,
337    text: &str,
338    letter_spacing_px: i8,
339) -> i32 {
340    let mut total = 0i32;
341    let mut glyph_count = 0u32;
342    let spacing = letter_spacing_px as i32 * 16;
343    for ch in text.chars() {
344        if is_zero_width_break(ch) {
345            continue;
346        }
347        if glyph_count > 0 {
348            total += spacing;
349        }
350        total += glyph_advance_fp16(font, ch);
351        glyph_count += 1;
352    }
353    total.max(0)
354}
355
356/// Shape `text` in logical LTR order with optional letter spacing.
357pub fn shape_text_ltr<'a>(
358    font: &'a dyn FontMetrics,
359    text: &str,
360    origin: (i32, i32),
361    letter_spacing_px: i8,
362) -> ShapedText<'a> {
363    let mut shaped = ShapedText::empty(origin);
364    shaped.font = Some(font);
365    let mut cursor_fp16 = 0i32;
366    let mut has_bounds = false;
367    let mut glyph_count = 0u32;
368    let spacing = letter_spacing_px as i32 * 16;
369
370    for ch in text.chars() {
371        if is_zero_width_break(ch) {
372            continue;
373        }
374        if glyph_count > 0 {
375            cursor_fp16 += spacing;
376        }
377        let Some(info) = font.glyph_metrics(ch) else {
378            cursor_fp16 += fallback_advance_fp16(font);
379            glyph_count += 1;
380            continue;
381        };
382        let placement = GlyphPlacement {
383            ch,
384            info,
385            x: origin.0 + ((cursor_fp16 + 8) >> 4),
386            y: origin.1,
387        };
388        let extent = placement.extent();
389        shaped.bounds = if has_bounds {
390            shaped.bounds.union(extent)
391        } else {
392            has_bounds = true;
393            extent
394        };
395        shaped.glyphs.push(placement);
396        cursor_fp16 += info.advance_fp16 as i32;
397        glyph_count += 1;
398    }
399
400    shaped.total_advance_fp16 = cursor_fp16.max(0);
401    if !has_bounds {
402        shaped.bounds = Rect {
403            x: origin.0,
404            y: origin.1,
405            width: 0,
406            height: 0,
407        };
408    }
409    shaped
410}
411
412/// Greedily wrap `text` for an LTR paragraph.
413///
414/// Break opportunities are space, hyphen-minus, and zero-width space. Hard
415/// newline characters always force a new line. Lines are returned as byte
416/// ranges into the original string; trailing spaces at soft line breaks are
417/// omitted from each line range.
418pub fn wrap_greedy_ltr<F: FontMetrics + ?Sized>(
419    font: &F,
420    text: &str,
421    max_width_px: i32,
422    letter_spacing_px: i8,
423    line_spacing_px: i8,
424) -> WrappedText {
425    let mut lines = Vec::new();
426    let mut paragraph_start = 0usize;
427
428    for (idx, ch) in text.char_indices() {
429        if ch == '\n' {
430            wrap_span(
431                font,
432                text,
433                paragraph_start,
434                idx,
435                max_width_px,
436                letter_spacing_px,
437                &mut lines,
438            );
439            paragraph_start = idx + ch.len_utf8();
440        }
441    }
442    wrap_span(
443        font,
444        text,
445        paragraph_start,
446        text.len(),
447        max_width_px,
448        letter_spacing_px,
449        &mut lines,
450    );
451
452    let metrics = font.line_metrics();
453    let line_count = lines.len() as i32;
454    let used_height = if line_count == 0 {
455        0
456    } else {
457        line_count * metrics.line_height as i32 + (line_count - 1) * line_spacing_px as i32
458    };
459    WrappedText { lines, used_height }
460}
461
462fn wrap_span<F: FontMetrics + ?Sized>(
463    font: &F,
464    text: &str,
465    start: usize,
466    end: usize,
467    max_width_px: i32,
468    letter_spacing_px: i8,
469    out: &mut Vec<WrappedLine>,
470) {
471    if start == end {
472        push_line(font, text, start, end, letter_spacing_px, out);
473        return;
474    }
475
476    let max_width_fp16 = max_width_px.max(0) * 16;
477    let mut line_start = skip_leading_spaces(text, start, end);
478
479    while line_start < end {
480        let mut line_end = line_start;
481        let mut last_break: Option<usize> = None;
482        let mut overflow_at: Option<usize> = None;
483
484        for (rel, ch) in text[line_start..end].char_indices() {
485            let abs = line_start + rel;
486            let candidate_end = abs + ch.len_utf8();
487            let width =
488                measure_text_fp16(font, &text[line_start..candidate_end], letter_spacing_px);
489            if width > max_width_fp16 {
490                overflow_at = Some(abs);
491                break;
492            }
493            line_end = candidate_end;
494            if is_soft_break(ch) {
495                last_break = Some(candidate_end);
496            }
497        }
498
499        match overflow_at {
500            None => {
501                push_line(
502                    font,
503                    text,
504                    line_start,
505                    trim_trailing_spaces(text, line_start, line_end),
506                    letter_spacing_px,
507                    out,
508                );
509                break;
510            }
511            Some(overflow) => {
512                if let Some(break_after) = last_break
513                    && break_after > line_start
514                {
515                    let soft_end = trim_trailing_spaces(text, line_start, break_after);
516                    push_line(font, text, line_start, soft_end, letter_spacing_px, out);
517                    line_start = skip_leading_spaces(text, break_after, end);
518                } else {
519                    let hard_end = if overflow == line_start {
520                        next_char_end(text, line_start, end).unwrap_or(end)
521                    } else {
522                        overflow
523                    };
524                    push_line(font, text, line_start, hard_end, letter_spacing_px, out);
525                    line_start = skip_leading_spaces(text, hard_end, end);
526                }
527            }
528        }
529    }
530}
531
532fn push_line<F: FontMetrics + ?Sized>(
533    font: &F,
534    text: &str,
535    start: usize,
536    end: usize,
537    letter_spacing_px: i8,
538    out: &mut Vec<WrappedLine>,
539) {
540    let advance_fp16 = measure_text_fp16(font, &text[start..end], letter_spacing_px);
541    out.push(WrappedLine {
542        start,
543        end,
544        advance_fp16,
545    });
546}
547
548fn glyph_advance_fp16<F: FontMetrics + ?Sized>(font: &F, ch: char) -> i32 {
549    font.glyph_metrics(ch)
550        .map(|info| info.advance_fp16 as i32)
551        .unwrap_or_else(|| fallback_advance_fp16(font))
552}
553
554fn fallback_advance_fp16<F: FontMetrics + ?Sized>(font: &F) -> i32 {
555    ((font.line_metrics().line_height as i32 + 1) / 2) * 16
556}
557
558fn is_soft_break(ch: char) -> bool {
559    ch == ' ' || ch == '-' || is_zero_width_break(ch)
560}
561
562fn is_zero_width_break(ch: char) -> bool {
563    ch == '\u{200B}'
564}
565
566fn skip_leading_spaces(text: &str, mut start: usize, end: usize) -> usize {
567    while start < end {
568        let Some(ch) = text[start..end].chars().next() else {
569            break;
570        };
571        if ch != ' ' {
572            break;
573        }
574        start += ch.len_utf8();
575    }
576    start
577}
578
579fn trim_trailing_spaces(text: &str, start: usize, mut end: usize) -> usize {
580    while start < end {
581        let Some((idx, ch)) = text[start..end].char_indices().next_back() else {
582            break;
583        };
584        if ch != ' ' {
585            break;
586        }
587        end = start + idx;
588    }
589    end
590}
591
592fn next_char_end(text: &str, start: usize, end: usize) -> Option<usize> {
593    text[start..end]
594        .chars()
595        .next()
596        .map(|ch| start + ch.len_utf8())
597}
598
599#[cfg(test)]
600mod widget_font_tests {
601    use super::*;
602
603    #[test]
604    fn unset_resolves_to_default_font() {
605        let wf = WidgetFont::new();
606        assert!(!wf.is_set());
607        // The default resolves to FONT_6X10's line metrics.
608        let lm = wf.resolve().line_metrics();
609        assert_eq!(
610            lm.line_height,
611            crate::bitmap_font::FONT_6X10.line_metrics().line_height
612        );
613    }
614
615    #[test]
616    fn set_then_resolve_returns_assigned_font() {
617        // PackedFont has different line metrics than FONT_6X10; use it to prove
618        // the assigned handle is what resolve() returns. Build a trivial second
619        // font via FONT_6X10 itself referenced through a fresh handle is not
620        // enough, so assert via is_set + that resolve advances.
621        let mut wf = WidgetFont::with_font(&crate::bitmap_font::FONT_6X10);
622        assert!(wf.is_set());
623        // measure a known string through the resolved font — non-zero advance.
624        assert!(wf.resolve().measure_fp16("A") > 0);
625        wf.clear();
626        assert!(!wf.is_set());
627    }
628}