Skip to main content

frus_widgets/
text.rs

1//! [`Text`]: a widget that displays a line of text.
2
3use frus_core::{
4    fits, Color, FontWeight, MaskShader, Point, Rect, ResolvedTextStyle, Scene, ShaderMask,
5    TextAlign, TextBlock, TextOverflow, TextStyle,
6};
7use frus_layout::{Dimension, Style};
8
9use crate::interaction::Status;
10use crate::theme::Theme;
11use crate::widget::Widget;
12use crate::widgettheme::DefaultTextStyle;
13
14/// A single-line text widget.
15///
16/// Its layout size comes from a **styled** measurement (`frus-text`, weight and
17/// italic included); it pushes a text primitive into the scene when painting. The
18/// [`TextStyle`]'s color is inherited from the theme when absent.
19pub struct Text {
20    content: String,
21    style: TextStyle,
22    /// Whether the text wraps at the width it is given. Unset, **on**, as in the
23    /// reference: a piece of prose put in a box narrower than itself is a paragraph.
24    wrap: Option<bool>,
25    /// What becomes of text that does not fit its box. Unset, clipped.
26    overflow: Option<TextOverflow>,
27    /// At most this many lines; the rest is dropped and `overflow` decides how the last
28    /// one ends.
29    max_lines: Option<usize>,
30    /// Where the lines sit inside the box. Unset, at the start.
31    align: Option<TextAlign>,
32    /// Whether this text is a **heading** — a landmark a screen reader's user can jump
33    /// to, rather than one more piece of prose. It changes nothing that is drawn.
34    heading: bool,
35    /// Whether this text is willing to be given **less than it asked for**.
36    ///
37    /// It is not the same question as what happens when it overflows, and conflating the
38    /// two would change every text in the framework: a flex item's automatic minimum size
39    /// is its content, so a plain text refuses to shrink and its siblings are pushed out
40    /// instead. Saying what to do on overflow is what says it may be squeezed — and an
41    /// **inherited** limit says it just as much as a called one, which is why this is
42    /// derived at resolution rather than stored.
43    shrinkable: bool,
44}
45
46/// A [`Text`]'s questions, all answered: what the caller said where they said it, what the
47/// subtree's [`DefaultTextStyle`](crate::DefaultTextStyle) said where they did not, and
48/// the framework's own default where neither did.
49///
50/// Everything the widget does — the width it asks for, the words it keeps, the glyphs it
51/// draws — reads from one of these, and every hook builds it the same way. That is not
52/// tidiness: a size resolved one way for the measurement and another way for the paint is
53/// a layout that is wrong everywhere at once, with nothing in the picture to say which of
54/// the two numbers was the mistake.
55pub(crate) struct Resolved {
56    pub style: ResolvedTextStyle,
57    pub align: TextAlign,
58    pub wrap: bool,
59    pub overflow: TextOverflow,
60    pub max_lines: Option<usize>,
61    /// Whether this text is willing to be given less than it asked for — which an
62    /// inherited overflow or line limit says just as much as a called one.
63    pub shrinkable: bool,
64}
65
66/// The ellipsis a cut line ends in.
67pub(crate) const ELLIPSIS: &str = "…";
68
69/// The longest prefix of `content` that fits in `max_width`, ending in an ellipsis when
70/// anything was cut. Returns `content` untouched when it already fits.
71///
72/// A box with **no room at all** gets an ellipsis and nothing else. It used to get the
73/// whole string, on the reasoning that a zero width meant "the layout has not run yet"
74/// rather than "there is no room" — inherited from the app bar, which cannot produce a
75/// zero anyway (its title room has a floor of 64 px). What the exception actually did was
76/// let a genuinely collapsed box draw its whole label, over whatever was beside it, which
77/// is the one thing ellipsising exists to prevent.
78///
79/// Character by character from the end: the strings this is used on are a line, not a
80/// document, and a binary search over char boundaries would buy nothing at that length.
81pub(crate) fn truncate(content: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
82    let measure = |text: &str| frus_text::measure_resolved(text, style).width;
83    if measure(content) <= max_width {
84        return content.to_string();
85    }
86    let mut chars: Vec<char> = content.chars().collect();
87    while !chars.is_empty() {
88        chars.pop();
89        let kept: String = chars.iter().collect();
90        let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
91        if measure(&candidate) <= max_width {
92            return candidate;
93        }
94    }
95    ELLIPSIS.to_string()
96}
97
98/// `line` cut so that it and an ellipsis together fit `max_width` — **always** ending in
99/// one, where [`truncate`] leaves a line that already fits alone.
100///
101/// The difference is which question is being asked. `truncate` asks whether this line
102/// fits; this asks how to end a line that is being cut because the *next* one was
103/// dropped, and there the line usually does fit and still has to say so.
104pub(crate) fn ellipsise(line: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
105    let measure = |text: &str| frus_text::measure_resolved(text, style).width;
106    let mut chars: Vec<char> = line.trim_end().chars().collect();
107    loop {
108        let kept: String = chars.iter().collect();
109        let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
110        if chars.is_empty() || max_width <= 0.0 || measure(&candidate) <= max_width {
111            return candidate;
112        }
113        chars.pop();
114    }
115}
116
117/// What a text came to after being fitted to its box: what to draw, and which way it ran
118/// over — the two are different questions, and a fade needs to know which.
119pub(crate) struct Fitted {
120    /// The text to draw, with the dropped lines gone and any ellipsis already in it.
121    pub text: String,
122    /// The last line is wider than the box.
123    pub too_wide: bool,
124    /// Lines were dropped off the bottom.
125    pub too_tall: bool,
126}
127
128impl Fitted {
129    /// Whether anything ran over at all.
130    fn over(&self) -> bool {
131        self.too_wide || self.too_tall
132    }
133}
134
135impl Text {
136    /// Creates a text that has **chosen nothing**: it takes its size, weight and colour
137    /// from whatever subtree it is put in, and the framework's own where nothing says
138    /// otherwise (16 px, regular, the theme's `on_surface`).
139    ///
140    /// It used to be written as a 16 px style, which read the same and meant something
141    /// else: a style that *answered* 16, so an app bar or a section could not dress it.
142    pub fn new(content: impl Into<String>) -> Self {
143        Self {
144            content: content.into(),
145            ..Self::defaults()
146        }
147    }
148
149    /// Creates a text from a full [`TextStyle`] — typically one step of the theme's
150    /// scale (`Text::styled("Title", theme.text.title_large)`).
151    /// The style is taken **exactly as written**: whatever it names it answers, and
152    /// whatever it leaves open stays open to a subtree.
153    ///
154    /// So `Text::styled(s, theme.text.title_medium)` wears that step of the scale and
155    /// still picks up a colour from the section it sits in, while
156    /// `Text::styled(s, TextStyle::new(20.0))` fixes the size alone and inherits the rest.
157    pub fn styled(content: impl Into<String>, style: TextStyle) -> Self {
158        Self {
159            content: content.into(),
160            style,
161            ..Self::defaults()
162        }
163    }
164
165    /// The settings every constructor starts from, so that adding one does not mean
166    /// touching each of them.
167    fn defaults() -> Self {
168        Self {
169            content: String::new(),
170            style: TextStyle::NONE,
171            wrap: None,
172            overflow: None,
173            max_lines: None,
174            align: None,
175            heading: false,
176            shrinkable: false,
177        }
178    }
179
180    /// Wraps at the width the parent offers. This is the default, and the call is kept
181    /// because saying so at the call site is not redundant when it is the whole point of
182    /// the widget being there.
183    /// Marks this text as a **heading**: a landmark assistive technology can jump
184    /// between, instead of one more run of prose.
185    ///
186    /// It changes nothing that is drawn. A screen reader's user moves through a screen by
187    /// its headings, and a title announced as a label gives them nothing to move between
188    /// — which is what an app bar's title was until milestone 397. The reference says the
189    /// same thing with `SemanticsProperties(header: true)` around its title.
190    pub fn heading(mut self) -> Self {
191        self.heading = true;
192        self
193    }
194
195    /// Wraps the text at the width it is given: [`soft_wrap(true)`](Self::soft_wrap). It is
196    /// what an unset text does already; this says so, and undoes a
197    /// [`no_wrap`](Self::no_wrap) further up a chain.
198    pub fn wrap(mut self) -> Self {
199        self.wrap = Some(true);
200        self
201    }
202
203    /// Keeps the text on **one line**, explicit newlines aside. It then runs past its box
204    /// rather than folding, and [`Text::overflow`] decides what becomes of the part that
205    /// hangs over.
206    pub fn no_wrap(mut self) -> Self {
207        self.wrap = Some(false);
208        self
209    }
210
211    /// Whether the text wraps at the width it is given. `wrap()` is `soft_wrap(true)`.
212    ///
213    /// Off, the text stays on one line — explicit newlines aside — and runs past its box
214    /// or is cut, according to [`Text::overflow`].
215    pub fn soft_wrap(mut self, soft_wrap: bool) -> Self {
216        self.wrap = Some(soft_wrap);
217        self
218    }
219
220    /// Where the lines sit **inside the box** the text was given.
221    ///
222    /// It does nothing to a text that shrink-wraps, and that is not a limitation of this
223    /// implementation: a box exactly as wide as its text has nowhere to align it to. It
224    /// takes effect once something has made the box wider — a stretched column, an
225    /// [`crate::Expanded`], a width.
226    pub fn align(mut self, align: TextAlign) -> Self {
227        self.align = Some(align);
228        self
229    }
230
231    /// At most `max_lines` lines; what is past them is dropped, and [`Text::overflow`]
232    /// decides how the last one ends.
233    ///
234    /// Asking for a limit also tells the layout this text may be given **less width than
235    /// it asked for** — a limit is only meaningful for a text expected to be squeezed.
236    pub fn max_lines(mut self, max_lines: usize) -> Self {
237        self.max_lines = Some(max_lines.max(1));
238        self.shrinkable = true;
239        self
240    }
241
242    /// What becomes of text that does not fit: cut, cut with an ellipsis, faded out, or
243    /// drawn past the box.
244    ///
245    /// Saying so also tells the layout this text may be given **less than it asked for**.
246    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
247        self.overflow = Some(overflow);
248        self.shrinkable = true;
249        self
250    }
251
252    /// Keeps the text on **one line and inside its box**, cut with an ellipsis when it
253    /// does not fit — the reference's `TextOverflow.ellipsis`.
254    ///
255    /// This is two things, and the second is the one that matters. The obvious half is the
256    /// cut. The other is that an ellipsising text tells the layout it may be given *less
257    /// than it asked for*: a flex item's automatic minimum size is its content, so a plain
258    /// `Text` in a row refuses to shrink and pushes its siblings out of the box instead —
259    /// which is how a long task name evicted its own delete button, off the card and out of
260    /// the hit registry, on a device (milestone 333). `min_width: 0` is what lets flexbox
261    /// do its job, and it is the same fix the web has needed for the same reason forever.
262    ///
263    /// Mutually exclusive with [`Text::wrap`], which is the other answer to the same
264    /// question: wrap grows downwards, this one cuts. The last one called wins.
265    pub fn ellipsis(mut self) -> Self {
266        self.wrap = Some(false);
267        self.overflow = Some(TextOverflow::Ellipsis);
268        self.shrinkable = true;
269        self
270    }
271
272    /// Sets the font size, in pixels.
273    pub fn size(mut self, size: f32) -> Self {
274        self.style.size = Some(size);
275        self
276    }
277
278    /// Sets the weight.
279    pub fn weight(mut self, weight: FontWeight) -> Self {
280        self.style.weight = Some(weight);
281        self
282    }
283
284    /// Switches to italic.
285    pub fn italic(mut self) -> Self {
286        self.style.italic = Some(true);
287        self
288    }
289
290    /// Sets the **font family** — the reference's
291    /// [`fontFamily`](frus_core::TextStyle::family).
292    ///
293    /// `Text::new("fn main()").family(FontFamily::Monospace)` for code;
294    /// `FontFamily::Named("Inter")` for a face registered with `frus_text::add_font`.
295    /// Unset, the application's default.
296    ///
297    /// **A run containing Arabic keeps the registered Arabic face** whatever is named
298    /// here, because a family without Arabic coverage renders nothing at all on Android
299    /// rather than falling back — see [`frus_core::TextStyle::family`].
300    pub fn family(mut self, family: frus_core::FontFamily) -> Self {
301        self.style.family = Some(family);
302        self
303    }
304
305    /// Sets the line height as a **multiple of the font size** — the reference's
306    /// [`TextStyle::height`](frus_core::TextStyle::height).
307    ///
308    /// `1.0` packs the lines to exactly the type's size, `1.6` opens a paragraph up.
309    /// Unset, the line height is whatever a surrounding [`crate::DefaultTextStyle`] said,
310    /// or [`DEFAULT_LINE_HEIGHT`](frus_core::DEFAULT_LINE_HEIGHT) at the end of the chain.
311    ///
312    /// A ratio and not a length, so the leading grows with the letters when a reader turns
313    /// the type up instead of staying where it was set.
314    pub fn height(mut self, height: f32) -> Self {
315        self.style.height = Some(height);
316        self
317    }
318
319    /// Sets the text color (otherwise the theme's).
320    pub fn color(mut self, color: Color) -> Self {
321        self.style.color = Some(color);
322        self
323    }
324
325    /// Underlines the text.
326    pub fn underline(mut self) -> Self {
327        self.style = self.style.underline();
328        self
329    }
330
331    /// Strikes the text through.
332    pub fn strikethrough(mut self) -> Self {
333        self.style = self.style.strikethrough();
334        self
335    }
336
337    /// Sets the decoration lines (freely combined).
338    pub fn decoration(mut self, decoration: frus_core::TextDecoration) -> Self {
339        self.style.decoration = Some(decoration);
340        self
341    }
342
343    /// Sets the decoration color (otherwise the text's).
344    pub fn decoration_color(mut self, color: Color) -> Self {
345        self.style.decoration_color = Some(color);
346        self
347    }
348}
349
350impl Text {
351    /// Every question this text asks of itself, answered once: **what the caller said ??
352    /// what the subtree handed down ?? what the framework ships**.
353    ///
354    /// `theme` is an `Option` because [`Widget::style`] has no theme to give — a widget
355    /// asked for its style outside a walk. `None` means nothing was handed down, which is
356    /// exactly what a fresh theme carries, so the two agree and the unthemed case costs
357    /// nothing.
358    ///
359    /// The typography is one `merge` and one `resolved`, and it was fourteen lines of
360    /// bookkeeping until [`TextStyle`]'s fields could each say *unset*. A style that never
361    /// named a size does not outrank a subtree that did; one that named it keeps it. The
362    /// same rule as before, now expressed by the type instead of shadowed by a flag.
363    pub(crate) fn resolved(&self, theme: Option<&Theme>) -> Resolved {
364        let handed = theme.map_or(DefaultTextStyle::NONE, |t| t.widgets.text);
365        Resolved {
366            style: handed.style.merge(self.style).resolved(),
367            align: self.align.or(handed.align).unwrap_or(TextAlign::Start),
368            wrap: self.wrap.or(handed.soft_wrap).unwrap_or(true),
369            overflow: self
370                .overflow
371                .or(handed.overflow)
372                .unwrap_or(TextOverflow::Clip),
373            max_lines: self.max_lines.or(handed.max_lines),
374            // An **inherited** overflow or line limit says the same thing a called one
375            // does: this text may be given less than it asked for. Leaving it out would
376            // ship a subtree that ellipsises its texts and never gets the chance to,
377            // because each of them still refuses to be squeezed.
378            shrinkable: self.shrinkable
379                || (self.overflow.is_none() && handed.overflow.is_some())
380                || (self.max_lines.is_none() && handed.max_lines.is_some()),
381        }
382    }
383
384    /// The text that actually fits `width`, and which way it ran over.
385    ///
386    /// Everything the box does to the words happens here: dropping the lines past the
387    /// limit, cutting the last one, deciding whether a line ran past the edge. The paint
388    /// draws what comes back, and the overflow mode decides how.
389    pub(crate) fn fitted(&self, width: f32, r: &Resolved) -> Fitted {
390        let (size, weight, italic) = (r.style.size, r.style.weight, r.style.italic);
391        // Only a line **limit** makes the words the widget's business. Left alone, the
392        // text goes to the renderer whole and is broken there.
393        //
394        // And what goes over is a **prefix**, cut at a break the shaper chose — never a
395        // list of lines glued back together. Lines glued back together are a paragraph
396        // *per line*, and rules that span a paragraph stop working: a justified block can
397        // only leave its last line ragged if it knows which line is the last.
398        let mut text = self.content.clone();
399        let mut too_tall = false;
400        if let Some(max) = r.max_lines {
401            let spans =
402                frus_text::line_spans(&self.content, size, weight, italic, Some(width), r.wrap);
403            if spans.len() > max {
404                too_tall = true;
405                let cut = spans[max].start;
406                let last = spans[max - 1].start;
407                text = if r.overflow == TextOverflow::Ellipsis {
408                    let ended = ellipsise(&self.content[last..cut], &r.style, width);
409                    format!("{}{ended}", &self.content[..last])
410                } else {
411                    self.content[..cut].to_string()
412                };
413            }
414        }
415        // A line can only be wider than the box when nothing may push it onto the next
416        // one. Where the text wraps, every line fits by construction.
417        let too_wide =
418            !r.wrap && frus_text::measure_styled(&text, size, weight, italic).width > width + 0.5;
419        if too_wide && !too_tall && r.overflow == TextOverflow::Ellipsis {
420            text = ellipsise(&text, &r.style, width);
421        }
422        Fitted {
423            text,
424            too_wide,
425            too_tall,
426        }
427    }
428
429    /// Whether this text needs the **whole width it is offered** rather than its own.
430    ///
431    /// Alignment is the only thing that does. A box exactly as wide as its text has
432    /// nowhere to align it to, so a text asked to centre itself and then given its own
433    /// width would be centred and look untouched — the setting would appear to do nothing
434    /// and there would be nothing to see. Left alone, a text still shrink-wraps.
435    fn fills(r: &Resolved) -> bool {
436        r.align != TextAlign::Start
437    }
438
439    /// A line limit is a **height** cap and nothing else: the words break where they
440    /// broke, and the ones past the limit are not drawn.
441    fn capped(height: f32, r: &Resolved) -> f32 {
442        match r.max_lines {
443            Some(max) => height.min(r.style.line_height() * max as f32),
444            None => height,
445        }
446    }
447
448    /// The fade that ends a cut text: opaque until the last stretch of the box, then out
449    /// to nothing at the edge it ran past.
450    fn fade(bounds: Rect, horizontal: bool, r: &Resolved) -> ShaderMask {
451        // A fifth of the box, and never more than three line heights of it. Over a long
452        // line a proportional fade would start halfway through words that are perfectly
453        // legible; over a short one an absolute fade would swallow the lot.
454        let extent = if horizontal {
455            bounds.width
456        } else {
457            bounds.height
458        };
459        let run = (extent * 0.2).min(r.style.line_height() * 3.0);
460        let (from, to) = if horizontal {
461            (
462                Point::new(bounds.x + bounds.width - run, bounds.y),
463                Point::new(bounds.x + bounds.width, bounds.y),
464            )
465        } else {
466            (
467                Point::new(bounds.x, bounds.y + bounds.height - run),
468                Point::new(bounds.x, bounds.y + bounds.height),
469            )
470        };
471        ShaderMask::new(MaskShader::Linear {
472            from,
473            to,
474            from_color: Color::WHITE,
475            to_color: Color::WHITE.fade(0.0),
476        })
477    }
478}
479
480impl Text {
481    /// The box this text asks for, once every question has been answered.
482    ///
483    /// Split out from the two style hooks rather than duplicated across them: they differ
484    /// only in whether a theme was there to be asked, and a second copy of this reasoning
485    /// is a second place for the themed and the unthemed answers to drift apart.
486    fn boxed(&self, r: &Resolved) -> Style {
487        // A flex item's automatic minimum size is its content, so a plain text refuses to
488        // shrink and pushes its siblings out instead. One that has said what to do when it
489        // overflows may be given less; the paint fits the words to whatever it gets.
490        let min_width = if r.shrinkable {
491            Dimension::Length(0.0)
492        } else {
493            Dimension::Auto
494        };
495        // A paragraph, or a text that has to know how wide its box is: free dimensions,
496        // and the size comes from `measure` — the only way a box can be *given* a width
497        // and answer with a height.
498        if r.wrap || Self::fills(r) {
499            return Style {
500                min_width,
501                ..Default::default()
502            };
503        }
504        // A single line is a box of a known size, and saying so is what keeps it from
505        // being folded: a measured leaf reports its narrowest useful width as its
506        // minimum content, and a row would take that as leave to squeeze it there.
507        let measured = frus_text::measure_resolved(&self.content, &r.style);
508        Style {
509            width: Dimension::Length(measured.width.ceil()),
510            height: Dimension::Length(Self::capped(measured.height, r).ceil()),
511            min_width,
512            // A text that has said what to do when it overflows is **clamped to its
513            // parent**, which is what the reference does to every one of them: a
514            // paragraph is laid out at `constraints.constrain(its own size)`. Without it a
515            // text declares the width it wants, a narrower box does not take it away, and
516            // the overflow mode never fires — the words simply draw past the edge, which
517            // is the behaviour it was set to prevent.
518            max_width: if r.shrinkable {
519                Dimension::Percent(1.0)
520            } else {
521                Dimension::Auto
522            },
523            ..Default::default()
524        }
525    }
526}
527
528impl<Msg> Widget<Msg> for Text {
529    fn style(&self) -> Style {
530        self.boxed(&self.resolved(None))
531    }
532
533    /// A subtree can hand down a size, a weight and a line limit, and every one of those
534    /// is a **box**, not a colour. Resolving them here rather than at paint is what lets
535    /// an app bar make the words inside it smaller and have them take less room, instead
536    /// of the same room with smaller writing in it.
537    fn style_themed(&self, theme: &Theme) -> Style {
538        self.boxed(&self.resolved(Some(theme)))
539    }
540
541    /// A text will not be **squeezed along a row**: it runs past the end of one rather
542    /// than being folded into a column of single words, which is the reference's rule and
543    /// was, until now, the only thing a declared width was doing here.
544    ///
545    /// A text that has said what to do when it overflows has already said the opposite,
546    /// and is left alone.
547    fn main_axis_floor(&self, theme: &Theme) -> Option<f32> {
548        let r = self.resolved(Some(theme));
549        // A single line already carries its width in its style; only a text measured
550        // under constraints needs to be told where to stop giving way.
551        if r.shrinkable || !(r.wrap || Self::fills(&r)) {
552            return None;
553        }
554        Some(
555            frus_text::measure_resolved(&self.content, &r.style)
556                .width
557                .ceil(),
558        )
559    }
560
561    /// The line this text sits on, measured from the top of its box. A `Text` is the
562    /// widget that actually *has* a baseline; every alignment that talks about one is
563    /// ultimately asking one of these.
564    fn text_baseline(&self, theme: &Theme) -> Option<f32> {
565        let r = self.resolved(Some(theme));
566        Some(frus_text::baseline(
567            r.style.size,
568            r.style.weight,
569            r.style.italic,
570        ))
571    }
572
573    fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
574        let r = self.resolved(Some(theme));
575        if !r.wrap && !Self::fills(&r) {
576            return None;
577        }
578        let content = self.content.clone();
579        let style = r.style;
580        let max_lines = r.max_lines;
581        let wrap = r.wrap;
582        Some(Box::new(move |max_width, _| {
583            let mut size = frus_text::measure_wrapped(
584                &content,
585                style.size,
586                style.weight,
587                style.italic,
588                // A text that only wanted the width to align inside is still one line:
589                // the constraint tells it where its box ends, not where to break.
590                if wrap { max_width } else { None },
591            );
592            if let Some(max) = max_lines {
593                // **Rounded up, like the measurement it clamps** (issue #54): a line
594                // height is a fraction of a size, so two allowed lines come to 28.8, and
595                // a box rounded to 28 is one the second line no longer fits in. The
596                // clamp says *how many lines*; the pixels those lines occupy are still
597                // pixels a layout will round.
598                size.height = size.height.min(fits(style.line_height() * max as f32));
599            }
600            size
601        }))
602    }
603
604    fn measure_key(&self, theme: &Theme) -> Option<u64> {
605        let r = self.resolved(Some(theme));
606        if !r.wrap && !Self::fills(&r) {
607            return None;
608        }
609        use std::hash::{Hash, Hasher};
610        let mut hasher = std::collections::hash_map::DefaultHasher::new();
611        self.content.hash(&mut hasher);
612        // The **resolved** style, not the written one: an inherited size changes the
613        // measurement, and a key that ignored it would hand back the geometry the text had
614        // before the subtree said anything.
615        r.style.size.to_bits().hash(&mut hasher);
616        r.style.weight.to_u16().hash(&mut hasher);
617        r.style.italic.hash(&mut hasher);
618        // The leading and the face, which milestones 409 and 410 put into the measurement
619        // and *not* into this key: two paragraphs alike but for their line height wrapped
620        // to the same box, and the second kept the first's geometry.
621        r.style.height.map(f32::to_bits).hash(&mut hasher);
622        r.style.family.hash(&mut hasher);
623        r.max_lines.hash(&mut hasher);
624        r.wrap.hash(&mut hasher);
625        Some(hasher.finish())
626    }
627
628    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
629        &[]
630    }
631
632    /// An aligned text takes the width its parent offers: a box exactly as wide as its
633    /// text has nowhere to align it to. It is the same request a [`crate::Row`] makes,
634    /// and it is answered by the same walk.
635    fn fill_axes(&self, theme: &Theme) -> crate::widget::FillAxes {
636        if Self::fills(&self.resolved(Some(theme))) {
637            crate::widget::FillAxes::WIDTH
638        } else {
639            crate::widget::FillAxes::NONE
640        }
641    }
642
643    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
644        let r = self.resolved(Some(theme));
645        let color = r
646            .style
647            .color
648            .unwrap_or(theme.on_surface)
649            .fade(status.opacity);
650        let fitted = self.fitted(bounds.width, &r);
651        let block = TextBlock {
652            // A width is handed over only when something is going to use it. Giving the
653            // renderer one it did not have changes where right-to-left text lands, which
654            // is a bug this codebase has already had once.
655            width: (r.wrap || r.align != TextAlign::Start).then_some(bounds.width),
656            soft_wrap: r.wrap,
657            align: r.align,
658        };
659        let draw = |scene: &mut Scene| {
660            scene.text_block(
661                Point::new(bounds.x, bounds.y),
662                fitted.text.clone(),
663                &r.style,
664                color,
665                block,
666            );
667        };
668        // Nothing hanging over the edge, or already cut to size, or told to spill: the
669        // three cases where the mode has nothing left to do.
670        if !fitted.over()
671            || r.overflow == TextOverflow::Visible
672            || r.overflow == TextOverflow::Ellipsis
673        {
674            draw(scene);
675            return;
676        }
677        // Only where it genuinely does not fit: a clip around every text would put a hard
678        // edge through the antialiasing of every one that does.
679        let outer = scene.current_clip();
680        scene.set_clip(outer.intersect(bounds));
681        match r.overflow {
682            TextOverflow::Fade => {
683                let horizontal = fitted.too_wide && !fitted.too_tall;
684                scene.masked(Self::fade(bounds, horizontal, &r), draw);
685            }
686            _ => draw(scene),
687        }
688        scene.set_clip(outer);
689    }
690
691    fn on_click(&self) -> Option<Msg> {
692        None
693    }
694
695    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
696        // A text carries its content as its accessible label — or as a **heading**, which
697        // is a landmark rather than a label and is what a screen reader's user navigates by.
698        let role = if self.heading {
699            frus_core::Role::Heading
700        } else {
701            frus_core::Role::Label
702        };
703        Some(frus_core::SemanticsProperties::new(role).label(self.content.clone()))
704    }
705}
706
707#[cfg(test)]
708mod tests {
709    /// An ellipsising text is **cut to the box it is given** instead of drawing past it.
710    ///
711    /// It also tells the layout it may be given less than it asked for (`min_width: 0`),
712    /// which is half of what a row needs to keep its trailing widget intact. The other
713    /// half arrived in milestone 349: a widget that did not ask to give way is not
714    /// squeezed, so the trailing button keeps its width without saying anything.
715    #[test]
716    fn an_ellipsising_text_is_cut_to_its_box() {
717        let long = "A task name that is really rather long indeed and keeps going";
718        let style = TextStyle::new(18.0);
719        let full = frus_text::measure_style(long, style).width;
720        assert!(full > 300.0, "the fixture has to overflow: {full}");
721
722        let cut = truncate(long, &style.resolved(), 150.0);
723        assert!(cut.ends_with(ELLIPSIS), "cut with an ellipsis: {cut}");
724        let cut_width = frus_text::measure_style(&cut, style).width;
725        assert!(cut_width <= 150.0, "and it fits: {cut_width}");
726        assert!(cut.len() > 4, "without cutting everything: {cut}");
727
728        // A width it already fits in leaves it alone, ellipsis and all.
729        assert_eq!(truncate("Short", &style.resolved(), 500.0), "Short");
730        // A box with no room at all draws an ellipsis and nothing else. It used to draw
731        // the whole string, which is the one thing ellipsising exists to prevent.
732        assert_eq!(truncate(long, &style.resolved(), 0.0), ELLIPSIS);
733    }
734
735    /// The layout half: an ellipsising text accepts less width than it asked for, a plain
736    /// one does not.
737    #[test]
738    fn an_ellipsising_text_lets_the_layout_shrink_it() {
739        use frus_layout::Dimension;
740        let plain =
741            Widget::<()>::style(&Text::new("A rather long label indeed").size(18.0).no_wrap());
742        let cut = Widget::<()>::style(
743            &Text::new("A rather long label indeed")
744                .size(18.0)
745                .ellipsis(),
746        );
747        assert_eq!(plain.min_width, Dimension::Auto, "content is its own floor");
748        assert_eq!(
749            cut.min_width,
750            Dimension::Length(0.0),
751            "and this one has none"
752        );
753        assert_eq!(plain.width, cut.width, "both still ask for the same width");
754    }
755
756    use super::*;
757    use frus_core::Primitive;
758    use frus_core::{MaskShader, TextAlign, TextOverflow};
759
760    /// A paragraph **sized to fit** — centred on a column's cross axis, so it gets
761    /// its natural width rather than the whole row — must be given a box the text
762    /// still fits in, and the thing below it must clear the lines it really has.
763    ///
764    /// This failed on a device before the measurement was rounded up: the natural
765    /// width came back as a fraction, the layout rounded the box down below it, the
766    /// text wrapped onto a second line when painted, and the label underneath sat on
767    /// top of that line — the layout having reserved the height of one.
768    #[test]
769    fn a_paragraph_sized_to_fit_is_not_squeezed_into_wrapping() {
770        use crate::{build_ui, Container, Flex, Runtime, Size};
771        use frus_core::FontWeight;
772        use frus_layout::Align;
773
774        const MARK: Color = Color {
775            r: 1.0,
776            g: 0.0,
777            b: 1.0,
778            a: 1.0,
779        };
780        // Two labels: one that fits on a line at its natural width, one that cannot
781        // fit the box at all and must reserve every line it wraps onto.
782        for (label, lines) in [
783            ("Write code", 1.0_f32),
784            ("A rather long task name that certainly wraps", 2.0),
785        ] {
786            let tree = Container::<()>::new().width(400.0).height(600.0).child(
787                Flex::column()
788                    .width(376.0)
789                    .align(Align::Center)
790                    .gap(18.0)
791                    .child(Text::new(label).size(24.0).weight(FontWeight::Bold).wrap())
792                    .child(Container::new().height(20.0).color(MARK)),
793            );
794            let ui = build_ui(
795                &tree,
796                Size::new(400.0, 600.0),
797                &Runtime::default(),
798                &Theme::default(),
799            );
800            let paragraph = ui
801                .scene()
802                .primitives()
803                .iter()
804                .find_map(|p| match p {
805                    Primitive::Text {
806                        position,
807                        max_width,
808                        ..
809                    } => Some((*position, max_width.unwrap_or(f32::MAX))),
810                    _ => None,
811                })
812                .expect("the paragraph is painted");
813            let below = ui
814                .scene()
815                .primitives()
816                .iter()
817                .find_map(|p| match p {
818                    Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
819                    _ => None,
820                })
821                .expect("the label under it is painted");
822            // What the box it was given really costs, shaped at that width.
823            let painted =
824                frus_text::measure_wrapped(label, 24.0, FontWeight::Bold, false, Some(paragraph.1));
825            // Against the **whole** height those lines occupy, which is what a
826            // measurement reports since milestone 497: a height of `lines × line_height`
827            // is fractional and would be shaved by the layout's rounding, so it is
828            // rounded up before it leaves. Dividing the rounded height back by the line
829            // height gives 2.014 lines, which is arithmetic rather than a wrap.
830            let want = frus_core::fits(lines * frus_text::line_height(24.0));
831            assert!(
832                (painted.height - want).abs() < 0.01,
833                "{label:?} wrapped onto {} lines in a box of {}",
834                painted.height / frus_text::line_height(24.0),
835                paragraph.1
836            );
837            assert!(
838                below.y >= paragraph.0.y + painted.height - 0.01,
839                "{label:?}: the label underneath overlaps the paragraph ({below:?} vs {painted:?})"
840            );
841        }
842    }
843
844    /// A line limit drops what is past it and, asked for an ellipsis, says so on the
845    /// last line it kept.
846    #[test]
847    fn a_line_limit_cuts_the_paragraph_and_marks_the_cut() {
848        let long = "one two three four five six seven eight nine ten eleven twelve";
849        let text = Text::new(long)
850            .size(14.0)
851            .wrap()
852            .max_lines(2)
853            .overflow(TextOverflow::Ellipsis);
854        let fitted = text.fitted(90.0, &text.resolved(None));
855        // What comes back is a **prefix**, not a list of lines: it wraps into two when the
856        // renderer breaks it, which is where the breaking belongs.
857        assert!(
858            fitted.text.lines().count() == 1,
859            "one string, no newlines in it: {:?}",
860            fitted.text
861        );
862        let lines = frus_text::line_spans(
863            &fitted.text,
864            14.0,
865            FontWeight::Regular,
866            false,
867            Some(90.0),
868            true,
869        );
870        assert_eq!(lines.len(), 2, "and it breaks into two: {:?}", fitted.text);
871        assert!(fitted.too_tall, "and there was more");
872        assert!(
873            fitted.text.ends_with(ELLIPSIS),
874            "ending in an ellipsis: {:?}",
875            fitted.text
876        );
877    }
878
879    /// The same paragraph with room for every line is left exactly as it was written.
880    #[test]
881    fn a_limit_it_does_not_reach_changes_nothing() {
882        let text = Text::new("one two").size(14.0).wrap().max_lines(4);
883        let fitted = text.fitted(400.0, &text.resolved(None));
884        assert_eq!(fitted.text, "one two");
885        assert!(!fitted.too_tall && !fitted.too_wide);
886    }
887
888    /// A limit is a height cap: the words still wrap where they wrapped, and the box
889    /// stops at the line it was allowed.
890    #[test]
891    fn a_limit_caps_the_measured_height() {
892        let long = "one two three four five six seven eight nine ten eleven twelve";
893        let free = Text::new(long).size(14.0).wrap();
894        let capped = Text::new(long).size(14.0).wrap().max_lines(2);
895        let at = |t: &Text| {
896            Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
897        };
898        assert!(at(&free) > at(&capped), "the limit is doing something");
899        assert!(
900            (at(&capped) - frus_text::line_height(14.0) * 2.0).abs() < 0.5,
901            "two lines exactly: {}",
902            at(&capped)
903        );
904    }
905
906    /// **A line limit counts the lines the style asked for**, not lines of a default
907    /// height.
908    ///
909    /// Milestone 409 gave a style a `height` and threaded it through the measurement and
910    /// the paint; it left twenty-four places computing `line_height(style.size)` — the
911    /// 1.2 default — while holding a style that said otherwise. A cap is one of them, and
912    /// it is the one a test can see: two lines of a doubled leading is twice the box, and
913    /// before this it was capped as though the leading had never been named (milestone
914    /// 412).
915    #[test]
916    fn a_limit_counts_lines_of_the_height_that_was_asked_for() {
917        let long = "one two three four five six seven eight nine ten eleven twelve";
918        let plain = Text::new(long).size(14.0).wrap().max_lines(2);
919        let open = Text::new(long).size(14.0).height(2.4).wrap().max_lines(2);
920        let at = |t: &Text| {
921            Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
922        };
923        let ratio = at(&open) / at(&plain);
924        assert!(
925            (ratio - 2.0).abs() < 0.02,
926            "a doubled leading doubles the two-line cap: {ratio}"
927        );
928    }
929
930    /// `Visible` draws past the box; `Clip` puts the box in the primitive's clip so the
931    /// renderer stops at the edge. Both draw the whole string — the difference is the
932    /// clip, not the text.
933    #[test]
934    fn clip_and_visible_differ_by_the_clip_and_not_the_words() {
935        let long = "a label far too long for the box it was given";
936        let paint = |overflow: TextOverflow| {
937            let text = Text::new(long).size(16.0).no_wrap().overflow(overflow);
938            let mut scene = Scene::new();
939            Widget::<()>::paint(
940                &text,
941                Rect::new(0.0, 0.0, 60.0, 20.0),
942                Status::default(),
943                &Theme::default(),
944                &mut scene,
945            );
946            match &scene.primitives()[0] {
947                Primitive::Text { text, clip, .. } => (text.clone(), *clip),
948                other => panic!("a text, not {other:?}"),
949            }
950        };
951        let (visible_text, visible_clip) = paint(TextOverflow::Visible);
952        let (clipped_text, clipped_clip) = paint(TextOverflow::Clip);
953        assert_eq!(visible_text, long, "nothing is cut");
954        assert_eq!(clipped_text, long, "nothing is cut here either");
955        assert_eq!(visible_clip, Rect::UNBOUNDED, "and nothing stops it");
956        assert_eq!(
957            clipped_clip,
958            Rect::new(0.0, 0.0, 60.0, 20.0),
959            "this one stops"
960        );
961    }
962
963    /// A **subtree** can hand a text its size, and a text that never chose one takes it.
964    ///
965    /// The half that matters is where it is taken: the size has to reach the **box**, not
966    /// only the glyphs. A text drawn at 24 px inside a box measured for 16 leaves every
967    /// row on the screen the wrong height, and nothing in the picture says which of the
968    /// two numbers was the mistake.
969    #[test]
970    fn a_subtree_hands_a_text_its_size() {
971        let mut theme = Theme::default();
972        theme.widgets.text.style.size = Some(30.0);
973        // One line, so the box it asks for is a number this test can read.
974        let text = Text::new("Handed down").no_wrap();
975
976        let alone = Widget::<()>::style(&text);
977        let handed = Widget::<()>::style_themed(&text, &theme);
978        let (Dimension::Length(alone_w), Dimension::Length(handed_w)) = (alone.width, handed.width)
979        else {
980            panic!("a single line is a box of a known size");
981        };
982        assert!(
983            handed_w > alone_w * 1.5,
984            "the box grew with the type: {alone_w} → {handed_w}"
985        );
986
987        let mut scene = Scene::new();
988        Widget::<()>::paint(
989            &text,
990            Rect::new(0.0, 0.0, handed_w, 40.0),
991            Status::default(),
992            &theme,
993            &mut scene,
994        );
995        match &scene.primitives()[0] {
996            Primitive::Text { size, .. } => assert_eq!(
997                *size, 30.0,
998                "and the glyphs agree with the box they were measured into"
999            ),
1000            other => panic!("a text, not {other:?}"),
1001        }
1002    }
1003
1004    /// A size the **caller** chose is not overruled — and a default nobody chose is.
1005    ///
1006    /// The two halves are the whole rule, and either alone would be wrong: without the
1007    /// first, a subtree silently resizes the one text that had asked to be different;
1008    /// without the second, the feature does nothing at all, because every `Text` already
1009    /// holds a size and a number cannot say whether anybody picked it.
1010    #[test]
1011    fn a_chosen_size_outranks_a_handed_one() {
1012        let mut theme = Theme::default();
1013        theme.widgets.text.style.size = Some(30.0);
1014        let width = |text: &Text| match Widget::<()>::style_themed(text, &theme).width {
1015            Dimension::Length(w) => w,
1016            other => panic!("a known width, not {other:?}"),
1017        };
1018        // The same 16 px, once as a default and once as an answer.
1019        let untouched = width(&Text::new("Handed down").no_wrap());
1020        let chosen = width(&Text::new("Handed down").no_wrap().size(16.0));
1021        assert!(
1022            untouched > chosen,
1023            "the one that chose kept its size: {untouched} vs {chosen}"
1024        );
1025        assert_eq!(
1026            chosen,
1027            width(&Text::new("Handed down").no_wrap().size(16.0)),
1028            "and it keeps it however often it is asked"
1029        );
1030    }
1031
1032    /// The merge is **field by field**: a subtree that sets a colour and nothing else
1033    /// leaves every size alone.
1034    ///
1035    /// A whole-style handover would be the easy implementation and the wrong one — an app
1036    /// bar recolouring its words would flatten the type scale of everything inside it.
1037    #[test]
1038    fn handing_down_a_colour_leaves_the_sizes_alone() {
1039        let red = Color::rgb(1.0, 0.0, 0.0);
1040        let mut theme = Theme::default();
1041        theme.widgets.text.style.color = Some(red);
1042        let text = Text::new("Words").no_wrap().size(22.0);
1043        assert_eq!(
1044            Widget::<()>::style_themed(&text, &theme).width,
1045            Widget::<()>::style(&text).width,
1046            "the box did not move"
1047        );
1048        let mut scene = Scene::new();
1049        Widget::<()>::paint(
1050            &text,
1051            Rect::new(0.0, 0.0, 300.0, 40.0),
1052            Status::default(),
1053            &theme,
1054            &mut scene,
1055        );
1056        match &scene.primitives()[0] {
1057            Primitive::Text { size, color, .. } => {
1058                assert_eq!(*color, red, "the colour came down");
1059                assert_eq!(*size, 22.0, "the size stayed put");
1060            }
1061            other => panic!("a text, not {other:?}"),
1062        }
1063    }
1064
1065    /// A handed-down line limit also says the text **may be squeezed**.
1066    ///
1067    /// It is the easy half to leave out, and leaving it out ships a subtree that
1068    /// ellipsises its texts and never gets the chance to: a flex item's automatic minimum
1069    /// size is its content, so each of them still refuses to give way and the mode never
1070    /// fires. Saying what to do on overflow is what says it may be squeezed — however the
1071    /// text was told.
1072    #[test]
1073    fn a_handed_down_limit_lets_the_text_give_way() {
1074        let mut theme = Theme::default();
1075        theme.widgets.text.max_lines = Some(1);
1076        theme.widgets.text.overflow = Some(TextOverflow::Ellipsis);
1077        let text = Text::new("a label far too long for the box it will be given");
1078        assert_eq!(
1079            Widget::<()>::style(&text).min_width,
1080            Dimension::Auto,
1081            "left alone it refuses to shrink"
1082        );
1083        assert_eq!(
1084            Widget::<()>::style_themed(&text, &theme).min_width,
1085            Dimension::Length(0.0),
1086            "handed a limit, it gives way"
1087        );
1088        let mut scene = Scene::new();
1089        Widget::<()>::paint(
1090            &text,
1091            Rect::new(0.0, 0.0, 80.0, 20.0),
1092            Status::default(),
1093            &theme,
1094            &mut scene,
1095        );
1096        match &scene.primitives()[0] {
1097            Primitive::Text { text, .. } => {
1098                assert!(text.ends_with(ELLIPSIS), "and it was cut: {text:?}")
1099            }
1100            other => panic!("a text, not {other:?}"),
1101        }
1102    }
1103
1104    /// A handed-down **alignment** also makes the text ask for its parent's width.
1105    ///
1106    /// Alignment is the one style setting that is also a *request*: a box exactly as wide
1107    /// as its own text has nowhere to centre it in, so a text told to centre asks to fill
1108    /// the line first. That request is made by `fill_axes`, which is why that hook
1109    /// had to see the theme too — without it, the one setting that arrives by inheritance
1110    /// would resolve correctly everywhere except where it takes effect, and centring a
1111    /// subtree's texts would silently do nothing.
1112    #[test]
1113    fn a_handed_down_alignment_still_asks_for_the_width() {
1114        let mut theme = Theme::default();
1115        theme.widgets.text.align = Some(TextAlign::Center);
1116        let text = Text::new("Centred").no_wrap();
1117        assert_eq!(
1118            Widget::<()>::fill_axes(&text, &Theme::default()),
1119            crate::widget::FillAxes::NONE,
1120            "left alone it hugs its words"
1121        );
1122        assert_eq!(
1123            Widget::<()>::fill_axes(&text, &theme),
1124            crate::widget::FillAxes::WIDTH,
1125            "handed a centring, it asks for the line"
1126        );
1127        let mut scene = Scene::new();
1128        Widget::<()>::paint(
1129            &text,
1130            Rect::new(0.0, 0.0, 300.0, 20.0),
1131            Status::default(),
1132            &theme,
1133            &mut scene,
1134        );
1135        match &scene.primitives()[0] {
1136            Primitive::Text {
1137                align, max_width, ..
1138            } => {
1139                assert_eq!(*align, TextAlign::Center);
1140                assert_eq!(*max_width, Some(300.0), "and it was given a box to sit in");
1141            }
1142            other => panic!("a text, not {other:?}"),
1143        }
1144    }
1145
1146    /// **Two nested wrappers each setting one field leave a text wearing both.**
1147    ///
1148    /// This is what makes the merge worth its complexity. A whole-style handover would
1149    /// look identical in a single-wrapper test and be wrong here: the inner wrapper would
1150    /// replace the outer one's colour with nothing at all, and a section that set a size
1151    /// inside a screen that set a colour would silently drop the colour.
1152    #[test]
1153    fn nested_wrappers_compose_field_by_field() {
1154        use crate::{build_ui, Runtime, Size};
1155        let muted = Color::rgb(0.4, 0.4, 0.45);
1156        let outer = crate::DefaultTextStyle::from_text_style(TextStyle::NONE.color(muted));
1157        let inner = crate::DefaultTextStyle::from_text_style(TextStyle::new(9.0));
1158        let root: Box<dyn Widget<()>> =
1159            Box::new(outer.around(inner.around(Text::new("Both").no_wrap())));
1160        let ui = build_ui(
1161            root.as_ref(),
1162            Size::new(300.0, 100.0),
1163            &Runtime::default(),
1164            &Theme::default(),
1165        );
1166        let found = ui.scene().primitives().iter().find_map(|p| match p {
1167            Primitive::Text { size, color, .. } => Some((*size, *color)),
1168            _ => None,
1169        });
1170        assert_eq!(
1171            found,
1172            Some((9.0, muted)),
1173            "the size came from the inner wrapper and the colour from the outer"
1174        );
1175    }
1176
1177    /// **A style may name a size and leave the weight to the subtree** — the thing this
1178    /// framework could not express until `TextStyle`'s fields could each say *unset*.
1179    ///
1180    /// `Text::styled(s, TextStyle::new(20.0))` used to answer a size, a weight *and* a
1181    /// slant, because the type had nowhere to put "not said". So a caller who wanted one
1182    /// step of the scale at a different size had to restate the weight, and a section that
1183    /// set a weight for its labels could not reach a single one of them.
1184    #[test]
1185    fn a_style_may_fix_the_size_and_still_inherit_the_weight() {
1186        let mut theme = Theme::default();
1187        theme.widgets.text.style.weight = Some(FontWeight::Bold);
1188        let text = Text::styled("Words", TextStyle::new(20.0)).no_wrap();
1189
1190        let mut scene = Scene::new();
1191        Widget::<()>::paint(
1192            &text,
1193            Rect::new(0.0, 0.0, 300.0, 40.0),
1194            Status::default(),
1195            &theme,
1196            &mut scene,
1197        );
1198        match &scene.primitives()[0] {
1199            Primitive::Text { size, weight, .. } => {
1200                assert_eq!(*size, 20.0, "the size it named");
1201                assert_eq!(*weight, FontWeight::Bold, "the weight it did not");
1202            }
1203            other => panic!("a text, not {other:?}"),
1204        }
1205        // And the box was measured at the same pair, not at one of each.
1206        let bold_20 = frus_text::measure_styled("Words", 20.0, FontWeight::Bold, false).width;
1207        match Widget::<()>::style_themed(&text, &theme).width {
1208            Dimension::Length(w) => assert!(
1209                (w - bold_20.ceil()).abs() < 0.01,
1210                "measured {w}, drawn at {bold_20}"
1211            ),
1212            other => panic!("a known width, not {other:?}"),
1213        }
1214    }
1215
1216    /// **The box grows with the reader's font size, and the glyphs agree with the box.**
1217    ///
1218    /// The second half is the whole risk of this feature and the reason it is resolved at
1219    /// one place rather than applied by each widget. Text measured at 16 and drawn at 24
1220    /// leaves every row on the screen the wrong height at once, and nothing in the picture
1221    /// says which of the two numbers was the mistake. So this asserts the pair, not the
1222    /// paint: what the layout reserved is what the renderer was asked to draw.
1223    #[test]
1224    fn a_reader_who_asked_for_larger_text_gets_a_larger_box_too() {
1225        use crate::{build_ui, MediaQuery, Runtime, Size};
1226        let drawn = |scaler: f32| {
1227            MediaQuery::new(Size::new(400.0, 200.0))
1228                .with_text_scaler(scaler)
1229                .scope(|| {
1230                    let text: Box<dyn Widget<()>> = Box::new(Text::new("Readable").no_wrap());
1231                    let ui = build_ui(
1232                        text.as_ref(),
1233                        Size::new(400.0, 200.0),
1234                        &Runtime::default(),
1235                        &Theme::default(),
1236                    );
1237                    let rect = ui.scene().primitives().iter().find_map(|p| match p {
1238                        Primitive::Text { size, bounds, .. } => Some((*size, *bounds)),
1239                        _ => None,
1240                    });
1241                    rect.expect("the text is drawn")
1242                })
1243        };
1244        let (plain_size, plain_box) = drawn(1.0);
1245        let (big_size, big_box) = drawn(1.5);
1246
1247        assert_eq!(plain_size, 16.0, "the framework's own, unscaled");
1248        assert_eq!(big_size, 24.0, "and the reader's 1.5 of it");
1249        assert!(
1250            big_box.width > plain_box.width * 1.4,
1251            "the box grew with the glyphs: {} → {}",
1252            plain_box.width,
1253            big_box.width
1254        );
1255        // The pair, which is the thing that must never come apart: the box the layout gave
1256        // this text is the box its own drawn size measures to.
1257        let measured = frus_text::measure_styled("Readable", big_size, FontWeight::Regular, false);
1258        assert!(
1259            (big_box.width - measured.width.ceil()).abs() < 0.51,
1260            "reserved {} for glyphs measuring {}",
1261            big_box.width,
1262            measured.width
1263        );
1264    }
1265
1266    /// Outside a described surface **nothing scales**, which is what every test in this
1267    /// file and every golden depends on: they build widgets with no `MediaQuery` around
1268    /// them, and a framework that scaled by default would move all of them at once.
1269    #[test]
1270    fn with_no_surface_described_nothing_is_scaled() {
1271        assert_eq!(
1272            Text::new("x").resolved(None).style.size,
1273            frus_core::DEFAULT_TEXT_SIZE
1274        );
1275    }
1276
1277    /// A text that fits is never clipped, whatever it asked for: a clip on every text
1278    /// would put a hard edge through the antialiasing of every one of them.
1279    #[test]
1280    fn a_text_that_fits_is_not_clipped() {
1281        let text = Text::new("short")
1282            .size(16.0)
1283            .no_wrap()
1284            .overflow(TextOverflow::Clip);
1285        let mut scene = Scene::new();
1286        Widget::<()>::paint(
1287            &text,
1288            Rect::new(0.0, 0.0, 300.0, 20.0),
1289            Status::default(),
1290            &Theme::default(),
1291            &mut scene,
1292        );
1293        match &scene.primitives()[0] {
1294            Primitive::Text { clip, .. } => assert_eq!(*clip, Rect::UNBOUNDED),
1295            other => panic!("a text, not {other:?}"),
1296        }
1297    }
1298
1299    /// `Fade` wraps the text in a masked group — the fade has to be a group, or the two
1300    /// halves of an overlapping glyph would fade against the background separately.
1301    #[test]
1302    fn fade_wraps_the_text_in_a_masked_group() {
1303        let text = Text::new("a label far too long for the box it was given")
1304            .size(16.0)
1305            .no_wrap()
1306            .overflow(TextOverflow::Fade);
1307        let mut scene = Scene::new();
1308        Widget::<()>::paint(
1309            &text,
1310            Rect::new(0.0, 0.0, 60.0, 20.0),
1311            Status::default(),
1312            &Theme::default(),
1313            &mut scene,
1314        );
1315        match &scene.primitives()[0] {
1316            Primitive::Layer {
1317                primitives, filter, ..
1318            } => {
1319                assert_eq!(primitives.len(), 1, "the text, and only the text");
1320                let mask = filter.mask.expect("a mask");
1321                match mask.shader {
1322                    // The fade runs to the right edge of the box, horizontally, because
1323                    // that is the edge the line ran past.
1324                    MaskShader::Linear { from, to, .. } => {
1325                        assert!(
1326                            to.x > from.x && (to.x - 60.0).abs() < 0.01,
1327                            "{from:?} {to:?}"
1328                        );
1329                        assert!((to.y - from.y).abs() < 0.01, "horizontal");
1330                    }
1331                    other => panic!("a linear fade, not {other:?}"),
1332                }
1333            }
1334            other => panic!("a masked layer, not {other:?}"),
1335        }
1336    }
1337
1338    /// Alignment reaches the primitive, and with it the width it aligns inside — which a
1339    /// start-aligned text is deliberately not given.
1340    #[test]
1341    fn alignment_hands_the_renderer_a_width_and_start_does_not() {
1342        let painted = |align: TextAlign| {
1343            let text = Text::new("x").size(16.0).no_wrap().align(align);
1344            let mut scene = Scene::new();
1345            Widget::<()>::paint(
1346                &text,
1347                Rect::new(0.0, 0.0, 200.0, 20.0),
1348                Status::default(),
1349                &Theme::default(),
1350                &mut scene,
1351            );
1352            match &scene.primitives()[0] {
1353                Primitive::Text {
1354                    align, max_width, ..
1355                } => (*align, *max_width),
1356                other => panic!("a text, not {other:?}"),
1357            }
1358        };
1359        assert_eq!(painted(TextAlign::Center), (TextAlign::Center, Some(200.0)));
1360        assert_eq!(painted(TextAlign::Start), (TextAlign::Start, None));
1361    }
1362
1363    /// Saying what to do on overflow is what tells the layout the text may be squeezed;
1364    /// a plain one still refuses, which is what keeps a row from losing its last widget.
1365    #[test]
1366    fn an_overflow_mode_is_what_makes_a_text_shrinkable() {
1367        use frus_layout::Dimension;
1368        let plain = Widget::<()>::style(&Text::new("a long enough label"));
1369        let clipped =
1370            Widget::<()>::style(&Text::new("a long enough label").overflow(TextOverflow::Clip));
1371        assert_eq!(plain.min_width, Dimension::Auto);
1372        assert_eq!(clipped.min_width, Dimension::Length(0.0));
1373    }
1374
1375    #[test]
1376    fn text_paints_a_text_primitive() {
1377        let text = Text::new("Salut")
1378            .size(20.0)
1379            .no_wrap()
1380            .color(Color::rgb(1.0, 0.0, 0.0));
1381        let mut scene = Scene::new();
1382        Widget::<()>::paint(
1383            &text,
1384            Rect::new(5.0, 6.0, 100.0, 24.0),
1385            Status::default(),
1386            &Theme::default(),
1387            &mut scene,
1388        );
1389
1390        assert_eq!(scene.primitives().len(), 1);
1391        assert_eq!(
1392            scene.primitives()[0],
1393            Primitive::Text {
1394                position: Point::new(5.0, 6.0),
1395                text: "Salut".to_string(),
1396                size: 20.0,
1397                color: Color::rgb(1.0, 0.0, 0.0),
1398                weight: FontWeight::Regular,
1399                italic: false,
1400                max_width: None,
1401                soft_wrap: false,
1402                align: TextAlign::Start,
1403                decoration: frus_core::TextDecoration::NONE,
1404                decoration_color: None,
1405                height: None,
1406                family: None,
1407                clip: Rect::UNBOUNDED,
1408                // Painted directly rather than through the widget walk, so no box was
1409                // declared: unknown, which the renderer reads as "covers everything".
1410                bounds: Rect::UNBOUNDED,
1411                owner: 0,
1412            }
1413        );
1414    }
1415
1416    #[test]
1417    fn wrapped_text_measures_to_the_offered_width() {
1418        let long = "a paragraph long enough to wrap onto several lines";
1419        let text = Text::new(long).wrap();
1420        // Measuring under constraints wraps: taller at 120 px than when free.
1421        let measure = Widget::<()>::measure(&text, &Theme::default()).expect("measure closure");
1422        let free = measure(None, None);
1423        let narrow = measure(Some(120.0), None);
1424        assert!(narrow.width <= 120.0);
1425        assert!(narrow.height > free.height, "wrapped → taller");
1426        // And the measure key changes with the content (the cache fix).
1427        let other = Text::new("short").wrap();
1428        assert_ne!(
1429            Widget::<()>::measure_key(&text, &Theme::default()),
1430            Widget::<()>::measure_key(&other, &Theme::default())
1431        );
1432        // A text that does not wrap is a box of a known size, and says so in its style
1433        // rather than through a measurement: a measured leaf reports its narrowest useful
1434        // width as its minimum content, and a row would take that as leave to fold it.
1435        let plain = Text::new(long).no_wrap();
1436        assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
1437        assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
1438    }
1439
1440    #[test]
1441    fn styled_text_carries_weight_and_italic() {
1442        let theme = Theme::default();
1443        // One step of the theme's scale (title_medium = 16 px, medium weight).
1444        let text = Text::styled("Title", theme.text.title_medium).italic();
1445        let mut scene = Scene::new();
1446        Widget::<()>::paint(
1447            &text,
1448            Rect::new(0.0, 0.0, 100.0, 24.0),
1449            Status::default(),
1450            &theme,
1451            &mut scene,
1452        );
1453        match &scene.primitives()[0] {
1454            Primitive::Text {
1455                size,
1456                weight,
1457                italic,
1458                color,
1459                ..
1460            } => {
1461                assert_eq!(*size, 16.0);
1462                assert_eq!(*weight, FontWeight::Medium);
1463                assert!(*italic);
1464                // Color inherited from the theme (the style did not set one).
1465                assert_eq!(*color, theme.on_surface);
1466            }
1467            _ => panic!("expected text"),
1468        }
1469    }
1470
1471    #[test]
1472    fn bold_text_lays_out_wider() {
1473        let w = |text: &Text| {
1474            Widget::<()>::measure(text, &Theme::default()).expect("measure")(None, None).width
1475        };
1476        assert!(
1477            w(&Text::new("Width").weight(FontWeight::Bold)) > w(&Text::new("Width")),
1478            "bold must be wider"
1479        );
1480    }
1481
1482    /// The default is the reference's: prose put in a box narrower than itself is a
1483    /// paragraph. Along a **row** it is not — a text runs past the end of one rather than
1484    /// folding into a column of single words — and that is the floor the parent applies.
1485    #[test]
1486    fn text_wraps_down_a_column_and_runs_on_along_a_row() {
1487        use crate::{build_ui, Container, Flex, Runtime, Size};
1488        let long = "one two three four five six seven eight nine ten eleven twelve";
1489        // The painted box, read from the scene: the text primitive carries the box it
1490        // was laid out in, which is the number this is about.
1491        let painted = |root: &dyn Widget<()>| {
1492            let rt = Runtime::default();
1493            let theme = Theme::default();
1494            let ui = build_ui(root, Size::new(120.0, 300.0), &rt, &theme);
1495            ui.scene()
1496                .primitives()
1497                .iter()
1498                .find_map(|p| match p {
1499                    Primitive::Text { bounds, .. } => Some(*bounds),
1500                    _ => None,
1501                })
1502                .expect("the text")
1503        };
1504        let column: Container<()> = Container::new()
1505            .width(120.0)
1506            .child(Flex::column().child(Text::new(long).size(12.0)));
1507        // Two children, because a box with a single child is handing a width down rather
1508        // than dividing a line up — and there a paragraph does wrap.
1509        let row: Container<()> = Container::new().width(120.0).child(
1510            Flex::row()
1511                .child(Text::new(long).size(12.0))
1512                .child(Container::new().width(10.0).height(10.0)),
1513        );
1514        let tall = painted(&column);
1515        let wide = painted(&row);
1516        assert!(
1517            tall.width <= 120.5,
1518            "the column gave it 120: {}",
1519            tall.width
1520        );
1521        assert!(tall.height > 20.0, "so it wrapped: {}", tall.height);
1522        assert!(wide.width > 120.0, "the row did not: {}", wide.width);
1523    }
1524}
1525
1526#[cfg(test)]
1527mod reader_font_size {
1528    use frus_core::{Primitive, Size};
1529
1530    use crate::theme::Theme;
1531    use crate::{build_ui_inspected, MediaQuery, Runtime, Widget};
1532
1533    type W = Box<dyn Widget<()>>;
1534    type Case = (&'static str, fn() -> W);
1535
1536    /// Every glyph a widget paints, and the size it was drawn at.
1537    fn glyphs(make: fn() -> W, scale: f32) -> Vec<(String, f32)> {
1538        let root = make();
1539        MediaQuery::new(Size::new(400.0, 300.0))
1540            .with_text_scaler(scale)
1541            .scope(|| {
1542                let (ui, _) = build_ui_inspected(
1543                    root.as_ref(),
1544                    Size::new(400.0, 300.0),
1545                    &Runtime::default(),
1546                    &Theme::default(),
1547                );
1548                ui.scene()
1549                    .primitives()
1550                    .iter()
1551                    .filter_map(|p| match p {
1552                        Primitive::Text { text, size, .. } => Some((text.clone(), *size)),
1553                        _ => None,
1554                    })
1555                    .collect()
1556            })
1557    }
1558
1559    fn cases() -> Vec<Case> {
1560        vec![
1561            ("Chip", || Box::new(crate::Chip::<()>::new("Filter"))),
1562            ("Button", || Box::new(crate::Button::<()>::new("Save"))),
1563            ("ListTile", || {
1564                Box::new(crate::ListTile::<()>::new().title("A row"))
1565            }),
1566            ("SnackBar", || Box::new(crate::SnackBar::<()>::new("Saved"))),
1567            ("Kbd", || Box::new(crate::Kbd::new("Ctrl"))),
1568            ("DropdownButton", || {
1569                Box::new(crate::DropdownButton::new("Pick", ()))
1570            }),
1571            ("Text", || Box::new(crate::Text::new("A line"))),
1572            ("TextField", || {
1573                Box::new(crate::TextField::<()>::new("typed"))
1574            }),
1575            ("Table", || {
1576                Box::new(crate::Table::<()>::new(2).row(&["one", "two"]))
1577            }),
1578            ("Tree", || {
1579                Box::new(crate::Tree::new(|_| ()).node(1, 0, "a branch", false, false))
1580            }),
1581        ]
1582    }
1583
1584    /// **The reader's font size reaches the text a widget paints itself.**
1585    ///
1586    /// `TextStyle::resolved` is the single place the setting is applied (milestone 403),
1587    /// and forty-seven paint sites went around it: they named an `f32` and handed it
1588    /// straight to the scene, so their text was the one size a reader could not change.
1589    /// Nothing failed and nothing was reported — the widget simply ignored the request.
1590    ///
1591    /// The door is shut now: [`frus_core::Scene::text`] takes a
1592    /// [`frus_core::ResolvedTextStyle`], so a bare number does not compile. This test is
1593    /// what keeps it shut, because a new widget can still reach for
1594    /// [`frus_core::ResolvedTextStyle::exact`] — which is right for an icon and wrong for
1595    /// a word.
1596    #[test]
1597    fn the_text_a_widget_paints_follows_the_readers_font_size() {
1598        for (name, make) in cases() {
1599            let plain = glyphs(make, 1.0);
1600            let doubled = glyphs(make, 2.0);
1601            assert!(!plain.is_empty(), "{name} paints no text at all");
1602            assert_eq!(
1603                plain.len(),
1604                doubled.len(),
1605                "{name}: a different number of runs at twice the size"
1606            );
1607            let grew = plain
1608                .iter()
1609                .zip(&doubled)
1610                .any(|((_, a), (_, b))| *b > *a + 0.01);
1611            assert!(
1612                grew,
1613                "{name}: not one glyph followed the reader — sizes {plain:?}"
1614            );
1615        }
1616    }
1617
1618    /// **And the box grows to hold it.** A default height is a *floor*, as it is in the
1619    /// reference — `max(_targetTileHeight, contentHeight)` — not a ceiling. A chip whose
1620    /// height was a constant needed 34 px of glyphs inside 32 px and cut them.
1621    #[test]
1622    fn a_box_that_holds_text_grows_with_it() {
1623        for (name, make) in cases() {
1624            let root = make();
1625            MediaQuery::new(Size::new(400.0, 300.0))
1626                .with_text_scaler(2.0)
1627                .scope(|| {
1628                    let (ui, _) = build_ui_inspected(
1629                        root.as_ref(),
1630                        Size::new(400.0, 300.0),
1631                        &Runtime::default(),
1632                        &Theme::default(),
1633                    );
1634                    let mut checked = 0;
1635                    for p in ui.scene().primitives() {
1636                        if let Primitive::Text {
1637                            text, size, bounds, ..
1638                        } = p
1639                        {
1640                            // The box the text was **laid out in**, not the widget's outermost
1641                            // rect: a table's outer box is enormous and would pass whatever its
1642                            // rows did. Checking the emitting box is what caught the table.
1643                            if bounds.height <= 0.0 {
1644                                continue;
1645                            }
1646                            // What the **shaper** gives back, not the nominal `line_height`:
1647                            // a face's real line box can be a fraction under the metric, and a
1648                            // box sized to the smaller number clips nothing.
1649                            let style = frus_core::ResolvedTextStyle::exact(*size);
1650                            let needed = frus_text::measure_resolved(text, &style).height;
1651                            checked += 1;
1652                            assert!(
1653                                needed <= bounds.height + 0.51,
1654                                "{name}: {text:?} needs {needed:.1} px in a box of {:.1}",
1655                                bounds.height
1656                            );
1657                        }
1658                    }
1659                    assert!(checked > 0, "{name}: no text was checked at all");
1660                });
1661        }
1662    }
1663
1664    /// **Chrome caps the type instead of growing**, which is the reference's other answer
1665    /// and the reason both exist. An app bar keeps its height — a toolbar that grew would
1666    /// push every screen down — so it clamps the title's scaler to 1.34 rather than let
1667    /// the reader out. Below that cap it follows like anything else.
1668    #[test]
1669    fn an_app_bar_caps_its_title_rather_than_growing() {
1670        let title_size = |scale: f32| {
1671            let bar = crate::AppBar::<()>::new().title(crate::Text::new("A title"));
1672            MediaQuery::new(Size::new(400.0, 300.0))
1673                .with_text_scaler(scale)
1674                .scope(|| {
1675                    let root = bar.build();
1676                    let (ui, _) = build_ui_inspected(
1677                        root.as_ref(),
1678                        Size::new(400.0, 300.0),
1679                        &Runtime::default(),
1680                        &Theme::default(),
1681                    );
1682                    ui.scene()
1683                        .primitives()
1684                        .iter()
1685                        .find_map(|p| match p {
1686                            Primitive::Text { text, size, .. } if text == "A title" => Some(*size),
1687                            _ => None,
1688                        })
1689                        .expect("the bar paints its title")
1690                })
1691        };
1692        let plain = title_size(1.0);
1693        assert!(
1694            (title_size(1.2) - plain * 1.2).abs() < 0.1,
1695            "under the cap the title follows the reader"
1696        );
1697        let capped = plain * crate::APP_BAR_MAX_TITLE_SCALE;
1698        for scale in [1.5, 2.0, 4.0] {
1699            assert!(
1700                (title_size(scale) - capped).abs() < 0.1,
1701                "at x{scale} the title is {} rather than the capped {capped}",
1702                title_size(scale)
1703            );
1704        }
1705    }
1706}