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