Skip to main content

frus_widgets/
region.rs

1//! A selection that spans **several texts**: [`SelectionArea`].
2//!
3//! A field's selection is [`Edit`](crate::Edit) state kept by the runtime under the field's
4//! identity, which is the right home for one widget's range and the wrong one for a range that
5//! begins in a paragraph and ends three widgets later. This one is kept by the area instead: two
6//! ends — a text and a character in it — and, worked out from them, what each text in between
7//! has selected. The texts are the ones the last frame registered inside the area
8//! ([`TextStop`](crate::TextStop)), in the order they were painted, which is reading order for
9//! everything a tree lays out in flow.
10//!
11//! The **ranges are stored, not derived while painting**: a text painted early in the walk
12//! cannot know whether the ends are before or after it, because the texts after it have not
13//! been walked yet. Whoever moves an end has the whole frame in hand — the shell — and works
14//! the ranges out once.
15
16use std::collections::HashMap;
17
18use std::cell::OnceCell;
19
20use frus_core::Color;
21
22use crate::interaction::WidgetId;
23use crate::selectiontoolbar::{SelectionToolbar, ToolbarContext, ToolbarItem};
24use crate::theme::Theme;
25use crate::widget::Widget;
26use crate::widgettheme::TextSelectionTheme;
27
28/// One end of a [`RegionSelection`]: a text, and a character boundary in it.
29#[derive(Copy, Clone, Debug, PartialEq, Eq)]
30pub struct RegionPoint {
31    /// The text the end is in.
32    pub text: WidgetId,
33    /// The character boundary in it, in **characters**.
34    pub index: usize,
35}
36
37/// The selection an area holds: where the press landed, where the pointer is now, and what
38/// that makes selected in each text.
39#[derive(Clone, Debug, PartialEq)]
40pub struct RegionSelection {
41    /// The area the selection belongs to: a drag never carries it into another.
42    pub area: WidgetId,
43    /// Where the press landed.
44    pub anchor: RegionPoint,
45    /// Where the pointer is now.
46    pub extent: RegionPoint,
47    /// The `(start, end)` character range selected in each text that has one.
48    pub ranges: HashMap<WidgetId, (usize, usize)>,
49    /// Whether every text of the area is selected whole — Select all would do nothing.
50    pub all: bool,
51    /// Whether the **bar** (Copy, Select all) shows over the selection: one made with a finger
52    /// does, one made with a mouse does not. Put away while a handle is dragged.
53    pub bar: bool,
54    /// Whether the selection carries **handles** at its two ends, for a finger to widen or
55    /// narrow it: one made with a finger does.
56    pub handles: bool,
57    /// Where the selection begins, in reading order — whichever of `anchor` and `extent` comes
58    /// first — as the first character selected. `None` when nothing is.
59    pub start: Option<RegionPoint>,
60    /// Where it ends, in reading order, as the boundary after the last character selected.
61    pub end: Option<RegionPoint>,
62}
63
64impl RegionSelection {
65    /// The selection from `anchor` to `extent` over `stops`, the area's texts in order, each
66    /// with its length in characters.
67    pub fn new(
68        area: WidgetId,
69        anchor: RegionPoint,
70        extent: RegionPoint,
71        stops: &[(WidgetId, usize)],
72    ) -> Self {
73        let ranges = region_ranges(stops, anchor, extent);
74        let all = !stops.is_empty()
75            && stops
76                .iter()
77                .all(|(id, len)| *len == 0 || ranges.get(id) == Some(&(0, *len)));
78        let covered = stops
79            .iter()
80            .filter_map(|(id, _)| ranges.get(id).map(|range| (*id, *range)));
81        let (mut start, mut end) = (None, None);
82        for (id, (low, high)) in covered {
83            start.get_or_insert(RegionPoint {
84                text: id,
85                index: low,
86            });
87            end = Some(RegionPoint {
88                text: id,
89                index: high,
90            });
91        }
92        Self {
93            area,
94            anchor,
95            extent,
96            ranges,
97            all,
98            bar: false,
99            handles: false,
100            start,
101            end,
102        }
103    }
104
105    /// This selection as one made with a finger, with its bar and its handles — or as one made
106    /// with a mouse, with neither.
107    #[must_use]
108    pub fn with_bar(mut self, bar: bool) -> Self {
109        self.bar = bar;
110        self.handles = bar;
111        self
112    }
113
114    /// Whether anything is selected: an end that has not moved from the other is a caret,
115    /// which is not.
116    pub fn is_empty(&self) -> bool {
117        self.ranges.is_empty()
118    }
119}
120
121/// What is selected in each of `stops` — the area's texts in reading order, each with its
122/// length in characters — between `anchor` and `extent`, in whichever order they were
123/// dragged.
124///
125/// The first text is selected from its end of the selection to its last character, every text
126/// between is selected whole, and the last is selected from its first character to its end of
127/// the selection. Both ends in one text is the range between them. An empty range is left out;
128/// an end whose text is not among `stops` (it scrolled out of the tree) gives no selection.
129pub fn region_ranges(
130    stops: &[(WidgetId, usize)],
131    anchor: RegionPoint,
132    extent: RegionPoint,
133) -> HashMap<WidgetId, (usize, usize)> {
134    let position = |point: RegionPoint| {
135        stops
136            .iter()
137            .position(|(id, _)| *id == point.text)
138            .map(|at| (at, point.index.min(stops[at].1)))
139    };
140    let (Some(a), Some(b)) = (position(anchor), position(extent)) else {
141        return HashMap::new();
142    };
143    let (start, end) = if a <= b { (a, b) } else { (b, a) };
144    let mut ranges = HashMap::new();
145    for (at, (id, len)) in stops.iter().enumerate().take(end.0 + 1).skip(start.0) {
146        let low = if at == start.0 { start.1 } else { 0 };
147        let high = if at == end.0 { end.1 } else { *len };
148        if low < high {
149            ranges.insert(*id, (low, high));
150        }
151    }
152    ranges
153}
154
155/// The words selected, as they are copied: each text's selected slice, in the order of
156/// `texts`, **one per line** — a paragraph broken into several texts is copied as those
157/// lines, which is what a reader who selected them across sees.
158pub fn region_copy(
159    texts: &[(WidgetId, &str)],
160    ranges: &HashMap<WidgetId, (usize, usize)>,
161) -> String {
162    let mut lines: Vec<String> = Vec::new();
163    for (id, text) in texts {
164        if let Some((start, end)) = ranges.get(id) {
165            lines.push(text.chars().skip(*start).take(end - start).collect());
166        }
167    }
168    lines.join("\n")
169}
170
171/// A text that takes part in a [`SelectionArea`], as one frame laid it out: what the shell
172/// hit-tests a press against and works a selection out from.
173#[derive(Copy, Clone, Debug, PartialEq)]
174pub struct TextStop {
175    /// The text's identity.
176    pub id: WidgetId,
177    /// The box it was given, in window coordinates.
178    pub rect: crate::Rect,
179    /// The area it is in.
180    pub area: WidgetId,
181}
182
183/// A subtree in which **every [`Text`](crate::Text) can be selected, and one drag selects
184/// across them**: a paragraph broken into three texts selects as a paragraph, and copying
185/// gives those three lines.
186///
187/// ```
188/// use frus_widgets::{column, SelectionArea, Text};
189///
190/// let terms: SelectionArea<()> = SelectionArea::around(column![
191///     Text::new("Terms of service").heading(),
192///     Text::new("You may copy any of this."),
193///     Text::new("Drag from one line to another."),
194/// ]);
195/// # let _ = terms;
196/// ```
197///
198/// How it looks and what its bar offers are the application's: the highlight and the handles
199/// take [`TextSelectionTheme`](crate::TextSelectionTheme), which
200/// [`selection_color`](Self::selection_color) and [`handle_color`](Self::handle_color) set for
201/// one area, and [`selection_toolbar`](Self::selection_toolbar) changes the list on the bar.
202///
203/// A text that is itself [`selectable`](crate::Text::selectable) keeps its own selection, and
204/// what takes a press — a button, a field, a link — keeps that: a press begins a selection
205/// only over words nothing else has a claim on.
206///
207/// The area does not draw anything and does not change the layout: it is a wrapper that is
208/// its child, and says so to the texts inside it.
209pub struct SelectionArea<Msg = crate::callback::Callback> {
210    inner: Box<dyn Widget<Msg>>,
211    /// The application's say over the bar, if it has said anything.
212    toolbar_build: Option<ToolbarBuild<Msg>>,
213    /// The bar for each context, built the first time it is asked for and kept: the walk
214    /// borrows the widget it floats for a whole frame.
215    toolbars: [OnceCell<Option<Box<dyn Widget<Msg>>>>; ToolbarContext::VARIANTS as usize],
216    /// How the selection looks here, laid over what the theme says.
217    look: TextSelectionTheme,
218}
219
220/// The bar for a context, as [`SelectionArea::selection_toolbar`] was told to make it.
221type ToolbarBuild<Msg> = Box<dyn Fn(&ToolbarContext) -> Option<Box<dyn Widget<Msg>>>>;
222
223impl<Msg: Clone + 'static> SelectionArea<Msg> {
224    /// Wraps `child` so that the texts in it can be selected together.
225    pub fn around(child: impl Widget<Msg> + 'static) -> Self {
226        Self {
227            inner: Box::new(child),
228            toolbar_build: None,
229            toolbars: Default::default(),
230            look: TextSelectionTheme::default(),
231        }
232    }
233
234    /// Changes what the bar over the area's selection offers, as
235    /// [`TextField::selection_toolbar`](crate::TextField::selection_toolbar) does for a field.
236    /// `build` is given the context and the default list — Copy, and Select all unless
237    /// everything is selected — and answers with the list to show; an empty one shows no bar.
238    ///
239    /// ```
240    /// use frus_widgets::{SelectionArea, Text, ToolbarItem};
241    ///
242    /// // Copy only: nothing to select all of on a one-line receipt.
243    /// let receipt: SelectionArea<()> = SelectionArea::around(Text::new("ORDER-4417"))
244    ///     .selection_toolbar(|_, items| {
245    ///         items.into_iter().filter(|item| item.label() == "Copy").collect()
246    ///     });
247    /// # let _ = receipt;
248    /// ```
249    #[must_use]
250    pub fn selection_toolbar(
251        mut self,
252        build: impl Fn(&ToolbarContext, Vec<ToolbarItem<Msg>>) -> Vec<ToolbarItem<Msg>> + 'static,
253    ) -> Self {
254        self.toolbar_build = Some(Box::new(move |context: &ToolbarContext| {
255            let mut items = Vec::new();
256            if context.has_selection {
257                items.push(ToolbarItem::copy());
258            }
259            if !context.all_selected {
260                items.push(ToolbarItem::select_all());
261            }
262            let items = build(context, items);
263            (!items.is_empty())
264                .then(|| Box::new(SelectionToolbar::new(items)) as Box<dyn Widget<Msg>>)
265        }));
266        self.toolbars = Default::default();
267        self
268    }
269
270    /// The highlight under the selected words in this area. Unset, the theme's
271    /// [`TextSelectionTheme::selection_color`].
272    #[must_use]
273    pub fn selection_color(mut self, color: Color) -> Self {
274        self.look.selection_color = Some(color);
275        self
276    }
277
278    /// The handles under a touch selection in this area. Unset, the theme's
279    /// [`TextSelectionTheme::handle_color`].
280    #[must_use]
281    pub fn handle_color(mut self, color: Color) -> Self {
282        self.look.handle_color = Some(color);
283        self
284    }
285}
286
287impl<Msg> SelectionArea<Msg> {
288    /// An area is not a box: its child's own, unchanged.
289    fn restyle(&self, base: frus_layout::Style) -> frus_layout::Style {
290        base
291    }
292}
293
294crate::transparent::forward_transparent!(SelectionArea {
295    /// What makes the texts below selectable together, and how their selection looks —
296    /// laid over the inherited theme, then whatever the child says about the theme.
297    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
298        let mut mine = inherited.clone();
299        mine.widgets.text.selectable = true;
300        let look = &mut mine.widgets.text_selection;
301        look.selection_color = self.look.selection_color.or(look.selection_color);
302        look.handle_color = self.look.handle_color.or(look.handle_color);
303        Some(
304            self.inner
305                .theme_override(&mine)
306                .unwrap_or_else(|| Box::new(mine)),
307        )
308    }
309
310    /// The bar the application asked for; `None` when it asked for nothing, so that the
311    /// texts offer theirs.
312    fn area_toolbar(&self, context: ToolbarContext) -> Option<Option<&dyn Widget<Msg>>> {
313        let build = self.toolbar_build.as_ref()?;
314        let bar = self.toolbars[usize::from(context.variant())].get_or_init(|| build(&context));
315        Some(bar.as_deref())
316    }
317
318    /// Forwarded: an area is not an identity, a place, a surface nor a form.
319    fn key(&self) -> Option<u64> {
320        self.inner.key()
321    }
322    fn positioned(&self) -> Option<crate::positioned::Positioning> {
323        self.inner.positioned()
324    }
325    fn media_override(&self, inherited: crate::MediaQuery) -> Option<crate::MediaQuery> {
326        self.inner.media_override(inherited)
327    }
328    fn scaffold_override(&self) -> Option<crate::ScaffoldInfo> {
329        self.inner.scaffold_override()
330    }
331    fn scroll_behavior_override(&self) -> Option<crate::ScrollBehavior> {
332        self.inner.scroll_behavior_override()
333    }
334    fn autofill_group(&self) -> bool {
335        self.inner.autofill_group()
336    }
337});
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn id(n: u64) -> WidgetId {
344        WidgetId::from_u64(n)
345    }
346
347    fn at(text: u64, index: usize) -> RegionPoint {
348        RegionPoint {
349            text: id(text),
350            index,
351        }
352    }
353
354    /// Three texts of 5, 10 and 4 characters.
355    fn stops() -> Vec<(WidgetId, usize)> {
356        vec![(id(1), 5), (id(2), 10), (id(3), 4)]
357    }
358
359    /// **A selection across texts takes the tail of the first, all of those between and the
360    /// head of the last.**
361    #[test]
362    fn a_selection_takes_the_tail_the_middle_and_the_head() {
363        let ranges = region_ranges(&stops(), at(1, 2), at(3, 3));
364        assert_eq!(ranges.len(), 3);
365        assert_eq!(ranges[&id(1)], (2, 5), "from the press to the end");
366        assert_eq!(ranges[&id(2)], (0, 10), "the whole of the one between");
367        assert_eq!(ranges[&id(3)], (0, 3), "from the start to the pointer");
368    }
369
370    /// **Dragged upward it is the same selection**: the ends are ordered by where they are, not
371    /// by which was pressed.
372    #[test]
373    fn dragging_upward_selects_the_same_words() {
374        let down = region_ranges(&stops(), at(1, 2), at(3, 3));
375        let up = region_ranges(&stops(), at(3, 3), at(1, 2));
376        assert_eq!(down, up);
377    }
378
379    /// Both ends in one text is the range between them, either way round; an end that has not
380    /// moved selects nothing.
381    #[test]
382    fn both_ends_in_one_text_are_the_range_between_them() {
383        assert_eq!(region_ranges(&stops(), at(2, 7), at(2, 3))[&id(2)], (3, 7));
384        assert_eq!(region_ranges(&stops(), at(2, 3), at(2, 7))[&id(2)], (3, 7));
385        assert!(region_ranges(&stops(), at(2, 4), at(2, 4)).is_empty());
386    }
387
388    /// An end at the very start of the first text, or the very end of the last, leaves that
389    /// text out when nothing of it is selected — no empty range is stored.
390    #[test]
391    fn a_text_with_nothing_selected_has_no_range() {
392        let ranges = region_ranges(&stops(), at(1, 5), at(3, 0));
393        assert_eq!(ranges.len(), 1, "only the one between: {ranges:?}");
394        assert_eq!(ranges[&id(2)], (0, 10));
395    }
396
397    /// An end past the last character is the end, and an end whose text is not there gives no
398    /// selection rather than a wrong one.
399    #[test]
400    fn an_end_is_clamped_and_a_missing_one_selects_nothing() {
401        assert_eq!(region_ranges(&stops(), at(1, 0), at(1, 99))[&id(1)], (0, 5));
402        assert!(region_ranges(&stops(), at(1, 0), at(9, 1)).is_empty());
403    }
404
405    /// **What is copied is one line per text**, in reading order and by character.
406    #[test]
407    fn the_copy_is_one_line_per_text() {
408        let texts = [(id(1), "héllo"), (id(2), "second line"), (id(3), "tail")];
409        let ranges = region_ranges(&stops_for(&texts), at(1, 1), at(3, 2));
410        assert_eq!(region_copy(&texts, &ranges), "éllo\nsecond line\nta");
411        let none = region_ranges(&stops_for(&texts), at(2, 3), at(2, 3));
412        assert_eq!(region_copy(&texts, &none), "");
413    }
414
415    /// **Everything selected is `all`**, and anything less is not — which is what takes Select
416    /// all off the bar.
417    #[test]
418    fn a_selection_knows_when_it_holds_everything() {
419        let every = RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops());
420        assert!(every.all);
421        let less = RegionSelection::new(id(9), at(1, 1), at(3, 4), &stops());
422        assert!(!less.all);
423        assert!(!RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops()).bar);
424        assert!(
425            RegionSelection::new(id(9), at(1, 0), at(3, 4), &stops())
426                .with_bar(true)
427                .bar
428        );
429    }
430
431    /// **The two ends are in reading order**, whichever way the selection was made: the start
432    /// is the first character selected, the end the boundary after the last.
433    #[test]
434    fn the_ends_are_in_reading_order() {
435        for (a, b) in [(at(1, 2), at(3, 3)), (at(3, 3), at(1, 2))] {
436            let selection = RegionSelection::new(id(9), a, b, &stops());
437            assert_eq!(selection.start, Some(at(1, 2)));
438            assert_eq!(selection.end, Some(at(3, 3)));
439        }
440        // An end at the very end of a text leaves it out; the next one starts the selection.
441        let skipped = RegionSelection::new(id(9), at(1, 5), at(3, 2), &stops());
442        assert_eq!(skipped.start, Some(at(2, 0)));
443        let nothing = RegionSelection::new(id(9), at(2, 4), at(2, 4), &stops());
444        assert_eq!((nothing.start, nothing.end), (None, None));
445    }
446
447    fn stops_for(texts: &[(WidgetId, &str)]) -> Vec<(WidgetId, usize)> {
448        texts
449            .iter()
450            .map(|(id, t)| (*id, t.chars().count()))
451            .collect()
452    }
453
454    /// The area's texts as a frame lays them out.
455    mod frame {
456        use super::*;
457        use crate::{build_ui, Container, Flex, Point, Rect, Runtime, Size, Text, Theme};
458
459        fn tree() -> impl Widget<()> {
460            Flex::column()
461                .width(300.0)
462                .height(400.0)
463                .child(Text::new("outside").no_wrap())
464                .child(SelectionArea::around(
465                    Flex::column()
466                        .child(Text::new("alpha").no_wrap())
467                        .child(Text::new("beta").no_wrap())
468                        .child(Text::new("").no_wrap()),
469                ))
470                .child(SelectionArea::around(
471                    Flex::column().child(Text::new("gamma").no_wrap()),
472                ))
473        }
474
475        fn frame(rt: &Runtime) -> crate::Ui<()> {
476            build_ui(&tree(), Size::new(300.0, 400.0), rt, &Theme::default())
477        }
478
479        /// **Every text in an area is registered, in painted order, under the area's identity** —
480        /// and not the text outside it, an empty one, or one in another area under the first's.
481        #[test]
482        fn the_texts_of_an_area_are_registered_in_order_under_its_identity() {
483            let ui = frame(&Runtime::default());
484            let stops = ui.text_stops();
485            assert_eq!(stops.len(), 3, "alpha, beta, gamma: {stops:?}");
486            assert_eq!(stops[0].area, stops[1].area, "one area holds the first two");
487            assert_ne!(stops[1].area, stops[2].area, "and another holds the third");
488            assert!(stops[0].rect.y < stops[1].rect.y, "in painted order");
489            let first: Vec<_> = ui.text_stops_in(stops[0].area).map(|s| s.id).collect();
490            assert_eq!(first, vec![stops[0].id, stops[1].id]);
491            // Not the words outside, which sit above the areas.
492            assert!(stops.iter().all(|s| s.rect.y > 10.0), "{stops:?}");
493        }
494
495        /// **A press finds the text under it, and a drag that left the words finds the nearest** —
496        /// of its own area, never of the other.
497        #[test]
498        fn a_press_finds_its_text_and_a_stray_drag_the_nearest() {
499            let ui = frame(&Runtime::default());
500            let stops: Vec<TextStop> = ui.text_stops().to_vec();
501            let inside = Point::new(stops[0].rect.x + 2.0, stops[0].rect.y + 2.0);
502            assert_eq!(ui.text_stop_at(inside).map(|s| s.id), Some(stops[0].id));
503            assert_eq!(
504                ui.text_stop_at(Point::new(290.0, 390.0)),
505                None,
506                "empty space"
507            );
508            // Far below everything: the last text of the area asked about, not the other area.
509            let far = Point::new(5.0, 399.0);
510            assert_eq!(
511                ui.nearest_text_stop(stops[0].area, far).map(|s| s.id),
512                Some(stops[1].id)
513            );
514            assert_eq!(
515                ui.nearest_text_stop(stops[2].area, far).map(|s| s.id),
516                Some(stops[2].id)
517            );
518            // Beside a line, it is that line's, not the one above.
519            let beside = Point::new(250.0, stops[1].rect.y + 3.0);
520            assert_eq!(
521                ui.nearest_text_stop(stops[0].area, beside).map(|s| s.id),
522                Some(stops[1].id)
523            );
524        }
525
526        /// **A repaint boundary that replays from the cache still registers its texts**: the
527        /// registry is captured with the rest of what a subtree adds, or a selection could
528        /// not begin in any text whose paint was reused.
529        #[test]
530        fn a_replayed_boundary_still_registers_its_texts() {
531            let tree = SelectionArea::around(
532                Container::new()
533                    .repaint_boundary()
534                    .child(Flex::column().child(Text::new("cached").no_wrap())),
535            );
536            let rt = Runtime::default();
537            let theme = Theme::default();
538            let size = Size::new(200.0, 100.0);
539            let first = build_ui::<()>(&tree, size, &rt, &theme);
540            let second = build_ui::<()>(&tree, size, &rt, &theme);
541            assert_eq!(
542                rt.paint_cache.borrow().last_frame_stats(),
543                (1, 0),
544                "the boundary was replayed"
545            );
546            assert_eq!(first.text_stops().len(), 1);
547            assert_eq!(second.text_stops(), first.text_stops());
548        }
549
550        /// **A highlight repaints a cached subtree**: the selection is part of what the cache's
551        /// fingerprint is made of, so a boundary whose text is newly selected is painted again
552        /// and not replayed as it was.
553        #[test]
554        fn a_selection_repaints_a_boundary_that_was_cached() {
555            let tree = SelectionArea::around(
556                Container::new()
557                    .repaint_boundary()
558                    .child(Flex::column().child(Text::new("cached").no_wrap())),
559            );
560            let mut rt = Runtime::default();
561            let theme = Theme::default();
562            let size = Size::new(200.0, 100.0);
563            let painted = |ui: &crate::Ui<()>| {
564                ui.scene()
565                    .primitives()
566                    .iter()
567                    .filter(|p| matches!(p, crate::Primitive::Rect { color, .. } if *color == theme.selection))
568                    .count()
569            };
570            let bare = build_ui::<()>(&tree, size, &rt, &theme);
571            assert_eq!(painted(&bare), 0);
572            let id = bare.text_stops()[0].id;
573            let area = bare.text_stops()[0].area;
574            let point = RegionPoint { text: id, index: 0 };
575            let end = RegionPoint { text: id, index: 3 };
576            rt.region = Some(RegionSelection::new(area, point, end, &[(id, 6)]));
577            let selected = build_ui::<()>(&tree, size, &rt, &theme);
578            assert_eq!(
579                painted(&selected),
580                1,
581                "the cached subtree was painted again"
582            );
583            let _: Rect = selected.text_stops()[0].rect;
584        }
585
586        /// **An area's colours paint its selection** (milestone 578): the highlight in the area's
587        /// selection colour and the two handles in its handle colour — and with neither set, the
588        /// theme's `selection` and `primary`, as before.
589        #[test]
590        fn an_areas_colours_paint_its_selection() {
591            let (red, blue) = (
592                frus_core::Color::rgb(1.0, 0.0, 0.0),
593                frus_core::Color::rgb(0.0, 0.0, 1.0),
594            );
595            let words = || Flex::column().child(Text::new("colour").no_wrap());
596            let theme = Theme::default();
597            let size = Size::new(200.0, 100.0);
598            let count = |ui: &crate::Ui<()>| {
599                let (mut rects, mut paths) = (Vec::new(), Vec::new());
600                for p in ui.scene().primitives() {
601                    match p {
602                        crate::Primitive::Rect { color, .. } => rects.push(*color),
603                        crate::Primitive::Path {
604                            fill: Some(color), ..
605                        } => paths.push(*color),
606                        _ => {}
607                    }
608                }
609                (rects, paths)
610            };
611            let selected = |tree: &dyn Widget<()>| {
612                let mut rt = Runtime::default();
613                let bare = build_ui::<()>(tree, size, &rt, &theme);
614                let stop = bare.text_stops()[0];
615                let (from, to) = (
616                    RegionPoint {
617                        text: stop.id,
618                        index: 1,
619                    },
620                    RegionPoint {
621                        text: stop.id,
622                        index: 4,
623                    },
624                );
625                rt.region =
626                    Some(RegionSelection::new(stop.area, from, to, &[(stop.id, 6)]).with_bar(true));
627                count(&build_ui::<()>(tree, size, &rt, &theme))
628            };
629
630            let tailored = SelectionArea::around(words())
631                .selection_color(red)
632                .handle_color(blue);
633            let (rects, paths) = selected(&tailored);
634            assert!(rects.contains(&red), "the highlight: {rects:?}");
635            assert!(!rects.contains(&theme.selection));
636            assert_eq!(paths.iter().filter(|c| **c == blue).count(), 2, "{paths:?}");
637
638            let plain = SelectionArea::around(words());
639            let (rects, paths) = selected(&plain);
640            assert!(rects.contains(&theme.selection));
641            let primary = theme.scheme.primary;
642            assert_eq!(paths.iter().filter(|c| **c == primary).count(), 2);
643        }
644
645        /// **What the application says about the bar is the area's answer**, through any wrapper
646        /// around it — nothing said, it says nothing and the texts offer theirs.
647        #[test]
648        fn the_area_answers_for_its_bar() {
649            let context = crate::ToolbarContext {
650                has_selection: true,
651                can_paste: false,
652                all_selected: false,
653            };
654            let plain = SelectionArea::<()>::around(Text::new("x"));
655            assert!(plain.area_toolbar(context).is_none());
656            let copy_only =
657                SelectionArea::<()>::around(Text::new("x")).selection_toolbar(|_, mut items| {
658                    items.truncate(1);
659                    items
660                });
661            assert!(matches!(copy_only.area_toolbar(context), Some(Some(_))));
662            let wrapped = crate::Keyed::new(7u64, copy_only);
663            assert!(matches!(wrapped.area_toolbar(context), Some(Some(_))));
664            let none =
665                SelectionArea::<()>::around(Text::new("x")).selection_toolbar(|_, _| Vec::new());
666            assert!(matches!(none.area_toolbar(context), Some(None)));
667            // And it is still an area: its texts take part.
668            let theme = Theme::default();
669            let scoped = Widget::<()>::theme_override(&none, &theme).expect("a theme");
670            assert!(scoped.widgets.text.selectable);
671        }
672    }
673}