agg_gui/widgets/rich_text/layout.rs
1//! Rich-text **layout engine** — wraps a [`RichDoc`] to a fixed width with
2//! per-run fonts and sizes, producing paint-ready line/fragment geometry.
3//!
4//! The engine is deliberately catalog-agnostic: it never loads fonts itself.
5//! The caller supplies a `resolver: Fn(&InlineStyle) -> Arc<Font>` that maps a
6//! run's style (family + bold/italic) to a concrete [`Font`]. The demo crate
7//! provides a resolver backed by the system-font catalog (with faux-bold /
8//! faux-italic fallback); the library and its tests stay independent of it.
9//!
10//! # What it computes
11//!
12//! * Word wrapping across style boundaries — a "word" is a maximal run of
13//! non-whitespace pieces even when they span several differently-styled runs,
14//! so a bold suffix never wraps away from its plain prefix.
15//! * Per-visual-line height = `max(ascent + descent) * LINE_SPACING` across the
16//! fragments on that line, so a large glyph grows the whole line.
17//! * Block alignment (left / center / right) within the text column.
18//! * List markers: `•` for bullets, `N.` for ordered items, hung in a gutter to
19//! the left of the text. Ordered numbering counts consecutive `Ordered`
20//! blocks at the same indent and restarts after any break.
21//!
22//! Geometry is expressed **top-down** (y grows downward from the document top);
23//! the read-only view flips it into the framework's Y-up space at paint time.
24
25use std::sync::Arc;
26
27use super::model::{Block, InlineStyle, ListKind, RichDoc};
28use crate::text::{measure_advance, Font};
29use crate::widgets::text_area::TextHAlign;
30
31/// Indent step, in logical pixels, per [`Block::indent`] level.
32pub const INDENT_PX: f64 = 24.0;
33/// Width reserved to the left of a list item's text for its marker.
34pub const LIST_GUTTER_PX: f64 = 24.0;
35/// Gap between a list marker's right edge and the text column.
36pub const MARKER_GAP_PX: f64 = 6.0;
37/// Line-height multiplier applied to the tallest (ascent + descent) on a line.
38pub const LINE_SPACING: f64 = 1.35;
39
40/// A resolver maps a run style to the concrete font to shape/measure it with.
41pub type FontResolver<'a> = dyn Fn(&InlineStyle) -> Arc<Font> + 'a;
42
43/// One piece of a run as it sits on a single visual line: the smallest unit the
44/// painter draws. A wrapped run may contribute several fragments (one per
45/// line); adjacent same-style pieces on a line are merged into one fragment.
46#[derive(Clone)]
47pub struct LineFragment {
48 /// The fragment's characters.
49 pub text: String,
50 /// The inline style shared by every character in the fragment.
51 pub style: InlineStyle,
52 /// Font this fragment was shaped/measured with (from the resolver).
53 pub font: Arc<Font>,
54 /// Font size (points) used for this fragment.
55 pub font_size: f64,
56 /// X offset of this fragment's left edge from the start of the line's text.
57 pub x: f64,
58 /// Advance width of the fragment.
59 pub width: f64,
60 /// Ascent (px above baseline) at this fragment's size.
61 pub ascent: f64,
62 /// Descent (px below baseline) at this fragment's size.
63 pub descent: f64,
64 /// Byte offset into the owning block's **flattened** text where this
65 /// fragment's `text` begins. Within a line, kept fragments are
66 /// byte-contiguous (dropped soft-wrap spaces only occur *between* lines),
67 /// so this plus `text.len()` yields the fragment's exclusive byte end. The
68 /// interactive editor uses it to map caret [`DocPos`](super::DocPos) byte
69 /// offsets to/from pixel geometry.
70 pub start_byte: usize,
71}
72
73/// One visual line within a block.
74#[derive(Clone)]
75pub struct LineLayout {
76 /// The fragments painted on this line, left to right.
77 pub fragments: Vec<LineFragment>,
78 /// Total advance width of the line's text.
79 pub width: f64,
80 /// Line-box height = `max(ascent + descent) * LINE_SPACING`.
81 pub height: f64,
82 /// Tallest ascent across the line's fragments.
83 pub ascent: f64,
84 /// Deepest descent across the line's fragments.
85 pub descent: f64,
86 /// Alignment offset added to every fragment's `x` within the text column.
87 pub align_dx: f64,
88 /// Baseline distance measured downward from the top of the line box.
89 pub baseline_from_top: f64,
90 /// Byte offset (into the block's flattened text) of the first character on
91 /// this line, and one past the last. A soft-wrapped line's `end_byte`
92 /// excludes the trailing space that was dropped at the wrap point, so a
93 /// caret byte in that gap falls between `end_byte` of one line and
94 /// `start_byte` of the next. Both are 0 for an empty block's reserved line.
95 pub start_byte: usize,
96 pub end_byte: usize,
97}
98
99/// Layout of one block/paragraph.
100#[derive(Clone)]
101pub struct BlockLayout {
102 /// The block's visual lines, top to bottom.
103 pub lines: Vec<LineLayout>,
104 /// Left edge of the text column (indent + optional list gutter).
105 pub text_left: f64,
106 /// List marker glyph/number, if any.
107 pub marker: Option<String>,
108 /// Marker font (resolved from the block's leading style).
109 pub marker_font: Option<Arc<Font>>,
110 /// Marker font size (points).
111 pub marker_font_size: f64,
112 /// X of the marker's left edge (right-aligned into the gutter).
113 pub marker_x: f64,
114 /// Total height of the block (sum of its line heights).
115 pub height: f64,
116 /// Rightmost extent of the block (`text_left` + widest line).
117 pub width: f64,
118}
119
120/// Layout of a whole document.
121#[derive(Clone)]
122pub struct DocLayout {
123 /// Laid-out blocks, top to bottom.
124 pub blocks: Vec<BlockLayout>,
125 /// Measured content width (`max(requested width, widest block)`).
126 pub width: f64,
127 /// Total content height (sum of block heights).
128 pub height: f64,
129}
130
131/// Effective font size for a run: its explicit size or the widget default.
132fn run_size(style: &InlineStyle, default_size: f64) -> f64 {
133 style.font_size.unwrap_or(default_size)
134}
135
136/// Lay `doc` out to `width` logical pixels using `resolver` for fonts and
137/// `default_font_size` where a run leaves its size inherited.
138pub fn layout_doc(
139 doc: &RichDoc,
140 width: f64,
141 default_font_size: f64,
142 resolver: &FontResolver,
143) -> DocLayout {
144 let numbers = ordered_numbers(&doc.blocks);
145 let mut blocks = Vec::with_capacity(doc.blocks.len());
146 let mut y = 0.0f64;
147 for (bi, block) in doc.blocks.iter().enumerate() {
148 let bl = layout_block(block, numbers[bi], width, default_font_size, resolver);
149 y += bl.height;
150 blocks.push(bl);
151 }
152 let max_w = blocks.iter().map(|b| b.width).fold(0.0f64, f64::max);
153 DocLayout {
154 blocks,
155 width: width.max(max_w),
156 height: y,
157 }
158}
159
160/// Compute the ordered-list ordinal for each block (0 when the block is not an
161/// ordered item). A sequence counts consecutive `Ordered` blocks at the same
162/// indent and restarts after any break (a non-ordered block, or a different
163/// indent).
164fn ordered_numbers(blocks: &[Block]) -> Vec<usize> {
165 let mut out = vec![0usize; blocks.len()];
166 let mut counters = [0usize; (MAX_LEVELS)];
167 let mut active = [false; MAX_LEVELS];
168 for (i, block) in blocks.iter().enumerate() {
169 let d = (block.indent as usize).min(MAX_LEVELS - 1);
170 match block.list {
171 ListKind::Ordered => {
172 counters[d] = if active[d] { counters[d] + 1 } else { 1 };
173 out[i] = counters[d];
174 // Starting/continuing a run at `d` breaks every other level.
175 for (k, a) in active.iter_mut().enumerate() {
176 *a = k == d;
177 }
178 }
179 // Any non-ordered block breaks all running sequences.
180 _ => {
181 for a in active.iter_mut() {
182 *a = false;
183 }
184 }
185 }
186 }
187 out
188}
189
190const MAX_LEVELS: usize = 16;
191
192/// Lay out one block: resolve its list marker, wrap its runs, and position the
193/// resulting lines.
194fn layout_block(
195 block: &Block,
196 ordinal: usize,
197 width: f64,
198 default_font_size: f64,
199 resolver: &FontResolver,
200) -> BlockLayout {
201 let base_indent = block.indent as f64 * INDENT_PX;
202 let is_list = block.list != ListKind::None;
203 let text_left = base_indent + if is_list { LIST_GUTTER_PX } else { 0.0 };
204 let avail = (width - text_left).max(1.0);
205
206 // The marker inherits the block's leading run style so it visually matches.
207 let lead_style = block
208 .runs
209 .first()
210 .map(|r| r.style.clone())
211 .unwrap_or_default();
212 let marker_font = resolver(&lead_style);
213 let marker_font_size = run_size(&lead_style, default_font_size);
214 let marker = match block.list {
215 ListKind::None => None,
216 ListKind::Bullet => Some("\u{2022}".to_string()),
217 ListKind::Ordered => Some(format!("{ordinal}.")),
218 };
219 let marker_x = if let Some(m) = &marker {
220 let mw = measure_advance(&marker_font, m, marker_font_size);
221 (text_left - MARKER_GAP_PX - mw).max(base_indent)
222 } else {
223 base_indent
224 };
225
226 let pieces = tokenize(block, default_font_size, resolver);
227 let mut lines = wrap(pieces, avail);
228 if lines.is_empty() {
229 // Empty paragraph: reserve one blank line at the default metrics.
230 let font = resolver(&lead_style);
231 let size = run_size(&lead_style, default_font_size);
232 lines.push(LineLayout {
233 fragments: Vec::new(),
234 width: 0.0,
235 height: (font.ascender_px(size) + font.descender_px(size)) * LINE_SPACING,
236 ascent: font.ascender_px(size),
237 descent: font.descender_px(size),
238 align_dx: 0.0,
239 baseline_from_top: 0.0,
240 start_byte: 0,
241 end_byte: 0,
242 });
243 }
244
245 // Finalise per-line metrics and alignment.
246 let mut height = 0.0f64;
247 let mut max_line_w = 0.0f64;
248 for line in &mut lines {
249 finalize_line(line, avail, block.align);
250 height += line.height;
251 max_line_w = max_line_w.max(line.width);
252 }
253
254 BlockLayout {
255 lines,
256 text_left,
257 marker,
258 marker_font: Some(marker_font),
259 marker_font_size,
260 marker_x,
261 height,
262 width: text_left + max_line_w,
263 }
264}
265
266/// A tokenized piece of a run: either whitespace or a non-whitespace chunk,
267/// carrying everything needed to measure and later paint it.
268struct Piece {
269 text: String,
270 style: InlineStyle,
271 font: Arc<Font>,
272 font_size: f64,
273 width: f64,
274 ascent: f64,
275 descent: f64,
276 is_ws: bool,
277 /// Byte offset of this piece within the block's flattened text.
278 start_byte: usize,
279}
280
281/// Split a block's runs into whitespace / non-whitespace pieces, resolving the
282/// font and metrics of each. Runs are assumed to contain no `\n` (newlines are
283/// block boundaries in the model).
284fn tokenize(block: &Block, default_font_size: f64, resolver: &FontResolver) -> Vec<Piece> {
285 let mut pieces = Vec::new();
286 // Byte offset of the current run's start within the block's flattened text.
287 let mut run_start = 0usize;
288 for run in &block.runs {
289 if run.text.is_empty() {
290 continue;
291 }
292 let font = resolver(&run.style);
293 let size = run_size(&run.style, default_font_size);
294 let ascent = font.ascender_px(size);
295 let descent = font.descender_px(size);
296 // `split_ws` covers the run contiguously, so the offset of each chunk
297 // within the run is the running sum of preceding chunk lengths.
298 let mut chunk_off = 0usize;
299 for chunk in split_ws(&run.text) {
300 let is_ws = chunk.chars().next().map(|c| c.is_whitespace()).unwrap_or(false);
301 let width = measure_advance(&font, chunk, size);
302 pieces.push(Piece {
303 text: chunk.to_string(),
304 style: run.style.clone(),
305 font: Arc::clone(&font),
306 font_size: size,
307 width,
308 ascent,
309 descent,
310 is_ws,
311 start_byte: run_start + chunk_off,
312 });
313 chunk_off += chunk.len();
314 }
315 run_start += run.text.len();
316 }
317 pieces
318}
319
320/// Yield maximal alternating whitespace / non-whitespace substrings of `s`.
321fn split_ws(s: &str) -> Vec<&str> {
322 let mut out = Vec::new();
323 let mut start = 0usize;
324 let mut prev_ws: Option<bool> = None;
325 for (i, c) in s.char_indices() {
326 let ws = c.is_whitespace();
327 match prev_ws {
328 Some(p) if p != ws => {
329 out.push(&s[start..i]);
330 start = i;
331 }
332 _ => {}
333 }
334 prev_ws = Some(ws);
335 }
336 if start < s.len() {
337 out.push(&s[start..]);
338 }
339 out
340}
341
342/// Greedy word-wrap `pieces` into visual lines fitting `avail` width. A word is
343/// a maximal group of consecutive non-whitespace pieces (possibly spanning
344/// style boundaries); words are never broken. A trailing space that would sit
345/// at a wrap point is dropped.
346fn wrap(pieces: Vec<Piece>, avail: f64) -> Vec<LineLayout> {
347 let mut lines: Vec<LineLayout> = Vec::new();
348 let mut cur: Vec<Piece> = Vec::new();
349 let mut cur_w = 0.0f64;
350 let mut pending_space: Option<Piece> = None;
351
352 // Group into words separated by single whitespace pieces.
353 let mut iter = pieces.into_iter().peekable();
354 while let Some(piece) = iter.next() {
355 if piece.is_ws {
356 if !cur.is_empty() {
357 pending_space = Some(piece);
358 }
359 continue;
360 }
361 // Accumulate the whole word (this piece + following non-ws pieces).
362 let mut word = vec![piece];
363 while let Some(next) = iter.peek() {
364 if next.is_ws {
365 break;
366 }
367 word.push(iter.next().unwrap());
368 }
369 let word_w: f64 = word.iter().map(|p| p.width).sum();
370 let space_w = pending_space.as_ref().map(|p| p.width).unwrap_or(0.0);
371
372 if !cur.is_empty() && cur_w + space_w + word_w > avail {
373 lines.push(finish_pieces(std::mem::take(&mut cur)));
374 cur_w = 0.0;
375 pending_space = None;
376 } else if let Some(sp) = pending_space.take() {
377 cur_w += sp.width;
378 cur.push(sp);
379 }
380 cur_w += word_w;
381 cur.extend(word);
382 }
383 // A space still pending at the very end is a *trailing* space (typed after
384 // the last word, with no following word to wrap against). Unlike a space at
385 // a wrap point — which is dropped above — this one must stay in the line so
386 // the caret can advance onto it (spaces have no glyph but still move the
387 // pen). Matches egui, where trailing spaces advance the cursor on the row.
388 if let (Some(sp), false) = (pending_space.take(), cur.is_empty()) {
389 cur.push(sp);
390 }
391 if !cur.is_empty() {
392 lines.push(finish_pieces(cur));
393 }
394 lines
395}
396
397/// Turn a line's pieces into a [`LineLayout`], merging adjacent same-style
398/// pieces into fragments and computing x offsets and metrics.
399fn finish_pieces(pieces: Vec<Piece>) -> LineLayout {
400 let mut fragments: Vec<LineFragment> = Vec::new();
401 let mut x = 0.0f64;
402 let mut ascent = 0.0f64;
403 let mut descent = 0.0f64;
404 for p in pieces {
405 ascent = ascent.max(p.ascent);
406 descent = descent.max(p.descent);
407 if let Some(last) = fragments.last_mut() {
408 if last.style == p.style
409 && Arc::ptr_eq(&last.font, &p.font)
410 && (last.font_size - p.font_size).abs() < 1e-9
411 {
412 last.text.push_str(&p.text);
413 last.width += p.width;
414 x += p.width;
415 continue;
416 }
417 }
418 fragments.push(LineFragment {
419 text: p.text,
420 style: p.style,
421 font: p.font,
422 font_size: p.font_size,
423 x,
424 width: p.width,
425 ascent: p.ascent,
426 descent: p.descent,
427 start_byte: p.start_byte,
428 });
429 x += p.width;
430 }
431 // Byte span covered by this line: first fragment's start to the last
432 // fragment's byte end (kept fragments on a line are byte-contiguous).
433 let (start_byte, end_byte) = match (fragments.first(), fragments.last()) {
434 (Some(first), Some(last)) => (first.start_byte, last.start_byte + last.text.len()),
435 _ => (0, 0),
436 };
437 LineLayout {
438 fragments,
439 width: x,
440 height: 0.0,
441 ascent,
442 descent,
443 align_dx: 0.0,
444 baseline_from_top: 0.0,
445 start_byte,
446 end_byte,
447 }
448}
449
450/// Fill in a line's height, baseline, and alignment offset now that its width
451/// and the column width are known.
452fn finalize_line(line: &mut LineLayout, avail: f64, align: TextHAlign) {
453 let content = line.ascent + line.descent;
454 line.height = content * LINE_SPACING;
455 let leading = line.height - content;
456 line.baseline_from_top = leading * 0.5 + line.ascent;
457 line.align_dx = match align {
458 TextHAlign::Left => 0.0,
459 TextHAlign::Center => ((avail - line.width) * 0.5).max(0.0),
460 TextHAlign::Right => (avail - line.width).max(0.0),
461 };
462}
463
464#[cfg(test)]
465#[path = "layout_tests.rs"]
466mod layout_tests;