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;