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