Skip to main content

rotulus_layout/
measure.rs

1//! Text measurement, abstracted.
2//!
3//! This trait is the reason the rest of the crate has no dependencies.
4//! Shaping text genuinely needs a font stack, but everything *around*
5//! shaping — wrapping, height indexing, scroll anchoring, the message
6//! model — does not, and keeping the font stack behind a trait is what
7//! lets the whole engine run under `cargo test` on display-less CI.
8//!
9//! The view supplies a Pango-backed implementation. The tests here
10//! supply [`FixedMeasure`], where every character is exactly N pixels
11//! wide, which makes wrap assertions exact and readable instead of
12//! font-dependent and brittle.
13//!
14//! It is also a hedge against xtext's worst performance bug. Its
15//! `find_next_wrap` (xtext.c:3685) called `backend_get_text_width_emph`
16//! **per character**, and that did a `pango_layout_set_text` +
17//! `pango_layout_get_pixel_size` round trip each time. The trait's unit
18//! of work is a run, never a character, so an implementation physically
19//! cannot repeat that mistake.
20
21use crate::span::Style;
22
23/// Font metrics the layout engine needs before it measures anything.
24#[derive(Clone, Copy, PartialEq, Debug)]
25pub struct FontMetrics {
26    /// Distance from one baseline to the next.
27    pub line_height: u32,
28    /// Baseline offset from the top of the line box.
29    pub ascent: u32,
30    /// Width of a space, used for the indent column's minimum.
31    pub space_width: u32,
32}
33
34impl Default for FontMetrics {
35    fn default() -> Self {
36        FontMetrics {
37            line_height: 16,
38            ascent: 12,
39            space_width: 8,
40        }
41    }
42}
43
44/// Measures runs of styled text.
45///
46/// Implementations must be deterministic for a given
47/// `(text, style, scale)` — the layout cache assumes that a re-measure
48/// at the same [`LayoutGeneration`](crate::LayoutGeneration) yields the
49/// same answer.
50pub trait TextMeasure {
51    /// Metrics at the current scale.
52    fn metrics(&self) -> FontMetrics;
53
54    /// Advance width of `text` drawn with `style`, in pixels.
55    ///
56    /// `text` is a single run: one style, no newlines.
57    fn run_width(&self, text: &str, style: Style) -> u32;
58
59    /// The largest prefix of `text` that fits in `max_width`, returned as
60    /// a byte offset, together with its width.
61    ///
62    /// # Contract
63    ///
64    /// - The returned offset is always on a `char` boundary.
65    /// - The returned width is `<= max_width` **except** in the
66    ///   minimum-progress case below.
67    /// - **Minimum progress:** for non-empty `text` and non-zero
68    ///   `max_width`, the offset is always `> 0`, even when not a single
69    ///   character fits. This is deliberate and the overflow is
70    ///   unavoidable — you cannot render less than one character, and the
71    ///   alternative (returning 0) makes the wrap loop in
72    ///   [`crate::wrap`] unable to advance. A single over-wide grapheme
73    ///   clips; an infinite loop hangs the UI.
74    ///
75    /// The default implementation is a binary search over
76    /// [`TextMeasure::run_width`] on character boundaries — correct for
77    /// any monotonic measurer and adequate for the fixed-width test one.
78    /// A Pango implementation should override it with a single
79    /// `pango_layout_line_index_to_x`-style query rather than paying
80    /// `log n` shaping passes.
81    fn fit_prefix(&self, text: &str, style: Style, max_width: u32) -> (usize, u32) {
82        if text.is_empty() {
83            return (0, 0);
84        }
85        let full = self.run_width(text, style);
86        if full <= max_width {
87            return (text.len(), full);
88        }
89        let mut lo = 0usize;
90        let mut hi = text.len();
91        let mut best = (0usize, 0u32);
92        while lo < hi {
93            let mut mid = lo + (hi - lo) / 2;
94            // Snap to a char boundary; without this the loop can stall
95            // on a multi-byte character.
96            while mid > lo && !text.is_char_boundary(mid) {
97                mid -= 1;
98            }
99            if mid == lo {
100                break;
101            }
102            let w = self.run_width(&text[..mid], style);
103            if w <= max_width {
104                best = (mid, w);
105                lo = mid;
106            } else {
107                hi = mid;
108            }
109        }
110        best
111    }
112
113    /// Rendered size of an image block, given its intrinsic size and the
114    /// width available.
115    ///
116    /// Default: scale down to fit, never up, preserving aspect ratio.
117    fn image_size(&self, intrinsic: (u32, u32), max_width: u32) -> (u32, u32) {
118        let (w, h) = intrinsic;
119        if w == 0 || h == 0 {
120            return (0, 0);
121        }
122        if w <= max_width {
123            return (w, h);
124        }
125        let scaled_h = ((h as u64 * max_width as u64) / w as u64) as u32;
126        (max_width, scaled_h.max(1))
127    }
128}
129
130/// Deterministic measurer for tests: every character is `char_width`
131/// pixels wide regardless of style.
132///
133/// Not a toy — it is what makes the wrap tests assert exact byte offsets
134/// rather than "roughly here", and it is the reference implementation
135/// the Pango one is checked against for monotonicity.
136#[derive(Clone, Copy, Debug)]
137pub struct FixedMeasure {
138    pub char_width: u32,
139    pub metrics: FontMetrics,
140}
141
142impl FixedMeasure {
143    pub fn new(char_width: u32) -> FixedMeasure {
144        FixedMeasure {
145            char_width,
146            metrics: FontMetrics {
147                line_height: 16,
148                ascent: 12,
149                space_width: char_width,
150            },
151        }
152    }
153}
154
155impl Default for FixedMeasure {
156    fn default() -> Self {
157        FixedMeasure::new(8)
158    }
159}
160
161impl TextMeasure for FixedMeasure {
162    fn metrics(&self) -> FontMetrics {
163        self.metrics
164    }
165
166    fn run_width(&self, text: &str, _style: Style) -> u32 {
167        (text.chars().count() as u32) * self.char_width
168    }
169}