1use frus_core::{BorderRadius, Color, Point, Rect, Scene, TextStyle};
24use frus_layout::{Dimension, Style};
25
26use crate::disabled::{disabled_container, disabled_content};
27use crate::icons::IconData;
28use crate::interaction::Status;
29use crate::theme::{TapTarget, Theme};
30use crate::widget::Widget;
31
32pub const ICON_BUTTON_SIZE: f32 = 40.0;
34pub const ICON_BUTTON_ICON_SIZE: f32 = 24.0;
36pub const ICON_BUTTON_BORDER_WIDTH: f32 = 1.0;
38
39#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
41pub enum IconButtonVariant {
42 #[default]
44 Standard,
45 Filled,
47 Tonal,
49 Outlined,
51}
52
53enum Content {
55 Icon(IconData),
56 Glyph(String),
57 Morph(crate::icons::AnimatedIconData, f32),
60 Image(Box<crate::ImageIcon>),
63}
64
65pub struct IconButton<Msg = crate::callback::Callback> {
67 content: Content,
68 variant: IconButtonVariant,
69 selected: Option<bool>,
71 enabled: bool,
72 label: Option<String>,
73 on_press: Option<Msg>,
74 size: Option<f32>,
75 icon_size: Option<f32>,
76 color: Option<Color>,
77 icon_color: Option<Color>,
78 border_color: Option<Color>,
79 border_width: Option<f32>,
80 radius: Option<BorderRadius>,
81 tap_target: Option<TapTarget>,
82}
83
84impl<Msg> IconButton<Msg> {
85 pub fn new(icon: IconData) -> Self {
87 Self::of(Content::Icon(icon))
88 }
89
90 pub fn glyph(glyph: impl Into<String>) -> Self {
93 Self::of(Content::Glyph(glyph.into()))
94 }
95
96 pub fn image(icon: crate::ImageIcon) -> Self {
106 Self::of(Content::Image(Box::new(icon)))
107 }
108
109 pub fn animated(icon: crate::icons::AnimatedIconData, on: bool) -> Self {
126 Self::of(Content::Morph(icon, if on { 1.0 } else { 0.0 }))
127 }
128
129 fn of(content: Content) -> Self {
130 Self {
131 content,
132 variant: IconButtonVariant::default(),
133 selected: None,
134 enabled: true,
135 label: None,
136 on_press: None,
137 size: None,
138 icon_size: None,
139 color: None,
140 icon_color: None,
141 border_color: None,
142 border_width: None,
143 radius: None,
144 tap_target: None,
145 }
146 }
147
148 pub fn label(mut self, label: impl Into<String>) -> Self {
151 self.label = Some(label.into());
152 self
153 }
154
155 pub fn variant(mut self, variant: IconButtonVariant) -> Self {
157 self.variant = variant;
158 self
159 }
160
161 pub fn selected(mut self, selected: bool) -> Self {
164 self.selected = Some(selected);
165 self
166 }
167
168 pub fn enabled(mut self, enabled: bool) -> Self {
170 self.enabled = enabled;
171 self
172 }
173
174 pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
176 self.on_press = Some(message.into());
177 self
178 }
179
180 pub fn size(mut self, size: f32) -> Self {
182 self.size = Some(size);
183 self
184 }
185
186 pub fn icon_size(mut self, size: f32) -> Self {
188 self.icon_size = Some(size);
189 self
190 }
191
192 pub fn color(mut self, color: Color) -> Self {
194 self.color = Some(color);
195 self
196 }
197
198 pub fn icon_color(mut self, color: Color) -> Self {
200 self.icon_color = Some(color);
201 self
202 }
203
204 pub fn border_color(mut self, color: Color) -> Self {
206 self.border_color = Some(color);
207 self
208 }
209
210 pub fn border_width(mut self, width: f32) -> Self {
212 self.border_width = Some(width);
213 self
214 }
215
216 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
218 self.radius = Some(radius.into());
219 self
220 }
221
222 pub fn tap_target(mut self, target: TapTarget) -> Self {
226 self.tap_target = Some(target);
227 self
228 }
229
230 fn side(&self, theme: &Theme) -> f32 {
231 self.size
232 .or(theme.widgets.icon_button.size)
233 .unwrap_or(ICON_BUTTON_SIZE)
234 }
235
236 fn reserved(&self, theme: &Theme) -> f32 {
239 self.tap_target
240 .or(theme.widgets.icon_button.tap_target)
241 .unwrap_or(theme.tap_target)
242 .min_side()
243 }
244
245 fn face(&self, bounds: Rect, theme: &Theme) -> Rect {
248 let side = self.side(theme);
249 Rect::new(
250 bounds.x + (bounds.width - side) * 0.5,
251 bounds.y + (bounds.height - side) * 0.5,
252 side,
253 side,
254 )
255 }
256
257 fn glyph_size(&self, theme: &Theme) -> f32 {
258 self.icon_size
259 .or(theme.widgets.icon_button.icon_size)
260 .unwrap_or(ICON_BUTTON_ICON_SIZE)
261 }
262
263 fn radius_of(&self, theme: &Theme, side: f32) -> BorderRadius {
264 self.radius
265 .or(theme.widgets.icon_button.radius)
266 .unwrap_or(BorderRadius::uniform(side / 2.0))
269 }
270
271 fn palette(&self, theme: &Theme) -> (Color, Color, Option<Color>) {
273 if !self.enabled {
274 return (
275 match self.variant {
276 IconButtonVariant::Standard | IconButtonVariant::Outlined => Color::TRANSPARENT,
277 _ => disabled_container(theme),
278 },
279 disabled_content(theme),
280 match self.variant {
285 IconButtonVariant::Outlined => Some(disabled_container(theme)),
286 _ => None,
287 },
288 );
289 }
290 let (surface, glyph, outline) = match self.variant {
291 IconButtonVariant::Standard => (
292 Color::TRANSPARENT,
293 if self.selected == Some(true) {
294 theme.scheme.primary
295 } else {
296 theme.scheme.on_surface_variant
297 },
298 None,
299 ),
300 IconButtonVariant::Filled => (theme.scheme.primary, theme.scheme.on_primary, None),
301 IconButtonVariant::Tonal => (
302 theme.scheme.secondary_container,
303 theme.scheme.on_secondary_container,
304 None,
305 ),
306 IconButtonVariant::Outlined => (
307 Color::TRANSPARENT,
308 if self.selected == Some(true) {
309 theme.scheme.primary
310 } else {
311 theme.scheme.on_surface_variant
312 },
313 Some(theme.scheme.outline),
314 ),
315 };
316 (
317 self.color
318 .or(theme.widgets.icon_button.color)
319 .unwrap_or(surface),
320 self.icon_color
321 .or(theme.widgets.icon_button.icon_color)
322 .unwrap_or(glyph),
323 self.border_color
324 .or(theme.widgets.icon_button.border_color)
325 .or(outline),
326 )
327 }
328}
329
330impl<Msg: Clone> Widget<Msg> for IconButton<Msg> {
331 fn style(&self) -> Style {
332 Widget::<Msg>::style_themed(self, &Theme::default())
333 }
334
335 fn style_themed(&self, theme: &Theme) -> Style {
340 let side = self.side(theme).max(self.reserved(theme));
341 Style {
342 width: Dimension::Length(side),
343 height: Dimension::Length(side),
344 ..Default::default()
345 }
346 }
347
348 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
349 &[]
350 }
351
352 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
353 let o = status.opacity;
354 let (surface, glyph, outline) = self.palette(theme);
355 let bounds = self.face(bounds, theme);
356 let radius = self.radius_of(theme, bounds.height.min(bounds.width));
357 let (border_width, border_color) = match (
358 self.border_width.or(theme.widgets.icon_button.border_width),
359 outline,
360 ) {
361 (Some(width), Some(color)) => (width, color),
362 (Some(width), None) => (width, theme.scheme.outline),
363 (None, Some(color)) => (ICON_BUTTON_BORDER_WIDTH, color),
364 (None, None) => (0.0, Color::TRANSPARENT),
365 };
366 let surface = if self.enabled {
367 theme.state_layer(surface, glyph, &status)
368 } else {
369 surface
370 };
371 scene.draw_rect(
372 bounds,
373 surface.fade(o),
374 radius,
375 border_width,
376 border_color.fade(o),
377 );
378
379 let size = self.glyph_size(theme);
380 match &self.content {
381 Content::Morph(pair, _) => {
385 let path = pair.at(status.value).placed(
386 size,
387 bounds.x + (bounds.width - size) / 2.0,
388 bounds.y + (bounds.height - size) / 2.0,
389 theme.direction,
390 );
391 scene.fill_path(&path, glyph.fade(o));
392 }
393 Content::Icon(name) => {
394 let path = name.placed(
395 size,
396 bounds.x + (bounds.width - size) / 2.0,
397 bounds.y + (bounds.height - size) / 2.0,
398 theme.direction,
399 );
400 scene.fill_path(&path, glyph.fade(o));
401 }
402 Content::Image(icon) => {
405 let square = Rect::new(
406 bounds.x + (bounds.width - size) / 2.0,
407 bounds.y + (bounds.height - size) / 2.0,
408 size,
409 size,
410 );
411 icon.paint_at(square, status, theme, scene);
412 }
413 Content::Glyph(text) => {
414 let style = TextStyle::new(size);
415 let measured = frus_text::measure_styled(
416 text,
417 size,
418 style.resolved().weight,
419 style.resolved().italic,
420 );
421 scene.text(
422 Point::new(
423 bounds.x + (bounds.width - measured.width) / 2.0,
424 bounds.y + (bounds.height - measured.height) / 2.0,
425 ),
426 text.clone(),
427 &style.resolved(),
428 glyph.fade(o),
429 );
430 }
431 }
432 }
433
434 fn anim_target(&self) -> Option<f32> {
437 match self.content {
438 Content::Morph(_, target) => Some(target),
439 _ => None,
440 }
441 }
442
443 fn on_click(&self) -> Option<Msg> {
444 if self.enabled {
445 self.on_press.clone()
446 } else {
447 None
448 }
449 }
450
451 fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
452 if !self.enabled {
453 return None;
454 }
455 let (_, glyph, _) = self.palette(theme);
456 let splash = theme.widgets.ink.color.unwrap_or_else(|| glyph.fade(0.16));
457 Some(
458 crate::InkStyle::of(theme)
459 .color(splash)
460 .radius(self.radius_of(theme, self.side(theme))),
461 )
462 }
463
464 fn focusable(&self) -> bool {
465 self.enabled
466 }
467
468 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
469 let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
470 let semantics = match &self.label {
471 Some(label) => semantics.label(label.clone()),
472 None => match &self.content {
475 Content::Glyph(text) => semantics.label(text.clone()),
476 Content::Icon(_) | Content::Morph(..) | Content::Image(_) => semantics,
477 },
478 };
479 Some(if self.enabled {
480 match self.selected {
481 Some(on) => semantics.toggled(on),
482 None => semantics,
483 }
484 .clickable()
485 } else {
486 semantics.disabled(true)
487 })
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::icons::Icons;
495 use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
496 use frus_core::Primitive;
497
498 #[derive(Clone, Debug, PartialEq)]
499 enum Msg {
500 Pressed,
501 }
502
503 fn painted(button: &IconButton<Msg>) -> Vec<Primitive> {
504 let mut scene = Scene::new();
505 Widget::<Msg>::paint(
506 button,
507 Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
508 Status::default(),
509 &Theme::default(),
510 &mut scene,
511 );
512 scene.primitives().to_vec()
513 }
514
515 #[test]
520 fn a_morphing_button_draws_the_crossing_and_still_says_what_it_does() {
521 let paint_at = |value: f32| {
522 let mut scene = Scene::new();
523 Widget::<Msg>::paint(
524 &IconButton::animated(crate::AnimatedIcons::MENU_CLOSE, true)
525 .label("Menu")
526 .on_press(Msg::Pressed),
527 Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
528 Status {
529 value,
530 ..Status::default()
531 },
532 &Theme::default(),
533 &mut scene,
534 );
535 scene
536 .primitives()
537 .iter()
538 .find_map(|p| match p {
539 Primitive::Path { path, .. } => Some(path.clone()),
540 _ => None,
541 })
542 .expect("a morphing button paints a path")
543 };
544 assert_ne!(paint_at(0.0).verbs(), paint_at(0.5).verbs());
545 assert_ne!(paint_at(0.5).verbs(), paint_at(1.0).verbs());
546
547 let button = IconButton::animated(crate::AnimatedIcons::MENU_CLOSE, false)
548 .label("Menu")
549 .on_press(Msg::Pressed);
550 assert_eq!(Widget::<Msg>::anim_target(&button), Some(0.0));
551 assert_eq!(
552 Widget::<Msg>::anim_target(&IconButton::<Msg>::new(Icons::CLOSE)),
553 None,
554 "a button that is one mark has no animated value"
555 );
556 }
557
558 #[test]
562 fn an_icon_button_reserves_room_for_a_finger() {
563 let theme = Theme::dark();
564 let side = |button: &IconButton<()>, theme: &Theme| match Widget::<()>::style_themed(
565 button, theme,
566 )
567 .width
568 {
569 Dimension::Length(w) => w,
570 other => panic!("{other:?}"),
571 };
572 assert_eq!(side(&IconButton::new(Icons::CLOSE), &theme), MIN_TAP_TARGET);
573 assert_eq!(
574 side(
575 &IconButton::new(Icons::CLOSE).tap_target(TapTarget::ShrinkWrap),
576 &theme
577 ),
578 SHRUNK_TAP_TARGET
579 );
580 assert_eq!(
583 side(&IconButton::new(Icons::CLOSE).size(64.0), &theme),
584 64.0
585 );
586 }
587
588 #[test]
591 fn the_face_is_centred_in_the_room() {
592 let theme = Theme::dark();
593 let mut scene = Scene::new();
594 Widget::<()>::paint(
595 &IconButton::new(Icons::CLOSE),
596 Rect::new(0.0, 0.0, MIN_TAP_TARGET, MIN_TAP_TARGET),
597 Status::default(),
598 &theme,
599 &mut scene,
600 );
601 let face = scene
602 .primitives()
603 .iter()
604 .find_map(|p| match p {
605 frus_core::Primitive::Rect { rect, .. } => Some(*rect),
606 _ => None,
607 })
608 .expect("an icon button paints its surface");
609 assert_eq!(face.width, ICON_BUTTON_SIZE);
610 assert_eq!(face.height, ICON_BUTTON_SIZE);
611 let inset = (MIN_TAP_TARGET - ICON_BUTTON_SIZE) * 0.5;
612 assert!(
613 (face.x - inset).abs() < 0.01 && (face.y - inset).abs() < 0.01,
614 "centred in the room: {face:?}"
615 );
616 }
617
618 #[test]
619 fn it_is_a_circle_as_wide_as_it_is_tall() {
620 let theme = Theme::default();
621 let style = Widget::<Msg>::style_themed(&IconButton::new(Icons::CLOSE), &theme);
624 assert_eq!(style.width, Dimension::Length(MIN_TAP_TARGET));
625 assert_eq!(style.height, Dimension::Length(MIN_TAP_TARGET));
626 let radius = painted(&IconButton::new(Icons::CLOSE))
627 .iter()
628 .find_map(|p| match p {
629 Primitive::Rect { radius, .. } => Some(*radius),
630 _ => None,
631 })
632 .expect("a box");
633 assert_eq!(radius, BorderRadius::uniform(ICON_BUTTON_SIZE / 2.0));
634 }
635
636 #[test]
637 fn a_standard_one_is_a_glyph_and_nothing_else() {
638 let theme = Theme::default();
639 let painted = painted(&IconButton::new(Icons::CLOSE));
640 let (color, border) = painted
641 .iter()
642 .find_map(|p| match p {
643 Primitive::Rect {
644 color,
645 border_width,
646 ..
647 } => Some((*color, *border_width)),
648 _ => None,
649 })
650 .expect("a box");
651 assert_eq!(color, Color::TRANSPARENT, "no surface at rest");
652 assert_eq!(border, 0.0);
653 let glyph = painted
654 .iter()
655 .find_map(|p| match p {
656 Primitive::Path { fill, .. } => *fill,
657 _ => None,
658 })
659 .expect("an icon");
660 assert_eq!(glyph, theme.scheme.on_surface_variant);
661 }
662
663 #[test]
664 fn a_glyph_is_drawn_where_the_set_has_no_icon() {
665 let painted = painted(&IconButton::<Msg>::glyph("\u{2190}"));
668 assert!(
669 painted
670 .iter()
671 .any(|p| matches!(p, Primitive::Text { text, .. } if text == "\u{2190}")),
672 "the glyph is drawn"
673 );
674 }
675
676 #[test]
680 fn only_a_selected_button_is_a_toggle() {
681 use frus_core::Toggled;
682 let toggled = |button: IconButton<Msg>| {
683 Widget::semantics(&button.on_press(Msg::Pressed))
684 .expect("an enabled button describes itself")
685 .toggled
686 };
687 assert_eq!(toggled(IconButton::new(Icons::STAR)), Toggled::None);
688 assert_eq!(
689 toggled(IconButton::new(Icons::STAR).selected(false)),
690 Toggled::False
691 );
692 assert_eq!(
693 toggled(IconButton::new(Icons::STAR).selected(true)),
694 Toggled::True
695 );
696 }
697
698 #[test]
699 fn selected_takes_the_accent() {
700 let theme = Theme::default();
701 let glyph = |selected: bool| {
702 painted(&IconButton::<Msg>::new(Icons::STAR).selected(selected))
703 .iter()
704 .find_map(|p| match p {
705 Primitive::Path { fill, .. } => *fill,
706 _ => None,
707 })
708 .expect("an icon")
709 };
710 assert_eq!(glyph(false), theme.scheme.on_surface_variant);
711 assert_eq!(glyph(true), theme.scheme.primary);
712 }
713
714 #[test]
715 fn the_variants_are_told_apart_by_their_surface() {
716 let theme = Theme::default();
717 let surface = |variant: IconButtonVariant| {
718 painted(&IconButton::<Msg>::new(Icons::CLOSE).variant(variant))
719 .iter()
720 .find_map(|p| match p {
721 Primitive::Rect {
722 color,
723 border_width,
724 ..
725 } => Some((*color, *border_width)),
726 _ => None,
727 })
728 .expect("a box")
729 };
730 assert_eq!(surface(IconButtonVariant::Filled).0, theme.scheme.primary);
731 assert_eq!(
732 surface(IconButtonVariant::Tonal).0,
733 theme.scheme.secondary_container
734 );
735 let outlined = surface(IconButtonVariant::Outlined);
736 assert_eq!(outlined.0, Color::TRANSPARENT);
737 assert_eq!(outlined.1, ICON_BUTTON_BORDER_WIDTH);
738 }
739
740 #[test]
741 fn disabled_is_inert_and_says_so() {
742 let button = IconButton::new(Icons::CLOSE)
743 .on_press(Msg::Pressed)
744 .enabled(false);
745 assert_eq!(Widget::on_click(&button), None);
746 assert!(!Widget::<Msg>::focusable(&button));
747 assert!(Widget::<Msg>::ink(&button, &Theme::default()).is_none());
748 let semantics = Widget::<Msg>::semantics(&button).expect("semantics");
749 assert!(semantics.disabled);
750 assert!(!semantics.clickable);
751 }
752
753 #[test]
754 fn an_icon_button_can_be_named() {
755 let named = IconButton::<Msg>::new(Icons::CLOSE).label("Remove");
757 assert_eq!(
758 Widget::<Msg>::semantics(&named).unwrap().label.as_deref(),
759 Some("Remove")
760 );
761 assert_eq!(
763 Widget::<Msg>::semantics(&IconButton::<Msg>::glyph("+"))
764 .unwrap()
765 .label
766 .as_deref(),
767 Some("+")
768 );
769 }
770
771 #[test]
772 fn every_measurement_is_the_callers_and_then_the_themes() {
773 let mut theme = Theme::default();
774 theme.widgets.icon_button.size = Some(56.0);
775 let side = |button: IconButton<Msg>, theme: &Theme| {
778 let mut scene = Scene::new();
779 Widget::<Msg>::paint(
780 &button,
781 Rect::new(0.0, 0.0, 96.0, 96.0),
782 Status::default(),
783 theme,
784 &mut scene,
785 );
786 scene
787 .primitives()
788 .iter()
789 .find_map(|p| match p {
790 Primitive::Rect { rect, .. } => Some(rect.width),
791 _ => None,
792 })
793 .expect("a box")
794 };
795 assert_eq!(
796 side(IconButton::new(Icons::CLOSE), &Theme::default()),
797 ICON_BUTTON_SIZE,
798 "the framework's"
799 );
800 assert_eq!(
801 side(IconButton::new(Icons::CLOSE), &theme),
802 56.0,
803 "the theme's"
804 );
805 assert_eq!(
806 side(IconButton::new(Icons::CLOSE).size(32.0), &theme),
807 32.0,
808 "the caller's, over the theme's"
809 );
810 assert_eq!(
813 match Widget::<Msg>::style_themed(&IconButton::new(Icons::CLOSE).size(32.0), &theme)
814 .width
815 {
816 Dimension::Length(w) => w,
817 other => panic!("{other:?}"),
818 },
819 MIN_TAP_TARGET
820 );
821 }
822}