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}