Skip to main content

i_slint_core/textlayout/sharedparley/
shaping.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// cSpell: ignore uncacheable unrepresentable
5
6//! From text to shaped paragraphs.
7//!
8//! Everything that ends up in a cache entry is produced here, and [`shape_paragraphs`] is the
9//! one function it happens through: measuring and drawing must register identical cache
10//! dependencies, so neither may shape through anything narrower.
11
12use super::*;
13
14/// Font size of inline `code` runs, as a fraction of the surrounding body
15/// text. Matches the convention used by GitHub-style markdown renderers — the
16/// glyphs sit a little smaller than body text, inside a translucent capsule
17/// that visually marks them as code.
18const INLINE_CODE_FONT_SCALE: f32 = 0.85;
19
20std::thread_local! {
21    static LAYOUT_CONTEXT: RefCell<parley::LayoutContext<Brush>> = Default::default();
22}
23
24#[derive(Debug, Default, PartialEq, Clone, Copy)]
25pub(super) struct Brush {
26    /// When set, this overrides the fill/stroke to use this color.
27    pub(super) override_fill_color: Option<Color>,
28    pub(super) stroke: Option<TextStrokeStyle>,
29    pub(super) link_color: Option<Color>,
30}
31
32pub(super) struct LayoutWithoutLineBreaksBuilder {
33    font_request: Option<FontRequest>,
34    pub(super) text_wrap: TextWrap,
35    stroke: Option<TextStrokeStyle>,
36    pub(super) scale_factor: ScaleFactor,
37    pub(super) pixel_size: LogicalLength,
38    /// When false, overlong words are not broken up. Only used to measure the
39    /// min-content width (the longest word), never to lay text out for display.
40    overflow_wrap_anywhere: bool,
41}
42
43impl LayoutWithoutLineBreaksBuilder {
44    pub(super) fn new(
45        font_request: Option<FontRequest>,
46        text_wrap: TextWrap,
47        stroke: Option<TextStrokeStyle>,
48        scale_factor: ScaleFactor,
49    ) -> Self {
50        let pixel_size = font_request
51            .as_ref()
52            .and_then(|font_request| font_request.pixel_size)
53            .unwrap_or(DEFAULT_FONT_SIZE);
54
55        Self {
56            font_request,
57            text_wrap,
58            stroke,
59            scale_factor,
60            pixel_size,
61            overflow_wrap_anywhere: true,
62        }
63    }
64
65    fn ranged_builder<'a>(
66        &self,
67        layout_ctx: &'a mut parley::LayoutContext<Brush>,
68        font_ctx: &'a mut parley::FontContext,
69        text: &'a str,
70    ) -> parley::RangedBuilder<'a, Brush> {
71        // Use the requested font's natural line-height ratio for every run so fallback fonts,
72        // such as the symbol font used for password characters, don't enlarge the line box.
73        // `FontSizeRelative` scales the result with each styled span's font size.
74        let line_height_ratio =
75            self.font_request.as_ref().and_then(|fr| line_height_ratio(font_ctx, fr));
76
77        let mut builder = layout_ctx.ranged_builder(font_ctx, text, self.scale_factor.get(), false);
78
79        if let Some(ratio) = line_height_ratio {
80            builder.push_default(parley::StyleProperty::LineHeight(
81                parley::style::LineHeight::FontSizeRelative(ratio),
82            ));
83        }
84
85        if let Some(ref font_request) = self.font_request {
86            let mut fallback_family_iter = sharedfontique::FALLBACK_FAMILIES
87                .into_iter()
88                .map(parley::style::FontFamilyName::Generic);
89
90            let font_families: &[parley::style::FontFamilyName] = if let Some(family) =
91                &font_request.family
92            {
93                let mut iter =
94                    core::iter::once(parley::style::FontFamilyName::named(family.as_str()))
95                        .chain(fallback_family_iter);
96                &core::array::from_fn::<
97                    _,
98                    { sharedfontique::FALLBACK_FAMILIES.as_slice().len() + 1 },
99                    _,
100                >(|_| iter.next().unwrap())
101            } else {
102                &core::array::from_fn::<_, { sharedfontique::FALLBACK_FAMILIES.as_slice().len() }, _>(
103                    |_| fallback_family_iter.next().unwrap(),
104                )
105            };
106
107            builder.push_default(parley::style::FontFamily::List(std::borrow::Cow::Borrowed(
108                font_families,
109            )));
110
111            if let Some(weight) = font_request.weight {
112                builder.push_default(parley::StyleProperty::FontWeight(
113                    parley::style::FontWeight::new(weight as f32),
114                ));
115            }
116            if let Some(letter_spacing) = font_request.letter_spacing {
117                builder.push_default(parley::StyleProperty::LetterSpacing(letter_spacing.get()));
118            }
119            builder.push_default(parley::StyleProperty::FontStyle(if font_request.italic {
120                parley::style::FontStyle::Italic
121            } else {
122                parley::style::FontStyle::Normal
123            }));
124        }
125        builder.push_default(parley::StyleProperty::FontSize(self.pixel_size.get()));
126        builder.push_default(parley::StyleProperty::WordBreak(match self.text_wrap {
127            TextWrap::NoWrap => parley::style::WordBreak::KeepAll,
128            TextWrap::WordWrap => parley::style::WordBreak::Normal,
129            TextWrap::CharWrap => parley::style::WordBreak::BreakAll,
130        }));
131        builder.push_default(parley::StyleProperty::OverflowWrap(
132            match (self.text_wrap, self.overflow_wrap_anywhere) {
133                (TextWrap::NoWrap, _) | (_, false) => parley::style::OverflowWrap::Normal,
134                (TextWrap::WordWrap | TextWrap::CharWrap, true) => {
135                    parley::style::OverflowWrap::Anywhere
136                }
137            },
138        ));
139        if self.text_wrap == TextWrap::NoWrap {
140            // Parley 0.9 removed the width parameter from `Layout::align()` and instead
141            // uses the `max_advance` set by `break_all_lines()` as the alignment container
142            // width. To allow passing `max_physical_width` to `break_all_lines` for alignment
143            // purposes without triggering actual line wrapping, we must set `TextWrapMode::NoWrap`.
144            builder.push_default(parley::StyleProperty::TextWrapMode(
145                parley::style::TextWrapMode::NoWrap,
146            ));
147        }
148
149        builder.push_default(parley::StyleProperty::Brush(Brush {
150            override_fill_color: None,
151            stroke: self.stroke,
152            link_color: None,
153        }));
154
155        builder
156    }
157
158    /// Note that the selection is deliberately absent here: it is a rendering concern, not a
159    /// styling one, and baking it into the layout both makes the layout uncacheable across
160    /// selection changes and makes sub-glyph selection boundaries unrepresentable. See
161    /// [`SelectionSpan`].
162    pub(super) fn build(
163        &self,
164        font_context: &mut parley::FontContext,
165        text: &str,
166        formatting: impl IntoIterator<Item = i_slint_common::styled_text::FormattedSpan>,
167        link_color: Option<Color>,
168    ) -> parley::Layout<Brush> {
169        use i_slint_common::styled_text::Style;
170
171        LAYOUT_CONTEXT.with_borrow_mut(|layout_ctx| {
172            let mut builder = self.ranged_builder(layout_ctx, font_context, text);
173
174            // filter empty ranges otherwise parley will panic on assert
175            for span in formatting.into_iter().filter(|s| !s.range.is_empty()) {
176                match span.style {
177                    Style::Emphasis => {
178                        builder.push(
179                            parley::StyleProperty::FontStyle(parley::style::FontStyle::Italic),
180                            span.range,
181                        );
182                    }
183                    Style::Strikethrough => {
184                        builder.push(parley::StyleProperty::Strikethrough(true), span.range);
185                    }
186                    Style::Strong => {
187                        builder.push(
188                            parley::StyleProperty::FontWeight(parley::style::FontWeight::BOLD),
189                            span.range,
190                        );
191                    }
192                    Style::Code => {
193                        builder.push(
194                            parley::StyleProperty::FontFamily(parley::style::FontFamily::Single(
195                                parley::style::FontFamilyName::Generic(
196                                    parley::style::GenericFamily::Monospace,
197                                ),
198                            )),
199                            span.range.clone(),
200                        );
201                        // Inline `code` reads as slightly smaller text on top of a
202                        // translucent capsule (drawn separately in `TextParagraph::draw`),
203                        // matching the convention used by common markdown renderers.
204                        builder.push(
205                            parley::StyleProperty::FontSize(
206                                self.pixel_size.get() * INLINE_CODE_FONT_SCALE,
207                            ),
208                            span.range,
209                        );
210                    }
211                    Style::Underline => {
212                        builder.push(parley::StyleProperty::Underline(true), span.range);
213                    }
214                    Style::Link => {
215                        builder.push(parley::StyleProperty::Underline(true), span.range.clone());
216                        builder.push(
217                            parley::StyleProperty::Brush(Brush {
218                                override_fill_color: None,
219                                stroke: self.stroke,
220                                link_color,
221                            }),
222                            span.range,
223                        );
224                    }
225                    Style::Color(color) => {
226                        builder.push(
227                            parley::StyleProperty::Brush(Brush {
228                                override_fill_color: Some(crate::Color::from_argb_encoded(color)),
229                                stroke: self.stroke,
230                                link_color: None,
231                            }),
232                            span.range,
233                        );
234                    }
235                }
236            }
237
238            builder.build(text)
239        })
240    }
241}
242
243/// The line-height ratio, relative to the font size, that every shaped line gets.
244pub(super) fn line_height_ratio(
245    font_ctx: &mut parley::FontContext,
246    font_request: &FontRequest,
247) -> Option<f32> {
248    let font = font_request.query_fontique(&mut font_ctx.collection, &mut font_ctx.source_cache)?;
249    let face = skrifa::FontRef::from_index(font.blob.data(), font.index).ok()?;
250    let location = face.axes().location(font.synthesis.variation_settings());
251    let metrics = face.metrics(skrifa::instance::Size::unscaled(), &location);
252    let units_per_em = metrics.units_per_em as f32;
253    (units_per_em > 0.0)
254        .then(|| (metrics.ascent - metrics.descent + metrics.leading) / units_per_em)
255        .map(|natural_ratio| {
256            font_request.line_height_for_natural_height(natural_ratio).unwrap_or(natural_ratio)
257        })
258}
259
260/// Splits plain text into paragraph byte ranges at `'\n'`. The `'\n'` and any preceding `'\r'`
261/// are excluded from the range: parley treats a lone CR as a mandatory line break, so a CRLF
262/// left in the paragraph would render an extra empty line.
263pub(super) fn paragraph_ranges(text: &str) -> impl Iterator<Item = Range<usize>> + '_ {
264    let mut start = 0;
265    text.split('\n').map(move |paragraph| {
266        let end = start + paragraph.len();
267        let range = if paragraph.ends_with('\r') { start..end - 1 } else { start..end };
268        start = end + 1;
269        range
270    })
271}
272
273pub(super) fn create_text_paragraphs(
274    layout_builder: &LayoutWithoutLineBreaksBuilder,
275    font_context: &mut parley::FontContext,
276    text: PlainOrStyledText,
277    link_color: Color,
278) -> Vec<TextParagraph> {
279    let paragraph_from_text =
280        |font_context: &mut parley::FontContext,
281         text: &str,
282         range: std::ops::Range<usize>,
283         formatting: Vec<i_slint_common::styled_text::FormattedSpan>,
284         links: Vec<(std::ops::Range<usize>, std::string::String)>| {
285            let code_ranges: alloc::vec::Vec<Range<usize>> = formatting
286                .iter()
287                .filter(|s| matches!(s.style, i_slint_common::styled_text::Style::Code))
288                .map(|s| s.range.clone())
289                .collect();
290
291            let layout = layout_builder.build(font_context, text, formatting, Some(link_color));
292
293            TextParagraph { range, y: PhysicalLength::default(), layout, links, code_ranges }
294        };
295
296    let mut paragraphs = Vec::with_capacity(1);
297
298    match text {
299        PlainOrStyledText::Plain(ref text) => {
300            for range in paragraph_ranges(text) {
301                paragraphs.push(paragraph_from_text(
302                    font_context,
303                    &text[range.clone()],
304                    range,
305                    Default::default(),
306                    Default::default(),
307                ));
308            }
309        }
310        PlainOrStyledText::Styled(rich_text) => {
311            for paragraph in rich_text.paragraphs {
312                paragraphs.push(paragraph_from_text(
313                    font_context,
314                    &paragraph.text,
315                    0..0,
316                    paragraph.formatting,
317                    paragraph.links,
318                ));
319            }
320        }
321    };
322
323    paragraphs
324}
325
326/// The builder the shaped paragraphs of `text` must be produced with. Measuring and drawing share
327/// cache entries, so they have to agree on every input baked into the shaping -- which is why this
328/// lives in one place rather than at each call site.
329pub(super) fn shaping_builder(
330    text: Pin<&dyn crate::item_rendering::RenderString>,
331    item_rc: Option<&crate::item_tree::ItemRc>,
332    text_wrap: TextWrap,
333    scale_factor: ScaleFactor,
334) -> LayoutWithoutLineBreaksBuilder {
335    let (stroke_brush, _, stroke_style) = text.stroke();
336    LayoutWithoutLineBreaksBuilder::new(
337        item_rc.map(|irc| text.font_request(irc)),
338        text_wrap,
339        (!stroke_brush.is_transparent()).then_some(stroke_style),
340        scale_factor,
341    )
342}
343
344/// The builder for measuring content widths, without an item to derive one from.
345///
346/// `WordWrap` gives `WordBreak::Normal`, so the min-content width becomes the longest word.
347/// Content widths are intrinsic to the text, so they don't depend on the item's actual wrap mode.
348/// `overflow_wrap_anywhere` is off because parley may otherwise break anywhere to keep overlong
349/// words from overflowing, which would make the min-content width a single character instead of
350/// the longest word.
351pub(super) fn content_widths_builder(
352    font_request: FontRequest,
353    scale_factor: ScaleFactor,
354) -> LayoutWithoutLineBreaksBuilder {
355    let mut builder = LayoutWithoutLineBreaksBuilder::new(
356        Some(font_request),
357        TextWrap::WordWrap,
358        None,
359        scale_factor,
360    );
361    builder.overflow_wrap_anywhere = false;
362    builder
363}
364
365/// A builder for tests, which have no item to derive one from. Everything else obtains its
366/// builder through [`shaping_builder`] or [`content_widths_builder`], so that it cannot disagree
367/// with what the item's cache entry was shaped with.
368#[cfg(test)]
369pub(super) fn plain_builder_for_tests() -> LayoutWithoutLineBreaksBuilder {
370    LayoutWithoutLineBreaksBuilder::new(None, TextWrap::NoWrap, None, ScaleFactor::new(1.0))
371}
372
373#[cfg(test)]
374pub(super) fn wrap_builder_for_tests() -> LayoutWithoutLineBreaksBuilder {
375    LayoutWithoutLineBreaksBuilder::new(None, TextWrap::WordWrap, None, ScaleFactor::new(1.0))
376}
377
378/// Shapes `text` the way both the drawing and the measuring paths need it, so that they can share
379/// one cache entry. `text_wrap` is passed separately because `text_size` measures the unwrapped
380/// width of items that are otherwise wrapped.
381pub(super) fn shape_paragraphs(
382    text: Pin<&dyn crate::item_rendering::RenderString>,
383    item_rc: Option<&crate::item_tree::ItemRc>,
384    text_wrap: TextWrap,
385    scale_factor: ScaleFactor,
386    font_context: &mut parley::FontContext,
387) -> Vec<TextParagraph> {
388    let builder = shaping_builder(text, item_rc, text_wrap, scale_factor);
389    create_text_paragraphs(&builder, font_context, text.text(), text.link_color())
390}
391
392pub(super) struct TextParagraph {
393    pub(super) range: Range<usize>,
394    pub(super) y: PhysicalLength,
395    pub(super) layout: parley::Layout<Brush>,
396    pub(super) links: std::vec::Vec<(Range<usize>, std::string::String)>,
397    /// Byte ranges within the paragraph's text that carry `Style::Code`. Drawn with a
398    /// translucent rounded background by `draw` for visual parity with common markdown
399    /// renderers.
400    pub(super) code_ranges: std::vec::Vec<Range<usize>>,
401}