1use frus_core::{
19 Alignment, BorderRadius, BorderSide, Color, Insets, Rect, Scene, ShapeBorder, Size, TextStyle,
20};
21use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
22
23use crate::interaction::Status;
24use crate::theme::{TapTarget, Theme};
25use crate::widget::Widget;
26use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
27
28pub const BUTTON_HEIGHT: f32 = 40.0;
30pub const BUTTON_MIN_WIDTH: f32 = 64.0;
32pub const BUTTON_PADDING: f32 = 24.0;
34pub const BUTTON_TEXT_PADDING: f32 = 12.0;
36pub const BUTTON_ELEVATION: f32 = 1.0;
38pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
40pub const BUTTON_ICON_SIZE: f32 = 18.0;
42
43#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
46pub enum Variant {
47 #[default]
50 Filled,
51 Tonal,
53 Elevated,
55 Outlined,
57 Text,
59 Danger,
65}
66
67#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
69pub enum IconAlignment {
70 #[default]
72 Start,
73 End,
75}
76
77#[derive(Clone, Debug, Default, PartialEq)]
86pub struct ButtonStyle {
87 pub text_style: Option<WidgetStateProperty<TextStyle>>,
89 pub background_color: Option<WidgetStateProperty<Color>>,
91 pub foreground_color: Option<WidgetStateProperty<Color>>,
93 pub overlay_color: Option<WidgetStateProperty<Color>>,
95 pub shadow_color: Option<WidgetStateProperty<Color>>,
97 pub elevation: Option<WidgetStateProperty<f32>>,
99 pub padding: Option<WidgetStateProperty<Insets>>,
101 pub minimum_size: Option<WidgetStateProperty<Size>>,
103 pub fixed_size: Option<WidgetStateProperty<Size>>,
105 pub maximum_size: Option<WidgetStateProperty<Size>>,
107 pub icon_color: Option<WidgetStateProperty<Color>>,
109 pub icon_size: Option<WidgetStateProperty<f32>>,
111 pub side: Option<WidgetStateProperty<BorderSide>>,
113 pub shape: Option<WidgetStateProperty<ShapeBorder>>,
115 pub visual_density: Option<crate::VisualDensity>,
117 pub tap_target: Option<TapTarget>,
119 pub alignment: Option<Alignment>,
121 pub icon_alignment: Option<IconAlignment>,
123}
124
125impl ButtonStyle {
126 pub fn new() -> Self {
128 Self::default()
129 }
130
131 #[must_use]
133 pub fn background_color(mut self, property: WidgetStateProperty<Color>) -> Self {
134 self.background_color = Some(property);
135 self
136 }
137
138 #[must_use]
140 pub fn foreground_color(mut self, property: WidgetStateProperty<Color>) -> Self {
141 self.foreground_color = Some(property);
142 self
143 }
144
145 #[must_use]
147 pub fn overlay_color(mut self, property: WidgetStateProperty<Color>) -> Self {
148 self.overlay_color = Some(property);
149 self
150 }
151
152 #[must_use]
154 pub fn elevation(mut self, property: WidgetStateProperty<f32>) -> Self {
155 self.elevation = Some(property);
156 self
157 }
158
159 #[must_use]
161 pub fn padding(mut self, property: WidgetStateProperty<Insets>) -> Self {
162 self.padding = Some(property);
163 self
164 }
165
166 #[must_use]
168 pub fn minimum_size(mut self, size: Size) -> Self {
169 self.minimum_size = Some(WidgetStateProperty::all(size));
170 self
171 }
172
173 #[must_use]
175 pub fn fixed_size(mut self, size: Size) -> Self {
176 self.fixed_size = Some(WidgetStateProperty::all(size));
177 self
178 }
179
180 #[must_use]
182 pub fn maximum_size(mut self, size: Size) -> Self {
183 self.maximum_size = Some(WidgetStateProperty::all(size));
184 self
185 }
186
187 #[must_use]
189 pub fn side(mut self, property: WidgetStateProperty<BorderSide>) -> Self {
190 self.side = Some(property);
191 self
192 }
193
194 #[must_use]
196 pub fn shape(mut self, shape: ShapeBorder) -> Self {
197 self.shape = Some(WidgetStateProperty::all(shape));
198 self
199 }
200
201 #[must_use]
203 pub fn icon_color(mut self, property: WidgetStateProperty<Color>) -> Self {
204 self.icon_color = Some(property);
205 self
206 }
207
208 #[must_use]
210 pub fn icon_size(mut self, size: f32) -> Self {
211 self.icon_size = Some(WidgetStateProperty::all(size));
212 self
213 }
214
215 #[must_use]
217 pub fn text_style(mut self, style: TextStyle) -> Self {
218 self.text_style = Some(WidgetStateProperty::all(style));
219 self
220 }
221
222 #[must_use]
224 pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
225 self.visual_density = Some(density);
226 self
227 }
228
229 #[must_use]
231 pub fn tap_target(mut self, target: TapTarget) -> Self {
232 self.tap_target = Some(target);
233 self
234 }
235
236 #[must_use]
238 pub fn alignment(mut self, alignment: Alignment) -> Self {
239 self.alignment = Some(alignment);
240 self
241 }
242
243 #[must_use]
245 pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
246 self.icon_alignment = Some(alignment);
247 self
248 }
249}
250
251fn enabled_only<T>(value: T) -> WidgetStateProperty<T> {
255 WidgetStateProperty::new().when(!crate::StateFilter::from(WidgetState::Disabled), value)
256}
257
258fn fill<T>(slot: &mut Option<T>, value: Option<T>) {
260 if slot.is_none() {
261 *slot = value;
262 }
263}
264
265fn scaled_padding(one: Insets, two: Insets, three: Insets, scale: f32) -> Insets {
268 let lerp = |a: Insets, b: Insets, t: f32| {
269 Insets::new(
270 a.top + (b.top - a.top) * t,
271 a.right + (b.right - a.right) * t,
272 a.bottom + (b.bottom - a.bottom) * t,
273 a.left + (b.left - a.left) * t,
274 )
275 };
276 if scale <= 1.0 {
277 one
278 } else if scale < 2.0 {
279 lerp(one, two, scale - 1.0)
280 } else if scale < 3.0 {
281 lerp(two, three, scale - 2.0)
282 } else {
283 three
284 }
285}
286
287fn directional(start: f32, top: f32, end: f32, bottom: f32, rtl: bool) -> Insets {
289 if rtl {
290 Insets::new(top, start, bottom, end)
291 } else {
292 Insets::new(top, end, bottom, start)
293 }
294}
295
296fn default_style(variant: Variant, theme: &Theme, with_icon: bool) -> ButtonStyle {
301 let c = &theme.scheme;
302 let disabled = WidgetState::Disabled;
303 let disabled_fill = crate::disabled::disabled_container(theme);
307 let disabled_ink = crate::disabled::disabled_content(theme);
308 let (background, foreground, overlay): (Option<Color>, Color, Color) = match variant {
309 Variant::Elevated => (Some(c.surface_container_low), c.primary, c.primary),
310 Variant::Filled => (Some(c.primary), c.on_primary, c.on_primary),
311 Variant::Tonal => (
312 Some(c.secondary_container),
313 c.on_secondary_container,
314 c.on_secondary_container,
315 ),
316 Variant::Danger => (Some(c.error), c.on_error, c.on_error),
317 Variant::Outlined | Variant::Text => (None, c.primary, c.primary),
318 };
319 let (rest, hovered) = match variant {
322 Variant::Elevated => (1.0, 3.0),
323 Variant::Filled | Variant::Tonal | Variant::Danger => (0.0, 1.0),
324 Variant::Outlined | Variant::Text => (0.0, 0.0),
325 };
326 let scale = text_size_scale(theme);
327 let rtl = theme.direction == frus_core::TextDirection::Rtl;
328 let padding = match (variant, with_icon) {
329 (Variant::Text, false) => scaled_padding(
330 Insets::new(8.0, 12.0, 8.0, 12.0),
331 Insets::new(0.0, 8.0, 0.0, 8.0),
332 Insets::new(0.0, 4.0, 0.0, 4.0),
333 scale,
334 ),
335 (Variant::Text, true) => scaled_padding(
336 directional(12.0, 8.0, 16.0, 8.0, rtl),
337 Insets::new(0.0, 4.0, 0.0, 4.0),
338 Insets::new(0.0, 4.0, 0.0, 4.0),
339 scale,
340 ),
341 (_, false) => scaled_padding(
342 Insets::new(0.0, BUTTON_PADDING, 0.0, BUTTON_PADDING),
343 Insets::new(0.0, 12.0, 0.0, 12.0),
344 Insets::new(0.0, 6.0, 0.0, 6.0),
345 scale,
346 ),
347 (_, true) => scaled_padding(
348 directional(16.0, 0.0, 24.0, 0.0, rtl),
349 directional(8.0, 0.0, 12.0, 0.0, rtl),
350 directional(4.0, 0.0, 6.0, 0.0, rtl),
351 scale,
352 ),
353 };
354 let mut style = ButtonStyle {
355 text_style: Some(WidgetStateProperty::all(theme.text.label_large)),
356 background_color: background.map(|bg| {
357 WidgetStateProperty::new()
358 .when(disabled, disabled_fill)
359 .otherwise(bg)
360 }),
361 foreground_color: Some(
362 WidgetStateProperty::new()
363 .when(disabled, disabled_ink)
364 .otherwise(foreground),
365 ),
366 overlay_color: Some(
369 WidgetStateProperty::new()
370 .when(WidgetState::Pressed, overlay.with_alpha(0.1))
371 .when(WidgetState::Hovered, overlay.with_alpha(0.08))
372 .when(WidgetState::Focused, overlay.with_alpha(0.1)),
373 ),
374 shadow_color: Some(WidgetStateProperty::all(c.shadow)),
375 elevation: Some(
376 WidgetStateProperty::new()
377 .when(disabled, 0.0)
378 .when(WidgetState::Pressed, rest)
379 .when(WidgetState::Hovered, hovered)
380 .otherwise(rest),
381 ),
382 padding: Some(WidgetStateProperty::all(padding)),
383 minimum_size: Some(WidgetStateProperty::all(Size::new(
384 BUTTON_MIN_WIDTH,
385 BUTTON_HEIGHT,
386 ))),
387 fixed_size: None,
388 maximum_size: Some(WidgetStateProperty::all(Size::new(
389 f32::INFINITY,
390 f32::INFINITY,
391 ))),
392 icon_color: None,
393 icon_size: Some(WidgetStateProperty::all(BUTTON_ICON_SIZE)),
394 side: None,
395 shape: Some(WidgetStateProperty::all(ShapeBorder::stadium())),
396 visual_density: Some(theme.visual_density()),
397 tap_target: Some(theme.tap_target),
398 alignment: Some(Alignment::CENTER),
399 icon_alignment: Some(IconAlignment::Start),
400 };
401 if variant == Variant::Outlined {
402 style.side = Some(
405 WidgetStateProperty::new()
406 .when(
407 disabled,
408 BorderSide::new(disabled_fill, BUTTON_BORDER_WIDTH),
409 )
410 .when(
411 WidgetState::Focused,
412 BorderSide::new(c.primary, BUTTON_BORDER_WIDTH),
413 )
414 .otherwise(BorderSide::new(c.outline, BUTTON_BORDER_WIDTH)),
415 );
416 }
417 style
418}
419
420fn text_size_scale(theme: &Theme) -> f32 {
423 let size = theme.text.label_large.size.unwrap_or(14.0);
424 size * frus_core::text_scale() / 14.0
425}
426
427fn resolve<T: Clone>(
429 states: WidgetStates,
430 get: impl Fn(&ButtonStyle) -> Option<&WidgetStateProperty<T>>,
431 styles: [Option<&ButtonStyle>; 3],
432) -> Option<T> {
433 styles
434 .into_iter()
435 .flatten()
436 .find_map(|style| get(style).and_then(|p| p.resolve(states)).cloned())
437}
438
439fn setting<T: Copy>(
441 get: impl Fn(&ButtonStyle) -> Option<T>,
442 styles: [Option<&ButtonStyle>; 3],
443) -> Option<T> {
444 styles.into_iter().flatten().find_map(get)
445}
446
447struct Parts<Msg> {
450 content: Box<dyn Widget<Msg>>,
451 icon: Option<Box<dyn Widget<Msg>>>,
452}
453
454pub struct Button<Msg = crate::callback::Callback> {
457 label: Option<String>,
458 parts: std::cell::RefCell<Option<Parts<Msg>>>,
459 built: std::cell::OnceCell<Vec<Box<dyn Widget<Msg>>>>,
460 has_icon: bool,
461 variant: Variant,
462 on_press: Option<Msg>,
463 on_long_press: Option<Msg>,
464 enabled: bool,
466 style: ButtonStyle,
467 min_width: Option<f32>,
470 height: Option<f32>,
471}
472
473impl<Msg: Clone + 'static> Button<Msg> {
474 pub fn new(label: impl Into<String>) -> Self {
476 let label = label.into();
477 let mut button = Self::with_child(crate::ExcludeSemantics::new(crate::Text::new(
480 label.clone(),
481 )));
482 button.label = Some(label);
483 button
484 }
485
486 pub fn with_child(child: impl Widget<Msg> + 'static) -> Self {
490 Self {
491 label: None,
492 parts: std::cell::RefCell::new(Some(Parts {
493 content: Box::new(child),
494 icon: None,
495 })),
496 built: std::cell::OnceCell::new(),
497 has_icon: false,
498 variant: Variant::default(),
499 on_press: None,
500 on_long_press: None,
501 enabled: true,
502 style: ButtonStyle::default(),
503 min_width: None,
504 height: None,
505 }
506 }
507
508 pub fn icon(mut self, icon: impl Widget<Msg> + 'static) -> Self {
513 if let Some(parts) = self.parts.get_mut().as_mut() {
514 parts.icon = Some(Box::new(icon));
515 self.has_icon = true;
516 }
517 self
518 }
519}
520
521impl<Msg> Button<Msg> {
522 pub fn enabled(mut self, enabled: bool) -> Self {
525 self.enabled = enabled;
526 self
527 }
528
529 fn is_enabled(&self) -> bool {
531 self.enabled && (self.on_press.is_some() || self.on_long_press.is_some())
532 }
533
534 #[must_use]
537 pub fn style(mut self, style: ButtonStyle) -> Self {
538 self.style = style;
539 self
540 }
541
542 pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
545 self.style.shape = Some(WidgetStateProperty::all(ShapeBorder::rounded(radius)));
546 self
547 }
548
549 #[must_use]
551 pub fn shape(mut self, shape: ShapeBorder) -> Self {
552 self.style.shape = Some(WidgetStateProperty::all(shape));
553 self
554 }
555
556 pub fn variant(mut self, variant: Variant) -> Self {
558 self.variant = variant;
559 self
560 }
561
562 pub fn label_style(mut self, style: TextStyle) -> Self {
564 self.style.text_style = Some(WidgetStateProperty::all(style));
565 self
566 }
567
568 pub fn size(mut self, size: f32) -> Self {
570 self.style.text_style = Some(WidgetStateProperty::all(TextStyle::new(size)));
571 self
572 }
573
574 pub fn color(mut self, color: Color) -> Self {
576 self.style.background_color = Some(enabled_only(color));
577 self
578 }
579
580 pub fn label_color(mut self, color: Color) -> Self {
582 self.style.foreground_color = Some(enabled_only(color));
583 self
584 }
585
586 pub fn border_color(mut self, color: Color) -> Self {
588 let width = self.border_width_said().unwrap_or(BUTTON_BORDER_WIDTH);
589 self.style.side = Some(enabled_only(BorderSide::new(color, width)));
590 self
591 }
592
593 pub fn border_width(mut self, width: f32) -> Self {
595 let color = self
596 .style
597 .side
598 .as_ref()
599 .and_then(|s| s.resolve(WidgetStates::EMPTY))
600 .map(|s| s.color);
601 self.style.side = Some(match color {
602 Some(color) => enabled_only(BorderSide::new(color, width)),
603 None => WidgetStateProperty::all(BorderSide::new(Color::TRANSPARENT, width)),
604 });
605 self
606 }
607
608 fn border_width_said(&self) -> Option<f32> {
609 self.style
610 .side
611 .as_ref()
612 .and_then(|s| s.resolve(WidgetStates::EMPTY))
613 .map(|s| s.width)
614 }
615
616 pub fn padding(mut self, padding: f32) -> Self {
618 self.style.padding = Some(WidgetStateProperty::all(Insets::new(
619 0.0, padding, 0.0, padding,
620 )));
621 self
622 }
623
624 pub fn height(mut self, height: f32) -> Self {
627 self.height = Some(height);
628 self
629 }
630
631 pub fn min_width(mut self, width: f32) -> Self {
633 self.min_width = Some(width);
634 self
635 }
636
637 pub fn elevation(mut self, elevation: f32) -> Self {
641 self.style.elevation = Some(enabled_only(elevation));
642 self
643 }
644
645 #[must_use]
647 pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
648 self.style.icon_alignment = Some(alignment);
649 self
650 }
651
652 pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
654 self.on_press = Some(message.into());
655 self
656 }
657
658 #[must_use]
660 pub fn on_long_press(mut self, message: impl Into<Msg>) -> Self {
661 self.on_long_press = Some(message.into());
662 self
663 }
664
665 fn theme_style(&self, theme: &Theme) -> ButtonStyle {
668 themed_style(theme, self.variant)
669 }
670}
671
672fn themed_style(theme: &Theme, variant: Variant) -> ButtonStyle {
675 let t = &theme.widgets.button;
676 let mut style = match variant {
677 Variant::Elevated => t.elevated_style.clone(),
678 Variant::Filled | Variant::Tonal | Variant::Danger => t.filled_style.clone(),
679 Variant::Outlined => t.outlined_style.clone(),
680 Variant::Text => t.text_button_style.clone(),
681 }
682 .unwrap_or_default();
683 fill(&mut style.background_color, t.color.map(enabled_only));
684 fill(&mut style.foreground_color, t.label_color.map(enabled_only));
685 fill(
686 &mut style.text_style,
687 t.label_style.map(WidgetStateProperty::all),
688 );
689 fill(
690 &mut style.shape,
691 t.shape
692 .or(t.radius.map(ShapeBorder::rounded))
693 .map(WidgetStateProperty::all),
694 );
695 fill(
696 &mut style.padding,
697 t.padding
698 .map(|p| WidgetStateProperty::all(Insets::new(0.0, p, 0.0, p))),
699 );
700 fill(&mut style.elevation, t.elevation.map(enabled_only));
701 if style.side.is_none() && (t.border_color.is_some() || t.border_width.is_some()) {
702 style.side = Some(enabled_only(BorderSide::new(
703 t.border_color.unwrap_or(theme.scheme.outline),
704 t.border_width.unwrap_or(BUTTON_BORDER_WIDTH),
705 )));
706 }
707 style
708}
709
710impl<Msg> Button<Msg> {
711 fn states(&self, status: &Status) -> WidgetStates {
713 if self.is_enabled() {
714 status.states()
715 } else {
716 WidgetStates::of(WidgetState::Disabled)
717 }
718 }
719
720 fn resolved(&self, theme: &Theme, states: WidgetStates) -> Resolved {
722 let own = &self.style;
723 let themed = self.theme_style(theme);
724 let default = default_style(self.variant, theme, self.has_icon);
725 let styles = [Some(own), Some(&themed), Some(&default)];
726 let r =
727 |f: fn(&ButtonStyle) -> Option<&WidgetStateProperty<Color>>| resolve(states, f, styles);
728 let foreground = r(|s| s.foreground_color.as_ref()).unwrap_or(theme.scheme.primary);
729 let icon_color = resolve(
733 states,
734 |s| s.icon_color.as_ref(),
735 [Some(own), Some(&themed), None],
736 )
737 .or_else(|| {
738 resolve(
739 states,
740 |s| s.foreground_color.as_ref(),
741 [Some(own), Some(&themed), None],
742 )
743 })
744 .or_else(|| {
745 resolve(
746 states,
747 |s| s.icon_color.as_ref(),
748 [Some(&default), None, None],
749 )
750 })
751 .unwrap_or(foreground);
752 let mut minimum = resolve(states, |s| s.minimum_size.as_ref(), styles)
753 .unwrap_or(Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT));
754 if let Some(w) = self.min_width {
755 minimum.width = w;
756 }
757
758 Resolved {
759 text_style: resolve(states, |s| s.text_style.as_ref(), styles)
760 .unwrap_or(theme.text.label_large),
761 background: r(|s| s.background_color.as_ref()),
762 foreground,
763 overlay: r(|s| s.overlay_color.as_ref()),
764 shadow: r(|s| s.shadow_color.as_ref()).unwrap_or(theme.scheme.shadow),
765 elevation: resolve(states, |s| s.elevation.as_ref(), styles).unwrap_or(0.0),
766 padding: resolve(states, |s| s.padding.as_ref(), styles).unwrap_or(Insets::ZERO),
767 minimum,
768 fixed: match (
769 resolve(states, |s| s.fixed_size.as_ref(), styles),
770 self.height,
771 ) {
772 (fixed, None) => fixed,
773 (Some(size), Some(h)) => Some(Size::new(size.width, h)),
774 (None, Some(h)) => Some(Size::new(f32::INFINITY, h)),
775 },
776 maximum: resolve(states, |s| s.maximum_size.as_ref(), styles)
777 .unwrap_or(Size::new(f32::INFINITY, f32::INFINITY)),
778 icon_color,
779 icon_size: resolve(states, |s| s.icon_size.as_ref(), styles)
780 .unwrap_or(BUTTON_ICON_SIZE),
781 side: resolve(states, |s| s.side.as_ref(), styles),
782 shape: resolve(states, |s| s.shape.as_ref(), styles).unwrap_or(ShapeBorder::stadium()),
783 density: setting(|s| s.visual_density, styles).unwrap_or(theme.visual_density()),
784 tap_target: setting(|s| s.tap_target, styles).unwrap_or(theme.tap_target),
785 alignment: setting(|s| s.alignment, styles).unwrap_or(Alignment::CENTER),
786 }
787 }
788
789 fn geometry(&self, r: &Resolved) -> Geometry {
793 let (dx, dy) = r.density.base_size_adjustment();
794 let (mut min_w, mut min_h) = r.density.effective_min_size(
795 (r.minimum.width, r.minimum.height),
796 (r.maximum.width, r.maximum.height),
797 );
798 let mut fixed = (None, None);
799 if let Some(size) = r.fixed {
800 if size.width.is_finite() {
801 let w = size.width.clamp(min_w, r.maximum.width.max(min_w));
802 min_w = w;
803 fixed.0 = Some(w);
804 }
805 if size.height.is_finite() {
806 let h = size.height.clamp(min_h, r.maximum.height.max(min_h));
807 min_h = h;
808 fixed.1 = Some(h);
809 }
810 }
811 let tap = match r.tap_target {
812 TapTarget::Padded => (crate::MIN_TAP_TARGET + dx, crate::MIN_TAP_TARGET + dy),
813 TapTarget::ShrinkWrap => (0.0, 0.0),
814 };
815 let extra = (
816 ((tap.0 - min_w) * 0.5).max(0.0),
817 ((tap.1 - min_h) * 0.5).max(0.0),
818 );
819 let p = r.padding;
822 let ddx = dx.max(0.0);
823 let padding = Insets::new(
824 (p.top + dy).max(0.0),
825 (p.right + ddx).max(0.0),
826 (p.bottom + dy).max(0.0),
827 (p.left + ddx).max(0.0),
828 );
829 Geometry {
830 extra,
831 min: (min_w, min_h),
832 fixed,
833 padding,
834 }
835 }
836}
837
838struct Resolved {
840 text_style: TextStyle,
841 background: Option<Color>,
842 foreground: Color,
843 overlay: Option<Color>,
844 shadow: Color,
845 elevation: f32,
846 padding: Insets,
847 minimum: Size,
848 fixed: Option<Size>,
849 maximum: Size,
850 icon_color: Color,
851 icon_size: f32,
852 side: Option<BorderSide>,
853 shape: ShapeBorder,
854 density: crate::VisualDensity,
855 tap_target: TapTarget,
856 alignment: Alignment,
857}
858
859struct Geometry {
861 extra: (f32, f32),
863 min: (f32, f32),
865 fixed: (Option<f32>, Option<f32>),
867 padding: Insets,
869}
870
871fn over(base: Color, top: Color, amount: f32) -> Color {
873 let a = (top.a * amount).clamp(0.0, 1.0);
874 if a <= 0.0 {
875 return base;
876 }
877 if base.a <= 0.0 {
878 return top.with_alpha(a);
879 }
880 base.lerp(top.with_alpha(base.a), a)
881}
882
883fn place(v: f32) -> (Justify, Align) {
885 let j = if v < -0.5 {
886 Justify::Start
887 } else if v > 0.5 {
888 Justify::End
889 } else {
890 Justify::Center
891 };
892 let a = match j {
893 Justify::Start => Align::Start,
894 Justify::End => Align::End,
895 _ => Align::Center,
896 };
897 (j, a)
898}
899
900struct IconLabel<Msg> {
904 children: Vec<Box<dyn Widget<Msg>>>,
905 own: Option<IconAlignment>,
907 variant: Variant,
909}
910
911impl<Msg> Widget<Msg> for IconLabel<Msg> {
912 fn style(&self) -> Style {
913 self.style_themed(&Theme::default())
914 }
915
916 fn style_themed(&self, theme: &Theme) -> Style {
917 let scale = (text_size_scale(theme).clamp(1.0, 2.0)) - 1.0;
918 Style {
919 flex_direction: if self
920 .own
921 .or_else(|| themed_style(theme, self.variant).icon_alignment)
922 .unwrap_or_default()
923 == IconAlignment::End
924 {
925 FlexDirection::RowReverse
926 } else {
927 FlexDirection::Row
928 },
929 align: Align::Center,
930 gap: 8.0 + (4.0 - 8.0) * scale,
931 ..Default::default()
932 }
933 }
934
935 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
936 &self.children
937 }
938
939 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
940
941 fn on_click(&self) -> Option<Msg> {
942 None
943 }
944}
945
946impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
947 fn style(&self) -> Style {
948 Widget::<Msg>::style_themed(self, &Theme::default())
949 }
950
951 fn style_themed(&self, theme: &Theme) -> Style {
952 let r = self.resolved(theme, self.states(&Status::default()));
953 let g = self.geometry(&r);
954 let (justify, _) = place(r.alignment.x);
955 let (_, align) = place(r.alignment.y);
956 let natural = self.label.as_ref().map(|label| {
961 let text = frus_text::measure_style(label, r.text_style).width;
962 let icon = if self.has_icon {
963 let scale = text_size_scale(theme).clamp(1.0, 2.0) - 1.0;
964 r.icon_size + 8.0 + (4.0 - 8.0) * scale
965 } else {
966 0.0
967 };
968 (text + icon + g.padding.left + g.padding.right)
969 .ceil()
970 .clamp(g.min.0, r.maximum.width.max(g.min.0))
971 });
972 let width = match (g.fixed.0, natural) {
973 (Some(w), _) | (None, Some(w)) => Dimension::Length(w + 2.0 * g.extra.0),
974 (None, None) => Dimension::Auto,
975 };
976 let visible_height = self.label.as_ref().map(|label| {
983 let line = frus_text::measure_style(label, r.text_style).height;
984 (line + g.padding.top + g.padding.bottom)
985 .ceil()
986 .clamp(g.min.1, r.maximum.height.max(g.min.1))
987 });
988 let (height, min_height, max_height) = match (g.fixed.1, visible_height) {
989 (Some(h), _) => (
990 Dimension::Length(h + 2.0 * g.extra.1),
991 Dimension::Length(g.min.1 + 2.0 * g.extra.1),
992 None,
993 ),
994 (None, Some(h)) if g.extra.1 > 0.0 => (
995 Dimension::Length(h + 2.0 * g.extra.1),
996 Dimension::Length(h),
997 Some(Dimension::Percent(1.0)),
998 ),
999 (None, Some(h)) => (Dimension::Length(h), Dimension::Auto, None),
1000 (None, None) => (
1001 Dimension::Auto,
1002 Dimension::Length(g.min.1 + 2.0 * g.extra.1),
1003 None,
1004 ),
1005 };
1006 let length = |v: f32| {
1007 if v.is_finite() {
1008 Dimension::Length(v)
1009 } else {
1010 Dimension::Auto
1011 }
1012 };
1013 Style {
1014 width,
1015 height,
1016 min_width: if matches!(width, Dimension::Length(_)) {
1020 Dimension::Auto
1021 } else {
1022 Dimension::Length(g.min.0 + 2.0 * g.extra.0)
1023 },
1024 min_height,
1025 max_width: length(r.maximum.width + 2.0 * g.extra.0),
1026 max_height: max_height.unwrap_or(length(r.maximum.height + 2.0 * g.extra.1)),
1027 flex_direction: FlexDirection::Row,
1028 justify,
1029 align,
1030 padding: Insets::new(
1031 g.padding.top + g.extra.1,
1032 g.padding.right + g.extra.0,
1033 g.padding.bottom + g.extra.1,
1034 g.padding.left + g.extra.0,
1035 ),
1036 ..Default::default()
1037 }
1038 }
1039
1040 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1041 self.built.get_or_init(|| {
1042 let Some(Parts { content, icon }) = self.parts.borrow_mut().take() else {
1043 return Vec::new();
1044 };
1045 let node: Box<dyn Widget<Msg>> = match icon {
1046 None => content,
1047 Some(icon) => Box::new(IconLabel {
1048 children: vec![icon, content],
1049 own: self.style.icon_alignment,
1050 variant: self.variant,
1051 }),
1052 };
1053 vec![node]
1054 })
1055 }
1056
1057 fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
1061 let r = self.resolved(inherited, self.states(&Status::default()));
1062 let mut theme = Box::new(inherited.clone());
1063 theme.widgets.text =
1068 crate::DefaultTextStyle::from_text_style(r.text_style.color(r.foreground));
1069 theme.widgets.icon.color = Some(r.icon_color);
1070 theme.widgets.icon.size = Some(r.icon_size);
1071 Some(theme)
1072 }
1073
1074 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1075 let o = status.opacity;
1076 let states = self.states(&status);
1077 let base = if self.is_enabled() {
1078 WidgetStates::EMPTY
1079 } else {
1080 WidgetStates::of(WidgetState::Disabled)
1081 };
1082 let r = self.resolved(theme, states);
1083 let g = self.geometry(&r);
1084 let room = |given: f32, least: f32, asked: f32| ((given - least) * 0.5).clamp(0.0, asked);
1087 let (ex, ey) = (
1088 room(bounds.width, g.min.0, g.extra.0),
1089 room(bounds.height, g.min.1, g.extra.1),
1090 );
1091 let visible = Rect::new(
1092 bounds.x + ex,
1093 bounds.y + ey,
1094 (bounds.width - 2.0 * ex).max(0.0),
1095 (bounds.height - 2.0 * ey).max(0.0),
1096 );
1097 let radius = r
1098 .shape
1099 .as_rounded(visible)
1100 .map(|(_, radius)| radius)
1101 .unwrap_or(BorderRadius::ZERO);
1102
1103 let at = |extra: Option<WidgetState>| {
1106 let s = extra.map_or(base, |e| base.set(e, true));
1107 self.resolved(theme, s).elevation
1108 };
1109 let elevation = if self.is_enabled() {
1110 let toward = |from: f32, to: f32, p: f32| from + (to - from) * p.clamp(0.0, 1.0);
1111 let e = toward(
1112 at(None),
1113 at(Some(WidgetState::Focused)),
1114 status.focus_progress,
1115 );
1116 let e = toward(e, at(Some(WidgetState::Hovered)), status.hover_progress);
1117 toward(e, at(Some(WidgetState::Pressed)), status.press_progress)
1118 } else {
1119 r.elevation
1120 };
1121 if elevation > 0.0 {
1122 frus_core::paint_elevation(scene, visible, radius, elevation, r.shadow.fade(o));
1123 }
1124
1125 let mut fill = r.background.unwrap_or(Color::TRANSPARENT);
1129 if self.is_enabled() {
1130 let overlay = |s: WidgetState| self.resolved(theme, base.set(s, true)).overlay;
1131 if let Some(focus) = overlay(WidgetState::Focused) {
1132 fill = over(fill, focus, status.focus_progress);
1133 }
1134 if let Some(hover) = overlay(WidgetState::Hovered) {
1135 fill = over(fill, hover, status.hover_progress);
1136 }
1137 }
1138 let shape = match r.side {
1139 Some(side) => r
1140 .shape
1141 .with_side(BorderSide::new(side.color.fade(o), side.width)),
1142 None => r.shape,
1143 };
1144 scene.draw_shape(visible, shape, fill.fade(o));
1145 }
1146
1147 fn on_click(&self) -> Option<Msg> {
1148 if self.is_enabled() {
1149 self.on_press.clone()
1150 } else {
1151 None
1152 }
1153 }
1154
1155 fn on_long_press(&self) -> Option<Msg> {
1156 if self.is_enabled() {
1157 self.on_long_press.clone()
1158 } else {
1159 None
1160 }
1161 }
1162
1163 fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
1164 if !self.is_enabled() {
1166 return None;
1167 }
1168 let pressed = self.resolved(theme, WidgetStates::of(WidgetState::Pressed));
1171 let splash = theme
1172 .widgets
1173 .ink
1174 .color
1175 .or(pressed.overlay)
1176 .unwrap_or_else(|| pressed.foreground.with_alpha(0.1));
1177 let g = self.geometry(&pressed);
1178 let side = g.min.1;
1179 Some(
1180 crate::InkStyle::of(theme).color(splash).radius(
1181 pressed
1182 .shape
1183 .as_rounded(Rect::new(0.0, 0.0, side, side))
1184 .map(|(_, radius)| radius)
1185 .unwrap_or(BorderRadius::ZERO),
1186 ),
1187 )
1188 }
1189
1190 fn focusable(&self) -> bool {
1191 self.is_enabled()
1192 }
1193
1194 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1195 let mut semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
1198 if let Some(label) = &self.label {
1199 semantics = semantics.label(label.clone());
1200 }
1201 Some(if self.is_enabled() {
1203 semantics.clickable()
1204 } else {
1205 semantics.disabled(true)
1206 })
1207 }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212 use super::*;
1213 use crate::{build_ui, Flex, Runtime, VisualDensity};
1214 use frus_core::{Primitive, TargetPlatform};
1215
1216 #[derive(Clone, Debug, PartialEq)]
1217 enum Msg {
1218 Pressed,
1219 Held,
1220 }
1221
1222 fn phone() -> Theme {
1225 Theme::dark().with_platform(TargetPlatform::Android)
1226 }
1227
1228 fn laid_out(button: Button<Msg>, theme: &Theme) -> (Rect, Vec<Primitive>) {
1231 let tree = Flex::<Msg>::column()
1232 .width(400.0)
1233 .height(300.0)
1234 .align(frus_layout::Align::Start)
1235 .child(button);
1236 let ui = build_ui(
1237 &tree as &dyn Widget<Msg>,
1238 Size::new(400.0, 300.0),
1239 &Runtime::default(),
1240 theme,
1241 );
1242 let prims = ui.scene().primitives().to_vec();
1243 let surface = prims
1244 .iter()
1245 .find_map(|p| match p {
1246 Primitive::Rect { rect, blur, .. } if *blur == 0.0 => Some(*rect),
1247 _ => None,
1248 })
1249 .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
1250 (surface, prims)
1251 }
1252
1253 fn label_colour(prims: &[Primitive]) -> Option<Color> {
1254 prims.iter().find_map(|p| match p {
1255 Primitive::Text { color, .. } => Some(*color),
1256 _ => None,
1257 })
1258 }
1259
1260 fn surface_colour(prims: &[Primitive]) -> Option<Color> {
1261 prims.iter().find_map(|p| match p {
1262 Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1263 _ => None,
1264 })
1265 }
1266
1267 #[test]
1271 fn a_button_is_the_reference_s_size() {
1272 let (short, _) = laid_out(Button::new("").on_press(Msg::Pressed), &phone());
1274 assert_eq!((short.width, short.height), (64.0, 40.0), "the minimum");
1275 assert_eq!(short.y, 4.0, "centred in a 48 px target");
1276 let (long, _) = laid_out(
1277 Button::new("A rather longer label").on_press(Msg::Pressed),
1278 &phone(),
1279 );
1280 let text = frus_text::measure_style("A rather longer label", phone().text.label_large);
1281 assert!(
1282 (long.width - (text.width + 48.0)).abs() < 1.0,
1283 "the label and 24 px either side: {} vs {}",
1284 long.width,
1285 text.width
1286 );
1287 let (text_button, _) = laid_out(
1289 Button::new("A rather longer label")
1290 .variant(Variant::Text)
1291 .on_press(Msg::Pressed),
1292 &phone(),
1293 );
1294 assert!((text_button.width - (text.width + 24.0)).abs() < 1.0);
1295 }
1296
1297 #[test]
1301 fn the_touch_target_gives_way_to_a_smaller_slot() {
1302 let t = phone();
1303 let tree = Flex::<Msg>::column()
1304 .width(400.0)
1305 .height(300.0)
1306 .align(frus_layout::Align::Start)
1307 .child(
1308 crate::Container::new()
1309 .height(40.0)
1310 .child(Button::new("Skip").on_press(Msg::Pressed)),
1311 );
1312 let (_, nodes) = crate::build_ui_inspected(
1313 &tree as &dyn Widget<Msg>,
1314 Size::new(400.0, 300.0),
1315 &Runtime::default(),
1316 &t,
1317 );
1318 let b = nodes.iter().find(|n| n.name == "Button").unwrap().rect;
1319 assert_eq!(b.height, 40.0, "{b:?}");
1320 let (free, _) = laid_out(Button::new("Skip").on_press(Msg::Pressed), &t);
1322 assert_eq!(
1323 (free.y, free.height),
1324 (4.0, 40.0),
1325 "a 40 px button in a 48 px target"
1326 );
1327 }
1328
1329 #[test]
1332 fn a_desktop_s_button_is_compact() {
1333 let desk = Theme::dark().with_platform(TargetPlatform::Windows);
1334 let (b, _) = laid_out(Button::new("").on_press(Msg::Pressed), &desk);
1335 assert_eq!((b.width, b.height, b.y), (56.0, 32.0, 4.0));
1336 let told = desk.with_visual_density(VisualDensity::STANDARD);
1337 let (b, _) = laid_out(Button::new("").on_press(Msg::Pressed), &told);
1338 assert_eq!((b.width, b.height), (64.0, 40.0));
1339 let mut wrapped = phone();
1341 wrapped.tap_target = TapTarget::ShrinkWrap;
1342 let (b, _) = laid_out(Button::new("OK").on_press(Msg::Pressed), &wrapped);
1343 assert_eq!(b.y, 0.0);
1344 }
1345
1346 #[test]
1349 fn the_padding_follows_the_text_size() {
1350 let p = |scale: f32| {
1351 frus_core::with_text_scale(scale, || {
1352 default_style(Variant::Filled, &phone(), false)
1353 .padding
1354 .unwrap()
1355 .resolve(WidgetStates::EMPTY)
1356 .copied()
1357 .unwrap()
1358 .left
1359 })
1360 };
1361 assert_eq!(p(1.0), 24.0);
1362 assert_eq!(p(1.5), 18.0);
1363 assert_eq!(p(2.0), 12.0);
1364 assert_eq!(p(3.0), 6.0);
1365 let icon = default_style(Variant::Filled, &phone(), true)
1367 .padding
1368 .unwrap()
1369 .resolve(WidgetStates::EMPTY)
1370 .copied()
1371 .unwrap();
1372 assert_eq!((icon.left, icon.right), (16.0, 24.0));
1373 let rtl = default_style(Variant::Filled, &phone().rtl(), true)
1374 .padding
1375 .unwrap()
1376 .resolve(WidgetStates::EMPTY)
1377 .copied()
1378 .unwrap();
1379 assert_eq!((rtl.left, rtl.right), (24.0, 16.0));
1380 }
1381
1382 #[test]
1385 fn each_kind_has_the_reference_s_colours() {
1386 let t = phone();
1387 let c = &t.scheme;
1388 let colours = |v: Variant, on: bool| {
1389 let b = Button::new("OK").variant(v);
1390 let b = if on { b.on_press(Msg::Pressed) } else { b };
1391 let (_, prims) = laid_out(b, &t);
1392 (surface_colour(&prims), label_colour(&prims))
1393 };
1394 assert_eq!(
1395 colours(Variant::Filled, true),
1396 (Some(c.primary), Some(c.on_primary))
1397 );
1398 assert_eq!(
1399 colours(Variant::Tonal, true),
1400 (Some(c.secondary_container), Some(c.on_secondary_container))
1401 );
1402 assert_eq!(
1403 colours(Variant::Elevated, true),
1404 (Some(c.surface_container_low), Some(c.primary))
1405 );
1406 assert_eq!(colours(Variant::Text, true).1, Some(c.primary));
1407 assert_eq!(
1410 colours(Variant::Filled, false),
1411 (
1412 Some(crate::disabled::disabled_container(&t)),
1413 Some(crate::disabled::disabled_content(&t))
1414 )
1415 );
1416 let off = Button::new("OK").variant(Variant::Filled);
1417 assert!(!Widget::<Msg>::focusable(&off));
1418 assert_eq!(Widget::<Msg>::on_click(&off), None);
1419 let held = Button::new("OK").on_long_press(Msg::Held);
1421 assert!(Widget::<Msg>::focusable(&held));
1422 assert_eq!(Widget::<Msg>::on_long_press(&held), Some(Msg::Held));
1423 }
1424
1425 #[test]
1429 fn the_overlays_are_the_reference_s() {
1430 let t = phone();
1431 let c = &t.scheme;
1432 let b = Button::new("OK").on_press(Msg::Pressed);
1433 let ink = Widget::<Msg>::ink(&b, &t).unwrap();
1434 assert_eq!(ink.color, c.on_primary.with_alpha(0.1));
1435 let e = Button::new("OK")
1436 .variant(Variant::Elevated)
1437 .on_press(Msg::Pressed);
1438 assert_eq!(
1439 Widget::<Msg>::ink(&e, &t).unwrap().color,
1440 c.primary.with_alpha(0.1)
1441 );
1442 let hover = e.resolved(&t, WidgetStates::of(WidgetState::Hovered));
1443 assert_eq!(hover.overlay, Some(c.primary.with_alpha(0.08)));
1444 let focus = e.resolved(&t, WidgetStates::of(WidgetState::Focused));
1445 assert_eq!(focus.overlay, Some(c.primary.with_alpha(0.1)));
1446 assert_eq!(hover.elevation, 3.0);
1449 assert_eq!(e.resolved(&t, WidgetStates::EMPTY).elevation, 1.0);
1450 assert_eq!(
1451 e.resolved(&t, WidgetState::Pressed | WidgetState::Hovered)
1452 .elevation,
1453 1.0
1454 );
1455 assert_eq!(
1456 b.resolved(&t, WidgetStates::of(WidgetState::Hovered))
1457 .elevation,
1458 1.0
1459 );
1460 assert_eq!(b.resolved(&t, WidgetStates::EMPTY).elevation, 0.0);
1461 }
1462
1463 #[test]
1466 fn an_outline_follows_the_state() {
1467 let t = phone();
1468 let c = &t.scheme;
1469 let b = Button::<Msg>::new("OK")
1470 .variant(Variant::Outlined)
1471 .on_press(Msg::Pressed);
1472 let side = |s: WidgetStates| b.resolved(&t, s).side.unwrap().color;
1473 assert_eq!(side(WidgetStates::EMPTY), c.outline);
1474 assert_eq!(side(WidgetStates::of(WidgetState::Focused)), c.primary);
1475 assert_eq!(
1476 side(WidgetStates::of(WidgetState::Disabled)),
1477 crate::disabled::disabled_container(&t)
1478 );
1479 }
1480
1481 #[test]
1485 fn any_content_takes_the_button_s_colours() {
1486 let t = phone();
1487 let b = Button::with_child(crate::Text::new("Mine"))
1488 .icon(crate::Icon::new(crate::Icons::ADD))
1489 .on_press(Msg::Pressed);
1490 let (surface, prims) = laid_out(b, &t);
1491 assert_eq!(label_colour(&prims), Some(t.scheme.on_primary));
1492 let glyph = prims.iter().find_map(|p| match p {
1493 Primitive::Path { fill: Some(c), .. } => Some(*c),
1494 _ => None,
1495 });
1496 assert_eq!(
1497 glyph,
1498 Some(t.scheme.on_primary),
1499 "the icon in the label's colour"
1500 );
1501 let icon_x = prims
1503 .iter()
1504 .find_map(|p| match p {
1505 Primitive::Path { path, .. } => path.verbs().iter().find_map(|v| match v {
1506 frus_core::PathVerb::MoveTo(pt) => Some(pt.x),
1507 _ => None,
1508 }),
1509 _ => None,
1510 })
1511 .unwrap();
1512 assert!(icon_x >= surface.x + 16.0 - 0.5, "{icon_x} vs {surface:?}");
1513 }
1514
1515 #[test]
1518 fn a_style_outranks_the_theme_which_outranks_the_default() {
1519 let mut t = phone();
1520 let red = Color::rgb8(200, 0, 0);
1521 let blue = Color::rgb8(0, 0, 200);
1522 t.widgets.button.filled_style =
1523 Some(ButtonStyle::new().background_color(WidgetStateProperty::all(red)));
1524 let (_, themed) = laid_out(Button::new("OK").on_press(Msg::Pressed), &t);
1525 assert_eq!(surface_colour(&themed), Some(red));
1526 let (_, mine) = laid_out(Button::new("OK").color(blue).on_press(Msg::Pressed), &t);
1527 assert_eq!(surface_colour(&mine), Some(blue));
1528 let (_, off) = laid_out(Button::new("OK").color(blue), &phone());
1529 assert_eq!(
1530 surface_colour(&off),
1531 Some(crate::disabled::disabled_container(&phone())),
1532 "a plain colour is the enabled one"
1533 );
1534 let sized = Button::new("OK")
1536 .style(ButtonStyle::new().fixed_size(Size::new(120.0, 50.0)))
1537 .on_press(Msg::Pressed);
1538 let (b, _) = laid_out(sized, &phone());
1539 assert_eq!((b.width, b.height), (120.0, 50.0));
1540 }
1541
1542 #[test]
1544 fn a_button_is_a_stadium() {
1545 let (_, prims) = laid_out(Button::new("OK").on_press(Msg::Pressed), &phone());
1546 let radius = prims.iter().find_map(|p| match p {
1547 Primitive::Rect { radius, blur, .. } if *blur == 0.0 => Some(*radius),
1548 _ => None,
1549 });
1550 assert_eq!(radius, Some(BorderRadius::uniform(20.0)));
1551 }
1552}