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            })
210            .collect()
211    }
212}
213
214impl<Msg> Widget<Msg> for RichText {
215    fn style(&self) -> Style {
216        let min_width = if self.shrinkable {
217            Dimension::Length(0.0)
218        } else {
219            Dimension::Auto
220        };
221        // A wrapped paragraph: free dimensions, the size comes from `measure()`.
222        if self.wrap || self.fills() {
223            return Style {
224                min_width,
225                ..Default::default()
226            };
227        }
228        let measured = frus_text::measure_runs(&self.runs(Color::WHITE, 1.0));
229        Style {
230            width: Dimension::Length(measured.width.ceil()),
231            height: Dimension::Length(self.capped(measured.height).ceil()),
232            min_width,
233            max_width: if self.shrinkable {
234                Dimension::Percent(1.0)
235            } else {
236                Dimension::Auto
237            },
238            ..Default::default()
239        }
240    }
241
242    /// A paragraph will not be squeezed along a row — see [`crate::Text`], which explains
243    /// which axis this is about and why the parent has to be the one to apply it.
244    fn main_axis_floor(&self, _theme: &Theme) -> Option<f32> {
245        if self.shrinkable || !(self.wrap || self.fills()) {
246            return None;
247        }
248        Some(
249            frus_text::measure_runs(&self.runs(Color::WHITE, 1.0))
250                .width
251                .ceil(),
252        )
253    }
254
255    /// The line the first row of runs sits on. Runs of different sizes share a line,
256    /// and the tallest ascender is what decides where it is.
257    fn text_baseline(&self, _theme: &Theme) -> Option<f32> {
258        frus_text::baseline_of_runs(&self.runs(Color::WHITE, 1.0))
259    }
260
261    fn measure(&self, _theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
262        if !self.wrap && !self.fills() {
263            return None;
264        }
265        let runs = self.runs(Color::WHITE, 1.0);
266        let wrap = self.wrap;
267        let max_lines = self.max_lines;
268        let base = self.base.resolved().size;
269        Some(Box::new(move |max_width, _| {
270            let mut size =
271                frus_text::measure_runs_wrapped(&runs, if wrap { max_width } else { None });
272            if let Some(max) = max_lines {
273                // Same clamp as `capped`, and rounded up for the same reason (issue #54).
274                size.height = size
275                    .height
276                    .min(frus_core::fits(frus_text::line_height(base) * max as f32));
277            }
278            size
279        }))
280    }
281
282    fn measure_key(&self, _theme: &Theme) -> Option<u64> {
283        if !self.wrap && !self.fills() {
284            return None;
285        }
286        use std::hash::{Hash, Hasher};
287        let mut hasher = std::collections::hash_map::DefaultHasher::new();
288        self.span.measure_hash(&mut hasher);
289        let base = self.base.resolved();
290        base.size.to_bits().hash(&mut hasher);
291        base.weight.to_u16().hash(&mut hasher);
292        base.italic.hash(&mut hasher);
293        self.max_lines.hash(&mut hasher);
294        Some(hasher.finish())
295    }
296
297    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
298        &[]
299    }
300
301    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
302        let runs = self.runs(theme.on_surface, status.opacity);
303        let natural = frus_text::measure_runs(&runs).width;
304        let (runs, too_tall) = self.fitted(runs, bounds.width);
305        // A paragraph that wraps has no line wider than its box; one that does not may.
306        let too_wide = !self.wrap && natural > bounds.width + 0.5;
307        let over = too_tall || too_wide;
308        let block = TextBlock {
309            width: (self.wrap || self.align != TextAlign::Start).then_some(bounds.width),
310            soft_wrap: self.wrap,
311            align: self.align,
312        };
313        let at = Point::new(bounds.x, bounds.y);
314        let draw = |scene: &mut Scene| {
315            scene.rich_text_block(at, runs.clone(), block);
316        };
317        if !over
318            || self.overflow == TextOverflow::Visible
319            || self.overflow == TextOverflow::Ellipsis
320        {
321            draw(scene);
322            return;
323        }
324        let outer = scene.current_clip();
325        scene.set_clip(outer.intersect(bounds));
326        match self.overflow {
327            TextOverflow::Fade => scene.masked(self.fade(bounds, too_wide && !too_tall), draw),
328            _ => draw(scene),
329        }
330        scene.set_clip(outer);
331    }
332
333    fn on_click(&self) -> Option<Msg> {
334        None
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::interaction::Status;
342    use frus_core::{FontWeight, Primitive};
343
344    #[test]
345    fn paints_resolved_runs_with_theme_colour() {
346        let theme = Theme::default();
347        let rich = RichText::new(
348            TextSpan::new("plain ")
349                .child(TextSpan::new("bold").bold())
350                .child(TextSpan::new("red").color(Color::rgb(1.0, 0.0, 0.0))),
351        )
352        .base_style(theme.text.body_large);
353
354        let mut scene = Scene::new();
355        Widget::<()>::paint(
356            &rich,
357            Rect::new(2.0, 3.0, 300.0, 24.0),
358            Status::default(),
359            &theme,
360            &mut scene,
361        );
362
363        assert_eq!(scene.primitives().len(), 1);
364        match &scene.primitives()[0] {
365            Primitive::RichText { position, runs, .. } => {
366                assert_eq!(*position, Point::new(2.0, 3.0));
367                assert_eq!(runs.len(), 3);
368                // Run 0: inherits everything from the base (16 px, the theme's color).
369                assert_eq!(runs[0].size, 16.0);
370                assert_eq!(runs[0].color, theme.on_surface);
371                // Run 1: bold, size and color inherited.
372                assert_eq!(runs[1].weight, FontWeight::Bold);
373                assert_eq!(runs[1].size, 16.0);
374                assert_eq!(runs[1].color, theme.on_surface);
375                // Run 2: an explicit color.
376                assert_eq!(runs[2].color, Color::rgb(1.0, 0.0, 0.0));
377            }
378            _ => panic!("expected rich text"),
379        }
380    }
381
382    #[test]
383    fn wrapped_rich_text_measures_and_keys_by_content() {
384        let para = |text: &str| {
385            RichText::new(TextSpan::new(text).child(TextSpan::new(" gras").bold())).wrap()
386        };
387        let rich = para("a rich paragraph long enough to wrap onto several lines");
388        // Measuring under constraints wraps: taller at 120 px than when free.
389        let theme = Theme::default();
390        let measure = Widget::<()>::measure(&rich, &theme).expect("measure closure");
391        let free = measure(None, None);
392        let narrow = measure(Some(120.0), None);
393        assert!(narrow.width <= 120.0);
394        assert!(narrow.height > free.height, "wrapped → taller");
395        // The measure key follows the content (the relayout cache fix)…
396        assert_ne!(
397            Widget::<()>::measure_key(&rich, &theme),
398            Widget::<()>::measure_key(&para("short"), &theme)
399        );
400        // … but not the color (no effect on geometry).
401        let recolored = RichText::new(
402            TextSpan::new("a rich paragraph long enough to wrap onto several lines")
403                .color(Color::rgb(1.0, 0.0, 0.0))
404                .child(TextSpan::new(" gras").bold()),
405        )
406        .wrap();
407        assert_eq!(
408            Widget::<()>::measure_key(&rich, &Theme::default()),
409            Widget::<()>::measure_key(&recolored, &Theme::default()),
410            "the color must not invalidate the layout"
411        );
412        // A paragraph told not to wrap is a box of a known size and says so in its
413        // style, exactly as a `Text` does — see the reasoning there.
414        let plain = RichText::new(TextSpan::new("x")).no_wrap();
415        assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
416        assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
417    }
418
419    /// A line limit cuts the runs where the **shaper** broke them, keeps the styles of
420    /// what is left, and ends the last line in an ellipsis when asked.
421    #[test]
422    fn a_limit_cuts_the_runs_and_keeps_their_styles() {
423        let span = TextSpan::new("one two three four ")
424            .child(TextSpan::new("five six seven eight").bold())
425            .child(TextSpan::new(" nine ten eleven twelve"));
426        let rich = RichText::new(span)
427            .base_style(TextStyle::new(12.0))
428            .max_lines(2)
429            .overflow(TextOverflow::Ellipsis);
430        let mut scene = Scene::new();
431        Widget::<()>::paint(
432            &rich,
433            Rect::new(0.0, 0.0, 90.0, 40.0),
434            Status::default(),
435            &Theme::default(),
436            &mut scene,
437        );
438        let runs = match &scene.primitives()[0] {
439            Primitive::RichText { runs, .. } => runs.clone(),
440            other => panic!("rich text, not {other:?}"),
441        };
442        let text: String = runs.iter().map(|r| r.text.as_str()).collect();
443        assert!(text.ends_with('…'), "cut and marked: {text:?}");
444        assert!(text.len() < 60, "and shorter than the whole: {text:?}");
445        // The bold run is still bold in what is left, which is the half a plain-text cut
446        // does not have to get right.
447        assert!(
448            runs.iter().any(|r| r.weight == FontWeight::Bold),
449            "the styles survived the cut: {runs:?}"
450        );
451    }
452
453    /// A limit it does not reach leaves the runs exactly as they were.
454    #[test]
455    fn a_limit_it_does_not_reach_leaves_the_runs_alone() {
456        let rich = RichText::new(TextSpan::new("short").child(TextSpan::new(" and bold").bold()))
457            .base_style(TextStyle::new(12.0))
458            .max_lines(4);
459        let mut scene = Scene::new();
460        Widget::<()>::paint(
461            &rich,
462            Rect::new(0.0, 0.0, 400.0, 40.0),
463            Status::default(),
464            &Theme::default(),
465            &mut scene,
466        );
467        match &scene.primitives()[0] {
468            Primitive::RichText { runs, .. } => {
469                let text: String = runs.iter().map(|r| r.text.as_str()).collect();
470                assert_eq!(text, "short and bold");
471            }
472            other => panic!("rich text, not {other:?}"),
473        }
474    }
475
476    /// Alignment reaches the primitive along with the width it aligns inside — and a
477    /// start-aligned, non-wrapping paragraph is deliberately given neither.
478    #[test]
479    fn alignment_travels_with_the_width_it_aligns_in() {
480        let painted = |rich: RichText| {
481            let mut scene = Scene::new();
482            Widget::<()>::paint(
483                &rich,
484                Rect::new(0.0, 0.0, 200.0, 40.0),
485                Status::default(),
486                &Theme::default(),
487                &mut scene,
488            );
489            match &scene.primitives()[0] {
490                Primitive::RichText {
491                    align, max_width, ..
492                } => (*align, *max_width),
493                other => panic!("rich text, not {other:?}"),
494            }
495        };
496        assert_eq!(
497            painted(RichText::new(TextSpan::new("x")).align(TextAlign::Center)),
498            (TextAlign::Center, Some(200.0))
499        );
500        assert_eq!(
501            painted(RichText::new(TextSpan::new("x")).no_wrap()),
502            (TextAlign::Start, None)
503        );
504    }
505
506    #[test]
507    fn layout_accounts_for_the_largest_run() {
508        // A 28 px run in the middle: the measured height exceeds that of a 16 px one.
509        let h = |rich: RichText| {
510            Widget::<()>::measure(&rich, &Theme::default()).expect("measured")(None, None).height
511        };
512        let small = h(RichText::new(TextSpan::new("plain")));
513        let tall = h(RichText::new(
514            TextSpan::new("plain").child(TextSpan::new("BIG").size(28.0)),
515        ));
516        assert!(tall > small, "the large run must grow the line");
517    }
518}