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