Skip to main content

i_slint_core/textlayout/sharedparley/
layout.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4//! From shaped paragraphs to a [`Layout`]: line breaking, alignment, and the elision and
5//! `max-lines` cuts, plus the queries the entry points ask of the result.
6
7use super::shaping::{Brush, TextParagraph};
8use super::*;
9use crate::items::TextCursorAffinity;
10
11impl From<TextCursorAffinity> for parley::layout::Affinity {
12    fn from(affinity: TextCursorAffinity) -> Self {
13        match affinity {
14            TextCursorAffinity::NextCharacter => Self::Downstream,
15            TextCursorAffinity::PreviousCharacter => Self::Upstream,
16        }
17    }
18}
19
20impl From<parley::layout::Affinity> for TextCursorAffinity {
21    fn from(affinity: parley::layout::Affinity) -> Self {
22        match affinity {
23            parley::layout::Affinity::Downstream => Self::NextCharacter,
24            parley::layout::Affinity::Upstream => Self::PreviousCharacter,
25        }
26    }
27}
28
29#[derive(Default)]
30pub(super) struct LayoutOptions {
31    pub(super) max_width: Option<LogicalLength>,
32    pub(super) max_height: Option<LogicalLength>,
33    /// Maximum number of visible lines across all paragraphs.
34    pub(super) max_lines: Option<usize>,
35    pub(super) horizontal_align: TextHorizontalAlignment,
36    pub(super) vertical_align: TextVerticalAlignment,
37    pub(super) text_overflow: TextOverflow,
38}
39
40impl LayoutOptions {
41    pub(super) fn new_from_textinput(
42        text_input: Pin<&crate::items::TextInput>,
43        max_width: Option<LogicalLength>,
44        max_height: Option<LogicalLength>,
45    ) -> Self {
46        Self {
47            max_width,
48            max_height,
49            max_lines: None,
50            horizontal_align: text_input.horizontal_alignment(),
51            vertical_align: text_input.vertical_alignment(),
52            text_overflow: TextOverflow::Clip,
53        }
54    }
55}
56
57/// The inputs the line breaking and its derived metrics depend on. Two [`layout`] calls with
58/// equal inputs produce identical breaking for the same shaped paragraphs, so a matching
59/// [`RetainedLineBreaking`] lets [`layout`] skip re-breaking every paragraph. Deliberately absent:
60/// `max_height` and the vertical alignment, which only feed the per-call `y_offset` and the
61/// height-based elision cut, both computed on the [`Layout`] itself.
62#[derive(Clone, Copy, PartialEq)]
63struct LineBreakingInputs {
64    max_physical_width: Option<PhysicalLength>,
65    /// The alignment as parley receives it, so `Start` and `Left` don't spuriously differ.
66    alignment: parley::Alignment,
67    max_lines: Option<usize>,
68    text_overflow: TextOverflow,
69}
70
71impl LineBreakingInputs {
72    fn new(options: &LayoutOptions, max_physical_width: Option<PhysicalLength>) -> Self {
73        Self {
74            max_physical_width,
75            alignment: match options.horizontal_align {
76                TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => {
77                    parley::Alignment::Left
78                }
79                TextHorizontalAlignment::Center => parley::Alignment::Center,
80                TextHorizontalAlignment::End | TextHorizontalAlignment::Right => {
81                    parley::Alignment::Right
82                }
83            },
84            max_lines: options.max_lines,
85            text_overflow: options.text_overflow,
86        }
87    }
88}
89
90/// What a full [`layout`] pass computed, retained in the cache entry alongside the shaped
91/// paragraphs. The parley layouts already hold their broken lines and every `TextParagraph::y`
92/// its position, so together with these metrics the next [`layout`] call with equal
93/// [`LineBreakingInputs`] has nothing left to do.
94pub(super) struct RetainedLineBreaking {
95    inputs: LineBreakingInputs,
96    line_limit_cut: Option<(usize, usize)>,
97    max_width: PhysicalLength,
98    height: PhysicalLength,
99    elision_info: Option<ElisionInfo>,
100}
101
102/// Where vertical alignment puts the text within the box. Per call, not retained: it depends on
103/// `max_height`, which changes freely (e.g. during a resize) without affecting the breaking.
104fn vertical_offset(
105    max_physical_height: Option<PhysicalLength>,
106    vertical_align: TextVerticalAlignment,
107    height: PhysicalLength,
108) -> PhysicalLength {
109    match (max_physical_height, vertical_align) {
110        (Some(max_height), TextVerticalAlignment::Center) => (max_height - height) / 2.0,
111        (Some(max_height), TextVerticalAlignment::Bottom) => max_height - height,
112        (None, _) | (Some(_), TextVerticalAlignment::Top) => PhysicalLength::new(0.0),
113    }
114}
115
116pub(super) fn layout(
117    layout_builder: &LayoutWithoutLineBreaksBuilder,
118    font_context: &mut parley::FontContext,
119    mut paragraphs: Vec<TextParagraph>,
120    scale_factor: ScaleFactor,
121    options: LayoutOptions,
122    line_breaking: Option<RetainedLineBreaking>,
123) -> Layout {
124    let max_physical_width = options.max_width.map(|max_width| max_width * scale_factor);
125    let max_physical_height = options.max_height.map(|max_height| max_height * scale_factor);
126
127    let inputs = LineBreakingInputs::new(&options, max_physical_width);
128    if let Some(line_breaking) =
129        line_breaking.filter(|line_breaking| line_breaking.inputs == inputs)
130    {
131        return Layout {
132            y_offset: vertical_offset(
133                max_physical_height,
134                options.vertical_align,
135                line_breaking.height,
136            ),
137            paragraphs,
138            max_width: line_breaking.max_width,
139            height: line_breaking.height,
140            max_physical_height,
141            elision_info: line_breaking.elision_info,
142            line_limit_cut: line_breaking.line_limit_cut,
143            line_breaking_inputs: inputs,
144            broke_lines: false,
145        };
146    }
147
148    // Returned None if failed to get the ellipsis glyph for some rare reason.
149    let get_ellipsis_glyph = |font_context: &mut parley::FontContext| {
150        let mut layout = layout_builder.build(font_context, "…", None, None);
151        layout.break_all_lines(None);
152        let line = layout.lines().next()?;
153        let item = line.items().next()?;
154        let run = match item {
155            parley::layout::PositionedLayoutItem::GlyphRun(run) => Some(run),
156            _ => return None,
157        }?;
158        let glyph = run.positioned_glyphs().next()?;
159        Some((glyph, run.run().font().clone()))
160    };
161
162    let elision_info = if let (TextOverflow::Elide, Some(max_physical_width)) =
163        (options.text_overflow, max_physical_width)
164    {
165        get_ellipsis_glyph(font_context).map(|(ellipsis_glyph, font_for_ellipsis_glyph)| {
166            ElisionInfo { ellipsis_glyph, font_for_ellipsis_glyph, max_physical_width }
167        })
168    } else {
169        None
170    };
171
172    let mut para_y = 0.0;
173    for para in paragraphs.iter_mut() {
174        para.layout.break_all_lines(max_physical_width.map(|width| width.get()));
175        para.layout.align(inputs.alignment, parley::AlignmentOptions::default());
176
177        para.y = PhysicalLength::new(para_y);
178        para_y += para.layout.height();
179    }
180
181    let line_limit_cut =
182        options.max_lines.and_then(|max_lines| line_limit_cut(&paragraphs, max_lines));
183    let visible_paragraph_count =
184        line_limit_cut.map_or(paragraphs.len(), |(last_paragraph, _)| last_paragraph + 1);
185
186    let max_width = paragraphs
187        .iter()
188        .take(visible_paragraph_count)
189        .enumerate()
190        .map(|(paragraph_index, p)| {
191            // The max width is used for the ellipsis computation when eliding text. We *want* to exclude whitespace
192            // for that, but we can't at the glyph run level, so the glyph runs always *do* include whitespace glyphs,
193            // and as such we must also accept the full width here including trailing whitespace, otherwise text with
194            // trailing whitespace will assigned a smaller width for rendering and thus the ellipsis will be placed.
195            match line_limit_cut {
196                // In the paragraph where the line limit lands, only the kept lines count towards
197                // the width; `full_width()` would also span the dropped lines below the cut. Per
198                // line, mirror parley's `full_width` formula (Slint doesn't use indentation).
199                Some((last_paragraph, last_line)) if paragraph_index == last_paragraph => p
200                    .layout
201                    .lines()
202                    .take(last_line + 1)
203                    .map(|line| {
204                        let metrics = line.metrics();
205                        PhysicalLength::new(metrics.inline_min_coord + metrics.advance)
206                    })
207                    .fold(PhysicalLength::zero(), PhysicalLength::max),
208                _ => PhysicalLength::new(p.layout.full_width()),
209            }
210        })
211        .fold(PhysicalLength::zero(), PhysicalLength::max);
212    // With an active line limit, the height only extends to the bottom of the last kept line, so
213    // that the preferred height and vertical alignment are based on what is actually shown.
214    let height = match line_limit_cut {
215        Some((last_paragraph, last_line)) => {
216            let para = &paragraphs[last_paragraph];
217            let line = para
218                .layout
219                .lines()
220                .nth(last_line)
221                .expect("line_limit_cut returns an existing line index");
222            para.y + PhysicalLength::new(line.metrics().block_max_coord)
223        }
224        None => paragraphs
225            .last()
226            .map_or(PhysicalLength::zero(), |p| p.y + PhysicalLength::new(p.layout.height())),
227    };
228
229    let y_offset = vertical_offset(max_physical_height, options.vertical_align, height);
230
231    Layout {
232        paragraphs,
233        y_offset,
234        elision_info,
235        max_width,
236        height,
237        max_physical_height,
238        line_limit_cut,
239        line_breaking_inputs: inputs,
240        broke_lines: true,
241    }
242}
243
244/// Where a `max-lines` limit cuts the text off: the (paragraph index, line index within that
245/// paragraph) of the last kept line. Returns `None` when all lines fit the limit, so an active
246/// cut always means that at least one line was dropped.
247fn line_limit_cut(paragraphs: &[TextParagraph], max_lines: usize) -> Option<(usize, usize)> {
248    let total_lines: usize = paragraphs.iter().map(|p| p.layout.lines().len()).sum();
249    if total_lines <= max_lines {
250        return None;
251    }
252
253    let mut seen_lines = 0;
254    for (paragraph_index, para) in paragraphs.iter().enumerate() {
255        let line_count = para.layout.lines().len();
256        // seen_lines < max_lines holds on entry, so the cut line index can't underflow and
257        // lands within this paragraph's lines.
258        if seen_lines + line_count >= max_lines {
259            return Some((paragraph_index, max_lines - seen_lines - 1));
260        }
261        seen_lines += line_count;
262    }
263    unreachable!("total_lines > max_lines, so the paragraph with the last kept line exists")
264}
265
266struct ElisionInfo {
267    ellipsis_glyph: parley::layout::Glyph,
268    font_for_ellipsis_glyph: parley::FontData,
269    max_physical_width: PhysicalLength,
270}
271
272/// Whether a line whose bottom edge is at `block_max_coord` fits within `max_physical_height`,
273/// rounding the height up so a sub-pixel overflow still counts as fitting.
274fn line_fits_height(block_max_coord: f32, max_physical_height: PhysicalLength) -> bool {
275    max_physical_height.get().ceil() >= block_max_coord
276}
277
278/// Where `overflow: elide` cuts text off, computed across all paragraphs (each explicit `\n`
279/// produces one paragraph). See [`Layout::elision_extent`].
280#[derive(Clone, Copy)]
281pub(super) struct ElisionCut {
282    /// Paragraph holding the last kept line.
283    pub(super) last_paragraph: usize,
284    /// Last kept line within `last_paragraph`.
285    pub(super) last_line: usize,
286    /// A line below the kept one was dropped for the height, so the kept line shows an ellipsis.
287    pub(super) needs_ellipsis: bool,
288}
289
290pub(super) struct Layout {
291    pub(super) paragraphs: Vec<TextParagraph>,
292    pub(super) y_offset: PhysicalLength,
293    pub(super) max_width: PhysicalLength,
294    pub(super) height: PhysicalLength,
295    max_physical_height: Option<PhysicalLength>,
296    elision_info: Option<ElisionInfo>,
297    /// Where an active `max-lines` limit drops lines, in the same coordinates as [`ElisionCut`]:
298    /// the (paragraph index, line index) of the last kept line. See [`line_limit_cut`].
299    pub(super) line_limit_cut: Option<(usize, usize)>,
300    /// What the paragraphs' lines are currently broken for; travels back into the cache entry.
301    line_breaking_inputs: LineBreakingInputs,
302    /// Whether this layout ran the full breaking pass rather than reusing a [`RetainedLineBreaking`].
303    /// Read by the `layout_miss_count` test counter.
304    pub(super) broke_lines: bool,
305}
306
307impl Layout {
308    /// Takes the layout apart into what the cache entry retains: the paragraphs (holding their
309    /// broken lines and y positions) and the [`RetainedLineBreaking`] that lets the next [`layout`] call
310    /// with equal inputs skip the breaking.
311    pub(super) fn dismantle(self) -> (Vec<TextParagraph>, RetainedLineBreaking) {
312        (
313            self.paragraphs,
314            RetainedLineBreaking {
315                inputs: self.line_breaking_inputs,
316                line_limit_cut: self.line_limit_cut,
317                max_width: self.max_width,
318                height: self.height,
319                elision_info: self.elision_info,
320            },
321        )
322    }
323}
324
325impl Layout {
326    /// Whether an ellipsis may be placed.
327    pub(super) fn is_eliding(&self) -> bool {
328        self.elision_info.is_some()
329    }
330
331    /// The paragraphs that have at least one line to show. Only differs from `paragraphs` when a
332    /// `max-lines` limit drops lines: paragraphs entirely below the cut don't take part in
333    /// hit-testing or selection.
334    pub(super) fn visible_paragraphs(&self) -> &[TextParagraph] {
335        match self.line_limit_cut {
336            Some((last_paragraph, _)) => &self.paragraphs[..=last_paragraph],
337            None => &self.paragraphs,
338        }
339    }
340
341    /// True when an active line limit dropped lines and `y` (in item coordinates) falls below
342    /// the last kept line, i.e. into the item region where the dropped lines would have been.
343    /// Nothing is shown there, so nothing there should hit-test. With an active cut, `height`
344    /// is the bottom of the last kept line.
345    pub(super) fn below_line_limit(&self, y: PhysicalLength) -> bool {
346        self.line_limit_cut.is_some() && y >= self.y_offset + self.height
347    }
348
349    /// The last line to draw, combining the height-based elision cut with the `max-lines` limit:
350    /// whichever cuts earlier wins. Unlike the elision cut, the line limit also applies with
351    /// `overflow: clip` -- just without the ellipsis.
352    pub(super) fn visible_extent(&self) -> Option<ElisionCut> {
353        let line_limit_cut = self.line_limit_cut.map(|(last_paragraph, last_line)| ElisionCut {
354            last_paragraph,
355            last_line,
356            // The cut only exists when lines were dropped below it, so when eliding, the last
357            // kept line always signals the truncation.
358            needs_ellipsis: self.elision_info.is_some(),
359        });
360        match (self.elision_extent(), line_limit_cut) {
361            (Some(elision), Some(line_limit)) => {
362                Some(core::cmp::min_by_key(elision, line_limit, |cut| {
363                    (cut.last_paragraph, cut.last_line)
364                }))
365            }
366            (elision, line_limit) => elision.or(line_limit),
367        }
368    }
369
370    /// Returns true if the very first line is taller than the available height, meaning the
371    /// vertical line dropping used for `overflow: elide` would discard it and render nothing.
372    /// In that case the caller keeps drawing the first line but applies a hard pixel clip to
373    /// trim its vertical overflow, so it is shown (clipped) rather than disappearing entirely.
374    pub(super) fn first_line_exceeds_height(&self) -> bool {
375        let Some(max_physical_height) = self.max_physical_height else {
376            return false;
377        };
378        self.paragraphs.first().and_then(|paragraph| paragraph.layout.lines().next()).is_some_and(
379            |line| !line_fits_height(line.metrics().block_max_coord, max_physical_height),
380        )
381    }
382
383    /// Whether a line of `paragraph` (with the metrics block range `block_min`..`block_max` in the
384    /// paragraph's local coordinates) falls within the box for `overflow: elide` with a height
385    /// limit. Accounts for vertical alignment via `y_offset`, which is negative for bottom/center
386    /// alignment. Without a height limit, or when not eliding, every line counts as within the box.
387    pub(super) fn paragraph_line_within_box(
388        &self,
389        paragraph: &TextParagraph,
390        block_min: f32,
391        block_max: f32,
392    ) -> bool {
393        match self.max_physical_height {
394            Some(max_physical_height) if self.elision_info.is_some() => {
395                let para_y = self.y_offset + paragraph.y;
396                // `line_fits_height` rounds the bottom up by a pixel; allow the same slack at the
397                // top so a line sitting right on the box edge isn't dropped to a rounding error.
398                line_fits_height(para_y.get() + block_max, max_physical_height)
399                    && para_y.get() + block_min >= -0.5
400            }
401            _ => true,
402        }
403    }
404
405    /// For `overflow: elide` with a height limit, work out the last line to keep across all
406    /// paragraphs. Explicit `\n` line breaks each produce a paragraph, and they have to elide as a
407    /// single block: lines below the box are dropped and the ellipsis goes on the last visible
408    /// line. Returns `None` when there is no height limit or elision (draw everything). When
409    /// nothing fits at all the very first line is kept (#12197) so the text never vanishes
410    /// entirely; `draw_text` then clips its vertical overflow.
411    fn elision_extent(&self) -> Option<ElisionCut> {
412        self.max_physical_height?;
413        self.elision_info.as_ref()?;
414
415        // The deepest line still within the box, scanning paragraphs and their lines from the
416        // bottom up. Bottom/center alignment clips lines off the top, so the visible block can
417        // start partway down, but its last line is always the lowest one that fits.
418        let last_within_box = self.paragraphs.iter().enumerate().rev().find_map(|(pi, para)| {
419            para.layout
420                .lines()
421                .enumerate()
422                .rev()
423                .find(|(_, line)| {
424                    let m = line.metrics();
425                    self.paragraph_line_within_box(para, m.block_min_coord, m.block_max_coord)
426                })
427                .map(|(li, _)| (pi, li))
428        });
429
430        // The very last line in document order, used to tell whether anything was dropped below
431        // the kept line (and so whether an ellipsis is needed).
432        let final_line = self
433            .paragraphs
434            .iter()
435            .enumerate()
436            .rev()
437            .find_map(|(pi, para)| para.layout.lines().len().checked_sub(1).map(|li| (pi, li)));
438
439        let (last_paragraph, last_line) = last_within_box.unwrap_or((0, 0));
440        let needs_ellipsis =
441            final_line.is_some_and(|final_line| final_line != (last_paragraph, last_line));
442        Some(ElisionCut { last_paragraph, last_line, needs_ellipsis })
443    }
444
445    /// Returns the last paragraph starting at or before the given byte offset. An offset in the
446    /// gap between two paragraph ranges (between a '\r' and its '\n') thus maps to the preceding
447    /// paragraph; callers have to clamp their local offset to the paragraph's range.
448    fn paragraph_by_byte_offset(&self, byte_offset: usize) -> Option<&TextParagraph> {
449        self.visible_paragraphs().iter().take_while(|p| p.range.start <= byte_offset).last()
450    }
451
452    pub(super) fn paragraph_by_y(&self, y: PhysicalLength) -> Option<&TextParagraph> {
453        // Positions on lines dropped by `max-lines` (within the cut paragraph, when the item is
454        // taller than the visible text) don't hit-test: nothing is rendered there.
455        if self.below_line_limit(y) {
456            return None;
457        }
458
459        // Adjust for vertical alignment
460        let y = y - self.y_offset;
461
462        if y < PhysicalLength::zero() {
463            return self.visible_paragraphs().first();
464        }
465
466        let idx = self.visible_paragraphs().binary_search_by(|paragraph| {
467            if y < paragraph.y {
468                core::cmp::Ordering::Greater
469            } else if y >= paragraph.y + PhysicalLength::new(paragraph.layout.height()) {
470                core::cmp::Ordering::Less
471            } else {
472                core::cmp::Ordering::Equal
473            }
474        });
475
476        match idx {
477            Ok(i) => self.visible_paragraphs().get(i),
478            Err(_) => self.visible_paragraphs().last(),
479        }
480    }
481
482    pub(super) fn byte_offset_from_point(
483        &self,
484        pos: PhysicalPoint,
485    ) -> (usize, crate::items::TextCursorAffinity) {
486        let Some(paragraph) = self.paragraph_by_y(pos.y_length()) else {
487            return (0, crate::items::TextCursorAffinity::NextCharacter);
488        };
489        let cursor = parley::editing::Cursor::from_point(
490            &paragraph.layout,
491            pos.x,
492            (pos.y_length() - self.y_offset - paragraph.y).get(),
493        );
494        (paragraph.range.start + cursor.index(), cursor.affinity().into())
495    }
496
497    pub(super) fn cursor_rect_for_byte_offset(
498        &self,
499        byte_offset: usize,
500        affinity: crate::items::TextCursorAffinity,
501        cursor_width: PhysicalLength,
502    ) -> PhysicalRect {
503        let Some(paragraph) = self.paragraph_by_byte_offset(byte_offset) else {
504            return PhysicalRect::new(PhysicalPoint::default(), PhysicalSize::new(1.0, 1.0));
505        };
506
507        let local_offset = (byte_offset - paragraph.range.start).min(paragraph.range.len());
508        let cursor = parley::editing::Cursor::from_byte_index(
509            &paragraph.layout,
510            local_offset,
511            affinity.into(),
512        );
513        let rect = cursor.geometry(&paragraph.layout, cursor_width.get());
514
515        PhysicalRect::new(
516            PhysicalPoint::from_lengths(
517                PhysicalLength::new(rect.x0 as _),
518                PhysicalLength::new(rect.y0 as _) + self.y_offset + paragraph.y,
519            ),
520            PhysicalSize::new(rect.width() as _, rect.height() as _),
521        )
522    }
523
524    /// Returns an iterator over the run's glyphs, truncated if necessary to fit within the max width,
525    /// plus an optional ellipsis glyph with its font and size to be drawn separately.
526    /// Call this function only for the last line of the layout.
527    pub(super) fn glyphs_with_elision<'a>(
528        &'a self,
529        glyph_run: &'a parley::layout::GlyphRun<Brush>,
530        // When set, place an ellipsis even if the run fits the width. Used when lines below were
531        // dropped for the height, so the last visible line signals the vertical truncation.
532        force_elision: bool,
533        // Advance width of the line's trailing whitespace. A vertically truncated line that fits
534        // the width anchors the appended ellipsis after the last non-whitespace glyph, so trailing
535        // spaces (e.g. left at a word-wrap break) don't push it away from the text.
536        trailing_whitespace: f32,
537    ) -> (
538        impl Iterator<Item = parley::layout::Glyph> + Clone + 'a,
539        Option<(parley::layout::Glyph, parley::FontData, PhysicalLength)>,
540    ) {
541        let ellipsis_advance =
542            self.elision_info.as_ref().map(|info| info.ellipsis_glyph.advance).unwrap_or(0.0);
543        let max_width = self
544            .elision_info
545            .as_ref()
546            .map(|info| info.max_physical_width)
547            .unwrap_or(PhysicalLength::new(f32::MAX));
548
549        let run_start = PhysicalLength::new(glyph_run.offset());
550        let run_end = PhysicalLength::new(glyph_run.offset() + glyph_run.advance());
551
552        // Run starts after where the ellipsis would go - skip entirely
553        let run_beyond_elision = run_start > max_width;
554        // Run extends beyond max width (or the lines below it were dropped) and needs an ellipsis
555        let needs_elision = !run_beyond_elision
556            && (force_elision || run_end.get().floor() > max_width.get().ceil());
557
558        let truncated_glyphs = glyph_run.positioned_glyphs().take_while(move |glyph| {
559            !run_beyond_elision
560                && (!needs_elision
561                    || PhysicalLength::new(glyph.x + glyph.advance + ellipsis_advance) <= max_width)
562        });
563
564        let ellipsis = if needs_elision {
565            self.elision_info.as_ref().map(|info| {
566                let ellipsis_x = glyph_run
567                    .positioned_glyphs()
568                    .find(|glyph| {
569                        PhysicalLength::new(glyph.x + glyph.advance + info.ellipsis_glyph.advance)
570                            > info.max_physical_width
571                    })
572                    .map(|g| g.x)
573                    // Nothing overflows horizontally (force_elision): put the ellipsis right after
574                    // the run's last non-whitespace glyph, i.e. before any trailing whitespace.
575                    .unwrap_or(run_end.get() - trailing_whitespace);
576
577                let mut ellipsis_glyph = info.ellipsis_glyph;
578                ellipsis_glyph.x = ellipsis_x;
579                // The ellipsis glyph comes from a standalone layout; place it on this run's
580                // baseline so it lands on the right line (not just the first one).
581                ellipsis_glyph.y = glyph_run.baseline();
582
583                let font_size = PhysicalLength::new(glyph_run.run().font_size());
584                (ellipsis_glyph, info.font_for_ellipsis_glyph.clone(), font_size)
585            })
586        } else {
587            None
588        };
589
590        (truncated_glyphs, ellipsis)
591    }
592}