Skip to main content

frus_widgets/
selectiontoolbar.rs

1//! The floating bar over a text selection: **Cut, Copy, Paste, Select all** (milestone 568,
2//! towards [#23]).
3//!
4//! A phone has no Ctrl key, so a selection made with a finger needs something to act on it.
5//! The bar is what a field shows over its selection after a hold, and after a handle has
6//! been dragged; on a desktop, what a right-click opens.
7//!
8//! **The items are decided by conditions, not written out.** Cut and Copy want a selection,
9//! Paste wants something on the clipboard, Select all wants something left unselected — and
10//! a field that must not give its text away (a password) offers neither Cut nor Copy. That
11//! is the [`ToolbarContext`], and the field turns it into a default list. An application
12//! that wants another list says so with [`TextField::selection_toolbar`], which is handed the
13//! context and the defaults and returns what to show: drop an item, add one of its own
14//! ([`ToolbarItem::custom`]), or return nothing to have no bar at all.
15//!
16//! **A button does not send a message.** Cut, Copy, Paste and Select all are things the
17//! *shell* does — it holds the clipboard and the editing state — so their buttons carry an
18//! [`EditAction`] and no message, and the shell performs it on the focused field. Only an
19//! application's own item carries a message.
20//!
21//! [#23]: https://github.com/KalybosPro/frus/issues/23
22//! [`TextField::selection_toolbar`]: crate::TextField::selection_toolbar
23
24use frus_core::{BorderRadius, Rect, Scene, ShapeBorder, Size};
25use frus_layout::{Align, Dimension, FlexDirection, Style};
26
27use crate::interaction::Status;
28use crate::theme::Theme;
29use crate::widget::Widget;
30
31/// What the shell does to the focused field when a built-in item of the bar is pressed.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33pub enum EditAction {
34    /// Copies the selection to the clipboard, then deletes it.
35    Cut,
36    /// Copies the selection to the clipboard.
37    Copy,
38    /// Types the clipboard's text where the selection or the caret is.
39    Paste,
40    /// Selects the whole text.
41    SelectAll,
42}
43
44impl EditAction {
45    /// The words on the button.
46    pub fn label(self) -> &'static str {
47        match self {
48            EditAction::Cut => "Cut",
49            EditAction::Copy => "Copy",
50            EditAction::Paste => "Paste",
51            EditAction::SelectAll => "Select all",
52        }
53    }
54}
55
56/// **What is true of the field and the clipboard** when the bar is asked for: the three
57/// conditions the default list is built from.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
59pub struct ToolbarContext {
60    /// Something is selected — Cut and Copy have something to take.
61    pub has_selection: bool,
62    /// The clipboard holds text — Paste has something to give. Where the platform cannot
63    /// say without asking (the web's clipboard is asynchronous), this is `true`.
64    pub can_paste: bool,
65    /// Everything is selected already, or there is nothing to select — Select all would
66    /// do nothing.
67    pub all_selected: bool,
68}
69
70impl ToolbarContext {
71    /// How many contexts there are: three conditions, two answers each.
72    pub(crate) const VARIANTS: u8 = 8;
73
74    /// A number for the context, `0..VARIANTS`: what a field caches its bars by, and what
75    /// the identity of a bar is derived from, so that two different lists never share one.
76    pub(crate) fn variant(self) -> u8 {
77        u8::from(self.has_selection)
78            | u8::from(self.can_paste) << 1
79            | u8::from(self.all_selected) << 2
80    }
81}
82
83/// One item of the bar: a built-in editing action, or the application's own.
84#[derive(Clone)]
85pub struct ToolbarItem<Msg = crate::callback::Callback> {
86    kind: ItemKind<Msg>,
87}
88
89#[derive(Clone)]
90enum ItemKind<Msg> {
91    Action(EditAction),
92    Custom { label: String, message: Msg },
93}
94
95impl<Msg> ToolbarItem<Msg> {
96    /// A built-in item, with its own words.
97    pub fn action(action: EditAction) -> Self {
98        Self {
99            kind: ItemKind::Action(action),
100        }
101    }
102
103    /// **Cut**.
104    pub fn cut() -> Self {
105        Self::action(EditAction::Cut)
106    }
107
108    /// **Copy**.
109    pub fn copy() -> Self {
110        Self::action(EditAction::Copy)
111    }
112
113    /// **Paste**.
114    pub fn paste() -> Self {
115        Self::action(EditAction::Paste)
116    }
117
118    /// **Select all**.
119    pub fn select_all() -> Self {
120        Self::action(EditAction::SelectAll)
121    }
122
123    /// An item of the application's own: `message` is what pressing it sends, like any
124    /// button. The bar closes when it is pressed.
125    pub fn custom(label: impl Into<String>, message: Msg) -> Self {
126        Self {
127            kind: ItemKind::Custom {
128                label: label.into(),
129                message,
130            },
131        }
132    }
133
134    /// The words on the item.
135    pub fn label(&self) -> &str {
136        match &self.kind {
137            ItemKind::Action(action) => action.label(),
138            ItemKind::Custom { label, .. } => label,
139        }
140    }
141
142    /// The built-in action this item performs, or `None` for an application's own item.
143    pub fn edit_action(&self) -> Option<EditAction> {
144        match &self.kind {
145            ItemKind::Action(action) => Some(*action),
146            ItemKind::Custom { .. } => None,
147        }
148    }
149}
150
151/// The bar's height, and so its ends' radius: half of it.
152const HEIGHT: f32 = 44.0;
153/// The room at either end of the bar, before the first label and after the last.
154const PAD_END: f32 = 14.5;
155/// The room either side of a label between two others.
156const PAD_MIDDLE: f32 = 9.5;
157/// A button's narrowest: a target a finger can hit.
158const MIN_WIDTH: f32 = 48.0;
159/// The bar's height above the surface: just off it, as the platform's own bar is
160/// (milestone 606). It was a menu's three, with a shadow that spread four times as far.
161const ELEVATION: f32 = 1.0;
162
163/// The bar's surface and the words on it: **white and black** on the default light theme,
164/// `#424242` and white on the default dark one — the platform's own bar, which the reference
165/// matches by eye — and the scheme's surface and on-surface once a theme names its own
166/// (milestone 606).
167fn colours(theme: &Theme) -> (frus_core::Color, frus_core::Color) {
168    let dark = theme.scheme.brightness == crate::Brightness::Dark;
169    let default = if dark { Theme::dark() } else { Theme::light() };
170    if theme.scheme.surface == default.scheme.surface
171        && theme.scheme.on_surface == default.scheme.on_surface
172    {
173        if dark {
174            (
175                frus_core::Color::rgb(
176                    0x42 as f32 / 255.0,
177                    0x42 as f32 / 255.0,
178                    0x42 as f32 / 255.0,
179                ),
180                frus_core::Color::WHITE,
181            )
182        } else {
183            (frus_core::Color::WHITE, frus_core::Color::BLACK)
184        }
185    } else {
186        (theme.scheme.surface, theme.scheme.on_surface)
187    }
188}
189
190/// Whether a theme whose platform is `platform` shows the **desktop menu** rather than the
191/// pill: Fuchsia, Linux and Windows, as the reference's adaptive toolbar does
192/// (`adaptive_text_selection_toolbar.dart:325`). Android and the Apple platforms show the
193/// pill; the reference's Apple bars have their own look, which frus does not have yet.
194pub(crate) fn uses_menu(platform: frus_core::TargetPlatform) -> bool {
195    use frus_core::TargetPlatform as P;
196    match platform {
197        P::Fuchsia | P::Linux | P::Windows => true,
198        P::Android | P::Ios | P::MacOs => false,
199    }
200}
201
202/// The desktop menu's width (`desktop_text_selection_toolbar.dart:17`).
203const MENU_WIDTH: f32 = 222.0;
204/// Its corners (`:56`).
205const MENU_RADIUS: f32 = 7.0;
206/// The room it keeps from the window's edges (`:16`).
207const MENU_SCREEN_PADDING: f32 = 8.0;
208/// A row's least height (`desktop_text_selection_toolbar_button.dart:69`).
209const ROW_HEIGHT: f32 = 36.0;
210/// The room before a row's words, after them and under them (`:19`).
211const ROW_PAD_X: f32 = 20.0;
212const ROW_PAD_BOTTOM: f32 = 3.0;
213
214/// The surface the buttons sit on, in the form the theme's platform takes: a pill, opaque,
215/// just lifted off the page, or on the desktops a menu of rows (milestone 618).
216pub(crate) struct SelectionToolbar<Msg> {
217    pill: Vec<Box<dyn Widget<Msg>>>,
218    menu: Vec<Box<dyn Widget<Msg>>>,
219    /// Which form the last layout chose, from the theme it was laid out under; read by the
220    /// walks that follow it in the same frame.
221    desktop: std::cell::Cell<bool>,
222}
223
224impl<Msg: Clone + 'static> SelectionToolbar<Msg> {
225    /// A bar of `items`, in order.
226    pub(crate) fn new(items: Vec<ToolbarItem<Msg>>) -> Self {
227        let total = items.len();
228        let pill = items
229            .iter()
230            .cloned()
231            .enumerate()
232            .map(|(index, item)| {
233                let button: Box<dyn Widget<Msg>> =
234                    Box::new(ToolbarButton::new(item, index == 0, index + 1 == total));
235                button
236            })
237            .collect();
238        let menu = items
239            .into_iter()
240            .map(|item| Box::new(MenuButton::new(item)) as Box<dyn Widget<Msg>>)
241            .collect();
242        Self {
243            pill,
244            menu,
245            desktop: std::cell::Cell::new(false),
246        }
247    }
248}
249
250impl<Msg: Clone> Widget<Msg> for SelectionToolbar<Msg> {
251    fn style_themed(&self, theme: &Theme) -> Style {
252        let desktop = uses_menu(theme.platform);
253        self.desktop.set(desktop);
254        if desktop {
255            Style {
256                width: Dimension::Length(MENU_WIDTH),
257                height: Dimension::Auto,
258                flex_direction: FlexDirection::Column,
259                ..Default::default()
260            }
261        } else {
262            Widget::<Msg>::style(self)
263        }
264    }
265
266    fn style(&self) -> Style {
267        Style {
268            width: Dimension::Auto,
269            height: Dimension::Length(HEIGHT),
270            flex_direction: FlexDirection::Row,
271            align: Align::Center,
272            ..Default::default()
273        }
274    }
275
276    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
277        if self.desktop.get() {
278            &self.menu
279        } else {
280            &self.pill
281        }
282    }
283
284    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
285        let o = status.opacity;
286        if self.desktop.get() {
287            // The desktop menu: a card, its corners rounded, one step off the page
288            // (`desktop_text_selection_toolbar.dart:55`).
289            let radius = BorderRadius::uniform(MENU_RADIUS);
290            frus_core::paint_elevation(
291                scene,
292                bounds,
293                radius,
294                ELEVATION,
295                theme.scheme.shadow.fade(o),
296            );
297            scene.draw_shape(
298                bounds,
299                ShapeBorder::rounded(radius),
300                theme.scheme.surface.fade(o),
301            );
302            return;
303        }
304        // A pill: the ends are half circles, whatever the theme's corners are.
305        let radius = BorderRadius::uniform(HEIGHT * 0.5);
306        let shape = ShapeBorder::rounded(radius);
307        // The reference's shadows for this height (milestone 606).
308        frus_core::paint_elevation(
309            scene,
310            bounds,
311            radius,
312            ELEVATION,
313            theme.scheme.shadow.fade(o),
314        );
315        scene.draw_shape(bounds, shape, colours(theme).0.fade(o));
316    }
317
318    fn on_click(&self) -> Option<Msg> {
319        None
320    }
321
322    /// A press on the bar that lands on no button is still a press **on the bar**: it must
323    /// not reach the page behind and put the selection away.
324    fn opaque(&self) -> bool {
325        true
326    }
327}
328
329/// One word on the bar.
330struct ToolbarButton<Msg> {
331    label: String,
332    action: Option<EditAction>,
333    message: Option<Msg>,
334    /// Whether it is the first on the bar, and the last: the ends keep more room, and
335    /// their highlight follows the pill's round ends.
336    first: bool,
337    last: bool,
338}
339
340impl<Msg> ToolbarButton<Msg> {
341    fn new(item: ToolbarItem<Msg>, first: bool, last: bool) -> Self {
342        let (label, action, message) = match item.kind {
343            ItemKind::Action(action) => (action.label().to_owned(), Some(action), None),
344            ItemKind::Custom { label, message } => (label, None, Some(message)),
345        };
346        Self {
347            label,
348            action,
349            message,
350            first,
351            last,
352        }
353    }
354
355    /// The room before and after the label.
356    fn padding(&self) -> (f32, f32) {
357        (
358            if self.first { PAD_END } else { PAD_MIDDLE },
359            if self.last { PAD_END } else { PAD_MIDDLE },
360        )
361    }
362}
363
364/// The words' style: the scheme's `label_large` at the regular weight, as the platform's
365/// own bar sets them.
366fn label_style(theme: Option<&Theme>) -> frus_core::TextStyle {
367    let mut style = crate::theme::type_scale(theme).label_large;
368    style.weight = Some(frus_core::FontWeight::Regular);
369    style
370}
371
372impl<Msg: Clone> Widget<Msg> for ToolbarButton<Msg> {
373    fn style_themed(&self, theme: &Theme) -> Style {
374        let measured = frus_text::measure_style(&self.label, label_style(Some(theme)));
375        let (before, after) = self.padding();
376        Style {
377            width: Dimension::Length((measured.width + before + after).ceil().max(MIN_WIDTH)),
378            height: Dimension::Length(HEIGHT),
379            ..Default::default()
380        }
381    }
382
383    fn style(&self) -> Style {
384        Widget::<Msg>::style_themed(self, &Theme::default())
385    }
386
387    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
388        &[]
389    }
390
391    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
392        let o = status.opacity;
393        let (_, ink) = colours(theme);
394        // Nothing at rest; the theme's state layer under a pointer or a finger. Square
395        // where it meets another button, round where it meets the pill's end, so it never
396        // spills past the bar.
397        let layer = theme.state_layer(frus_core::Color::TRANSPARENT, ink, &status);
398        let end = HEIGHT * 0.5;
399        let (start_r, end_r) = (
400            if self.first { end } else { 0.0 },
401            if self.last { end } else { 0.0 },
402        );
403        let radius = BorderRadius {
404            top_left: start_r,
405            bottom_left: start_r,
406            top_right: end_r,
407            bottom_right: end_r,
408        };
409        scene.draw_shape(bounds, ShapeBorder::rounded(radius), layer.fade(o));
410        let resolved = label_style(Some(theme)).resolved();
411        let measured = frus_text::measure_resolved(&self.label, &resolved);
412        let (before, after) = self.padding();
413        // Centred in the room between the paddings, which differ at the ends.
414        let x = bounds.x + before + (bounds.width - before - after - measured.width) / 2.0;
415        scene.text(
416            frus_core::Point::new(x, bounds.y + (bounds.height - measured.height) / 2.0),
417            self.label.clone(),
418            &resolved,
419            ink.fade(o),
420        );
421    }
422
423    fn on_click(&self) -> Option<Msg> {
424        self.message.clone()
425    }
426
427    /// A built-in item has no message, so nothing would register it as a target: this is
428    /// what makes a press on it a press **on it**, and so a press the shell can resolve
429    /// to its [`EditAction`].
430    fn opaque(&self) -> bool {
431        self.action.is_some()
432    }
433
434    fn edit_action(&self) -> Option<EditAction> {
435        self.action
436    }
437
438    /// The field keeps the focus while its bar is pressed: a bar that took it would end
439    /// the selection it acts on.
440    fn focusable(&self) -> bool {
441        false
442    }
443
444    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
445        Some(
446            frus_core::SemanticsProperties::new(frus_core::Role::Button)
447                .label(self.label.clone())
448                .clickable(),
449        )
450    }
451}
452
453/// One row of the desktop menu (`desktop_text_selection_toolbar_button.dart`): the words at
454/// its start, white on a dark scheme and black at 87 % on a light one, in a square button
455/// the width of the menu.
456struct MenuButton<Msg> {
457    label: String,
458    action: Option<EditAction>,
459    message: Option<Msg>,
460}
461
462impl<Msg> MenuButton<Msg> {
463    fn new(item: ToolbarItem<Msg>) -> Self {
464        let (label, action, message) = match item.kind {
465            ItemKind::Action(action) => (action.label().to_owned(), Some(action), None),
466            ItemKind::Custom { label, message } => (label, None, Some(message)),
467        };
468        Self {
469            label,
470            action,
471            message,
472        }
473    }
474}
475
476/// A row's words: 14 px at the regular weight (`desktop_text_selection_toolbar_button.dart:12`).
477fn menu_label_style() -> frus_core::TextStyle {
478    frus_core::TextStyle::new(14.0).weight(frus_core::FontWeight::Regular)
479}
480
481/// A row's ink: white on a dark scheme, black at 87 % on a light one (`:42`).
482fn menu_ink(theme: &Theme) -> frus_core::Color {
483    if theme.scheme.brightness == crate::Brightness::Dark {
484        frus_core::Color::WHITE
485    } else {
486        frus_core::Color::BLACK.fade(0.87)
487    }
488}
489
490impl<Msg: Clone> Widget<Msg> for MenuButton<Msg> {
491    fn style(&self) -> Style {
492        let measured = frus_text::measure_style(&self.label, menu_label_style());
493        Style {
494            width: Dimension::Length(MENU_WIDTH),
495            height: Dimension::Length(ROW_HEIGHT.max(measured.height + ROW_PAD_BOTTOM).ceil()),
496            ..Default::default()
497        }
498    }
499
500    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
501        &[]
502    }
503
504    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
505        let o = status.opacity;
506        let ink = menu_ink(theme);
507        // Square, the menu's width: the reference's `RoundedRectangleBorder()` with no
508        // radius, so a row's highlight meets the next one edge to edge.
509        let layer = theme.state_layer(frus_core::Color::TRANSPARENT, ink, &status);
510        scene.draw_rect(
511            bounds,
512            layer.fade(o),
513            0.0,
514            0.0,
515            frus_core::Color::TRANSPARENT,
516        );
517        let resolved = menu_label_style().resolved();
518        let measured = frus_text::measure_resolved(&self.label, &resolved);
519        // At the start, centred in the room above the bottom padding.
520        let room = bounds.height - ROW_PAD_BOTTOM;
521        scene.text(
522            frus_core::Point::new(
523                bounds.x + ROW_PAD_X,
524                bounds.y + (room - measured.height) / 2.0,
525            ),
526            self.label.clone(),
527            &resolved,
528            ink.fade(o),
529        );
530    }
531
532    fn on_click(&self) -> Option<Msg> {
533        self.message.clone()
534    }
535
536    /// As on the pill: a built-in row is a target with no message, resolved to its action.
537    fn opaque(&self) -> bool {
538        self.action.is_some()
539    }
540
541    fn edit_action(&self) -> Option<EditAction> {
542        self.action
543    }
544
545    /// The field keeps the focus while its menu is used.
546    fn focusable(&self) -> bool {
547        false
548    }
549
550    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
551        Some(
552            frus_core::SemanticsProperties::new(frus_core::Role::Button)
553                .label(self.label.clone())
554                .clickable(),
555        )
556    }
557}
558
559/// **Where the desktop menu goes**, its top-left corner: at `anchor`, moved back by as much
560/// as it would hang past the window's padded edge on the right or at the bottom
561/// (`desktop_text_selection_toolbar_layout_delegate.dart:39`).
562pub(crate) fn place_menu(anchor: frus_core::Point, size: Size, window: Rect) -> (f32, f32) {
563    let right = window.x + window.width - MENU_SCREEN_PADDING;
564    let bottom = window.y + window.height - MENU_SCREEN_PADDING;
565    let over_x = anchor.x + size.width - right;
566    let over_y = anchor.y + size.height - bottom;
567    (
568        if over_x > 0.0 {
569            anchor.x - over_x
570        } else {
571            anchor.x
572        },
573        if over_y > 0.0 {
574            anchor.y - over_y
575        } else {
576            anchor.y
577        },
578    )
579}
580
581/// The point a menu opened without a pointer hangs from: the top of the selection, at its
582/// middle — the reference's primary anchor (`text_selection_toolbar_anchors.dart:45`).
583pub(crate) fn primary_anchor(selection: Rect) -> frus_core::Point {
584    frus_core::Point::new(selection.x + selection.width * 0.5, selection.y)
585}
586
587/// The gap between the bar and the selection it acts on, in px (the reference's
588/// `_kToolbarContentDistance`).
589pub(crate) const GAP: f32 = 8.0;
590/// The room a bar keeps from the window's edges, in px.
591pub(crate) const MARGIN: f32 = 8.0;
592
593/// **Where the bar goes**, its top-left corner: centred on the selection and **above** it;
594/// **below** it when there is no room above — clearing the handles that hang under the
595/// selection, `below_clear` px — and held inside the window on either side.
596///
597/// `anchor` is the selection's box, `size` the bar's, `window` the room there is.
598pub(crate) fn place(anchor: Rect, size: Size, window: Rect, below_clear: f32) -> (f32, f32) {
599    let centre = anchor.x + anchor.width * 0.5;
600    let max_x = (window.x + window.width - size.width - MARGIN).max(window.x + MARGIN);
601    let x = (centre - size.width * 0.5).clamp(window.x + MARGIN, max_x);
602    let above = anchor.y - size.height - GAP;
603    let y = if above >= window.y + MARGIN {
604        above
605    } else {
606        let below = anchor.y + anchor.height + below_clear + GAP;
607        // Even below it might not fit; then it stays on the window rather than off it.
608        below.min((window.y + window.height - size.height - MARGIN).max(window.y))
609    };
610    (x, y)
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    const WINDOW: Rect = Rect::new(0.0, 0.0, 400.0, 800.0);
618
619    #[test]
620    fn every_context_has_its_own_number() {
621        let mut seen = std::collections::HashSet::new();
622        for has_selection in [false, true] {
623            for can_paste in [false, true] {
624                for all_selected in [false, true] {
625                    let context = ToolbarContext {
626                        has_selection,
627                        can_paste,
628                        all_selected,
629                    };
630                    let variant = context.variant();
631                    assert!(variant < ToolbarContext::VARIANTS);
632                    assert!(seen.insert(variant), "two contexts share {variant}");
633                }
634            }
635        }
636        assert_eq!(seen.len(), usize::from(ToolbarContext::VARIANTS));
637    }
638
639    /// **Which platforms get the menu** (`adaptive_text_selection_toolbar.dart:325`):
640    /// Fuchsia, Linux and Windows; Android and the Apple platforms keep the pill.
641    #[test]
642    fn the_desktops_get_the_menu() {
643        use frus_core::TargetPlatform as P;
644        for platform in P::ALL {
645            let menu = matches!(platform, P::Fuchsia | P::Linux | P::Windows);
646            assert_eq!(uses_menu(platform), menu, "{platform}");
647        }
648    }
649
650    /// **The menu hangs from its anchor**, and is pushed back by as much as it would hang
651    /// past the window's padded right or bottom edge — no more.
652    #[test]
653    fn the_menu_hangs_from_its_anchor_inside_the_window() {
654        let size = Size::new(MENU_WIDTH, 144.0);
655        let at = |x: f32, y: f32| place_menu(frus_core::Point::new(x, y), size, WINDOW);
656        assert_eq!(at(50.0, 60.0), (50.0, 60.0), "room: at the anchor");
657        assert_eq!(
658            at(300.0, 60.0).0,
659            400.0 - 8.0 - MENU_WIDTH,
660            "pushed back from the right"
661        );
662        assert_eq!(
663            at(50.0, 750.0).1,
664            800.0 - 8.0 - 144.0,
665            "and up from the bottom"
666        );
667        assert_eq!(
668            primary_anchor(Rect::new(100.0, 40.0, 60.0, 20.0)),
669            frus_core::Point::new(130.0, 40.0),
670            "a selection's anchor is the middle of its top"
671        );
672    }
673
674    /// **On the desktops, a menu of rows** (`desktop_text_selection_toolbar.dart`): 222 px
675    /// wide, a 36 px row per item, top to bottom in the items' order, the words at the
676    /// start, on the scheme's surface with rounded corners — whatever this test runs on.
677    #[test]
678    fn on_the_desktops_it_is_a_menu_of_rows() {
679        let theme = Theme::dark().with_platform(frus_core::TargetPlatform::Linux);
680        let ui = crate::build_ui(
681            &four(),
682            Size::new(600.0, 400.0),
683            &crate::Runtime::default(),
684            &theme,
685        );
686        let words: Vec<(String, frus_core::Point)> = ui
687            .scene()
688            .primitives()
689            .iter()
690            .filter_map(|p| match p {
691                frus_core::Primitive::Text { text, position, .. } => {
692                    Some((text.clone(), *position))
693                }
694                _ => None,
695            })
696            .collect();
697        let labels: Vec<&str> = words.iter().map(|(t, _)| t.as_str()).collect();
698        assert_eq!(labels, ["Cut", "Copy", "Paste", "Select all"]);
699        for (i, (_, origin)) in words.iter().enumerate() {
700            assert_eq!(origin.x, ROW_PAD_X, "at the start");
701            assert!(
702                origin.y >= ROW_HEIGHT * i as f32 && origin.y < ROW_HEIGHT * (i + 1) as f32,
703                "row {i} of 36 px: {origin:?}"
704            );
705        }
706        let panel = ui
707            .scene()
708            .primitives()
709            .iter()
710            .find_map(|p| match p {
711                frus_core::Primitive::Rect {
712                    rect,
713                    color,
714                    radius,
715                    ..
716                } if rect.width == MENU_WIDTH && *color == theme.scheme.surface => {
717                    Some((*rect, radius.top_left))
718                }
719                _ => None,
720            })
721            .expect("the menu's surface");
722        assert_eq!(panel.0.height, ROW_HEIGHT * 4.0);
723        assert_eq!(panel.1, MENU_RADIUS);
724    }
725
726    #[test]
727    fn the_bar_sits_above_the_selection_centred_on_it() {
728        let anchor = Rect::new(150.0, 300.0, 100.0, 20.0);
729        let size = Size::new(200.0, 44.0);
730        let (x, y) = place(anchor, size, WINDOW, 22.0);
731        assert_eq!(x, 100.0, "centred: 200 + 0 - 100");
732        assert_eq!(y, 300.0 - 44.0 - GAP);
733    }
734
735    #[test]
736    fn with_no_room_above_it_goes_below_and_clears_the_handles() {
737        let anchor = Rect::new(150.0, 20.0, 100.0, 20.0);
738        let size = Size::new(200.0, 44.0);
739        let (_, y) = place(anchor, size, WINDOW, 22.0);
740        assert_eq!(y, 20.0 + 20.0 + 22.0 + GAP);
741    }
742
743    #[test]
744    fn against_an_edge_it_is_held_inside_the_window() {
745        let size = Size::new(200.0, 44.0);
746        // A selection at the far left: centring would put the bar off the left edge.
747        let left = place(Rect::new(0.0, 300.0, 30.0, 20.0), size, WINDOW, 22.0);
748        assert_eq!(left.0, MARGIN);
749        // And at the far right.
750        let right = place(Rect::new(380.0, 300.0, 20.0, 20.0), size, WINDOW, 22.0);
751        assert_eq!(right.0, 400.0 - 200.0 - MARGIN);
752    }
753
754    #[test]
755    fn a_window_too_narrow_for_the_bar_pins_it_to_the_left_margin() {
756        let (x, _) = place(
757            Rect::new(10.0, 300.0, 20.0, 20.0),
758            Size::new(500.0, 44.0),
759            WINDOW,
760            22.0,
761        );
762        assert_eq!(x, MARGIN);
763    }
764
765    #[test]
766    fn below_the_selection_it_still_stays_on_the_window() {
767        // No room above, and the selection is so low that below does not fit either.
768        let window = Rect::new(0.0, 0.0, 400.0, 100.0);
769        let (_, y) = place(
770            Rect::new(150.0, 30.0, 100.0, 20.0),
771            Size::new(200.0, 44.0),
772            window,
773            22.0,
774        );
775        assert_eq!(y, 100.0 - 44.0 - MARGIN);
776    }
777
778    #[test]
779    fn a_built_in_item_says_its_action_and_an_application_item_does_not() {
780        assert_eq!(
781            ToolbarItem::<()>::cut().edit_action(),
782            Some(EditAction::Cut)
783        );
784        assert_eq!(ToolbarItem::<()>::select_all().label(), "Select all");
785        let own = ToolbarItem::custom("Translate", 7);
786        assert_eq!(own.edit_action(), None);
787        assert_eq!(own.label(), "Translate");
788    }
789
790    #[test]
791    fn a_built_in_button_is_a_target_without_a_message_and_never_takes_the_focus() {
792        let button = ToolbarButton::<u8>::new(ToolbarItem::copy(), true, true);
793        assert!(Widget::<u8>::opaque(&button));
794        assert_eq!(Widget::<u8>::on_click(&button), None);
795        assert_eq!(Widget::<u8>::edit_action(&button), Some(EditAction::Copy));
796        assert!(!Widget::<u8>::focusable(&button));
797    }
798
799    #[test]
800    fn an_application_button_sends_its_message_and_is_not_an_edit() {
801        let button = ToolbarButton::new(ToolbarItem::custom("Translate", 7u8), true, true);
802        assert_eq!(Widget::<u8>::on_click(&button), Some(7));
803        assert_eq!(Widget::<u8>::edit_action(&button), None);
804        assert!(!Widget::<u8>::opaque(&button));
805    }
806
807    /// The four built-in items, the way a field shows them.
808    fn four() -> SelectionToolbar<u8> {
809        SelectionToolbar::new(vec![
810            ToolbarItem::cut(),
811            ToolbarItem::copy(),
812            ToolbarItem::paste(),
813            ToolbarItem::select_all(),
814        ])
815    }
816
817    /// The bar laid out as the **pill**: under the theme given, following Android, which is
818    /// where the pill is the platform's (milestone 618) — whatever this test runs on.
819    fn frame(theme: &Theme) -> crate::Ui<u8> {
820        crate::build_ui(
821            &four(),
822            Size::new(600.0, 100.0),
823            &crate::Runtime::default(),
824            &theme
825                .clone()
826                .with_platform(frus_core::TargetPlatform::Android),
827        )
828    }
829
830    /// **As the platform's own bar looks** (milestone 606): a white pill on the default
831    /// light theme, `#424242` on the default dark one, just lifted off the page, with its
832    /// words in black or white.
833    #[test]
834    fn it_is_a_pill_just_off_the_page_in_the_platforms_colours() {
835        for (theme, surface, ink) in [
836            (
837                Theme::light(),
838                frus_core::Color::WHITE,
839                frus_core::Color::BLACK,
840            ),
841            (
842                Theme::dark(),
843                frus_core::Color::rgb(
844                    0x42 as f32 / 255.0,
845                    0x42 as f32 / 255.0,
846                    0x42 as f32 / 255.0,
847                ),
848                frus_core::Color::WHITE,
849            ),
850        ] {
851            let ui = frame(&theme);
852            let bar = ui
853                .scene()
854                .primitives()
855                .iter()
856                .find_map(|p| match p {
857                    frus_core::Primitive::Rect {
858                        rect,
859                        color,
860                        radius,
861                        blur,
862                        ..
863                    } if *blur == 0.0 && *color == surface => Some((*rect, *radius)),
864                    _ => None,
865                })
866                .expect("the bar's surface");
867            assert_eq!(bar.0.height, HEIGHT);
868            assert_eq!(bar.1, BorderRadius::uniform(HEIGHT * 0.5), "round ends");
869            assert_eq!(crate::shadowprobe::height(ui.scene().primitives()), 1.0);
870            let words: Vec<_> = ui
871                .scene()
872                .primitives()
873                .iter()
874                .filter_map(|p| match p {
875                    frus_core::Primitive::Text { color, .. } => Some(*color),
876                    _ => None,
877                })
878                .collect();
879            assert_eq!(words, vec![ink; 4]);
880        }
881    }
882
883    /// **A theme with its own surface** gets its surface and its on-surface.
884    #[test]
885    fn a_theme_of_its_own_colours_it() {
886        let mut theme = Theme::light();
887        theme.scheme.surface = frus_core::Color::rgb(0.9, 0.95, 1.0);
888        theme.scheme.on_surface = frus_core::Color::rgb(0.1, 0.1, 0.3);
889        assert_eq!(
890            colours(&theme),
891            (theme.scheme.surface, theme.scheme.on_surface)
892        );
893    }
894
895    /// **More room at the ends than between**, fourteen and a half against nine and a half,
896    /// so the first and last words sit clear of the round ends; and a short word still gets
897    /// a target a finger can hit.
898    #[test]
899    fn the_ends_keep_more_room_and_a_short_word_a_wide_enough_target() {
900        let theme = Theme::light();
901        let style = label_style(Some(&theme));
902        let width = |label: &str| frus_text::measure_style(label, style).width;
903        let first = ToolbarButton::<u8>::new(ToolbarItem::cut(), true, false);
904        let middle = ToolbarButton::<u8>::new(ToolbarItem::copy(), false, false);
905        let last = ToolbarButton::<u8>::new(ToolbarItem::select_all(), false, true);
906        let styled = |b: &ToolbarButton<u8>| match Widget::<u8>::style_themed(b, &theme).width {
907            Dimension::Length(w) => w,
908            _ => unreachable!(),
909        };
910        assert_eq!(
911            styled(&first),
912            (width("Cut") + PAD_END + PAD_MIDDLE).ceil().max(MIN_WIDTH)
913        );
914        assert_eq!(
915            styled(&middle),
916            (width("Copy") + 2.0 * PAD_MIDDLE).ceil().max(MIN_WIDTH)
917        );
918        assert_eq!(
919            styled(&last),
920            (width("Select all") + PAD_MIDDLE + PAD_END).ceil()
921        );
922        let tiny = ToolbarButton::<u8>::new(ToolbarItem::custom("A", 1), false, false);
923        assert_eq!(styled(&tiny), MIN_WIDTH);
924    }
925}