Skip to main content

smudge_text/
layout.rs

1//! A laid out block of text.
2//!
3//! [`TextLayout`] wraps parley's layout so that callers shape one style of
4//! text against a maximum width and read back the resulting size and glyph
5//! runs. Parley stays an implementation detail of the layout itself: the
6//! rest of Smudge sees this wrapper, the font style types this crate
7//! re-exports, and, through the run iterator, parley's own public run types.
8
9use crate::glyphs::ellipsis_glyph;
10use crate::style::TextStyle;
11use crate::system::TextSystem;
12use parley::PositionedLayoutItem;
13use parley::StyleProperty;
14use parley::style::LineHeight;
15use smudge_core::geometry::Size;
16
17/// A block of text laid out in logical pixels against one style.
18///
19/// The layout is computed eagerly by [`TextLayout::new`] and never changes
20/// afterwards. A new layout replaces an old one rather than being edited. It
21/// carries no color: layout runs without a theme, and the caller paints the
22/// glyph runs in whatever color it resolves when it paints.
23pub struct TextLayout {
24    /// The shaped and line-broken text.
25    pub(crate) layout: parley::Layout<()>,
26    /// The truncation the layout was last cut to, or `None` when it shows
27    /// every line.
28    ///
29    /// The text stays shaped and broken whole: truncation only decides what
30    /// [`TextLayout::size`] measures and which lines and glyphs the
31    /// conversion reports, so a later re-break drops it for free.
32    pub(crate) truncation: Option<Truncation>,
33    /// The ellipsis of the style's primary font, which truncation falls
34    /// back to when the font of the run it cuts maps none. `None` until a cut
35    /// first needs it, and `Some(None)` when that font maps none either.
36    ///
37    /// It is resolved at most once per shaped text, since a style change
38    /// lays the text out anew.
39    fallback_ellipsis: Option<Option<Ellipsis>>,
40}
41
42/// The truncation of a laid out text block: how many lines survive and how
43/// the last of them is cut.
44#[derive(Clone, Debug)]
45pub(crate) struct Truncation {
46    /// The number of leading lines the truncated layout keeps.
47    pub(crate) kept_lines: usize,
48    /// The cut of the last kept line, or `None` when that line keeps all of
49    /// itself: it has no glyph runs to cut, or the layout runs right to left
50    /// and only drops lines.
51    pub(crate) cut: Option<Cut>,
52    /// The width the truncated layout reports.
53    pub(crate) width: f32,
54    /// The height the truncated layout reports.
55    pub(crate) height: f32,
56}
57
58/// The cut of one line: where its glyphs stop and the ellipsis that replaces
59/// the rest.
60#[derive(Clone, Debug)]
61pub(crate) struct Cut {
62    /// The index of the glyph run the cut falls into, counted over the glyph
63    /// runs of the line in visual order. The runs before it survive whole.
64    pub(crate) run: usize,
65    /// The number of leading glyphs of that run the line keeps, which always
66    /// ends on a cluster boundary.
67    pub(crate) glyphs: usize,
68    /// The pen position the ellipsis starts at, relative to the layout's
69    /// left edge.
70    pub(crate) x: f32,
71    /// The baseline of the cut line, relative to the layout's top edge.
72    pub(crate) baseline: f32,
73    /// The ellipsis appended after the kept glyphs, or `None` when no font
74    /// maps one and the line ends without it.
75    pub(crate) ellipsis: Option<Ellipsis>,
76}
77
78/// The ellipsis glyph truncation appends in place of cut text.
79#[derive(Clone, Debug)]
80pub(crate) struct Ellipsis {
81    /// The font the glyph comes from.
82    pub(crate) font: parley::FontData,
83    /// The font size the glyph paints at.
84    pub(crate) size: f32,
85    /// The glyph identifier of the horizontal ellipsis, or of the full stop
86    /// when the font lacks one.
87    pub(crate) glyph: u32,
88    /// How many times the glyph paints: once for the ellipsis, three times
89    /// for the full stops that stand in for it.
90    pub(crate) dots: u32,
91    /// The advance of one painted glyph in logical pixels.
92    pub(crate) advance: f32,
93}
94
95impl Ellipsis {
96    /// The advance of the whole ellipsis, every painted dot included.
97    pub(crate) fn width(&self) -> f32 {
98        self.advance * self.dots as f32
99    }
100}
101
102/// The longest prefix of a line found so far that fits with its ellipsis.
103struct Candidate {
104    /// The glyph run the prefix ends in.
105    run: usize,
106    /// The glyphs of that run the prefix keeps.
107    glyphs: usize,
108    /// The pen position after the prefix.
109    x: f32,
110    /// The pen position after the prefix and its ellipsis, measured from the
111    /// line's start.
112    end: f32,
113}
114
115impl TextLayout {
116    /// Lays out `text` with `style`, wrapping lines at `max_width` logical
117    /// pixels, or not at all when it is `None`.
118    ///
119    /// Layout runs against the current thread's [`TextSystem`].
120    ///
121    /// # Panics
122    ///
123    /// Panics under the conditions of [`TextSystem::with`], in particular
124    /// when called from inside another `with` call on the same thread.
125    pub fn new(text: &str, style: &TextStyle, max_width: Option<f32>) -> TextLayout {
126        TextSystem::with(|system| {
127            Self::build(
128                text,
129                style,
130                max_width,
131                &mut system.fonts,
132                &mut system.layouts,
133            )
134        })
135    }
136
137    /// Lays out `text` with `style` against `max_width` using the font and
138    /// layout contexts the caller already holds.
139    ///
140    /// [`TextLayout::new`] is this over the current thread's [`TextSystem`].
141    /// The editor keeps one `with` call open across its work and builds its
142    /// paragraph layouts through this, which a nested `with` would panic on.
143    pub(crate) fn build(
144        text: &str,
145        style: &TextStyle,
146        max_width: Option<f32>,
147        fonts: &mut parley::FontContext,
148        layouts: &mut parley::LayoutContext<()>,
149    ) -> TextLayout {
150        // Layout units are logical pixels; scaling to a device happens in
151        // the renderer, not here. Coordinates stay fractional so that a
152        // renderer can quantize them itself.
153        #[cfg(test)]
154        SHAPES.with(|shapes| shapes.set(shapes.get() + 1));
155        let mut builder = layouts.ranged_builder(fonts, text, 1.0, false);
156        builder.push_default(StyleProperty::FontFamily(style.family.clone()));
157        builder.push_default(StyleProperty::FontSize(style.size));
158        builder.push_default(StyleProperty::FontWeight(style.weight));
159        builder.push_default(StyleProperty::LineHeight(LineHeight::FontSizeRelative(
160            style.line_height,
161        )));
162
163        let mut layout = builder.build(text);
164        layout.break_all_lines(max_width);
165        TextLayout {
166            layout,
167            truncation: None,
168            fallback_ellipsis: None,
169        }
170    }
171
172    /// Re-wraps the laid out text at `max_width` without shaping it again.
173    ///
174    /// Shaping runs once per text and style in [`TextLayout::new`]; the width
175    /// only decides where lines break, so a layout engine that asks for the
176    /// same text at several widths in one pass pays for the breaks alone.
177    /// The lines start over, so a truncation cut by [`TextLayout::truncate`]
178    /// is dropped.
179    pub(crate) fn rebreak(&mut self, max_width: Option<f32>) {
180        self.layout.break_all_lines(max_width);
181        self.truncation = None;
182    }
183
184    /// Cuts the layout to at most `max_lines` lines, ending the last kept
185    /// line with an ellipsis that fits `width`.
186    ///
187    /// Lines after the `max_lines` first are dropped. When more lines than
188    /// that result at `width`, or the last kept line is wider than `width`,
189    /// the last kept line keeps the longest prefix of whole clusters that
190    /// fits the width once the ellipsis is counted, and the ellipsis
191    /// replaces the rest of the line. Whitespace never ends the kept prefix.
192    /// The ellipsis is the horizontal ellipsis, or three full stops where a
193    /// font lacks it, from the font of the run it ends, or from the style's
194    /// primary font when that font maps neither. When no font maps one, the
195    /// line ends without it. It stands alone when the width cannot fit even
196    /// it.
197    ///
198    /// Without a width the last kept line stays whole and the ellipsis
199    /// follows it.
200    ///
201    /// The text stays laid out whole, so a later [`TextLayout::rebreak`]
202    /// undoes the cut without reshaping. A layout whose dominant direction
203    /// is right-to-left keeps its first lines without an ellipsis, since the
204    /// ellipsis would have to lead the line rather than close it.
205    ///
206    /// `style` must be the style the layout was built with.
207    ///
208    /// # Panics
209    ///
210    /// Panics under the conditions of [`TextSystem::with`] when the style's
211    /// ellipsis has to be resolved, in particular when called from inside
212    /// another `with` call on the same thread.
213    pub(crate) fn truncate(
214        &mut self,
215        max_lines: Option<u32>,
216        width: Option<f32>,
217        style: &TextStyle,
218    ) {
219        self.truncate_resolving(max_lines, width, style, &mut ellipsis_glyph);
220    }
221
222    /// Cuts the layout as [`TextLayout::truncate`] does, with `own`
223    /// resolving the ellipsis of a run's own font and the style's ellipsis
224    /// standing in where it resolves none.
225    ///
226    /// The style's ellipsis is laid out the first time a cut needs it and
227    /// kept with the shaped text, so later cuts and re-breaks reuse it.
228    pub(crate) fn truncate_resolving(
229        &mut self,
230        max_lines: Option<u32>,
231        width: Option<f32>,
232        style: &TextStyle,
233        own: &mut dyn FnMut(&parley::FontData, f32) -> Option<Ellipsis>,
234    ) {
235        let mut fallback = self.fallback_ellipsis.take();
236        self.truncate_with(max_lines, width, &mut |font, size| {
237            own(font, size).or_else(|| {
238                fallback
239                    .get_or_insert_with(|| style_ellipsis(style))
240                    .clone()
241            })
242        });
243        self.fallback_ellipsis = fallback;
244    }
245
246    /// Cuts the layout as [`TextLayout::truncate`] does, with the ellipsis
247    /// that `ellipsis_for` resolves for a run's font and size, or none when
248    /// it returns `None`.
249    pub(crate) fn truncate_with(
250        &mut self,
251        max_lines: Option<u32>,
252        width: Option<f32>,
253        ellipsis_for: &mut dyn FnMut(&parley::FontData, f32) -> Option<Ellipsis>,
254    ) {
255        self.truncation = None;
256        let Some(max_lines) = max_lines else {
257            return;
258        };
259        let kept_lines = (max_lines as usize).min(self.layout.len());
260        if kept_lines == 0 {
261            if !self.layout.is_empty() {
262                self.truncation = Some(Truncation {
263                    kept_lines: 0,
264                    cut: None,
265                    width: 0.0,
266                    height: 0.0,
267                });
268            }
269            return;
270        }
271        let Some(line) = self.layout.get(kept_lines - 1) else {
272            return;
273        };
274        let overflows = width.is_some_and(|width| line_width(&line) > width);
275        if self.layout.len() == kept_lines && !overflows {
276            return;
277        }
278        let height = self.kept_height(kept_lines);
279        if self.layout.is_rtl() {
280            self.truncation = Some(Truncation {
281                kept_lines,
282                cut: None,
283                width: self.kept_width(kept_lines),
284                height,
285            });
286            return;
287        }
288        let Some((cut, end)) = cut_line(&line, width.unwrap_or(f32::INFINITY), ellipsis_for) else {
289            // The cut line has no glyph runs, so there is nothing to cut and
290            // no ellipsis to place; the lines after it still go.
291            self.truncation = Some(Truncation {
292                kept_lines,
293                cut: None,
294                width: self.kept_width(kept_lines),
295                height,
296            });
297            return;
298        };
299        // The cut line reports the clusters it keeps plus the ellipsis, so
300        // the lines before it alone decide the widest kept line.
301        self.truncation = Some(Truncation {
302            kept_lines,
303            cut: Some(cut),
304            width: self.kept_width(kept_lines - 1).max(end),
305            height,
306        });
307    }
308
309    /// Returns the widest advance of the first `kept_lines` lines, without
310    /// the whitespace that hangs past a line's end, which is the measure
311    /// [`parley::Layout::width`] reports.
312    fn kept_width(&self, kept_lines: usize) -> f32 {
313        self.layout
314            .lines()
315            .take(kept_lines)
316            .map(|line| line_width(&line))
317            .fold(0.0f32, f32::max)
318    }
319
320    /// Returns the summed line height of the first `kept_lines` lines.
321    fn kept_height(&self, kept_lines: usize) -> f32 {
322        self.layout
323            .lines()
324            .take(kept_lines)
325            .map(|line| line.metrics().line_height)
326            .sum()
327    }
328
329    /// Returns the width and height of the laid out text in logical pixels.
330    ///
331    /// The width is the longest line, without hanging whitespace. The height
332    /// is the sum of the line heights. A layout cut by truncation reports
333    /// the kept lines and the cut line's glyphs plus its ellipsis, so
334    /// callers measure what paint draws.
335    pub fn size(&self) -> Size {
336        match &self.truncation {
337            Some(truncation) => Size {
338                width: truncation.width,
339                height: truncation.height,
340            },
341            None => Size {
342                width: self.layout.width(),
343                height: self.layout.height(),
344            },
345        }
346    }
347
348    /// Returns the number of lines the text was broken into.
349    ///
350    /// A layout cut by truncation reports the lines it keeps.
351    pub fn line_count(&self) -> usize {
352        match &self.truncation {
353            Some(truncation) => truncation.kept_lines,
354            None => self.layout.len(),
355        }
356    }
357
358    /// Iterates the glyph runs of every line, in visual order.
359    ///
360    /// A glyph run carries its font, size and positioned glyphs, and no color:
361    /// the caller supplies it when converting runs with `glyph_runs_at`. The
362    /// item type is parley's, and its coordinates are relative to the top
363    /// left of the layout. The runs of lines a truncation dropped are not
364    /// yielded.
365    pub fn glyph_runs(&self) -> impl Iterator<Item = parley::GlyphRun<'_, ()>> + Clone + '_ {
366        let kept_lines = self
367            .truncation
368            .as_ref()
369            .map_or(usize::MAX, |truncation| truncation.kept_lines);
370        self.layout.lines().take(kept_lines).flat_map(|line| {
371            line.items().filter_map(|item| match item {
372                PositionedLayoutItem::GlyphRun(run) => Some(run),
373                PositionedLayoutItem::InlineBox(_) => None,
374            })
375        })
376    }
377}
378
379/// Returns the advance of `line` without the whitespace that hangs past
380/// its end, which is the measure [`parley::Layout::width`] reports.
381fn line_width(line: &parley::Line<'_, ()>) -> f32 {
382    let metrics = line.metrics();
383    metrics.advance - metrics.hanging_advance
384}
385
386/// Finds the longest prefix of whole clusters of `line` that fits `width`
387/// together with the ellipsis of the run it ends in, and returns the cut
388/// with the pen position after its ellipsis. Returns `None` when the line
389/// has no glyph runs.
390///
391/// A cluster run together with the ligature continuations that share its
392/// glyphs forms one unit, so the cut never splits a cluster or a ligature.
393/// A prefix that ends in whitespace is not a cut, so the ellipsis never
394/// follows a space or a line break. The empty prefix always is one: an
395/// ellipsis wider than the width stands alone.
396///
397/// A label's text has one style, so each glyph run covers its whole run and
398/// the run's clusters account for every glyph it paints.
399fn cut_line(
400    line: &parley::Line<'_, ()>,
401    width: f32,
402    ellipsis_for: &mut dyn FnMut(&parley::FontData, f32) -> Option<Ellipsis>,
403) -> Option<(Cut, f32)> {
404    let mut best: Option<Candidate> = None;
405    let mut best_ellipsis: Option<Ellipsis> = None;
406    let mut spent = 0.0f32;
407    let mut run_index = 0usize;
408    'line: for item in line.items() {
409        let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
410            continue;
411        };
412        let run = glyph_run.run();
413        let ellipsis = ellipsis_for(run.font(), run.font_size());
414        let dots = ellipsis.as_ref().map_or(0.0, Ellipsis::width);
415        if best.is_none() {
416            best = Some(Candidate {
417                run: run_index,
418                glyphs: 0,
419                x: glyph_run.offset(),
420                end: dots,
421            });
422            best_ellipsis.clone_from(&ellipsis);
423        }
424        let run_start = spent;
425        let rtl = run.is_rtl();
426        let mut glyphs = 0usize;
427        let mut unit_glyphs = 0usize;
428        let mut unit_advance = 0.0f32;
429        let mut unit_blank = true;
430        let mut clusters = run.visual_clusters().peekable();
431        while let Some(cluster) = clusters.next() {
432            unit_glyphs += cluster.glyphs().count();
433            unit_advance += cluster.advance();
434            unit_blank &= cluster.is_space_or_nbsp() || cluster.is_hard_line_break();
435            // In visual order a left-to-right ligature starts its unit and a
436            // right-to-left one ends it.
437            let complete = if rtl {
438                !cluster.is_ligature_continuation()
439            } else {
440                clusters
441                    .peek()
442                    .is_none_or(|next| !next.is_ligature_continuation())
443            };
444            if !complete {
445                continue;
446            }
447            if spent + unit_advance > width {
448                break 'line;
449            }
450            spent += unit_advance;
451            glyphs += unit_glyphs;
452            if !unit_blank && spent + dots <= width {
453                if best.as_ref().is_some_and(|best| best.run != run_index) {
454                    best_ellipsis.clone_from(&ellipsis);
455                }
456                best = Some(Candidate {
457                    run: run_index,
458                    glyphs,
459                    x: glyph_run.offset() + (spent - run_start),
460                    end: spent + dots,
461                });
462            }
463            unit_glyphs = 0;
464            unit_advance = 0.0;
465            unit_blank = true;
466        }
467        run_index += 1;
468    }
469    let best = best?;
470    Some((
471        Cut {
472            run: best.run,
473            glyphs: best.glyphs,
474            x: best.x,
475            baseline: line.metrics().baseline,
476            ellipsis: best_ellipsis,
477        },
478        best.end,
479    ))
480}
481
482/// Resolves the ellipsis of `style`'s primary font by laying the ellipsis
483/// out with the style, or `None` when the font the style resolves it to
484/// maps neither an ellipsis nor a full stop.
485pub(crate) fn style_ellipsis(style: &TextStyle) -> Option<Ellipsis> {
486    let layout = TextLayout::new("\u{2026}", style, None);
487    let run = layout.glyph_runs().next()?;
488    ellipsis_glyph(run.run().font(), run.run().font_size())
489}
490
491#[cfg(test)]
492thread_local! {
493    /// How many times this thread shaped text, which tests read to tell a
494    /// re-break from a reshape.
495    pub(crate) static SHAPES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
496}
497
498#[cfg(test)]
499#[path = "layout_tests.rs"]
500mod tests;