Skip to main content

frus_widgets/
text.rs

1//! [`Text`]: a widget that displays a line of text.
2
3use std::any::Any;
4use std::cell::{Cell, OnceCell};
5
6use frus_core::{
7    fits, Color, FontWeight, MaskShader, Point, Rect, ResolvedTextStyle, Scene, ShaderMask,
8    TextAlign, TextBlock, TextOverflow, TextStyle,
9};
10use frus_layout::{Dimension, Style};
11use frus_text::TextLayout;
12
13use crate::interaction::{Key, Status};
14use crate::runtime::Edit;
15use crate::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
16use crate::textinput::{
17    handle_at, handles_in, move_caret, paint_handle, paint_handles, selection_box_in, word_range,
18    ToolbarBuild,
19};
20use crate::theme::Theme;
21use crate::widget::Widget;
22use crate::widgettheme::DefaultTextStyle;
23
24/// A single-line text widget.
25///
26/// Its layout size comes from a **styled** measurement (`frus-text`, weight and
27/// italic included); it pushes a text primitive into the scene when painting. The
28/// [`TextStyle`]'s color is inherited from the theme when absent.
29pub struct Text {
30    content: String,
31    style: TextStyle,
32    /// Whether the text wraps at the width it is given. Unset, **on**, as in the
33    /// reference: a piece of prose put in a box narrower than itself is a paragraph.
34    wrap: Option<bool>,
35    /// What becomes of text that does not fit its box. Unset, clipped.
36    overflow: Option<TextOverflow>,
37    /// At most this many lines; the rest is dropped and `overflow` decides how the last
38    /// one ends.
39    max_lines: Option<usize>,
40    /// Where the lines sit inside the box. Unset, at the start.
41    align: Option<TextAlign>,
42    /// Whether this text is a **heading** — a landmark a screen reader's user can jump
43    /// to, rather than one more piece of prose. It changes nothing that is drawn.
44    heading: bool,
45    /// Whether this text is willing to be given **less than it asked for**.
46    ///
47    /// It is not the same question as what happens when it overflows, and conflating the
48    /// two would change every text in the framework: a flex item's automatic minimum size
49    /// is its content, so a plain text refuses to shrink and its siblings are pushed out
50    /// instead. Saying what to do on overflow is what says it may be squeezed — and an
51    /// **inherited** limit says it just as much as a called one, which is why this is
52    /// derived at resolution rather than stored.
53    shrinkable: bool,
54    /// What makes the text **selectable** — see [`Text::selectable`]. Boxed, and absent for
55    /// the vast majority of texts, which are read and nothing more: the widget that is built
56    /// by the thousand does not carry a selection's state around.
57    selection: Option<Box<Selection>>,
58    /// The typography this text was **last laid out and painted with**, which is what a hit,
59    /// a highlight and a handle have to be measured against.
60    ///
61    /// A hit test is asked for outside a frame, with no theme and no reader's font setting in
62    /// force — and a text's size may be handed down by a subtree and scaled by that setting,
63    /// neither of which the widget can see from where it is asked. So layout and paint write
64    /// down what they resolved, and the questions read it back.
65    shaping: Cell<Option<Shaping>>,
66    /// The reader's font-size setting when the text was built, for a question asked before
67    /// any layout has: the best guess there is, and the right one for a text that sets its
68    /// own size.
69    scale: f32,
70    /// The bars this text shows when a [`SelectionArea`](crate::SelectionArea)'s selection is
71    /// placed against it, one per context, built the first time each is asked for. A pointer
72    /// until then: a text is built by the thousand and very few ever carry a bar.
73    area_bars: OnceCell<Box<AreaBars>>,
74}
75
76/// One bar per [`ToolbarContext`], each an `Option<Box<dyn Widget<Msg>>>` behind `dyn Any`.
77type AreaBars = [OnceCell<Box<dyn Any>>; ToolbarContext::VARIANTS as usize];
78
79/// The state of a text that can be selected and copied.
80struct Selection {
81    /// Whether the text is selectable at all. [`Text::selection_toolbar`] may have made
82    /// the state before [`Text::selectable`] is called, in either order.
83    on: bool,
84    /// The application's say over the bar shown above a selection: a
85    /// [`ToolbarBuild`] of the application's message type, kept as `dyn Any` because a
86    /// `Text` is not generic over it.
87    toolbar_build: Option<Box<dyn Any>>,
88    /// The bar for each context, built the first time it is asked for and kept: the walk
89    /// borrows it for a frame, so it must live as long as the text does. Each holds an
90    /// `Option<Box<dyn Widget<Msg>>>`.
91    toolbars: [OnceCell<Box<dyn Any>>; ToolbarContext::VARIANTS as usize],
92}
93
94/// What a selection is measured against: the typography a text resolved, and whether it
95/// wraps at the width it is given.
96#[derive(Copy, Clone)]
97struct Shaping {
98    style: ResolvedTextStyle,
99    wrap: bool,
100}
101
102/// A [`Text`]'s questions, all answered: what the caller said where they said it, what the
103/// subtree's [`DefaultTextStyle`](crate::DefaultTextStyle) said where they did not, and
104/// the framework's own default where neither did.
105///
106/// Everything the widget does — the width it asks for, the words it keeps, the glyphs it
107/// draws — reads from one of these, and every hook builds it the same way. That is not
108/// tidiness: a size resolved one way for the measurement and another way for the paint is
109/// a layout that is wrong everywhere at once, with nothing in the picture to say which of
110/// the two numbers was the mistake.
111pub(crate) struct Resolved {
112    pub style: ResolvedTextStyle,
113    pub align: TextAlign,
114    pub wrap: bool,
115    pub overflow: TextOverflow,
116    pub max_lines: Option<usize>,
117    /// Whether this text is willing to be given less than it asked for — which an
118    /// inherited overflow or line limit says just as much as a called one.
119    pub shrinkable: bool,
120}
121
122/// The ellipsis a cut line ends in.
123pub(crate) const ELLIPSIS: &str = "…";
124
125/// The longest prefix of `content` that fits in `max_width`, ending in an ellipsis when
126/// anything was cut. Returns `content` untouched when it already fits.
127///
128/// A box with **no room at all** gets an ellipsis and nothing else. It used to get the
129/// whole string, on the reasoning that a zero width meant "the layout has not run yet"
130/// rather than "there is no room" — inherited from the app bar, which cannot produce a
131/// zero anyway (its title room has a floor of 64 px). What the exception actually did was
132/// let a genuinely collapsed box draw its whole label, over whatever was beside it, which
133/// is the one thing ellipsising exists to prevent.
134///
135/// Character by character from the end: the strings this is used on are a line, not a
136/// document, and a binary search over char boundaries would buy nothing at that length.
137pub(crate) fn truncate(content: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
138    let measure = |text: &str| frus_text::measure_resolved(text, style).width;
139    if measure(content) <= max_width {
140        return content.to_string();
141    }
142    let mut chars: Vec<char> = content.chars().collect();
143    while !chars.is_empty() {
144        chars.pop();
145        let kept: String = chars.iter().collect();
146        let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
147        if measure(&candidate) <= max_width {
148            return candidate;
149        }
150    }
151    ELLIPSIS.to_string()
152}
153
154/// `line` cut so that it and an ellipsis together fit `max_width` — **always** ending in
155/// one, where [`truncate`] leaves a line that already fits alone.
156///
157/// The difference is which question is being asked. `truncate` asks whether this line
158/// fits; this asks how to end a line that is being cut because the *next* one was
159/// dropped, and there the line usually does fit and still has to say so.
160pub(crate) fn ellipsise(line: &str, style: &ResolvedTextStyle, max_width: f32) -> String {
161    let measure = |text: &str| frus_text::measure_resolved(text, style).width;
162    let mut chars: Vec<char> = line.trim_end().chars().collect();
163    loop {
164        let kept: String = chars.iter().collect();
165        let candidate = format!("{}{ELLIPSIS}", kept.trim_end());
166        if chars.is_empty() || max_width <= 0.0 || measure(&candidate) <= max_width {
167            return candidate;
168        }
169        chars.pop();
170    }
171}
172
173/// What a text came to after being fitted to its box: what to draw, and which way it ran
174/// over — the two are different questions, and a fade needs to know which.
175pub(crate) struct Fitted {
176    /// The text to draw, with the dropped lines gone and any ellipsis already in it.
177    pub text: String,
178    /// The last line is wider than the box.
179    pub too_wide: bool,
180    /// Lines were dropped off the bottom.
181    pub too_tall: bool,
182}
183
184impl Fitted {
185    /// Whether anything ran over at all.
186    fn over(&self) -> bool {
187        self.too_wide || self.too_tall
188    }
189}
190
191impl Text {
192    /// Creates a text that has **chosen nothing**: it takes its size, weight and colour
193    /// from whatever subtree it is put in, and the framework's own where nothing says
194    /// otherwise (16 px, regular, the theme's `on_surface`).
195    ///
196    /// It used to be written as a 16 px style, which read the same and meant something
197    /// else: a style that *answered* 16, so an app bar or a section could not dress it.
198    pub fn new(content: impl Into<String>) -> Self {
199        Self {
200            content: content.into(),
201            ..Self::defaults()
202        }
203    }
204
205    /// Creates a text from a full [`TextStyle`] — typically one step of the theme's
206    /// scale (`Text::styled("Title", theme.text.title_large)`).
207    /// The style is taken **exactly as written**: whatever it names it answers, and
208    /// whatever it leaves open stays open to a subtree.
209    ///
210    /// So `Text::styled(s, theme.text.title_medium)` wears that step of the scale and
211    /// still picks up a colour from the section it sits in, while
212    /// `Text::styled(s, TextStyle::new(20.0))` fixes the size alone and inherits the rest.
213    pub fn styled(content: impl Into<String>, style: TextStyle) -> Self {
214        Self {
215            content: content.into(),
216            style,
217            ..Self::defaults()
218        }
219    }
220
221    /// The settings every constructor starts from, so that adding one does not mean
222    /// touching each of them.
223    fn defaults() -> Self {
224        Self {
225            content: String::new(),
226            style: TextStyle::NONE,
227            wrap: None,
228            overflow: None,
229            max_lines: None,
230            align: None,
231            heading: false,
232            shrinkable: false,
233            selection: None,
234            shaping: Cell::new(None),
235            scale: frus_core::text_scale(),
236            area_bars: OnceCell::new(),
237        }
238    }
239
240    /// Makes this text **selectable**: a press, a drag, a double click or a long press
241    /// selects words in it, and Ctrl+C, or the bar that opens over the selection, copies
242    /// them. Ordinary text is not — it is drawn, and there is nothing under the pointer to
243    /// select — and choosing which of an application's texts a reader may copy from is
244    /// the application's business: an order number, an address, a code, an error message.
245    ///
246    /// A selectable text can take focus, so Tab reaches it and a screen reader announces
247    /// it as it does any text; it does **not** open the software keyboard, which is for
248    /// text that can be typed into. Its selection shows the highlight
249    /// ([`Theme::selection`]) and, when made with a finger, the handles.
250    ///
251    /// ```
252    /// use frus_widgets::Text;
253    ///
254    /// let code = Text::new("ORDER-4417").selectable();
255    /// # let _ = code;
256    /// ```
257    ///
258    /// **Limits.** What is selected is what is drawn from the start of each line: a text
259    /// aligned to the centre or the end, or one that a line limit or an ellipsis cuts short,
260    /// is highlighted as if it were not. Up and Down move focus rather than the caret.
261    /// A selectable text inside something that takes a tap — a button, a list tile — takes
262    /// the press instead of it: make selectable the texts that are not the button's label.
263    pub fn selectable(mut self) -> Self {
264        self.selection_mut().on = true;
265        self
266    }
267
268    /// Changes what the bar over a selectable text's selection offers — the application's
269    /// say, as [`TextField::selection_toolbar`](crate::TextField::selection_toolbar) is for a
270    /// field. `build` is given the context and the default list (Copy when something is
271    /// selected, Select all unless everything is) and answers with the list to show; an
272    /// empty one shows no bar.
273    ///
274    /// Only for a text that is [`selectable`](Self::selectable). The bar carries the
275    /// application's messages, so `Msg` is the message type of the view the text is in.
276    pub fn selection_toolbar<Msg: 'static>(
277        mut self,
278        build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
279    ) -> Self {
280        let build: ToolbarBuild<Msg> = Box::new(build);
281        let selection = self.selection_mut();
282        selection.toolbar_build = Some(Box::new(build));
283        // Whatever was built for the old answer is not the answer any more.
284        selection.toolbars = Default::default();
285        self
286    }
287
288    /// The state of the selection, made when first asked for.
289    fn selection_mut(&mut self) -> &mut Selection {
290        self.selection.get_or_insert_with(|| {
291            Box::new(Selection {
292                on: false,
293                toolbar_build: None,
294                toolbars: Default::default(),
295            })
296        })
297    }
298
299    /// The selection state, if this text is selectable.
300    fn selecting(&self) -> Option<&Selection> {
301        self.selection.as_deref().filter(|selection| selection.on)
302    }
303
304    /// The layout of this text as it was drawn, at `width`: what a hit, a highlight, a
305    /// handle or the bar's anchor is worked out from. `None` for a text that is not
306    /// selectable.
307    fn selection_layout(&self, width: f32) -> Option<TextLayout> {
308        self.selecting()?;
309        Some(self.text_layout(width))
310    }
311
312    /// The layout of this text as it was drawn, at `width` — for any text, selectable or
313    /// not: a [`SelectionArea`](crate::SelectionArea) selects in texts that never asked to be.
314    fn text_layout(&self, width: f32) -> TextLayout {
315        let shaping = self.shaping.get().unwrap_or_else(|| {
316            let resolved = frus_core::with_text_scale(self.scale, || self.resolved(None));
317            Shaping {
318                style: resolved.style,
319                wrap: resolved.wrap,
320            }
321        });
322        TextLayout::resolved(&self.content, &shaping.style, shaping.wrap.then_some(width))
323    }
324
325    /// Writes down what this text resolved, for the questions a selection is asked later.
326    fn remember(&self, r: &Resolved) {
327        self.shaping.set(Some(Shaping {
328            style: r.style,
329            wrap: r.wrap,
330        }));
331    }
332
333    /// Wraps at the width the parent offers. This is the default, and the call is kept
334    /// because saying so at the call site is not redundant when it is the whole point of
335    /// the widget being there.
336    /// Marks this text as a **heading**: a landmark assistive technology can jump
337    /// between, instead of one more run of prose.
338    ///
339    /// It changes nothing that is drawn. A screen reader's user moves through a screen by
340    /// its headings, and a title announced as a label gives them nothing to move between
341    /// — which is what an app bar's title was until milestone 397. The reference says the
342    /// same thing with `SemanticsProperties(header: true)` around its title.
343    pub fn heading(mut self) -> Self {
344        self.heading = true;
345        self
346    }
347
348    /// Wraps the text at the width it is given: [`soft_wrap(true)`](Self::soft_wrap). It is
349    /// what an unset text does already; this says so, and undoes a
350    /// [`no_wrap`](Self::no_wrap) further up a chain.
351    pub fn wrap(mut self) -> Self {
352        self.wrap = Some(true);
353        self
354    }
355
356    /// Keeps the text on **one line**, explicit newlines aside. It then runs past its box
357    /// rather than folding, and [`Text::overflow`] decides what becomes of the part that
358    /// hangs over.
359    pub fn no_wrap(mut self) -> Self {
360        self.wrap = Some(false);
361        self
362    }
363
364    /// Whether the text wraps at the width it is given. `wrap()` is `soft_wrap(true)`.
365    ///
366    /// Off, the text stays on one line — explicit newlines aside — and runs past its box
367    /// or is cut, according to [`Text::overflow`].
368    pub fn soft_wrap(mut self, soft_wrap: bool) -> Self {
369        self.wrap = Some(soft_wrap);
370        self
371    }
372
373    /// Where the lines sit **inside the box** the text was given.
374    ///
375    /// It does nothing to a text that shrink-wraps, and that is not a limitation of this
376    /// implementation: a box exactly as wide as its text has nowhere to align it to. It
377    /// takes effect once something has made the box wider — a stretched column, an
378    /// [`crate::Expanded`], a width.
379    pub fn align(mut self, align: TextAlign) -> Self {
380        self.align = Some(align);
381        self
382    }
383
384    /// At most `max_lines` lines; what is past them is dropped, and [`Text::overflow`]
385    /// decides how the last one ends.
386    ///
387    /// Asking for a limit also tells the layout this text may be given **less width than
388    /// it asked for** — a limit is only meaningful for a text expected to be squeezed.
389    pub fn max_lines(mut self, max_lines: usize) -> Self {
390        self.max_lines = Some(max_lines.max(1));
391        self.shrinkable = true;
392        self
393    }
394
395    /// What becomes of text that does not fit: cut, cut with an ellipsis, faded out, or
396    /// drawn past the box.
397    ///
398    /// Saying so also tells the layout this text may be given **less than it asked for**.
399    pub fn overflow(mut self, overflow: TextOverflow) -> Self {
400        self.overflow = Some(overflow);
401        self.shrinkable = true;
402        self
403    }
404
405    /// Keeps the text on **one line and inside its box**, cut with an ellipsis when it
406    /// does not fit — the reference's `TextOverflow.ellipsis`.
407    ///
408    /// This is two things, and the second is the one that matters. The obvious half is the
409    /// cut. The other is that an ellipsising text tells the layout it may be given *less
410    /// than it asked for*: a flex item's automatic minimum size is its content, so a plain
411    /// `Text` in a row refuses to shrink and pushes its siblings out of the box instead —
412    /// which is how a long task name evicted its own delete button, off the card and out of
413    /// the hit registry, on a device (milestone 333). `min_width: 0` is what lets flexbox
414    /// do its job, and it is the same fix the web has needed for the same reason forever.
415    ///
416    /// Mutually exclusive with [`Text::wrap`], which is the other answer to the same
417    /// question: wrap grows downwards, this one cuts. The last one called wins.
418    pub fn ellipsis(mut self) -> Self {
419        self.wrap = Some(false);
420        self.overflow = Some(TextOverflow::Ellipsis);
421        self.shrinkable = true;
422        self
423    }
424
425    /// Sets the font size, in pixels.
426    pub fn size(mut self, size: f32) -> Self {
427        self.style.size = Some(size);
428        self
429    }
430
431    /// Sets the weight.
432    pub fn weight(mut self, weight: FontWeight) -> Self {
433        self.style.weight = Some(weight);
434        self
435    }
436
437    /// Switches to italic.
438    pub fn italic(mut self) -> Self {
439        self.style.italic = Some(true);
440        self
441    }
442
443    /// Sets the **font family** — the reference's
444    /// [`fontFamily`](frus_core::TextStyle::family).
445    ///
446    /// `Text::new("fn main()").family(FontFamily::Monospace)` for code;
447    /// `FontFamily::Named("Inter")` for a face registered with `frus_text::add_font`.
448    /// Unset, the application's default.
449    ///
450    /// **A run containing Arabic keeps the registered Arabic face** whatever is named
451    /// here, because a family without Arabic coverage renders nothing at all on Android
452    /// rather than falling back — see [`frus_core::TextStyle::family`].
453    pub fn family(mut self, family: frus_core::FontFamily) -> Self {
454        self.style.family = Some(family);
455        self
456    }
457
458    /// Sets the line height as a **multiple of the font size** — the reference's
459    /// [`TextStyle::height`](frus_core::TextStyle::height).
460    ///
461    /// `1.0` packs the lines to exactly the type's size, `1.6` opens a paragraph up.
462    /// Unset, the line height is whatever a surrounding [`crate::DefaultTextStyle`] said,
463    /// or [`DEFAULT_LINE_HEIGHT`](frus_core::DEFAULT_LINE_HEIGHT) at the end of the chain.
464    ///
465    /// A ratio and not a length, so the leading grows with the letters when a reader turns
466    /// the type up instead of staying where it was set.
467    pub fn height(mut self, height: f32) -> Self {
468        self.style.height = Some(height);
469        self
470    }
471
472    /// Sets the text color (otherwise the theme's).
473    pub fn color(mut self, color: Color) -> Self {
474        self.style.color = Some(color);
475        self
476    }
477
478    /// Underlines the text.
479    pub fn underline(mut self) -> Self {
480        self.style = self.style.underline();
481        self
482    }
483
484    /// Strikes the text through.
485    pub fn strikethrough(mut self) -> Self {
486        self.style = self.style.strikethrough();
487        self
488    }
489
490    /// Sets the decoration lines (freely combined).
491    pub fn decoration(mut self, decoration: frus_core::TextDecoration) -> Self {
492        self.style.decoration = Some(decoration);
493        self
494    }
495
496    /// Sets the decoration color (otherwise the text's).
497    pub fn decoration_color(mut self, color: Color) -> Self {
498        self.style.decoration_color = Some(color);
499        self
500    }
501}
502
503impl Text {
504    /// Every question this text asks of itself, answered once: **what the caller said ??
505    /// what the subtree handed down ?? what the framework ships**.
506    ///
507    /// `theme` is an `Option` because [`Widget::style`] has no theme to give — a widget
508    /// asked for its style outside a walk. `None` means nothing was handed down, which is
509    /// exactly what a fresh theme carries, so the two agree and the unthemed case costs
510    /// nothing.
511    ///
512    /// The typography is one `merge` and one `resolved`, and it was fourteen lines of
513    /// bookkeeping until [`TextStyle`]'s fields could each say *unset*. A style that never
514    /// named a size does not outrank a subtree that did; one that named it keeps it. The
515    /// same rule as before, now expressed by the type instead of shadowed by a flag.
516    pub(crate) fn resolved(&self, theme: Option<&Theme>) -> Resolved {
517        let handed = theme.map_or(DefaultTextStyle::NONE, |t| t.widgets.text);
518        Resolved {
519            style: handed.style.merge(self.style).resolved(),
520            align: self.align.or(handed.align).unwrap_or(TextAlign::Start),
521            wrap: self.wrap.or(handed.soft_wrap).unwrap_or(true),
522            overflow: self
523                .overflow
524                .or(handed.overflow)
525                .unwrap_or(TextOverflow::Clip),
526            max_lines: self.max_lines.or(handed.max_lines),
527            // An **inherited** overflow or line limit says the same thing a called one
528            // does: this text may be given less than it asked for. Leaving it out would
529            // ship a subtree that ellipsises its texts and never gets the chance to,
530            // because each of them still refuses to be squeezed.
531            shrinkable: self.shrinkable
532                || (self.overflow.is_none() && handed.overflow.is_some())
533                || (self.max_lines.is_none() && handed.max_lines.is_some()),
534        }
535    }
536
537    /// The text that actually fits `width`, and which way it ran over.
538    ///
539    /// Everything the box does to the words happens here: dropping the lines past the
540    /// limit, cutting the last one, deciding whether a line ran past the edge. The paint
541    /// draws what comes back, and the overflow mode decides how.
542    pub(crate) fn fitted(&self, width: f32, r: &Resolved) -> Fitted {
543        // Only a line **limit** makes the words the widget's business. Left alone, the
544        // text goes to the renderer whole and is broken there.
545        //
546        // And what goes over is a **prefix**, cut at a break the shaper chose — never a
547        // list of lines glued back together. Lines glued back together are a paragraph
548        // *per line*, and rules that span a paragraph stop working: a justified block can
549        // only leave its last line ragged if it knows which line is the last.
550        let mut text = self.content.clone();
551        let mut too_tall = false;
552        if let Some(max) = r.max_lines {
553            // Under the whole style — its line height, family and letter spacing break
554            // the lines as the renderer will (milestone 623).
555            let spans =
556                frus_text::line_spans_resolved(&self.content, &r.style, Some(width), r.wrap);
557            if spans.len() > max {
558                too_tall = true;
559                let cut = spans[max].start;
560                let last = spans[max - 1].start;
561                text = if r.overflow == TextOverflow::Ellipsis {
562                    let ended = ellipsise(&self.content[last..cut], &r.style, width);
563                    format!("{}{ended}", &self.content[..last])
564                } else {
565                    self.content[..cut].to_string()
566                };
567            }
568        }
569        // A line can only be wider than the box when nothing may push it onto the next
570        // one. Where the text wraps, every line fits by construction.
571        let too_wide = !r.wrap && frus_text::measure_resolved(&text, &r.style).width > width + 0.5;
572        if too_wide && !too_tall && r.overflow == TextOverflow::Ellipsis {
573            text = ellipsise(&text, &r.style, width);
574        }
575        Fitted {
576            text,
577            too_wide,
578            too_tall,
579        }
580    }
581
582    /// Whether this text needs the **whole width it is offered** rather than its own.
583    ///
584    /// Alignment is the only thing that does. A box exactly as wide as its text has
585    /// nowhere to align it to, so a text asked to centre itself and then given its own
586    /// width would be centred and look untouched — the setting would appear to do nothing
587    /// and there would be nothing to see. Left alone, a text still shrink-wraps.
588    fn fills(r: &Resolved) -> bool {
589        r.align != TextAlign::Start
590    }
591
592    /// A line limit is a **height** cap and nothing else: the words break where they
593    /// broke, and the ones past the limit are not drawn.
594    fn capped(height: f32, r: &Resolved) -> f32 {
595        match r.max_lines {
596            Some(max) => height.min(r.style.line_height() * max as f32),
597            None => height,
598        }
599    }
600
601    /// The fade that ends a cut text: opaque until the last stretch of the box, then out
602    /// to nothing at the edge it ran past.
603    /// The two handles of a touch selection, hanging below their ends of it. Drawn last,
604    /// over the words, and outside the text's own clip: they hang past its last line.
605    fn paint_grips(&self, bounds: Rect, status: &Status, theme: &Theme, scene: &mut Scene) {
606        // The handles of a selection area's touch selection that hang from this text.
607        let (start, end) = status.region_grips;
608        if start.is_some() || end.is_some() {
609            let layout = self.text_layout(bounds.width);
610            let color = theme
611                .widgets
612                .text_selection
613                .handle_color_in(theme)
614                .fade(status.opacity);
615            let offset = Point::new(bounds.x, bounds.y);
616            let origin = Point::new(0.0, 0.0);
617            for (index, is_start) in [(start, true), (end, false)] {
618                if let Some(index) = index {
619                    let handle = handle_at(&layout, origin, index, is_start);
620                    paint_handle(scene, handle, offset, color, is_start);
621                }
622            }
623        }
624        if !(status.focused && status.handles) {
625            return;
626        }
627        let Some((start, end)) = status.selection else {
628            return;
629        };
630        let Some(layout) = self.selection_layout(bounds.width) else {
631            return;
632        };
633        let edit = Edit {
634            cursor: end,
635            anchor: Some(start),
636            composing: None,
637        };
638        let len = self.content.chars().count();
639        if let Some(handles) = handles_in(&layout, Point::new(0.0, 0.0), len, &edit) {
640            let color = theme
641                .widgets
642                .text_selection
643                .handle_color_in(theme)
644                .fade(status.opacity);
645            paint_handles(scene, handles, Point::new(bounds.x, bounds.y), color);
646        }
647    }
648
649    fn fade(bounds: Rect, horizontal: bool, r: &Resolved) -> ShaderMask {
650        // A fifth of the box, and never more than three line heights of it. Over a long
651        // line a proportional fade would start halfway through words that are perfectly
652        // legible; over a short one an absolute fade would swallow the lot.
653        let extent = if horizontal {
654            bounds.width
655        } else {
656            bounds.height
657        };
658        let run = (extent * 0.2).min(r.style.line_height() * 3.0);
659        let (from, to) = if horizontal {
660            (
661                Point::new(bounds.x + bounds.width - run, bounds.y),
662                Point::new(bounds.x + bounds.width, bounds.y),
663            )
664        } else {
665            (
666                Point::new(bounds.x, bounds.y + bounds.height - run),
667                Point::new(bounds.x, bounds.y + bounds.height),
668            )
669        };
670        ShaderMask::new(MaskShader::Linear {
671            from,
672            to,
673            from_color: Color::WHITE,
674            to_color: Color::WHITE.fade(0.0),
675        })
676    }
677}
678
679impl Text {
680    /// The box this text asks for, once every question has been answered.
681    ///
682    /// Split out from the two style hooks rather than duplicated across them: they differ
683    /// only in whether a theme was there to be asked, and a second copy of this reasoning
684    /// is a second place for the themed and the unthemed answers to drift apart.
685    fn boxed(&self, r: &Resolved) -> Style {
686        // A flex item's automatic minimum size is its content, so a plain text refuses to
687        // shrink and pushes its siblings out instead. One that has said what to do when it
688        // overflows may be given less; the paint fits the words to whatever it gets.
689        let min_width = if r.shrinkable {
690            Dimension::Length(0.0)
691        } else {
692            Dimension::Auto
693        };
694        // A paragraph, or a text that has to know how wide its box is: free dimensions,
695        // and the size comes from `measure` — the only way a box can be *given* a width
696        // and answer with a height.
697        if r.wrap || Self::fills(r) {
698            return Style {
699                min_width,
700                ..Default::default()
701            };
702        }
703        // A single line is a box of a known size, and saying so is what keeps it from
704        // being folded: a measured leaf reports its narrowest useful width as its
705        // minimum content, and a row would take that as leave to squeeze it there.
706        let measured = frus_text::measure_resolved(&self.content, &r.style);
707        Style {
708            width: Dimension::Length(measured.width.ceil()),
709            height: Dimension::Length(Self::capped(measured.height, r).ceil()),
710            min_width,
711            // A text that has said what to do when it overflows is **clamped to its
712            // parent**, which is what the reference does to every one of them: a
713            // paragraph is laid out at `constraints.constrain(its own size)`. Without it a
714            // text declares the width it wants, a narrower box does not take it away, and
715            // the overflow mode never fires — the words simply draw past the edge, which
716            // is the behaviour it was set to prevent.
717            max_width: if r.shrinkable {
718                Dimension::Percent(1.0)
719            } else {
720                Dimension::Auto
721            },
722            ..Default::default()
723        }
724    }
725}
726
727impl<Msg: Clone + 'static> Widget<Msg> for Text {
728    fn style(&self) -> Style {
729        self.boxed(&self.resolved(None))
730    }
731
732    /// A subtree can hand down a size, a weight and a line limit, and every one of those
733    /// is a **box**, not a colour. Resolving them here rather than at paint is what lets
734    /// an app bar make the words inside it smaller and have them take less room, instead
735    /// of the same room with smaller writing in it.
736    fn style_themed(&self, theme: &Theme) -> Style {
737        let r = self.resolved(Some(theme));
738        self.remember(&r);
739        self.boxed(&r)
740    }
741
742    /// A text will not be **squeezed along a row**: it runs past the end of one rather
743    /// than being folded into a column of single words, which is the reference's rule and
744    /// was, until now, the only thing a declared width was doing here.
745    ///
746    /// A text that has said what to do when it overflows has already said the opposite,
747    /// and is left alone.
748    fn main_axis_floor(&self, theme: &Theme) -> Option<f32> {
749        let r = self.resolved(Some(theme));
750        // A single line already carries its width in its style; only a text measured
751        // under constraints needs to be told where to stop giving way.
752        if r.shrinkable || !(r.wrap || Self::fills(&r)) {
753            return None;
754        }
755        Some(
756            frus_text::measure_resolved(&self.content, &r.style)
757                .width
758                .ceil(),
759        )
760    }
761
762    /// The line this text sits on, measured from the top of its box. A `Text` is the
763    /// widget that actually *has* a baseline; every alignment that talks about one is
764    /// ultimately asking one of these.
765    fn text_baseline(&self, theme: &Theme) -> Option<f32> {
766        let r = self.resolved(Some(theme));
767        Some(frus_text::baseline(
768            r.style.size,
769            r.style.weight,
770            r.style.italic,
771        ))
772    }
773
774    fn measure(&self, theme: &Theme) -> Option<frus_layout::MeasureFn<'_>> {
775        let r = self.resolved(Some(theme));
776        if !r.wrap && !Self::fills(&r) {
777            return None;
778        }
779        let content = self.content.clone();
780        let style = r.style;
781        let max_lines = r.max_lines;
782        let wrap = r.wrap;
783        Some(Box::new(move |max_width, _| {
784            // Under the whole style, as the renderer draws it: its line height, its family
785            // and its letter spacing (milestone 623). Measured by size, weight and slant
786            // alone, a text with a family or a line height of its own was measured in
787            // another face, at another height, than it was drawn.
788            let mut size = frus_text::measure_wrapped_resolved(
789                &content,
790                &style,
791                // A text that only wanted the width to align inside is still one line:
792                // the constraint tells it where its box ends, not where to break.
793                if wrap { max_width } else { None },
794            );
795            if let Some(max) = max_lines {
796                // **Rounded up, like the measurement it clamps** (issue #54): a line
797                // height is a fraction of a size, so two allowed lines come to 28.8, and
798                // a box rounded to 28 is one the second line no longer fits in. The
799                // clamp says *how many lines*; the pixels those lines occupy are still
800                // pixels a layout will round.
801                size.height = size.height.min(fits(style.line_height() * max as f32));
802            }
803            size
804        }))
805    }
806
807    fn measure_key(&self, theme: &Theme) -> Option<u64> {
808        let r = self.resolved(Some(theme));
809        if !r.wrap && !Self::fills(&r) {
810            return None;
811        }
812        use std::hash::{Hash, Hasher};
813        let mut hasher = std::collections::hash_map::DefaultHasher::new();
814        self.content.hash(&mut hasher);
815        // The **resolved** style, not the written one: an inherited size changes the
816        // measurement, and a key that ignored it would hand back the geometry the text had
817        // before the subtree said anything.
818        r.style.size.to_bits().hash(&mut hasher);
819        r.style.weight.to_u16().hash(&mut hasher);
820        r.style.italic.hash(&mut hasher);
821        // The leading and the face, which milestones 409 and 410 put into the measurement
822        // and *not* into this key: two paragraphs alike but for their line height wrapped
823        // to the same box, and the second kept the first's geometry.
824        r.style.height.map(f32::to_bits).hash(&mut hasher);
825        r.style.family.hash(&mut hasher);
826        r.max_lines.hash(&mut hasher);
827        r.wrap.hash(&mut hasher);
828        Some(hasher.finish())
829    }
830
831    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
832        &[]
833    }
834
835    /// An aligned text takes the width its parent offers: a box exactly as wide as its
836    /// text has nowhere to align it to. It is the same request a [`crate::Row`] makes,
837    /// and it is answered by the same walk.
838    fn fill_axes(&self, theme: &Theme) -> crate::widget::FillAxes {
839        if Self::fills(&self.resolved(Some(theme))) {
840            crate::widget::FillAxes::WIDTH
841        } else {
842            crate::widget::FillAxes::NONE
843        }
844    }
845
846    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
847        let r = self.resolved(Some(theme));
848        self.remember(&r);
849        let color = r
850            .style
851            .color
852            .unwrap_or(theme.on_surface)
853            .fade(status.opacity);
854        let fitted = self.fitted(bounds.width, &r);
855        // The selection: the highlight below the words (the handles of a touch selection are
856        // drawn after them). A selection area's covers this text whether or not it is focused;
857        // a selectable text's own only while it is.
858        let own = status
859            .selection
860            .filter(|_| status.focused && self.selecting().is_some());
861        if let Some((start, end)) = status.region.or(own) {
862            let highlight = theme
863                .widgets
864                .text_selection
865                .selection_color_in(theme)
866                .fade(status.opacity);
867            {
868                let layout = self.text_layout(bounds.width);
869                let len = self.content.chars().count();
870                for rect in layout.selection_rects(start.min(len), end.min(len)) {
871                    scene.fill_rect(
872                        Rect::new(
873                            bounds.x + rect.x,
874                            bounds.y + rect.y,
875                            rect.width,
876                            rect.height,
877                        ),
878                        highlight,
879                    );
880                }
881            }
882        }
883        let block = TextBlock {
884            // A width is handed over only when something is going to use it. Giving the
885            // renderer one it did not have changes where right-to-left text lands, which
886            // is a bug this codebase has already had once.
887            width: (r.wrap || r.align != TextAlign::Start).then_some(bounds.width),
888            soft_wrap: r.wrap,
889            align: r.align,
890        };
891        let draw = |scene: &mut Scene| {
892            scene.text_block(
893                Point::new(bounds.x, bounds.y),
894                fitted.text.clone(),
895                &r.style,
896                color,
897                block,
898            );
899        };
900        // Nothing hanging over the edge, or already cut to size, or told to spill: the
901        // three cases where the mode has nothing left to do.
902        if !fitted.over()
903            || r.overflow == TextOverflow::Visible
904            || r.overflow == TextOverflow::Ellipsis
905        {
906            draw(scene);
907            self.paint_grips(bounds, &status, theme, scene);
908            return;
909        }
910        // Only where it genuinely does not fit: a clip around every text would put a hard
911        // edge through the antialiasing of every one that does.
912        let outer = scene.current_clip();
913        scene.set_clip(outer.intersect(bounds));
914        match r.overflow {
915            TextOverflow::Fade => {
916                let horizontal = fitted.too_wide && !fitted.too_tall;
917                scene.masked(Self::fade(bounds, horizontal, &r), draw);
918            }
919            _ => draw(scene),
920        }
921        scene.set_clip(outer);
922        self.paint_grips(bounds, &status, theme, scene);
923    }
924
925    fn on_click(&self) -> Option<Msg> {
926        None
927    }
928
929    // What follows is a **selectable** text's: a read-only text field, in effect. None of it
930    // answers for a text that is not.
931
932    fn focusable(&self) -> bool {
933        self.selecting().is_some()
934    }
935
936    /// A selectable text's words, so that the shell treats it as the text field it is
937    /// for the caret's sake: the arrows move the caret, Ctrl+A selects, the context-menu
938    /// key opens the bar. Not for the keyboard's — see [`Widget::takes_typing`].
939    fn text_value(&self) -> Option<&str> {
940        self.selecting().map(|_| self.content.as_str())
941    }
942
943    fn takes_typing(&self) -> bool {
944        false
945    }
946
947    /// The words, for a selection that spans texts: any text with something to select.
948    fn selection_text(&self) -> Option<&str> {
949        (!self.content.is_empty()).then_some(self.content.as_str())
950    }
951
952    /// Copy, and Select all unless everything is selected — against the box of the part of the
953    /// selection that lies in this text.
954    fn region_toolbar(
955        &self,
956        context: ToolbarContext,
957        width: f32,
958        range: (usize, usize),
959    ) -> Option<(Rect, &dyn Widget<Msg>)> {
960        let layout = self.text_layout(width);
961        let len = self.content.chars().count();
962        let edit = Edit {
963            cursor: range.1,
964            anchor: Some(range.0),
965            composing: None,
966        };
967        let anchor = selection_box_in(&layout, Point::new(0.0, 0.0), len, &edit);
968        let bars = self.area_bars.get_or_init(Box::default);
969        let bar = bars[usize::from(context.variant())]
970            .get_or_init(|| {
971                let mut items: Vec<ToolbarItem<Msg>> = Vec::new();
972                if context.has_selection {
973                    items.push(ToolbarItem::copy());
974                }
975                if !context.all_selected {
976                    items.push(ToolbarItem::select_all());
977                }
978                let bar: Option<Box<dyn Widget<Msg>>> = (!items.is_empty())
979                    .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>);
980                Box::new(bar) as Box<dyn Any>
981            })
982            .downcast_ref::<Option<Box<dyn Widget<Msg>>>>()?
983            .as_deref()?;
984        Some((anchor, bar))
985    }
986
987    fn selection_grip(
988        &self,
989        width: f32,
990        index: usize,
991        start: bool,
992    ) -> Option<crate::SelectionHandle> {
993        let len = self.content.chars().count();
994        Some(handle_at(
995            &self.text_layout(width),
996            Point::new(0.0, 0.0),
997            index.min(len),
998            start,
999        ))
1000    }
1001
1002    fn selection_hit(&self, local_x: f32, local_y: f32, width: f32) -> Option<usize> {
1003        if self.content.is_empty() {
1004            return None;
1005        }
1006        Some(
1007            self.text_layout(width)
1008                .hit_test(Point::new(local_x, local_y)),
1009        )
1010    }
1011
1012    fn cursor_at(
1013        &self,
1014        local_x: f32,
1015        local_y: f32,
1016        width: f32,
1017        _scroll_cursor: usize,
1018    ) -> Option<usize> {
1019        let layout = self.selection_layout(width)?;
1020        Some(layout.hit_test(Point::new(local_x, local_y)))
1021    }
1022
1023    fn word_at(&self, index: usize) -> Option<(usize, usize)> {
1024        self.selecting()?;
1025        let chars: Vec<char> = self.content.chars().collect();
1026        word_range(&chars, index)
1027    }
1028
1029    fn selected_text(&self, edit: &Edit) -> Option<String> {
1030        self.selecting()?;
1031        let chars: Vec<char> = self.content.chars().collect();
1032        let len = chars.len();
1033        let (start, end) = edit.selection_range()?;
1034        let (start, end) = (start.min(len), end.min(len));
1035        (start < end).then(|| chars[start..end].iter().collect())
1036    }
1037
1038    /// The caret's keys — the arrows, home and end — and nothing else: a text that cannot
1039    /// be typed into takes no other key, and answers no message.
1040    fn on_edit(&self, edit: &mut Edit, key: &Key) -> Option<Msg> {
1041        self.selecting()?;
1042        let chars: Vec<char> = self.content.chars().collect();
1043        let len = chars.len();
1044        let mut cursor = edit.cursor.min(len);
1045        let mut anchor = edit.anchor.map(|anchor| anchor.min(len));
1046        if move_caret(&chars, &mut cursor, &mut anchor, key) {
1047            edit.cursor = cursor;
1048            edit.anchor = anchor;
1049        }
1050        None
1051    }
1052
1053    fn selection_handles(
1054        &self,
1055        width: f32,
1056        edit: &Edit,
1057        _scroll_y: f32,
1058    ) -> Option<[crate::SelectionHandle; 2]> {
1059        let layout = self.selection_layout(width)?;
1060        let len = self.content.chars().count();
1061        handles_in(&layout, Point::new(0.0, 0.0), len, edit)
1062    }
1063
1064    fn selection_anchor(&self, width: f32, edit: &Edit, _scroll_y: f32) -> Option<Rect> {
1065        let layout = self.selection_layout(width)?;
1066        let len = self.content.chars().count();
1067        Some(selection_box_in(&layout, Point::new(0.0, 0.0), len, edit))
1068    }
1069
1070    /// Copy, when something is selected, and Select all, unless everything is: a text
1071    /// cannot be cut from or pasted into, so those are not offered.
1072    fn selection_toolbar(&self, context: ToolbarContext) -> Option<&dyn Widget<Msg>> {
1073        let selection = self.selecting()?;
1074        selection.toolbars[usize::from(context.variant())]
1075            .get_or_init(|| {
1076                let mut items = Vec::new();
1077                if context.has_selection {
1078                    items.push(ToolbarItem::copy());
1079                }
1080                if !context.all_selected {
1081                    items.push(ToolbarItem::select_all());
1082                }
1083                let build = selection
1084                    .toolbar_build
1085                    .as_ref()
1086                    .and_then(|build| build.downcast_ref::<ToolbarBuild<Msg>>());
1087                let items = match build {
1088                    Some(build) => build(&context, items),
1089                    None => items,
1090                };
1091                let bar: Option<Box<dyn Widget<Msg>>> = (!items.is_empty())
1092                    .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>);
1093                Box::new(bar) as Box<dyn Any>
1094            })
1095            .downcast_ref::<Option<Box<dyn Widget<Msg>>>>()?
1096            .as_deref()
1097    }
1098
1099    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1100        // A text carries its content as its accessible label — or as a **heading**, which
1101        // is a landmark rather than a label and is what a screen reader's user navigates by.
1102        let role = if self.heading {
1103            frus_core::Role::Heading
1104        } else {
1105            frus_core::Role::Label
1106        };
1107        Some(frus_core::SemanticsProperties::new(role).label(self.content.clone()))
1108    }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113    /// An ellipsising text is **cut to the box it is given** instead of drawing past it.
1114    ///
1115    /// It also tells the layout it may be given less than it asked for (`min_width: 0`),
1116    /// which is half of what a row needs to keep its trailing widget intact. The other
1117    /// half arrived in milestone 349: a widget that did not ask to give way is not
1118    /// squeezed, so the trailing button keeps its width without saying anything.
1119    #[test]
1120    fn an_ellipsising_text_is_cut_to_its_box() {
1121        let long = "A task name that is really rather long indeed and keeps going";
1122        let style = TextStyle::new(18.0);
1123        let full = frus_text::measure_style(long, style).width;
1124        assert!(full > 300.0, "the fixture has to overflow: {full}");
1125
1126        let cut = truncate(long, &style.resolved(), 150.0);
1127        assert!(cut.ends_with(ELLIPSIS), "cut with an ellipsis: {cut}");
1128        let cut_width = frus_text::measure_style(&cut, style).width;
1129        assert!(cut_width <= 150.0, "and it fits: {cut_width}");
1130        assert!(cut.len() > 4, "without cutting everything: {cut}");
1131
1132        // A width it already fits in leaves it alone, ellipsis and all.
1133        assert_eq!(truncate("Short", &style.resolved(), 500.0), "Short");
1134        // A box with no room at all draws an ellipsis and nothing else. It used to draw
1135        // the whole string, which is the one thing ellipsising exists to prevent.
1136        assert_eq!(truncate(long, &style.resolved(), 0.0), ELLIPSIS);
1137    }
1138
1139    /// The layout half: an ellipsising text accepts less width than it asked for, a plain
1140    /// one does not.
1141    #[test]
1142    fn an_ellipsising_text_lets_the_layout_shrink_it() {
1143        use frus_layout::Dimension;
1144        let plain =
1145            Widget::<()>::style(&Text::new("A rather long label indeed").size(18.0).no_wrap());
1146        let cut = Widget::<()>::style(
1147            &Text::new("A rather long label indeed")
1148                .size(18.0)
1149                .ellipsis(),
1150        );
1151        assert_eq!(plain.min_width, Dimension::Auto, "content is its own floor");
1152        assert_eq!(
1153            cut.min_width,
1154            Dimension::Length(0.0),
1155            "and this one has none"
1156        );
1157        assert_eq!(plain.width, cut.width, "both still ask for the same width");
1158    }
1159
1160    use super::*;
1161    use frus_core::Primitive;
1162    use frus_core::{MaskShader, TextAlign, TextOverflow};
1163
1164    /// A paragraph **sized to fit** — centred on a column's cross axis, so it gets
1165    /// its natural width rather than the whole row — must be given a box the text
1166    /// still fits in, and the thing below it must clear the lines it really has.
1167    ///
1168    /// This failed on a device before the measurement was rounded up: the natural
1169    /// width came back as a fraction, the layout rounded the box down below it, the
1170    /// text wrapped onto a second line when painted, and the label underneath sat on
1171    /// top of that line — the layout having reserved the height of one.
1172    #[test]
1173    fn a_paragraph_sized_to_fit_is_not_squeezed_into_wrapping() {
1174        use crate::{build_ui, Container, Flex, Runtime, Size};
1175        use frus_core::FontWeight;
1176        use frus_layout::Align;
1177
1178        const MARK: Color = Color {
1179            r: 1.0,
1180            g: 0.0,
1181            b: 1.0,
1182            a: 1.0,
1183        };
1184        // Two labels: one that fits on a line at its natural width, one that cannot
1185        // fit the box at all and must reserve every line it wraps onto.
1186        for (label, lines) in [
1187            ("Write code", 1.0_f32),
1188            ("A rather long task name that certainly wraps", 2.0),
1189        ] {
1190            let tree = Container::<()>::new().width(400.0).height(600.0).child(
1191                Flex::column()
1192                    .width(376.0)
1193                    .align(Align::Center)
1194                    .gap(18.0)
1195                    .child(Text::new(label).size(24.0).weight(FontWeight::Bold).wrap())
1196                    .child(Container::new().height(20.0).color(MARK)),
1197            );
1198            let ui = build_ui(
1199                &tree,
1200                Size::new(400.0, 600.0),
1201                &Runtime::default(),
1202                &Theme::default(),
1203            );
1204            let paragraph = ui
1205                .scene()
1206                .primitives()
1207                .iter()
1208                .find_map(|p| match p {
1209                    Primitive::Text {
1210                        position,
1211                        max_width,
1212                        ..
1213                    } => Some((*position, max_width.unwrap_or(f32::MAX))),
1214                    _ => None,
1215                })
1216                .expect("the paragraph is painted");
1217            let below = ui
1218                .scene()
1219                .primitives()
1220                .iter()
1221                .find_map(|p| match p {
1222                    Primitive::Rect { rect, color, .. } if *color == MARK => Some(*rect),
1223                    _ => None,
1224                })
1225                .expect("the label under it is painted");
1226            // What the box it was given really costs, shaped at that width.
1227            let painted =
1228                frus_text::measure_wrapped(label, 24.0, FontWeight::Bold, false, Some(paragraph.1));
1229            // Against the **whole** height those lines occupy, which is what a
1230            // measurement reports since milestone 497: a height of `lines × line_height`
1231            // is fractional and would be shaved by the layout's rounding, so it is
1232            // rounded up before it leaves. Dividing the rounded height back by the line
1233            // height gives 2.014 lines, which is arithmetic rather than a wrap.
1234            let want = frus_core::fits(lines * frus_text::line_height(24.0));
1235            assert!(
1236                (painted.height - want).abs() < 0.01,
1237                "{label:?} wrapped onto {} lines in a box of {}",
1238                painted.height / frus_text::line_height(24.0),
1239                paragraph.1
1240            );
1241            assert!(
1242                below.y >= paragraph.0.y + painted.height - 0.01,
1243                "{label:?}: the label underneath overlaps the paragraph ({below:?} vs {painted:?})"
1244            );
1245        }
1246    }
1247
1248    /// A line limit drops what is past it and, asked for an ellipsis, says so on the
1249    /// last line it kept.
1250    #[test]
1251    fn a_line_limit_cuts_the_paragraph_and_marks_the_cut() {
1252        let long = "one two three four five six seven eight nine ten eleven twelve";
1253        let text = Text::new(long)
1254            .size(14.0)
1255            .wrap()
1256            .max_lines(2)
1257            .overflow(TextOverflow::Ellipsis);
1258        let fitted = text.fitted(90.0, &text.resolved(None));
1259        // What comes back is a **prefix**, not a list of lines: it wraps into two when the
1260        // renderer breaks it, which is where the breaking belongs.
1261        assert!(
1262            fitted.text.lines().count() == 1,
1263            "one string, no newlines in it: {:?}",
1264            fitted.text
1265        );
1266        let lines = frus_text::line_spans(
1267            &fitted.text,
1268            14.0,
1269            FontWeight::Regular,
1270            false,
1271            Some(90.0),
1272            true,
1273        );
1274        assert_eq!(lines.len(), 2, "and it breaks into two: {:?}", fitted.text);
1275        assert!(fitted.too_tall, "and there was more");
1276        assert!(
1277            fitted.text.ends_with(ELLIPSIS),
1278            "ending in an ellipsis: {:?}",
1279            fitted.text
1280        );
1281    }
1282
1283    /// The same paragraph with room for every line is left exactly as it was written.
1284    #[test]
1285    fn a_limit_it_does_not_reach_changes_nothing() {
1286        let text = Text::new("one two").size(14.0).wrap().max_lines(4);
1287        let fitted = text.fitted(400.0, &text.resolved(None));
1288        assert_eq!(fitted.text, "one two");
1289        assert!(!fitted.too_tall && !fitted.too_wide);
1290    }
1291
1292    /// A limit is a height cap: the words still wrap where they wrapped, and the box
1293    /// stops at the line it was allowed.
1294    #[test]
1295    fn a_limit_caps_the_measured_height() {
1296        let long = "one two three four five six seven eight nine ten eleven twelve";
1297        let free = Text::new(long).size(14.0).wrap();
1298        let capped = Text::new(long).size(14.0).wrap().max_lines(2);
1299        let at = |t: &Text| {
1300            Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
1301        };
1302        assert!(at(&free) > at(&capped), "the limit is doing something");
1303        assert!(
1304            (at(&capped) - frus_text::line_height(14.0) * 2.0).abs() < 0.5,
1305            "two lines exactly: {}",
1306            at(&capped)
1307        );
1308    }
1309
1310    /// **A line limit counts the lines the style asked for**, not lines of a default
1311    /// height.
1312    ///
1313    /// Milestone 409 gave a style a `height` and threaded it through the measurement and
1314    /// the paint; it left twenty-four places computing `line_height(style.size)` — the
1315    /// 1.2 default — while holding a style that said otherwise. A cap is one of them, and
1316    /// it is the one a test can see: two lines of a doubled leading is twice the box, and
1317    /// before this it was capped as though the leading had never been named (milestone
1318    /// 412).
1319    #[test]
1320    fn a_limit_counts_lines_of_the_height_that_was_asked_for() {
1321        let long = "one two three four five six seven eight nine ten eleven twelve";
1322        let plain = Text::new(long).size(14.0).wrap().max_lines(2);
1323        let open = Text::new(long).size(14.0).height(2.4).wrap().max_lines(2);
1324        let at = |t: &Text| {
1325            Widget::<()>::measure(t, &Theme::default()).expect("measure")(Some(90.0), None).height
1326        };
1327        let ratio = at(&open) / at(&plain);
1328        assert!(
1329            (ratio - 2.0).abs() < 0.02,
1330            "a doubled leading doubles the two-line cap: {ratio}"
1331        );
1332    }
1333
1334    /// `Visible` draws past the box; `Clip` puts the box in the primitive's clip so the
1335    /// renderer stops at the edge. Both draw the whole string — the difference is the
1336    /// clip, not the text.
1337    #[test]
1338    fn clip_and_visible_differ_by_the_clip_and_not_the_words() {
1339        let long = "a label far too long for the box it was given";
1340        let paint = |overflow: TextOverflow| {
1341            let text = Text::new(long).size(16.0).no_wrap().overflow(overflow);
1342            let mut scene = Scene::new();
1343            Widget::<()>::paint(
1344                &text,
1345                Rect::new(0.0, 0.0, 60.0, 20.0),
1346                Status::default(),
1347                &Theme::default(),
1348                &mut scene,
1349            );
1350            match &scene.primitives()[0] {
1351                Primitive::Text { text, clip, .. } => (text.clone(), *clip),
1352                other => panic!("a text, not {other:?}"),
1353            }
1354        };
1355        let (visible_text, visible_clip) = paint(TextOverflow::Visible);
1356        let (clipped_text, clipped_clip) = paint(TextOverflow::Clip);
1357        assert_eq!(visible_text, long, "nothing is cut");
1358        assert_eq!(clipped_text, long, "nothing is cut here either");
1359        assert_eq!(visible_clip, Rect::UNBOUNDED, "and nothing stops it");
1360        assert_eq!(
1361            clipped_clip,
1362            Rect::new(0.0, 0.0, 60.0, 20.0),
1363            "this one stops"
1364        );
1365    }
1366
1367    /// A **subtree** can hand a text its size, and a text that never chose one takes it.
1368    ///
1369    /// The half that matters is where it is taken: the size has to reach the **box**, not
1370    /// only the glyphs. A text drawn at 24 px inside a box measured for 16 leaves every
1371    /// row on the screen the wrong height, and nothing in the picture says which of the
1372    /// two numbers was the mistake.
1373    #[test]
1374    fn a_subtree_hands_a_text_its_size() {
1375        let mut theme = Theme::default();
1376        theme.widgets.text.style.size = Some(30.0);
1377        // One line, so the box it asks for is a number this test can read.
1378        let text = Text::new("Handed down").no_wrap();
1379
1380        let alone = Widget::<()>::style(&text);
1381        let handed = Widget::<()>::style_themed(&text, &theme);
1382        let (Dimension::Length(alone_w), Dimension::Length(handed_w)) = (alone.width, handed.width)
1383        else {
1384            panic!("a single line is a box of a known size");
1385        };
1386        assert!(
1387            handed_w > alone_w * 1.5,
1388            "the box grew with the type: {alone_w} → {handed_w}"
1389        );
1390
1391        let mut scene = Scene::new();
1392        Widget::<()>::paint(
1393            &text,
1394            Rect::new(0.0, 0.0, handed_w, 40.0),
1395            Status::default(),
1396            &theme,
1397            &mut scene,
1398        );
1399        match &scene.primitives()[0] {
1400            Primitive::Text { size, .. } => assert_eq!(
1401                *size, 30.0,
1402                "and the glyphs agree with the box they were measured into"
1403            ),
1404            other => panic!("a text, not {other:?}"),
1405        }
1406    }
1407
1408    /// A size the **caller** chose is not overruled — and a default nobody chose is.
1409    ///
1410    /// The two halves are the whole rule, and either alone would be wrong: without the
1411    /// first, a subtree silently resizes the one text that had asked to be different;
1412    /// without the second, the feature does nothing at all, because every `Text` already
1413    /// holds a size and a number cannot say whether anybody picked it.
1414    #[test]
1415    fn a_chosen_size_outranks_a_handed_one() {
1416        let mut theme = Theme::default();
1417        theme.widgets.text.style.size = Some(30.0);
1418        let width = |text: &Text| match Widget::<()>::style_themed(text, &theme).width {
1419            Dimension::Length(w) => w,
1420            other => panic!("a known width, not {other:?}"),
1421        };
1422        // The same 16 px, once as a default and once as an answer.
1423        let untouched = width(&Text::new("Handed down").no_wrap());
1424        let chosen = width(&Text::new("Handed down").no_wrap().size(16.0));
1425        assert!(
1426            untouched > chosen,
1427            "the one that chose kept its size: {untouched} vs {chosen}"
1428        );
1429        assert_eq!(
1430            chosen,
1431            width(&Text::new("Handed down").no_wrap().size(16.0)),
1432            "and it keeps it however often it is asked"
1433        );
1434    }
1435
1436    /// The merge is **field by field**: a subtree that sets a colour and nothing else
1437    /// leaves every size alone.
1438    ///
1439    /// A whole-style handover would be the easy implementation and the wrong one — an app
1440    /// bar recolouring its words would flatten the type scale of everything inside it.
1441    #[test]
1442    fn handing_down_a_colour_leaves_the_sizes_alone() {
1443        let red = Color::rgb(1.0, 0.0, 0.0);
1444        let mut theme = Theme::default();
1445        theme.widgets.text.style.color = Some(red);
1446        let text = Text::new("Words").no_wrap().size(22.0);
1447        assert_eq!(
1448            Widget::<()>::style_themed(&text, &theme).width,
1449            Widget::<()>::style(&text).width,
1450            "the box did not move"
1451        );
1452        let mut scene = Scene::new();
1453        Widget::<()>::paint(
1454            &text,
1455            Rect::new(0.0, 0.0, 300.0, 40.0),
1456            Status::default(),
1457            &theme,
1458            &mut scene,
1459        );
1460        match &scene.primitives()[0] {
1461            Primitive::Text { size, color, .. } => {
1462                assert_eq!(*color, red, "the colour came down");
1463                assert_eq!(*size, 22.0, "the size stayed put");
1464            }
1465            other => panic!("a text, not {other:?}"),
1466        }
1467    }
1468
1469    /// A handed-down line limit also says the text **may be squeezed**.
1470    ///
1471    /// It is the easy half to leave out, and leaving it out ships a subtree that
1472    /// ellipsises its texts and never gets the chance to: a flex item's automatic minimum
1473    /// size is its content, so each of them still refuses to give way and the mode never
1474    /// fires. Saying what to do on overflow is what says it may be squeezed — however the
1475    /// text was told.
1476    #[test]
1477    fn a_handed_down_limit_lets_the_text_give_way() {
1478        let mut theme = Theme::default();
1479        theme.widgets.text.max_lines = Some(1);
1480        theme.widgets.text.overflow = Some(TextOverflow::Ellipsis);
1481        let text = Text::new("a label far too long for the box it will be given");
1482        assert_eq!(
1483            Widget::<()>::style(&text).min_width,
1484            Dimension::Auto,
1485            "left alone it refuses to shrink"
1486        );
1487        assert_eq!(
1488            Widget::<()>::style_themed(&text, &theme).min_width,
1489            Dimension::Length(0.0),
1490            "handed a limit, it gives way"
1491        );
1492        let mut scene = Scene::new();
1493        Widget::<()>::paint(
1494            &text,
1495            Rect::new(0.0, 0.0, 80.0, 20.0),
1496            Status::default(),
1497            &theme,
1498            &mut scene,
1499        );
1500        match &scene.primitives()[0] {
1501            Primitive::Text { text, .. } => {
1502                assert!(text.ends_with(ELLIPSIS), "and it was cut: {text:?}")
1503            }
1504            other => panic!("a text, not {other:?}"),
1505        }
1506    }
1507
1508    /// A handed-down **alignment** also makes the text ask for its parent's width.
1509    ///
1510    /// Alignment is the one style setting that is also a *request*: a box exactly as wide
1511    /// as its own text has nowhere to centre it in, so a text told to centre asks to fill
1512    /// the line first. That request is made by `fill_axes`, which is why that hook
1513    /// had to see the theme too — without it, the one setting that arrives by inheritance
1514    /// would resolve correctly everywhere except where it takes effect, and centring a
1515    /// subtree's texts would silently do nothing.
1516    #[test]
1517    fn a_handed_down_alignment_still_asks_for_the_width() {
1518        let mut theme = Theme::default();
1519        theme.widgets.text.align = Some(TextAlign::Center);
1520        let text = Text::new("Centred").no_wrap();
1521        assert_eq!(
1522            Widget::<()>::fill_axes(&text, &Theme::default()),
1523            crate::widget::FillAxes::NONE,
1524            "left alone it hugs its words"
1525        );
1526        assert_eq!(
1527            Widget::<()>::fill_axes(&text, &theme),
1528            crate::widget::FillAxes::WIDTH,
1529            "handed a centring, it asks for the line"
1530        );
1531        let mut scene = Scene::new();
1532        Widget::<()>::paint(
1533            &text,
1534            Rect::new(0.0, 0.0, 300.0, 20.0),
1535            Status::default(),
1536            &theme,
1537            &mut scene,
1538        );
1539        match &scene.primitives()[0] {
1540            Primitive::Text {
1541                align, max_width, ..
1542            } => {
1543                assert_eq!(*align, TextAlign::Center);
1544                assert_eq!(*max_width, Some(300.0), "and it was given a box to sit in");
1545            }
1546            other => panic!("a text, not {other:?}"),
1547        }
1548    }
1549
1550    /// **Two nested wrappers each setting one field leave a text wearing both.**
1551    ///
1552    /// This is what makes the merge worth its complexity. A whole-style handover would
1553    /// look identical in a single-wrapper test and be wrong here: the inner wrapper would
1554    /// replace the outer one's colour with nothing at all, and a section that set a size
1555    /// inside a screen that set a colour would silently drop the colour.
1556    #[test]
1557    fn nested_wrappers_compose_field_by_field() {
1558        use crate::{build_ui, Runtime, Size};
1559        let muted = Color::rgb(0.4, 0.4, 0.45);
1560        let outer = crate::DefaultTextStyle::from_text_style(TextStyle::NONE.color(muted));
1561        let inner = crate::DefaultTextStyle::from_text_style(TextStyle::new(9.0));
1562        let root: Box<dyn Widget<()>> =
1563            Box::new(outer.around(inner.around(Text::new("Both").no_wrap())));
1564        let ui = build_ui(
1565            root.as_ref(),
1566            Size::new(300.0, 100.0),
1567            &Runtime::default(),
1568            &Theme::default(),
1569        );
1570        let found = ui.scene().primitives().iter().find_map(|p| match p {
1571            Primitive::Text { size, color, .. } => Some((*size, *color)),
1572            _ => None,
1573        });
1574        assert_eq!(
1575            found,
1576            Some((9.0, muted)),
1577            "the size came from the inner wrapper and the colour from the outer"
1578        );
1579    }
1580
1581    /// **A style may name a size and leave the weight to the subtree** — the thing this
1582    /// framework could not express until `TextStyle`'s fields could each say *unset*.
1583    ///
1584    /// `Text::styled(s, TextStyle::new(20.0))` used to answer a size, a weight *and* a
1585    /// slant, because the type had nowhere to put "not said". So a caller who wanted one
1586    /// step of the scale at a different size had to restate the weight, and a section that
1587    /// set a weight for its labels could not reach a single one of them.
1588    #[test]
1589    fn a_style_may_fix_the_size_and_still_inherit_the_weight() {
1590        let mut theme = Theme::default();
1591        theme.widgets.text.style.weight = Some(FontWeight::Bold);
1592        let text = Text::styled("Words", TextStyle::new(20.0)).no_wrap();
1593
1594        let mut scene = Scene::new();
1595        Widget::<()>::paint(
1596            &text,
1597            Rect::new(0.0, 0.0, 300.0, 40.0),
1598            Status::default(),
1599            &theme,
1600            &mut scene,
1601        );
1602        match &scene.primitives()[0] {
1603            Primitive::Text { size, weight, .. } => {
1604                assert_eq!(*size, 20.0, "the size it named");
1605                assert_eq!(*weight, FontWeight::Bold, "the weight it did not");
1606            }
1607            other => panic!("a text, not {other:?}"),
1608        }
1609        // And the box was measured at the same pair, not at one of each.
1610        let bold_20 = frus_text::measure_styled("Words", 20.0, FontWeight::Bold, false).width;
1611        match Widget::<()>::style_themed(&text, &theme).width {
1612            Dimension::Length(w) => assert!(
1613                (w - bold_20.ceil()).abs() < 0.01,
1614                "measured {w}, drawn at {bold_20}"
1615            ),
1616            other => panic!("a known width, not {other:?}"),
1617        }
1618    }
1619
1620    /// **The box grows with the reader's font size, and the glyphs agree with the box.**
1621    ///
1622    /// The second half is the whole risk of this feature and the reason it is resolved at
1623    /// one place rather than applied by each widget. Text measured at 16 and drawn at 24
1624    /// leaves every row on the screen the wrong height at once, and nothing in the picture
1625    /// says which of the two numbers was the mistake. So this asserts the pair, not the
1626    /// paint: what the layout reserved is what the renderer was asked to draw.
1627    #[test]
1628    fn a_reader_who_asked_for_larger_text_gets_a_larger_box_too() {
1629        use crate::{build_ui, MediaQuery, Runtime, Size};
1630        let drawn = |scaler: f32| {
1631            MediaQuery::new(Size::new(400.0, 200.0))
1632                .with_text_scaler(scaler)
1633                .scope(|| {
1634                    let text: Box<dyn Widget<()>> = Box::new(Text::new("Readable").no_wrap());
1635                    let ui = build_ui(
1636                        text.as_ref(),
1637                        Size::new(400.0, 200.0),
1638                        &Runtime::default(),
1639                        &Theme::default(),
1640                    );
1641                    let rect = ui.scene().primitives().iter().find_map(|p| match p {
1642                        Primitive::Text { size, bounds, .. } => Some((*size, *bounds)),
1643                        _ => None,
1644                    });
1645                    rect.expect("the text is drawn")
1646                })
1647        };
1648        let (plain_size, plain_box) = drawn(1.0);
1649        let (big_size, big_box) = drawn(1.5);
1650
1651        assert_eq!(plain_size, 16.0, "the framework's own, unscaled");
1652        assert_eq!(big_size, 24.0, "and the reader's 1.5 of it");
1653        assert!(
1654            big_box.width > plain_box.width * 1.4,
1655            "the box grew with the glyphs: {} → {}",
1656            plain_box.width,
1657            big_box.width
1658        );
1659        // The pair, which is the thing that must never come apart: the box the layout gave
1660        // this text is the box its own drawn size measures to.
1661        let measured = frus_text::measure_styled("Readable", big_size, FontWeight::Regular, false);
1662        assert!(
1663            (big_box.width - measured.width.ceil()).abs() < 0.51,
1664            "reserved {} for glyphs measuring {}",
1665            big_box.width,
1666            measured.width
1667        );
1668    }
1669
1670    /// Outside a described surface **nothing scales**, which is what every test in this
1671    /// file and every golden depends on: they build widgets with no `MediaQuery` around
1672    /// them, and a framework that scaled by default would move all of them at once.
1673    #[test]
1674    fn with_no_surface_described_nothing_is_scaled() {
1675        assert_eq!(
1676            Text::new("x").resolved(None).style.size,
1677            frus_core::DEFAULT_TEXT_SIZE
1678        );
1679    }
1680
1681    /// A text that fits is never clipped, whatever it asked for: a clip on every text
1682    /// would put a hard edge through the antialiasing of every one of them.
1683    #[test]
1684    fn a_text_that_fits_is_not_clipped() {
1685        let text = Text::new("short")
1686            .size(16.0)
1687            .no_wrap()
1688            .overflow(TextOverflow::Clip);
1689        let mut scene = Scene::new();
1690        Widget::<()>::paint(
1691            &text,
1692            Rect::new(0.0, 0.0, 300.0, 20.0),
1693            Status::default(),
1694            &Theme::default(),
1695            &mut scene,
1696        );
1697        match &scene.primitives()[0] {
1698            Primitive::Text { clip, .. } => assert_eq!(*clip, Rect::UNBOUNDED),
1699            other => panic!("a text, not {other:?}"),
1700        }
1701    }
1702
1703    /// `Fade` wraps the text in a masked group — the fade has to be a group, or the two
1704    /// halves of an overlapping glyph would fade against the background separately.
1705    #[test]
1706    fn fade_wraps_the_text_in_a_masked_group() {
1707        let text = Text::new("a label far too long for the box it was given")
1708            .size(16.0)
1709            .no_wrap()
1710            .overflow(TextOverflow::Fade);
1711        let mut scene = Scene::new();
1712        Widget::<()>::paint(
1713            &text,
1714            Rect::new(0.0, 0.0, 60.0, 20.0),
1715            Status::default(),
1716            &Theme::default(),
1717            &mut scene,
1718        );
1719        match &scene.primitives()[0] {
1720            Primitive::Layer {
1721                primitives, filter, ..
1722            } => {
1723                assert_eq!(primitives.len(), 1, "the text, and only the text");
1724                let mask = filter.mask.expect("a mask");
1725                match mask.shader {
1726                    // The fade runs to the right edge of the box, horizontally, because
1727                    // that is the edge the line ran past.
1728                    MaskShader::Linear { from, to, .. } => {
1729                        assert!(
1730                            to.x > from.x && (to.x - 60.0).abs() < 0.01,
1731                            "{from:?} {to:?}"
1732                        );
1733                        assert!((to.y - from.y).abs() < 0.01, "horizontal");
1734                    }
1735                    other => panic!("a linear fade, not {other:?}"),
1736                }
1737            }
1738            other => panic!("a masked layer, not {other:?}"),
1739        }
1740    }
1741
1742    /// Alignment reaches the primitive, and with it the width it aligns inside — which a
1743    /// start-aligned text is deliberately not given.
1744    #[test]
1745    fn alignment_hands_the_renderer_a_width_and_start_does_not() {
1746        let painted = |align: TextAlign| {
1747            let text = Text::new("x").size(16.0).no_wrap().align(align);
1748            let mut scene = Scene::new();
1749            Widget::<()>::paint(
1750                &text,
1751                Rect::new(0.0, 0.0, 200.0, 20.0),
1752                Status::default(),
1753                &Theme::default(),
1754                &mut scene,
1755            );
1756            match &scene.primitives()[0] {
1757                Primitive::Text {
1758                    align, max_width, ..
1759                } => (*align, *max_width),
1760                other => panic!("a text, not {other:?}"),
1761            }
1762        };
1763        assert_eq!(painted(TextAlign::Center), (TextAlign::Center, Some(200.0)));
1764        assert_eq!(painted(TextAlign::Start), (TextAlign::Start, None));
1765    }
1766
1767    /// Saying what to do on overflow is what tells the layout the text may be squeezed;
1768    /// a plain one still refuses, which is what keeps a row from losing its last widget.
1769    #[test]
1770    fn an_overflow_mode_is_what_makes_a_text_shrinkable() {
1771        use frus_layout::Dimension;
1772        let plain = Widget::<()>::style(&Text::new("a long enough label"));
1773        let clipped =
1774            Widget::<()>::style(&Text::new("a long enough label").overflow(TextOverflow::Clip));
1775        assert_eq!(plain.min_width, Dimension::Auto);
1776        assert_eq!(clipped.min_width, Dimension::Length(0.0));
1777    }
1778
1779    #[test]
1780    fn text_paints_a_text_primitive() {
1781        let text = Text::new("Salut")
1782            .size(20.0)
1783            .no_wrap()
1784            .color(Color::rgb(1.0, 0.0, 0.0));
1785        let mut scene = Scene::new();
1786        Widget::<()>::paint(
1787            &text,
1788            Rect::new(5.0, 6.0, 100.0, 24.0),
1789            Status::default(),
1790            &Theme::default(),
1791            &mut scene,
1792        );
1793
1794        assert_eq!(scene.primitives().len(), 1);
1795        assert_eq!(
1796            scene.primitives()[0],
1797            Primitive::Text {
1798                position: Point::new(5.0, 6.0),
1799                text: "Salut".to_string(),
1800                size: 20.0,
1801                color: Color::rgb(1.0, 0.0, 0.0),
1802                weight: FontWeight::Regular,
1803                italic: false,
1804                max_width: None,
1805                soft_wrap: false,
1806                align: TextAlign::Start,
1807                decoration: frus_core::TextDecoration::NONE,
1808                decoration_color: None,
1809                height: None,
1810                family: None,
1811                clip: Rect::UNBOUNDED,
1812                // Painted directly rather than through the widget walk, so no box was
1813                // declared: unknown, which the renderer reads as "covers everything".
1814                bounds: Rect::UNBOUNDED,
1815                owner: 0,
1816                letter_spacing: 0.0,
1817            }
1818        );
1819    }
1820
1821    #[test]
1822    fn wrapped_text_measures_to_the_offered_width() {
1823        let long = "a paragraph long enough to wrap onto several lines";
1824        let text = Text::new(long).wrap();
1825        // Measuring under constraints wraps: taller at 120 px than when free.
1826        let measure = Widget::<()>::measure(&text, &Theme::default()).expect("measure closure");
1827        let free = measure(None, None);
1828        let narrow = measure(Some(120.0), None);
1829        assert!(narrow.width <= 120.0);
1830        assert!(narrow.height > free.height, "wrapped → taller");
1831        // And the measure key changes with the content (the cache fix).
1832        let other = Text::new("short").wrap();
1833        assert_ne!(
1834            Widget::<()>::measure_key(&text, &Theme::default()),
1835            Widget::<()>::measure_key(&other, &Theme::default())
1836        );
1837        // A text that does not wrap is a box of a known size, and says so in its style
1838        // rather than through a measurement: a measured leaf reports its narrowest useful
1839        // width as its minimum content, and a row would take that as leave to fold it.
1840        let plain = Text::new(long).no_wrap();
1841        assert!(Widget::<()>::measure(&plain, &Theme::default()).is_none());
1842        assert!(Widget::<()>::measure_key(&plain, &Theme::default()).is_none());
1843    }
1844
1845    #[test]
1846    fn styled_text_carries_weight_and_italic() {
1847        let theme = Theme::default();
1848        // One step of the theme's scale (title_medium = 16 px, medium weight).
1849        let text = Text::styled("Title", theme.text.title_medium).italic();
1850        let mut scene = Scene::new();
1851        Widget::<()>::paint(
1852            &text,
1853            Rect::new(0.0, 0.0, 100.0, 24.0),
1854            Status::default(),
1855            &theme,
1856            &mut scene,
1857        );
1858        match &scene.primitives()[0] {
1859            Primitive::Text {
1860                size,
1861                weight,
1862                italic,
1863                color,
1864                ..
1865            } => {
1866                assert_eq!(*size, 16.0);
1867                assert_eq!(*weight, FontWeight::Medium);
1868                assert!(*italic);
1869                // Color inherited from the theme (the style did not set one).
1870                assert_eq!(*color, theme.on_surface);
1871            }
1872            _ => panic!("expected text"),
1873        }
1874    }
1875
1876    #[test]
1877    fn bold_text_lays_out_wider() {
1878        let w = |text: &Text| {
1879            Widget::<()>::measure(text, &Theme::default()).expect("measure")(None, None).width
1880        };
1881        assert!(
1882            w(&Text::new("Width").weight(FontWeight::Bold)) > w(&Text::new("Width")),
1883            "bold must be wider"
1884        );
1885    }
1886
1887    /// The default is the reference's: prose put in a box narrower than itself is a
1888    /// paragraph. Along a **row** it is not — a text runs past the end of one rather than
1889    /// folding into a column of single words — and that is the floor the parent applies.
1890    #[test]
1891    fn text_wraps_down_a_column_and_runs_on_along_a_row() {
1892        use crate::{build_ui, Container, Flex, Runtime, Size};
1893        let long = "one two three four five six seven eight nine ten eleven twelve";
1894        // The painted box, read from the scene: the text primitive carries the box it
1895        // was laid out in, which is the number this is about.
1896        let painted = |root: &dyn Widget<()>| {
1897            let rt = Runtime::default();
1898            let theme = Theme::default();
1899            let ui = build_ui(root, Size::new(120.0, 300.0), &rt, &theme);
1900            ui.scene()
1901                .primitives()
1902                .iter()
1903                .find_map(|p| match p {
1904                    Primitive::Text { bounds, .. } => Some(*bounds),
1905                    _ => None,
1906                })
1907                .expect("the text")
1908        };
1909        let column: Container<()> = Container::new()
1910            .width(120.0)
1911            .child(Flex::column().child(Text::new(long).size(12.0)));
1912        // Two children, because a box with a single child is handing a width down rather
1913        // than dividing a line up — and there a paragraph does wrap.
1914        let row: Container<()> = Container::new().width(120.0).child(
1915            Flex::row()
1916                .child(Text::new(long).size(12.0))
1917                .child(Container::new().width(10.0).height(10.0)),
1918        );
1919        let tall = painted(&column);
1920        let wide = painted(&row);
1921        assert!(
1922            tall.width <= 120.5,
1923            "the column gave it 120: {}",
1924            tall.width
1925        );
1926        assert!(tall.height > 20.0, "so it wrapped: {}", tall.height);
1927        assert!(wide.width > 120.0, "the row did not: {}", wide.width);
1928    }
1929}
1930
1931#[cfg(test)]
1932mod reader_font_size {
1933    use frus_core::{Primitive, Size};
1934
1935    use crate::theme::Theme;
1936    use crate::{build_ui_inspected, MediaQuery, Runtime, Widget};
1937
1938    type W = Box<dyn Widget<()>>;
1939    type Case = (&'static str, fn() -> W);
1940
1941    /// Every glyph a widget paints, and the size it was drawn at.
1942    fn glyphs(make: fn() -> W, scale: f32) -> Vec<(String, f32)> {
1943        let root = make();
1944        MediaQuery::new(Size::new(400.0, 300.0))
1945            .with_text_scaler(scale)
1946            .scope(|| {
1947                let (ui, _) = build_ui_inspected(
1948                    root.as_ref(),
1949                    Size::new(400.0, 300.0),
1950                    &Runtime::default(),
1951                    &Theme::default(),
1952                );
1953                ui.scene()
1954                    .primitives()
1955                    .iter()
1956                    .filter_map(|p| match p {
1957                        Primitive::Text { text, size, .. } => Some((text.clone(), *size)),
1958                        _ => None,
1959                    })
1960                    .collect()
1961            })
1962    }
1963
1964    fn cases() -> Vec<Case> {
1965        vec![
1966            ("Chip", || Box::new(crate::Chip::<()>::new("Filter"))),
1967            ("Button", || Box::new(crate::Button::<()>::new("Save"))),
1968            ("ListTile", || {
1969                Box::new(crate::ListTile::<()>::new().title("A row"))
1970            }),
1971            ("SnackBar", || Box::new(crate::SnackBar::<()>::new("Saved"))),
1972            ("Kbd", || Box::new(crate::Kbd::new("Ctrl"))),
1973            ("DropdownButton", || {
1974                Box::new(crate::DropdownButton::new("Pick", ()))
1975            }),
1976            ("Text", || Box::new(crate::Text::new("A line"))),
1977            ("TextField", || {
1978                Box::new(crate::TextField::<()>::new("typed"))
1979            }),
1980            ("Table", || {
1981                Box::new(crate::Table::<()>::new(2).row(&["one", "two"]))
1982            }),
1983            ("Tree", || {
1984                Box::new(crate::Tree::new(|_| ()).node(1, 0, "a branch", false, false))
1985            }),
1986        ]
1987    }
1988
1989    /// **The reader's font size reaches the text a widget paints itself.**
1990    ///
1991    /// `TextStyle::resolved` is the single place the setting is applied (milestone 403),
1992    /// and forty-seven paint sites went around it: they named an `f32` and handed it
1993    /// straight to the scene, so their text was the one size a reader could not change.
1994    /// Nothing failed and nothing was reported — the widget simply ignored the request.
1995    ///
1996    /// The door is shut now: [`frus_core::Scene::text`] takes a
1997    /// [`frus_core::ResolvedTextStyle`], so a bare number does not compile. This test is
1998    /// what keeps it shut, because a new widget can still reach for
1999    /// [`frus_core::ResolvedTextStyle::exact`] — which is right for an icon and wrong for
2000    /// a word.
2001    #[test]
2002    fn the_text_a_widget_paints_follows_the_readers_font_size() {
2003        for (name, make) in cases() {
2004            let plain = glyphs(make, 1.0);
2005            let doubled = glyphs(make, 2.0);
2006            assert!(!plain.is_empty(), "{name} paints no text at all");
2007            assert_eq!(
2008                plain.len(),
2009                doubled.len(),
2010                "{name}: a different number of runs at twice the size"
2011            );
2012            let grew = plain
2013                .iter()
2014                .zip(&doubled)
2015                .any(|((_, a), (_, b))| *b > *a + 0.01);
2016            assert!(
2017                grew,
2018                "{name}: not one glyph followed the reader — sizes {plain:?}"
2019            );
2020        }
2021    }
2022
2023    /// **And the box grows to hold it.** A default height is a *floor*, as it is in the
2024    /// reference — `max(_targetTileHeight, contentHeight)` — not a ceiling. A chip whose
2025    /// height was a constant needed 34 px of glyphs inside 32 px and cut them.
2026    #[test]
2027    fn a_box_that_holds_text_grows_with_it() {
2028        for (name, make) in cases() {
2029            let root = make();
2030            MediaQuery::new(Size::new(400.0, 300.0))
2031                .with_text_scaler(2.0)
2032                .scope(|| {
2033                    let (ui, _) = build_ui_inspected(
2034                        root.as_ref(),
2035                        Size::new(400.0, 300.0),
2036                        &Runtime::default(),
2037                        &Theme::default(),
2038                    );
2039                    let mut checked = 0;
2040                    for p in ui.scene().primitives() {
2041                        if let Primitive::Text {
2042                            text, size, bounds, ..
2043                        } = p
2044                        {
2045                            // The box the text was **laid out in**, not the widget's outermost
2046                            // rect: a table's outer box is enormous and would pass whatever its
2047                            // rows did. Checking the emitting box is what caught the table.
2048                            if bounds.height <= 0.0 {
2049                                continue;
2050                            }
2051                            // What the **shaper** gives back, not the nominal `line_height`:
2052                            // a face's real line box can be a fraction under the metric, and a
2053                            // box sized to the smaller number clips nothing.
2054                            let style = frus_core::ResolvedTextStyle::exact(*size);
2055                            let needed = frus_text::measure_resolved(text, &style).height;
2056                            checked += 1;
2057                            assert!(
2058                                needed <= bounds.height + 0.51,
2059                                "{name}: {text:?} needs {needed:.1} px in a box of {:.1}",
2060                                bounds.height
2061                            );
2062                        }
2063                    }
2064                    assert!(checked > 0, "{name}: no text was checked at all");
2065                });
2066        }
2067    }
2068
2069    /// **Chrome caps the type instead of growing**, which is the reference's other answer
2070    /// and the reason both exist. An app bar keeps its height — a toolbar that grew would
2071    /// push every screen down — so it clamps the title's scaler to 1.34 rather than let
2072    /// the reader out. Below that cap it follows like anything else.
2073    #[test]
2074    fn an_app_bar_caps_its_title_rather_than_growing() {
2075        let title_size = |scale: f32| {
2076            let bar = crate::AppBar::<()>::new().title(crate::Text::new("A title"));
2077            MediaQuery::new(Size::new(400.0, 300.0))
2078                .with_text_scaler(scale)
2079                .scope(|| {
2080                    let root = bar.build();
2081                    let (ui, _) = build_ui_inspected(
2082                        root.as_ref(),
2083                        Size::new(400.0, 300.0),
2084                        &Runtime::default(),
2085                        &Theme::default(),
2086                    );
2087                    ui.scene()
2088                        .primitives()
2089                        .iter()
2090                        .find_map(|p| match p {
2091                            Primitive::Text { text, size, .. } if text == "A title" => Some(*size),
2092                            _ => None,
2093                        })
2094                        .expect("the bar paints its title")
2095                })
2096        };
2097        let plain = title_size(1.0);
2098        assert!(
2099            (title_size(1.2) - plain * 1.2).abs() < 0.1,
2100            "under the cap the title follows the reader"
2101        );
2102        let capped = plain * crate::APP_BAR_MAX_TITLE_SCALE;
2103        for scale in [1.5, 2.0, 4.0] {
2104            assert!(
2105                (title_size(scale) - capped).abs() < 0.1,
2106                "at x{scale} the title is {} rather than the capped {capped}",
2107                title_size(scale)
2108            );
2109        }
2110    }
2111}
2112
2113#[cfg(test)]
2114mod selectable_tests {
2115    use super::*;
2116    use crate::{build_ui, EditAction, Flex, Runtime, Size};
2117    use frus_core::Primitive;
2118
2119    const WORDS: &str = "one two three four five six seven eight nine ten";
2120
2121    fn hit(text: &Text, x: f32, y: f32, width: f32) -> Option<usize> {
2122        Widget::<()>::cursor_at(text, x, y, width, 0)
2123    }
2124
2125    fn selected(cursor: usize, anchor: Option<usize>) -> Edit {
2126        Edit {
2127            cursor,
2128            anchor,
2129            composing: None,
2130        }
2131    }
2132
2133    /// The width of `prefix` in the framework's default text style.
2134    fn width_of(prefix: &str) -> f32 {
2135        frus_text::measure_resolved(prefix, &TextStyle::NONE.resolved()).width
2136    }
2137
2138    /// The actions on the bar a context gets, in order.
2139    fn bar_for(text: &Text, context: ToolbarContext) -> Option<Vec<EditAction>> {
2140        let bar = Widget::<()>::selection_toolbar(text, context)?;
2141        Some(
2142            bar.children()
2143                .iter()
2144                .map(|button| button.edit_action().expect("a built-in action"))
2145                .collect(),
2146        )
2147    }
2148
2149    /// **An ordinary text is not selectable**, and nothing about it changes: it takes no
2150    /// focus, answers no hit test, and has no bar. Selectable is opted into, per text.
2151    #[test]
2152    fn an_ordinary_text_is_not_selectable() {
2153        let text = Text::new("ORDER-4417");
2154        assert!(!Widget::<()>::focusable(&text));
2155        assert_eq!(Widget::<()>::text_value(&text), None);
2156        assert_eq!(hit(&text, 10.0, 5.0, 200.0), None);
2157        assert_eq!(Widget::<()>::word_at(&text, 2), None);
2158        assert_eq!(
2159            Widget::<()>::selected_text(&text, &selected(5, Some(0))),
2160            None
2161        );
2162        assert!(Widget::<()>::selection_toolbar(&text, ToolbarContext::default()).is_none());
2163        // A bar configured for a text that is not selectable changes nothing about it.
2164        let configured = Text::new("x").selection_toolbar::<()>(|_, items| items);
2165        assert!(!Widget::<()>::focusable(&configured));
2166        assert_eq!(Widget::<()>::text_value(&configured), None);
2167    }
2168
2169    /// **A selectable text takes focus and is a text field for the caret's sake, and not
2170    /// for the keyboard's**: the arrows are its own, and the soft keyboard is not asked for.
2171    #[test]
2172    fn a_selectable_text_takes_focus_but_no_typing() {
2173        let text = Text::new("ORDER-4417").selectable();
2174        assert!(Widget::<()>::focusable(&text));
2175        assert_eq!(Widget::<()>::text_value(&text), Some("ORDER-4417"));
2176        assert!(!Widget::<()>::takes_typing(&text));
2177    }
2178
2179    /// **A press lands on the character it is over**: at the start, between two letters, and
2180    /// past the end.
2181    #[test]
2182    fn a_press_lands_between_the_letters_it_is_over() {
2183        let text = Text::new("hello world").selectable();
2184        assert_eq!(hit(&text, 0.0, 5.0, 400.0), Some(0));
2185        assert_eq!(hit(&text, width_of("hello"), 5.0, 400.0), Some(5));
2186        assert_eq!(hit(&text, width_of("hello w"), 5.0, 400.0), Some(7));
2187        assert_eq!(hit(&text, 1000.0, 5.0, 400.0), Some(11));
2188    }
2189
2190    /// **A wrapped paragraph is hit line by line**: the second line's first letter is the
2191    /// first character after the break, not one on the first line at the same `x`.
2192    #[test]
2193    fn a_wrapped_paragraph_is_hit_line_by_line() {
2194        let text = Text::new(WORDS).selectable();
2195        let style = TextStyle::NONE.resolved();
2196        let spans = frus_text::line_spans(
2197            WORDS,
2198            style.size,
2199            style.weight,
2200            style.italic,
2201            Some(90.0),
2202            true,
2203        );
2204        assert!(spans.len() >= 3, "the fixture wraps: {spans:?}");
2205        let line = style.line_height();
2206        for (index, span) in spans.iter().enumerate().take(3) {
2207            let y = line * index as f32 + line * 0.5;
2208            assert_eq!(
2209                hit(&text, 0.0, y, 90.0),
2210                Some(span.start),
2211                "the start of line {index}"
2212            );
2213        }
2214    }
2215
2216    /// **The layout follows the paragraph's leading**: a text set at twice the line height
2217    /// has its second line twice as far down, and a hit test that assumed the default would
2218    /// find the third line where the second is drawn.
2219    #[test]
2220    fn a_hit_follows_the_leading_the_paragraph_is_drawn_with() {
2221        let text = Text::styled(WORDS, TextStyle::new(16.0).height(2.0)).selectable();
2222        let spans =
2223            frus_text::line_spans(WORDS, 16.0, FontWeight::Regular, false, Some(90.0), true);
2224        assert!(spans.len() >= 3, "the fixture wraps: {spans:?}");
2225        // 32 px a line: 48 is the middle of the second, which is the third at 19.2 a line.
2226        assert_eq!(hit(&text, 0.0, 48.0, 90.0), Some(spans[1].start));
2227    }
2228
2229    /// **A size handed down by a subtree is the size a press is measured at.** The hooks are
2230    /// asked outside a frame, with no theme in reach, so the text writes down what its layout
2231    /// resolved; answering from the framework's own default would put every press of a text in
2232    /// a large-type section a few letters from where it was made.
2233    #[test]
2234    fn a_hit_follows_the_size_a_subtree_handed_down() {
2235        let mut theme = Theme::default();
2236        theme.widgets.text.style.size = Some(30.0);
2237        let text = Text::new("hello world").selectable().no_wrap();
2238        // Laid out and painted, as any tree on its way to the screen is.
2239        let _ = build_ui::<()>(&text, Size::new(400.0, 100.0), &Runtime::default(), &theme);
2240        let big = frus_text::measure_resolved("hello", &TextStyle::new(30.0).resolved()).width;
2241        assert!(big > width_of("hello") * 1.5, "the fixture: {big}");
2242        assert_eq!(hit(&text, big, 10.0, 400.0), Some(5));
2243    }
2244
2245    /// **And the reader's font-size setting**, which is installed while a view is built and
2246    /// not while a press is handled: the text remembers the one it was built under.
2247    #[test]
2248    fn a_hit_follows_the_readers_font_setting() {
2249        let text =
2250            frus_core::with_text_scale(2.0, || Text::new("hello world").selectable().no_wrap());
2251        let doubled = frus_text::measure_resolved(
2252            "hello",
2253            &frus_core::with_text_scale(2.0, || TextStyle::NONE.resolved()),
2254        )
2255        .width;
2256        assert!(doubled > width_of("hello") * 1.9, "the fixture: {doubled}");
2257        assert_eq!(hit(&text, doubled, 10.0, 600.0), Some(5));
2258    }
2259
2260    /// **What is copied is what is selected**, by character and not by byte, and in whichever
2261    /// direction it was dragged.
2262    #[test]
2263    fn the_selected_text_is_the_characters_between_the_ends() {
2264        let text = Text::new("héllo wörld").selectable();
2265        let copy = |edit| Widget::<()>::selected_text(&text, &edit);
2266        assert_eq!(copy(selected(4, Some(1))).as_deref(), Some("éll"));
2267        assert_eq!(copy(selected(1, Some(4))).as_deref(), Some("éll"));
2268        assert_eq!(copy(selected(3, Some(3))), None, "a caret selects nothing");
2269        assert_eq!(copy(selected(3, None)), None);
2270        assert_eq!(
2271            copy(selected(usize::MAX, Some(0))).as_deref(),
2272            Some("héllo wörld"),
2273            "the end of everything, as Select all asks for it"
2274        );
2275    }
2276
2277    #[test]
2278    fn a_double_click_selects_the_word() {
2279        let text = Text::new("hello wörld_2!").selectable();
2280        assert_eq!(Widget::<()>::word_at(&text, 8), Some((6, 13)));
2281        assert_eq!(
2282            Widget::<()>::word_at(&text, 13),
2283            Some((13, 14)),
2284            "a separator"
2285        );
2286    }
2287
2288    /// **The caret's keys move the caret, and no other key does anything** — a text that
2289    /// cannot be typed into does not change, and answers no message.
2290    #[test]
2291    fn the_arrows_move_the_caret_and_typing_does_nothing() {
2292        let text = Text::new("hello world").selectable();
2293        let press = |edit: &mut Edit, key: Key| Widget::<()>::on_edit(&text, edit, &key);
2294        let mut edit = selected(2, None);
2295        let right = Key::Right {
2296            shift: false,
2297            word: false,
2298        };
2299        assert_eq!(press(&mut edit, right), None);
2300        assert_eq!((edit.cursor, edit.anchor), (3, None));
2301        let shift_right = Key::Right {
2302            shift: true,
2303            word: false,
2304        };
2305        press(&mut edit, shift_right);
2306        assert_eq!(edit.selection_range(), Some((3, 4)), "Shift extends");
2307        let word_left = Key::Left {
2308            shift: false,
2309            word: true,
2310        };
2311        press(&mut edit, word_left);
2312        assert_eq!((edit.cursor, edit.anchor), (0, None), "a word to the left");
2313        let select_to_end = Key::End {
2314            shift: true,
2315            doc: true,
2316        };
2317        press(&mut edit, select_to_end);
2318        assert_eq!(edit.selection_range(), Some((0, 11)));
2319
2320        let before = edit;
2321        for key in [
2322            Key::Text("x".into()),
2323            Key::Backspace,
2324            Key::Delete,
2325            Key::Enter,
2326            Key::Escape,
2327        ] {
2328            assert_eq!(press(&mut edit, key.clone()), None);
2329            assert_eq!(edit, before, "{key:?} changed nothing");
2330        }
2331    }
2332
2333    fn paint(text: &Text, status: Status) -> Vec<Primitive> {
2334        let mut scene = Scene::new();
2335        Widget::<()>::paint(
2336            text,
2337            Rect::new(10.0, 20.0, 300.0, 40.0),
2338            status,
2339            &Theme::default(),
2340            &mut scene,
2341        );
2342        scene.primitives().to_vec()
2343    }
2344
2345    fn highlights(primitives: &[Primitive]) -> Vec<Rect> {
2346        let selection = Theme::default().selection;
2347        primitives
2348            .iter()
2349            .filter_map(|p| match p {
2350                Primitive::Rect { rect, color, .. } if *color == selection => Some(*rect),
2351                _ => None,
2352            })
2353            .collect()
2354    }
2355
2356    /// **A focused selection is highlighted, under the words** — over the letters it
2357    /// covers, in the theme's selection colour, and only while the text has the focus.
2358    #[test]
2359    fn a_selection_is_highlighted_under_the_words() {
2360        let text = Text::new("hello world").selectable().no_wrap();
2361        let focused = Status {
2362            focused: true,
2363            selection: Some((0, 5)),
2364            ..Status::default()
2365        };
2366        let primitives = paint(&text, focused);
2367        let boxes = highlights(&primitives);
2368        assert_eq!(boxes.len(), 1, "one line, one box: {boxes:?}");
2369        assert!(
2370            (boxes[0].x - 10.0).abs() < 0.5,
2371            "from the text's left: {:?}",
2372            boxes[0]
2373        );
2374        assert!(
2375            (boxes[0].width - width_of("hello")).abs() < 1.0,
2376            "as wide as the word: {:?}",
2377            boxes[0]
2378        );
2379        assert_eq!(boxes[0].y, 20.0, "on the text's line");
2380        let rect = primitives
2381            .iter()
2382            .position(|p| matches!(p, Primitive::Rect { .. }));
2383        let words = primitives
2384            .iter()
2385            .position(|p| matches!(p, Primitive::Text { .. }));
2386        assert!(rect < words, "below the words, so they stay readable");
2387
2388        let unfocused = Status {
2389            selection: Some((0, 5)),
2390            ..Status::default()
2391        };
2392        assert!(
2393            highlights(&paint(&text, unfocused)).is_empty(),
2394            "not focused"
2395        );
2396        // And a text that is not selectable paints exactly what it always has.
2397        assert!(highlights(&paint(&Text::new("hello world"), focused)).is_empty());
2398    }
2399
2400    /// **A touch selection has its two handles**, hanging below the ends of it, and where
2401    /// they are painted is where a finger is told to look for them.
2402    #[test]
2403    fn a_touch_selection_has_handles_where_they_are_painted() {
2404        let text = Text::new("hello world").selectable().no_wrap();
2405        let touched = Status {
2406            focused: true,
2407            handles: true,
2408            selection: Some((0, 5)),
2409            ..Status::default()
2410        };
2411        let paths = |primitives: &[Primitive]| {
2412            primitives
2413                .iter()
2414                .filter(|p| matches!(p, Primitive::Path { .. }))
2415                .count()
2416        };
2417        assert_eq!(paths(&paint(&text, touched)), 2, "two handles");
2418        let no_handles = Status {
2419            handles: false,
2420            ..touched
2421        };
2422        assert_eq!(paths(&paint(&text, no_handles)), 0);
2423
2424        let [start, end] =
2425            Widget::<()>::selection_handles(&text, 300.0, &selected(5, Some(0)), 0.0)
2426                .expect("the handles of a selection");
2427        assert!(
2428            (end.line_center.x - start.line_center.x - width_of("hello")).abs() < 1.0,
2429            "{start:?} {end:?}"
2430        );
2431        assert!(
2432            start.rect.y > start.line_center.y,
2433            "they hang below their line"
2434        );
2435        assert!(
2436            Widget::<()>::selection_handles(&text, 300.0, &selected(5, None), 0.0).is_none(),
2437            "a caret has none"
2438        );
2439    }
2440
2441    /// **The bar is placed against the selection's own box.**
2442    #[test]
2443    fn the_bar_is_anchored_on_the_selection() {
2444        let text = Text::new("hello world").selectable().no_wrap();
2445        let anchor = Widget::<()>::selection_anchor(&text, 300.0, &selected(5, Some(0)), 0.0)
2446            .expect("an anchor");
2447        assert!(anchor.x.abs() < 0.5, "{anchor:?}");
2448        assert!((anchor.width - width_of("hello")).abs() < 1.0, "{anchor:?}");
2449    }
2450
2451    /// **The bar offers what a text can do**: Copy of what is selected and Select all — no
2452    /// Cut, and no Paste, for text that cannot be changed.
2453    #[test]
2454    fn the_bar_offers_copy_and_select_all_only() {
2455        use EditAction::{Copy, SelectAll};
2456        let text = Text::new("hello world").selectable();
2457        let context = |has_selection, can_paste, all_selected| ToolbarContext {
2458            has_selection,
2459            can_paste,
2460            all_selected,
2461        };
2462        assert_eq!(
2463            bar_for(&text, context(true, true, false)),
2464            Some(vec![Copy, SelectAll])
2465        );
2466        assert_eq!(bar_for(&text, context(true, false, true)), Some(vec![Copy]));
2467        assert_eq!(
2468            bar_for(&text, context(false, true, false)),
2469            Some(vec![SelectAll])
2470        );
2471        assert_eq!(
2472            bar_for(&text, context(false, false, true)),
2473            None,
2474            "nothing to offer"
2475        );
2476    }
2477
2478    /// **The application has its say over the bar**, as it has over a field's.
2479    #[test]
2480    fn the_application_can_change_the_bar() {
2481        let context = ToolbarContext {
2482            has_selection: true,
2483            can_paste: false,
2484            all_selected: false,
2485        };
2486        let more = Text::new("hello")
2487            .selectable()
2488            .selection_toolbar::<()>(|_, mut items| {
2489                items.push(ToolbarItem::custom("Share", ()));
2490                items
2491            });
2492        let bar = Widget::<()>::selection_toolbar(&more, context).expect("a bar");
2493        assert_eq!(
2494            bar.children().len(),
2495            3,
2496            "Copy, Select all and the application's"
2497        );
2498
2499        let none = Text::new("hello")
2500            .selection_toolbar::<()>(|_, _| Vec::new())
2501            .selectable();
2502        assert!(
2503            Widget::<()>::selection_toolbar(&none, context).is_none(),
2504            "an empty list shows no bar, whichever order the calls were made in"
2505        );
2506    }
2507
2508    /// **In a tree, a press on a selectable text finds it** — the widget the shell gives the
2509    /// focus to and asks where the caret goes — and a text that is not selectable is not found.
2510    #[test]
2511    fn a_selectable_text_is_a_target_in_a_tree() {
2512        let found = |selectable: bool| {
2513            let text = if selectable {
2514                Text::new("ORDER-4417").selectable()
2515            } else {
2516                Text::new("ORDER-4417")
2517            };
2518            let tree = Flex::column().width(300.0).height(100.0).child(text);
2519            let ui = build_ui::<()>(
2520                &tree,
2521                Size::new(300.0, 100.0),
2522                &Runtime::default(),
2523                &Theme::default(),
2524            );
2525            ui.focus_hit(Point::new(5.0, 5.0)).is_some()
2526        };
2527        assert!(found(true));
2528        assert!(!found(false));
2529    }
2530}