Skip to main content

frus_widgets/
richtext.rs

1//! [`RichText`]: a **rich text** paragraph — a [`TextSpan`] tree (mixed styles,
2//! cascading inheritance) flattened into resolved runs and laid out in one
3//! piece.
4//!
5//! ```ignore
6//! RichText::new(
7//!     TextSpan::new("frus is ")
8//!         .child(TextSpan::new("fast").bold())
9//!         .child(TextSpan::new(" and portable.").italic()),
10//! )
11//! ```
12
13use frus_core::{
14    Color, MaskShader, Point, Rect, Scene, ShaderMask, TextAlign, TextBlock, TextOverflow, TextRun,
15    TextSpan, TextStyle,
16};
17use frus_layout::{Dimension, Style};
18
19use crate::interaction::Status;
20use crate::theme::Theme;
21use crate::widget::Widget;
22
23/// A rich text paragraph. It wraps at the width it is offered, as [`crate::Text`] does
24/// and for the same reason: a piece of prose in a box narrower than itself is a paragraph.
25///
26/// Everything milestones 343 and 344 gave `Text` is here — [`RichText::align`],
27/// [`RichText::max_lines`], [`RichText::overflow`], [`RichText::no_wrap`] — because the
28/// three questions they answer are questions about *text*, and the styles being mixed
29/// changes none of them.
30pub struct RichText {
31    span: TextSpan,
32    /// The paragraph's **base** style: the root of the cascade (what the spans
33    /// inherit when they set nothing themselves).
34    base: TextStyle,
35    /// A paragraph wrapped to the offered width.
36    wrap: bool,
37    /// What becomes of runs that do not fit their box.
38    overflow: TextOverflow,
39    /// At most this many lines.
40    max_lines: Option<usize>,
41    /// Where the lines sit inside the box.
42    align: TextAlign,
43    /// Whether this paragraph is willing to be given less than it asked for. See
44    /// [`crate::Text`], where the same distinction is drawn for the same reason.
45    shrinkable: bool,
46}
47
48impl RichText {
49    /// Creates a paragraph (base: 16 px, regular weight, the theme's color).
50    pub fn new(span: TextSpan) -> Self {
51        Self {
52            span,
53            base: TextStyle::new(16.0),
54            wrap: true,
55            overflow: TextOverflow::Clip,
56            max_lines: None,
57            align: TextAlign::Start,
58            shrinkable: false,
59        }
60    }
61
62    /// Overrides the base style (the root of the cascade) — typically one step of
63    /// the theme's scale (`.base_style(theme.text.body_large)`).
64    pub fn base_style(mut self, style: TextStyle) -> Self {
65        self.base = style;
66        self
67    }
68
69    /// Wraps at the width the parent offers. This is the default; the call is kept
70    /// because saying so at the call site is not redundant when it is the point.
71    pub fn wrap(mut self) -> Self {
72        self.wrap = true;
73        self
74    }
75
76    /// Keeps the runs on **one line**, explicit newlines aside.
77    pub fn no_wrap(mut self) -> Self {
78        self.wrap = false;
79        self
80    }
81
82    /// Where the lines sit **inside the box** the paragraph was given. As with
83    /// [`crate::Text`], it does nothing to a paragraph that shrink-wraps — and makes the
84    /// paragraph take the width it is offered, so that there is something to sit in.
85    pub fn align(mut self, align: TextAlign) -> Self {
86        self.align = align;
87        self
88    }
89
90    /// At most `max_lines` lines; the rest is dropped and [`RichText::overflow`] decides
91    /// how the last one ends. Saying so also tells the layout this paragraph may be given
92    /// **less than it asked for**.
93    pub fn max_lines(mut self, max_lines: usize) -> Self {
94        self.max_lines = Some(max_lines.max(1));
95        self.shrinkable = true;
96        self
97    }
98
99    /// What becomes of runs that do not fit: cut, cut with an ellipsis, faded out, or
100    /// drawn past the box.
101    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
102        self.overflow = overflow;
103        self.shrinkable = true;
104        self
105    }
106
107    /// Whether this paragraph needs the whole width it is offered — see
108    /// [`crate::Text`], where the same question is asked of a single style.
109    fn fills(&self) -> bool {
110        self.align != TextAlign::Start
111    }
112
113    /// The runs that actually fit `width`, and whether anything was left over.
114    ///
115    /// The cut lands on a break the shaper chose, which is why the offset comes from
116    /// `frus-text` rather than from arithmetic here; splitting the runs at it is the easy
117    /// half. The ellipsis inherits the style of the run it ends, because it is that run's
118    /// last character as far as a reader is concerned.
119    fn fitted(&self, runs: Vec<TextRun>, width: f32) -> (Vec<TextRun>, bool) {
120        let Some(max) = self.max_lines else {
121            return (runs, false);
122        };
123        let Some(cut) = frus_text::runs_cut_at(&runs, Some(width), self.wrap, max) else {
124            return (runs, false);
125        };
126        let mut kept: Vec<TextRun> = Vec::new();
127        let mut at = 0usize;
128        for run in runs {
129            let end = at + run.text.len();
130            if cut >= end {
131                at = end;
132                kept.push(run);
133                continue;
134            }
135            if cut > at {
136                let mut head = run.clone();
137                head.text = run.text[..cut - at].to_string();
138                kept.push(head);
139            }
140            break;
141        }
142        if self.overflow == TextOverflow::Ellipsis {
143            if let Some(last) = kept.last_mut() {
144                let style = TextStyle::new(last.size)
145                    .weight(last.weight)
146                    .color(last.color);
147                let style = if last.italic { style.italic() } else { style };
148                last.text = crate::text::ellipsise(&last.text, &style.resolved(), width);
149            }
150        }
151        (kept, true)
152    }
153
154    /// A line limit is a height cap and nothing else.
155    fn capped(&self, height: f32) -> f32 {
156        match self.max_lines {
157            // Rounded up for the reason `Text`'s own clamp is (issue #54): the pixels
158            // the allowed lines occupy are pixels a layout will round.
159            Some(max) => height.min(frus_core::fits(
160                self.base.resolved().line_height() * max as f32,
161            )),
162            None => height,
163        }
164    }
165
166    /// The fade that ends a cut paragraph — the same rule as [`crate::Text`]'s.
167    fn fade(&self, bounds: Rect, horizontal: bool) -> ShaderMask {
168        let extent = if horizontal {
169            bounds.width
170        } else {
171            bounds.height
172        };
173        let run = (extent * 0.2).min(self.base.resolved().line_height() * 3.0);
174        let (from, to) = if horizontal {
175            (
176                Point::new(bounds.x + bounds.width - run, bounds.y),
177                Point::new(bounds.x + bounds.width, bounds.y),
178            )
179        } else {
180            (
181                Point::new(bounds.x, bounds.y + bounds.height - run),
182                Point::new(bounds.x, bounds.y + bounds.height),
183            )
184        };
185        ShaderMask::new(MaskShader::Linear {
186            from,
187            to,
188            from_color: Color::WHITE,
189            to_color: Color::WHITE.fade(0.0),
190        })
191    }
192
193    /// Resolved runs: the inherited color is settled against `fallback` and
194    /// modulated by `opacity`. (For measuring, the color makes no difference.)
195    fn runs(&self, fallback: Color, opacity: f32) -> Vec<TextRun> {
196        self.span
197            .flatten(self.base)
198            .into_iter()
199            .map(|(text, style)| TextRun {
200                text,
201                size: style.size,
202                weight: style.weight,
203                italic: style.italic,
204                color: style.color.unwrap_or(fallback).fade(opacity),
205                decoration: style.decoration,
206                // Inherited = the run's color: resolved here so that the
207                // fade-out applies to the decorations too.
208                decoration_color: style.decoration_color.map(|c| c.fade(opacity)),
209                letter_spacing: style.letter_spacing,
210            })
211            .collect()
212    }
213}
214
215impl<Msg> Widget<Msg> for RichText {
216    fn style(&self) -> Style {
217        let min_width = if self.shrinkable {
218            Dimension::Length(0.0)
219        } else {
220            Dimension::Auto
221        };
222        // A wrapped paragraph: free dimensions, the size comes from `measure()`.
223        if self.wrap || self.fills() {
224            return Style {
225                min_width,
226                ..Default::default()
227            };
228        }
229        let measured = frus_text::measure_runs(&self.runs(Color::WHITE, 1.0));
230        Style {
231            width: Dimension::Length(measured.width.ceil()),
232            height: Dimension::Length(self.capped(measured.height).ceil()),
233            min_width,
234            max_width: if self.shrinkable {
235                Dimension::Percent(1.0)
236            } else {
237                Dimension::Auto
238            },
239            ..Default::default()
240        }
241    }
242
243    /// A paragraph will not be squeezed along a row — see [`crate::Text`], which explains
244    /// which axis this is about and why the parent has to be the one to apply it.
245    fn main_axis_floor(&self, _theme: &Theme) -> Option<f32> {
246        if self.shrinkable || !(self.wrap || self.fills()) {
247            return None;
248        }
249        Some(
250            frus_text::measure_runs(&self.runs(Color::WHITE, 1.0))
251                .width
252                .ceil(),
253        )
254    }
255
256    /// The line the first row of runs sits on. Runs of different sizes share a line,
257    /// and the tallest ascender is what decides where it is.
258    fn text_baseline(&self, _theme: &Theme) -> Option<f32> {
259        frus_text::baseline_of_runs(&self.runs(Color::WHITE, 1.0))
260    }
261
262    fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
263        if !self.wrap && !self.fills() {
264            return None;
265        }
266        let runs = self.runs(Color::WHITE, 1.0);
267        let wrap = self.wrap;
268        let max_lines = self.max_lines;
269        let base = self.base.resolved().size;
270        Some(Box::new(move |max_width, _| {
271            let mut size =
272                frus_text::measure_runs_wrapped(&runs, if wrap { max_width } else { None });
273            if let Some(max) = max_lines {
274                // Same clamp as `capped`, and rounded up for the same reason (issue #54).
275                size.height = size
276                    .height
277                    .min(frus_core::fits(frus_text::line_height(base) * max as f32));
278            }
279            size
280        }))
281    }
282
283    fn measure_key(&self, _theme: &Theme) -> Option<u64> {
284        if !self.wrap && !self.fills() {
285            return None;
286        }
287        use std::hash::{Hash, Hasher};
288        let mut hasher = std::collections::hash_map::DefaultHasher::new();
289        self.span.measure_hash(&mut hasher);
290        let base = self.base.resolved();
291        base.size.to_bits().hash(&mut hasher);
292        base.weight.to_u16().hash(&mut hasher);
293        base.italic.hash(&mut hasher);
294        self.max_lines.hash(&mut hasher);
295        Some(hasher.finish())
296    }
297
298    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
299        &[]
300    }
301
302    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
303        let runs = self.runs(theme.on_surface, status.opacity);
304        let natural = frus_text::measure_runs(&runs).width;
305        let (runs, too_tall) = self.fitted(runs, bounds.width);
306        // A paragraph that wraps has no line wider than its box; one that does not may.
307        let too_wide = !self.wrap && natural > bounds.width + 0.5;
308        let over = too_tall || too_wide;
309        let block = TextBlock {
310            width: (self.wrap || self.align != TextAlign::Start).then_some(bounds.width),
311            soft_wrap: self.wrap,
312            align: self.align,
313        };
314        let at = Point::new(bounds.x, bounds.y);
315        let draw = |scene: &mut Scene| {
316            scene.rich_text_block(at, runs.clone(), block);
317        };
318        if !over
319            || self.overflow == TextOverflow::Visible
320            || self.overflow == TextOverflow::Ellipsis
321        {
322            draw(scene);
323            return;
324        }
325        let outer = scene.current_clip();
326        scene.set_clip(outer.intersect(bounds));
327        match self.overflow {
328            TextOverflow::Fade => scene.masked(self.fade(bounds, too_wide && !too_tall), draw),
329            _ => draw(scene),
330        }
331        scene.set_clip(outer);
332    }
333
334    fn on_click(&self) -> Option<Msg> {
335        None
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::interaction::Status;
343    use frus_core::{FontWeight, Primitive};
344
345    #[test]
346    fn paints_resolved_runs_with_theme_colour() {
347        let theme = Theme::default();
348        let rich = RichText::new(
349            TextSpan::new("plain ")
350                .child(TextSpan::new("bold").bold())
351                .child(TextSpan::new("red").color(Color::rgb(1.0, 0.0, 0.0))),
352        )
353        .base_style(theme.text.body_large);
354
355        let mut scene = Scene::new();
356        Widget::<()>::paint(
357            &rich,
358            Rect::new(2.0, 3.0, 300.0, 24.0),
359            Status::default(),
360            &theme,
361            &mut scene,
362        );
363
364        assert_eq!(scene.primitives().len(), 1);
365        match &scene.primitives()[0] {
366            Primitive::RichText { position, runs, .. } => {
367                assert_eq!(*position, Point::new(2.0, 3.0));
368                assert_eq!(runs.len(), 3);
369                // Run 0: inherits everything from the base (16 px, the theme's color).
370                assert_eq!(runs[0].size, 16.0);
371                assert_eq!(runs[0].color, theme.on_surface);
372                // Run 1: bold, size and color inherited.
373                assert_eq!(runs[1].weight, FontWeight::Bold);
374                assert_eq!(runs[1].size, 16.0);
375                assert_eq!(runs[1].color, theme.on_surface);
376                // Run 2: an explicit color.
377                assert_eq!(runs[2].color, Color::rgb(1.0, 0.0, 0.0));
378            }
379            _ => panic!("expected rich text"),
380        }
381    }
382
383    #[test]
384    fn wrapped_rich_text_measures_and_keys_by_content() {
385        let para = |text: &str| {
386            RichText::new(TextSpan::new(text).child(TextSpan::new(" gras").bold())).wrap()
387        };
388        let rich = para("a rich paragraph long enough to wrap onto several lines");
389        // Measuring under constraints wraps: taller at 120 px than when free.
390        let theme = Theme::default();
391        let measure = Widget::<()>::measure(&rich, &theme).expect("measure closure");
392        let free = measure(None, None);
393        let narrow = measure(Some(120.0), None);
394        assert!(narrow.width <= 120.0);
395        assert!(narrow.height > free.height, "wrapped → taller");
396        // The measure key follows the content (the relayout cache fix)…
397        assert_ne!(
398            Widget::<()>::measure_key(&rich, &theme),
399            Widget::<()>::measure_key(&para("short"), &theme)
400        );
401        // … but not the color (no effect on geometry).
402        let recolored = RichText::new(
403            TextSpan::new("a rich paragraph long enough to wrap onto several lines")
404                .color(Color::rgb(1.0, 0.0, 0.0))
405                .child(TextSpan::new(" gras").bold()),
406        )
407        .wrap();
408        assert_eq!(
409            Widget::<()>::measure_key(&rich, &Theme::default()),
410            Widget::<()>::measure_key(&recolored, &Theme::default()),
411            "the color must not invalidate the layout"
412        );
413        // A paragraph told not to wrap is a box of a known size and says so in its
414        // style, exactly as a `Text` does — see the reasoning there.
415        let plain = RichText::new(TextSpan::new("x")).no_wrap();
416        assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
417        assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
418    }
419
420    /// A line limit cuts the runs where the **shaper** broke them, keeps the styles of
421    /// what is left, and ends the last line in an ellipsis when asked.
422    #[test]
423    fn a_limit_cuts_the_runs_and_keeps_their_styles() {
424        let span = TextSpan::new("one two three four ")
425            .child(TextSpan::new("five six seven eight").bold())
426            .child(TextSpan::new(" nine ten eleven twelve"));
427        let rich = RichText::new(span)
428            .base_style(TextStyle::new(12.0))
429            .max_lines(2)
430            .overflow(TextOverflow::Ellipsis);
431        let mut scene = Scene::new();
432        Widget::<()>::paint(
433            &rich,
434            Rect::new(0.0, 0.0, 90.0, 40.0),
435            Status::default(),
436            &Theme::default(),
437            &mut scene,
438        );
439        let runs = match &scene.primitives()[0] {
440            Primitive::RichText { runs, .. } => runs.clone(),
441            other => panic!("rich text, not {other:?}"),
442        };
443        let text: String = runs.iter().map(|r| r.text.as_str()).collect();
444        assert!(text.ends_with('…'), "cut and marked: {text:?}");
445        assert!(text.len() < 60, "and shorter than the whole: {text:?}");
446        // The bold run is still bold in what is left, which is the half a plain-text cut
447        // does not have to get right.
448        assert!(
449            runs.iter().any(|r| r.weight == FontWeight::Bold),
450            "the styles survived the cut: {runs:?}"
451        );
452    }
453
454    /// A limit it does not reach leaves the runs exactly as they were.
455    #[test]
456    fn a_limit_it_does_not_reach_leaves_the_runs_alone() {
457        let rich = RichText::new(TextSpan::new("short").child(TextSpan::new(" and bold").bold()))
458            .base_style(TextStyle::new(12.0))
459            .max_lines(4);
460        let mut scene = Scene::new();
461        Widget::<()>::paint(
462            &rich,
463            Rect::new(0.0, 0.0, 400.0, 40.0),
464            Status::default(),
465            &Theme::default(),
466            &mut scene,
467        );
468        match &scene.primitives()[0] {
469            Primitive::RichText { runs, .. } => {
470                let text: String = runs.iter().map(|r| r.text.as_str()).collect();
471                assert_eq!(text, "short and bold");
472            }
473            other => panic!("rich text, not {other:?}"),
474        }
475    }
476
477    /// Alignment reaches the primitive along with the width it aligns inside — and a
478    /// start-aligned, non-wrapping paragraph is deliberately given neither.
479    #[test]
480    fn alignment_travels_with_the_width_it_aligns_in() {
481        let painted = |rich: RichText| {
482            let mut scene = Scene::new();
483            Widget::<()>::paint(
484                &rich,
485                Rect::new(0.0, 0.0, 200.0, 40.0),
486                Status::default(),
487                &Theme::default(),
488                &mut scene,
489            );
490            match &scene.primitives()[0] {
491                Primitive::RichText {
492                    align, max_width, ..
493                } => (*align, *max_width),
494                other => panic!("rich text, not {other:?}"),
495            }
496        };
497        assert_eq!(
498            painted(RichText::new(TextSpan::new("x")).align(TextAlign::Center)),
499            (TextAlign::Center, Some(200.0))
500        );
501        assert_eq!(
502            painted(RichText::new(TextSpan::new("x")).no_wrap()),
503            (TextAlign::Start, None)
504        );
505    }
506
507    #[test]
508    fn layout_accounts_for_the_largest_run() {
509        // A 28 px run in the middle: the measured height exceeds that of a 16 px one.
510        let h = |rich: RichText| {
511            Widget::<()>::measure(&rich, &Theme::default()).expect("measured")(None, None).height
512        };
513        let small = h(RichText::new(TextSpan::new("plain")));
514        let tall = h(RichText::new(
515            TextSpan::new("plain").child(TextSpan::new("BIG").size(28.0)),
516        ));
517        assert!(tall > small, "the large run must grow the line");
518    }
519}