Skip to main content

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;