Skip to main content

frus_widgets/
actionbutton.rs

1//! [`BackButton`], [`CloseButton`], [`DrawerButton`] and [`EndDrawerButton`]: the four
2//! icon buttons whose meaning is fixed and whose **name is not**.
3//!
4//! The reference calls them action buttons (`action_buttons.dart`) and they are all one
5//! idea: an [`IconButton`](crate::IconButton) that already knows its glyph and takes its
6//! tooltip from the framework's own words. Nothing here had them — the one back button in
7//! the crate was `IconButton::glyph("←")`, a **character**, named by hand at its one call
8//! site.
9
10use frus_core::{Color, Rect, Scene};
11use frus_layout::Style;
12
13use crate::icons::{IconData, Icons};
14use crate::interaction::Status;
15use crate::theme::Theme;
16use crate::widget::Widget;
17use crate::{IconButton, Tooltip};
18
19/// Which of the four this is. The type carries the glyph and the words, so a caller
20/// choosing a back button cannot end up with a close button's tooltip.
21#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
22enum Kind {
23    Back,
24    Close,
25    Drawer,
26    EndDrawer,
27}
28
29impl Kind {
30    /// The glyph, with the theme's override ahead of the framework's own.
31    fn icon(self, theme: &Theme) -> IconData {
32        let t = &theme.widgets.action_icons;
33        match self {
34            Kind::Back => t.back.unwrap_or(default_back_icon(theme.platform)),
35            Kind::Close => t.close.unwrap_or(Icons::CLOSE),
36            Kind::Drawer => t.drawer.unwrap_or(Icons::MENU),
37            Kind::EndDrawer => t.end_drawer.unwrap_or(Icons::MENU),
38        }
39    }
40
41    /// What the framework calls it, in the reader's language.
42    fn label(self) -> String {
43        let words = crate::localizations::of();
44        match self {
45            Kind::Back => words.back_button_label().to_owned(),
46            Kind::Close => words.close_button_label().to_owned(),
47            // Both drawers, one word. That is the reference's own arrangement
48            // (`action_buttons.dart:331` and `:362` return the same string): a reader
49            // being told *which edge* the panel comes in from would be told about the
50            // layout rather than about the action.
51            Kind::Drawer | Kind::EndDrawer => words.open_drawer_label().to_owned(),
52        }
53    }
54}
55
56/// **The back arrow the platform draws**, as the reference picks it
57/// (`action_buttons.dart:132`): always the arrow on the web, the iOS arrow under a theme
58/// whose platform is iOS or macOS, the arrow everywhere else (milestone 615).
59///
60/// The reference's iOS arrow is the rounded one. Its glyphs come with the
61/// `icons-rounded` feature; without it, the filled iOS arrow stands in, which is the same
62/// shape with sharper ends.
63const fn default_back_icon(platform: frus_core::TargetPlatform) -> IconData {
64    if frus_core::IS_WEB {
65        return Icons::ARROW_BACK;
66    }
67    match platform {
68        frus_core::TargetPlatform::Ios | frus_core::TargetPlatform::MacOs => {
69            #[cfg(feature = "icons-rounded")]
70            {
71                Icons::ARROW_BACK_IOS_NEW_ROUNDED
72            }
73            #[cfg(not(feature = "icons-rounded"))]
74            {
75                Icons::ARROW_BACK_IOS_NEW
76            }
77        }
78        frus_core::TargetPlatform::Android
79        | frus_core::TargetPlatform::Fuchsia
80        | frus_core::TargetPlatform::Linux
81        | frus_core::TargetPlatform::Windows => Icons::ARROW_BACK,
82    }
83}
84
85/// The body the four share: an icon button, wrapped in a tooltip carrying the same words
86/// its semantics carry.
87struct Action<Msg> {
88    kind: Kind,
89    on_press: Option<Msg>,
90    color: Option<Color>,
91    icon_size: Option<f32>,
92    enabled: bool,
93    built: std::cell::OnceCell<Vec<Box<dyn Widget<Msg>>>>,
94}
95
96impl<Msg: Clone + 'static> Action<Msg> {
97    fn new(kind: Kind) -> Self {
98        Self {
99            kind,
100            on_press: None,
101            color: None,
102            icon_size: None,
103            enabled: true,
104            built: std::cell::OnceCell::new(),
105        }
106    }
107
108    fn assemble(&self, theme: &Theme) -> Box<dyn Widget<Msg>> {
109        let label = self.kind.label();
110        let mut button = IconButton::new(self.kind.icon(theme))
111            .label(label.clone())
112            .enabled(self.enabled);
113        if let Some(color) = self.color {
114            button = button.icon_color(color);
115        }
116        if let Some(size) = self.icon_size {
117            button = button.icon_size(size);
118        }
119        if let Some(message) = self.on_press.clone() {
120            button = button.on_press(message);
121        }
122        // The words twice, on purpose: once where a reader hears them and once where a
123        // pointer sees them. The reference does the same (`action_buttons.dart:54`), and
124        // the alternative is a control that is named for one of its two audiences.
125        Box::new(Tooltip::new(label).child(button))
126    }
127}
128
129// The four public faces, **written out** rather than produced by a macro.
130//
131// They differ only in which `Kind` they carry, and a macro would say them once. That is
132// what milestone 465 tried in the control tiles, and the crate's own guard failed on it:
133// `every_control_with_an_enabled_flag_honours_all_four` reads the **source** of every
134// module carrying an `enabled` flag and checks that each of the four hooks consults it,
135// and a hook inside a `macro_rules!` body is text it cannot parse.
136//
137// A safety net a widget can hide from is a net with a hole in it. So these are typed
138// out, and the repetition is the price of staying inside the net.
139
140/// **The back arrow**, named in the reader's language.
141///
142/// Its glyph follows the platform: a chevron where the platform's own back control is a
143/// chevron, an arrow everywhere else (`action_buttons.dart:132`).
144pub struct BackButton<Msg = crate::callback::Callback>(Action<Msg>);
145
146impl<Msg: Clone + 'static> BackButton<Msg> {
147    /// A back button, inert until it is given a message.
148    #[allow(clippy::new_without_default)]
149    pub fn new() -> Self {
150        Self(Action::new(Kind::Back))
151    }
152
153    /// What it does. Without a message it is **inert**: it draws, it is named, and it
154    /// answers nothing. Disabled is the other thing, and says so.
155    #[must_use]
156    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
157        self.0.on_press = Some(message.into());
158        self
159    }
160
161    /// The glyph's colour.
162    #[must_use]
163    pub fn color(mut self, color: Color) -> Self {
164        self.0.color = Some(color);
165        self
166    }
167
168    /// The glyph's size.
169    #[must_use]
170    pub fn icon_size(mut self, size: f32) -> Self {
171        self.0.icon_size = Some(size);
172        self
173    }
174
175    /// Whether it can be pressed.
176    #[must_use]
177    pub fn enabled(mut self, enabled: bool) -> Self {
178        self.0.enabled = enabled;
179        self
180    }
181}
182
183impl<Msg: Clone + 'static> Widget<Msg> for BackButton<Msg> {
184    fn style(&self) -> Style {
185        Style::default()
186    }
187
188    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
189        self.0.built.get().map(Vec::as_slice).unwrap_or(&[])
190    }
191
192    /// Assembled on the first walk, under the theme it will be drawn in: the glyph is a
193    /// theme's to override, and a button built by a builder never sees one.
194    fn build_themed(&self, theme: &Theme) {
195        let _ = self.0.built.set(vec![self.0.assemble(theme)]);
196    }
197
198    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
199
200    /// The button inside answers, not this — including the `enabled` flag, which it was
201    /// built with. A second click here would send the message twice for one press.
202    fn on_click(&self) -> Option<Msg> {
203        None
204    }
205}
206
207/// **The cross that dismisses something**, named in the reader's language.
208pub struct CloseButton<Msg = crate::callback::Callback>(Action<Msg>);
209
210impl<Msg: Clone + 'static> CloseButton<Msg> {
211    /// A close button, inert until it is given a message.
212    #[allow(clippy::new_without_default)]
213    pub fn new() -> Self {
214        Self(Action::new(Kind::Close))
215    }
216
217    /// What it does. Without a message it is **inert**: it draws, it is named, and it
218    /// answers nothing. Disabled is the other thing, and says so.
219    #[must_use]
220    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
221        self.0.on_press = Some(message.into());
222        self
223    }
224
225    /// The glyph's colour.
226    #[must_use]
227    pub fn color(mut self, color: Color) -> Self {
228        self.0.color = Some(color);
229        self
230    }
231
232    /// The glyph's size.
233    #[must_use]
234    pub fn icon_size(mut self, size: f32) -> Self {
235        self.0.icon_size = Some(size);
236        self
237    }
238
239    /// Whether it can be pressed.
240    #[must_use]
241    pub fn enabled(mut self, enabled: bool) -> Self {
242        self.0.enabled = enabled;
243        self
244    }
245}
246
247impl<Msg: Clone + 'static> Widget<Msg> for CloseButton<Msg> {
248    fn style(&self) -> Style {
249        Style::default()
250    }
251
252    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
253        self.0.built.get().map(Vec::as_slice).unwrap_or(&[])
254    }
255
256    /// Assembled on the first walk, under the theme it will be drawn in: the glyph is a
257    /// theme's to override, and a button built by a builder never sees one.
258    fn build_themed(&self, theme: &Theme) {
259        let _ = self.0.built.set(vec![self.0.assemble(theme)]);
260    }
261
262    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
263
264    /// The button inside answers, not this — including the `enabled` flag, which it was
265    /// built with. A second click here would send the message twice for one press.
266    fn on_click(&self) -> Option<Msg> {
267        None
268    }
269}
270
271/// **The bars that open the panel on the leading edge**, named in the reader's
272/// language.
273pub struct DrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
274
275impl<Msg: Clone + 'static> DrawerButton<Msg> {
276    /// A drawer button, inert until it is given a message.
277    #[allow(clippy::new_without_default)]
278    pub fn new() -> Self {
279        Self(Action::new(Kind::Drawer))
280    }
281
282    /// What it does. Without a message it is **inert**: it draws, it is named, and it
283    /// answers nothing. Disabled is the other thing, and says so.
284    #[must_use]
285    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
286        self.0.on_press = Some(message.into());
287        self
288    }
289
290    /// The glyph's colour.
291    #[must_use]
292    pub fn color(mut self, color: Color) -> Self {
293        self.0.color = Some(color);
294        self
295    }
296
297    /// The glyph's size.
298    #[must_use]
299    pub fn icon_size(mut self, size: f32) -> Self {
300        self.0.icon_size = Some(size);
301        self
302    }
303
304    /// Whether it can be pressed.
305    #[must_use]
306    pub fn enabled(mut self, enabled: bool) -> Self {
307        self.0.enabled = enabled;
308        self
309    }
310}
311
312impl<Msg: Clone + 'static> Widget<Msg> for DrawerButton<Msg> {
313    fn style(&self) -> Style {
314        Style::default()
315    }
316
317    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
318        self.0.built.get().map(Vec::as_slice).unwrap_or(&[])
319    }
320
321    /// Assembled on the first walk, under the theme it will be drawn in: the glyph is a
322    /// theme's to override, and a button built by a builder never sees one.
323    fn build_themed(&self, theme: &Theme) {
324        let _ = self.0.built.set(vec![self.0.assemble(theme)]);
325    }
326
327    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
328
329    /// The button inside answers, not this — including the `enabled` flag, which it was
330    /// built with. A second click here would send the message twice for one press.
331    fn on_click(&self) -> Option<Msg> {
332        None
333    }
334}
335
336/// **The bars that open the panel on the trailing edge.**
337///
338/// It takes the *same* words as [`DrawerButton`], which is the reference's own
339/// arrangement: a reader told which edge a panel comes in from is being told about the
340/// layout rather than about the action.
341pub struct EndDrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
342
343impl<Msg: Clone + 'static> EndDrawerButton<Msg> {
344    /// An end-drawer button, inert until it is given a message.
345    #[allow(clippy::new_without_default)]
346    pub fn new() -> Self {
347        Self(Action::new(Kind::EndDrawer))
348    }
349
350    /// What it does. Without a message it is **inert**: it draws, it is named, and it
351    /// answers nothing. Disabled is the other thing, and says so.
352    #[must_use]
353    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
354        self.0.on_press = Some(message.into());
355        self
356    }
357
358    /// The glyph's colour.
359    #[must_use]
360    pub fn color(mut self, color: Color) -> Self {
361        self.0.color = Some(color);
362        self
363    }
364
365    /// The glyph's size.
366    #[must_use]
367    pub fn icon_size(mut self, size: f32) -> Self {
368        self.0.icon_size = Some(size);
369        self
370    }
371
372    /// Whether it can be pressed.
373    #[must_use]
374    pub fn enabled(mut self, enabled: bool) -> Self {
375        self.0.enabled = enabled;
376        self
377    }
378}
379
380impl<Msg: Clone + 'static> Widget<Msg> for EndDrawerButton<Msg> {
381    fn style(&self) -> Style {
382        Style::default()
383    }
384
385    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
386        self.0.built.get().map(Vec::as_slice).unwrap_or(&[])
387    }
388
389    /// Assembled on the first walk, under the theme it will be drawn in: the glyph is a
390    /// theme's to override, and a button built by a builder never sees one.
391    fn build_themed(&self, theme: &Theme) {
392        let _ = self.0.built.set(vec![self.0.assemble(theme)]);
393    }
394
395    fn paint(&self, _b: Rect, _s: Status, _t: &Theme, _scene: &mut Scene) {}
396
397    /// The button inside answers, not this — including the `enabled` flag, which it was
398    /// built with. A second click here would send the message twice for one press.
399    fn on_click(&self) -> Option<Msg> {
400        None
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::{build_ui, Runtime, Size};
408    use std::rc::Rc;
409
410    #[derive(Clone, Debug, PartialEq)]
411    enum Msg {
412        Pop,
413    }
414
415    /// A table in another language, to prove the words travel.
416    struct French;
417    impl crate::localizations::Localizations for French {
418        fn back_button_label(&self) -> &str {
419            "Retour"
420        }
421        fn open_drawer_label(&self) -> &str {
422            "Ouvrir le menu"
423        }
424    }
425
426    fn labels<W: Widget<Msg>>(widget: &W) -> Vec<String> {
427        let ui = build_ui(
428            widget,
429            Size::new(200.0, 80.0),
430            &Runtime::default(),
431            &Theme::default(),
432        );
433        ui.semantics()
434            .iter()
435            .filter_map(|(_, _, node)| node.label.clone())
436            .collect()
437    }
438
439    /// **The one back button in the crate was a character.** `NavigationBar::on_back`
440    /// built `IconButton::glyph("←")` — a codepoint, at the mercy of whatever font is
441    /// loaded, with no control over its weight and none over its size relative to the
442    /// other glyphs beside it. These four carry a **drawn** icon.
443    #[test]
444    fn a_back_button_carries_a_drawn_glyph_and_not_a_character() {
445        let button = BackButton::<Msg>::new().on_press(Msg::Pop);
446        let ui = build_ui(
447            &button,
448            Size::new(200.0, 80.0),
449            &Runtime::default(),
450            &Theme::default(),
451        );
452        let paths = ui
453            .scene()
454            .primitives()
455            .iter()
456            .filter(|p| matches!(p, frus_core::Primitive::Path { .. }))
457            .count();
458        assert!(paths >= 1, "a path, not a text run");
459    }
460
461    /// **They are named, and named in the reader's language.** An icon has no words of
462    /// its own, so a back arrow with nothing said about it is a shape a screen reader
463    /// cannot announce — and a back arrow announced as "Back" to a French reader is a
464    /// shape announced wrongly.
465    ///
466    /// The words go in **twice**, as the reference's do: once where a reader hears them
467    /// and once where a pointer sees them. A control named for only one of its two
468    /// audiences is named for neither.
469    #[test]
470    fn the_four_are_named_in_the_reader_s_language() {
471        assert!(labels(&BackButton::<Msg>::new().on_press(Msg::Pop))
472            .iter()
473            .any(|l| l.contains("Back")));
474
475        crate::localizations::scope(Rc::new(French), || {
476            let said = labels(&BackButton::<Msg>::new().on_press(Msg::Pop));
477            assert!(
478                said.iter().any(|l| l.contains("Retour")),
479                "the reader's own word: {said:?}"
480            );
481            let drawer = labels(&DrawerButton::<Msg>::new().on_press(Msg::Pop));
482            assert!(
483                drawer.iter().any(|l| l.contains("Ouvrir le menu")),
484                "{drawer:?}"
485            );
486        });
487    }
488
489    /// **Both drawers take the same word**, which is the reference's own arrangement
490    /// (`action_buttons.dart:331` against `:362`): a reader told which *edge* a panel
491    /// comes in from is being told about the layout rather than about the action.
492    #[test]
493    fn both_drawer_buttons_say_the_same_thing() {
494        assert_eq!(Kind::Drawer.label(), Kind::EndDrawer.label());
495    }
496
497    /// **The back arrow is the theme's platform's** (`action_buttons.dart:132`,
498    /// milestone 615): the iOS arrow under iOS and macOS, the plain arrow under the other
499    /// four, whatever this test runs on.
500    #[test]
501    fn the_back_arrow_follows_the_theme_s_platform() {
502        use frus_core::TargetPlatform as P;
503        #[cfg(feature = "icons-rounded")]
504        let ios = Icons::ARROW_BACK_IOS_NEW_ROUNDED;
505        #[cfg(not(feature = "icons-rounded"))]
506        let ios = Icons::ARROW_BACK_IOS_NEW;
507        for platform in P::ALL {
508            let theme = Theme::default().with_platform(platform);
509            let expected = if platform.is_apple() {
510                ios
511            } else {
512                Icons::ARROW_BACK
513            };
514            assert_eq!(Kind::Back.icon(&theme), expected, "{platform}");
515        }
516    }
517
518    /// **A theme can replace the glyphs** — the reference's `ActionIconTheme`. An
519    /// application with its own icon set should not have to leave four of the framework's
520    /// showing through.
521    #[test]
522    fn a_theme_can_replace_the_glyphs() {
523        let mut theme = Theme::default();
524        theme.widgets.action_icons.back = Some(Icons::CHEVRON_LEFT);
525        assert_eq!(Kind::Back.icon(&theme), Icons::CHEVRON_LEFT);
526        assert_eq!(
527            Kind::Back.icon(&Theme::default()),
528            default_back_icon(Theme::default().platform),
529            "and the framework's own is the platform's"
530        );
531    }
532
533    /// A button with nothing to say is **inert**, not disabled: it draws and it is named,
534    /// and it answers nothing. Disabled is the other thing, and says so.
535    #[test]
536    fn a_button_with_no_message_answers_nothing() {
537        let ui = build_ui(
538            &BackButton::<Msg>::new(),
539            Size::new(200.0, 80.0),
540            &Runtime::default(),
541            &Theme::default(),
542        );
543        assert!(ui
544            .hit(frus_core::Point::new(20.0, 20.0))
545            .and_then(|id| ui.msg_for(id))
546            .is_none());
547    }
548}