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}