1use 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#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
22enum Kind {
23 Back,
24 Close,
25 Drawer,
26 EndDrawer,
27}
28
29impl Kind {
30 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 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 Kind::Drawer | Kind::EndDrawer => words.open_drawer_label().to_owned(),
52 }
53 }
54}
55
56const 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
73struct 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 Box::new(Tooltip::new(label).child(button))
114 }
115}
116
117pub struct BackButton<Msg = crate::callback::Callback>(Action<Msg>);
133
134impl<Msg: Clone + 'static> BackButton<Msg> {
135 #[allow(clippy::new_without_default)]
137 pub fn new() -> Self {
138 Self(Action::new(Kind::Back))
139 }
140
141 #[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 #[must_use]
151 pub fn color(mut self, color: Color) -> Self {
152 self.0.color = Some(color);
153 self
154 }
155
156 #[must_use]
158 pub fn icon_size(mut self, size: f32) -> Self {
159 self.0.icon_size = Some(size);
160 self
161 }
162
163 #[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 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 fn on_click(&self) -> Option<Msg> {
191 None
192 }
193}
194
195pub struct CloseButton<Msg = crate::callback::Callback>(Action<Msg>);
197
198impl<Msg: Clone + 'static> CloseButton<Msg> {
199 #[allow(clippy::new_without_default)]
201 pub fn new() -> Self {
202 Self(Action::new(Kind::Close))
203 }
204
205 #[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 #[must_use]
215 pub fn color(mut self, color: Color) -> Self {
216 self.0.color = Some(color);
217 self
218 }
219
220 #[must_use]
222 pub fn icon_size(mut self, size: f32) -> Self {
223 self.0.icon_size = Some(size);
224 self
225 }
226
227 #[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 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 fn on_click(&self) -> Option<Msg> {
255 None
256 }
257}
258
259pub struct DrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
262
263impl<Msg: Clone + 'static> DrawerButton<Msg> {
264 #[allow(clippy::new_without_default)]
266 pub fn new() -> Self {
267 Self(Action::new(Kind::Drawer))
268 }
269
270 #[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 #[must_use]
280 pub fn color(mut self, color: Color) -> Self {
281 self.0.color = Some(color);
282 self
283 }
284
285 #[must_use]
287 pub fn icon_size(mut self, size: f32) -> Self {
288 self.0.icon_size = Some(size);
289 self
290 }
291
292 #[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 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 fn on_click(&self) -> Option<Msg> {
320 None
321 }
322}
323
324pub struct EndDrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
330
331impl<Msg: Clone + 'static> EndDrawerButton<Msg> {
332 #[allow(clippy::new_without_default)]
334 pub fn new() -> Self {
335 Self(Action::new(Kind::EndDrawer))
336 }
337
338 #[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 #[must_use]
348 pub fn color(mut self, color: Color) -> Self {
349 self.0.color = Some(color);
350 self
351 }
352
353 #[must_use]
355 pub fn icon_size(mut self, size: f32) -> Self {
356 self.0.icon_size = Some(size);
357 self
358 }
359
360 #[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 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 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 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 #[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 #[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 #[test]
481 fn both_drawer_buttons_say_the_same_thing() {
482 assert_eq!(Kind::Drawer.label(), Kind::EndDrawer.label());
483 }
484
485 #[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 #[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}