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(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 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(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
85struct 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 Box::new(Tooltip::new(label).child(button))
126 }
127}
128
129pub struct BackButton<Msg = crate::callback::Callback>(Action<Msg>);
145
146impl<Msg: Clone + 'static> BackButton<Msg> {
147 #[allow(clippy::new_without_default)]
149 pub fn new() -> Self {
150 Self(Action::new(Kind::Back))
151 }
152
153 #[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 #[must_use]
163 pub fn color(mut self, color: Color) -> Self {
164 self.0.color = Some(color);
165 self
166 }
167
168 #[must_use]
170 pub fn icon_size(mut self, size: f32) -> Self {
171 self.0.icon_size = Some(size);
172 self
173 }
174
175 #[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 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 fn on_click(&self) -> Option<Msg> {
203 None
204 }
205}
206
207pub struct CloseButton<Msg = crate::callback::Callback>(Action<Msg>);
209
210impl<Msg: Clone + 'static> CloseButton<Msg> {
211 #[allow(clippy::new_without_default)]
213 pub fn new() -> Self {
214 Self(Action::new(Kind::Close))
215 }
216
217 #[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 #[must_use]
227 pub fn color(mut self, color: Color) -> Self {
228 self.0.color = Some(color);
229 self
230 }
231
232 #[must_use]
234 pub fn icon_size(mut self, size: f32) -> Self {
235 self.0.icon_size = Some(size);
236 self
237 }
238
239 #[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 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 fn on_click(&self) -> Option<Msg> {
267 None
268 }
269}
270
271pub struct DrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
274
275impl<Msg: Clone + 'static> DrawerButton<Msg> {
276 #[allow(clippy::new_without_default)]
278 pub fn new() -> Self {
279 Self(Action::new(Kind::Drawer))
280 }
281
282 #[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 #[must_use]
292 pub fn color(mut self, color: Color) -> Self {
293 self.0.color = Some(color);
294 self
295 }
296
297 #[must_use]
299 pub fn icon_size(mut self, size: f32) -> Self {
300 self.0.icon_size = Some(size);
301 self
302 }
303
304 #[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 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 fn on_click(&self) -> Option<Msg> {
332 None
333 }
334}
335
336pub struct EndDrawerButton<Msg = crate::callback::Callback>(Action<Msg>);
342
343impl<Msg: Clone + 'static> EndDrawerButton<Msg> {
344 #[allow(clippy::new_without_default)]
346 pub fn new() -> Self {
347 Self(Action::new(Kind::EndDrawer))
348 }
349
350 #[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 #[must_use]
360 pub fn color(mut self, color: Color) -> Self {
361 self.0.color = Some(color);
362 self
363 }
364
365 #[must_use]
367 pub fn icon_size(mut self, size: f32) -> Self {
368 self.0.icon_size = Some(size);
369 self
370 }
371
372 #[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 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 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 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 #[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 #[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 #[test]
493 fn both_drawer_buttons_say_the_same_thing() {
494 assert_eq!(Kind::Drawer.label(), Kind::EndDrawer.label());
495 }
496
497 #[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 #[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 #[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}