use core::fmt::{self, Debug};
use core::marker::PhantomData;
use waterui::accessibility::{AccessibilityChildren, AccessibilityRole};
use waterui::border::Border;
use waterui::color::Color;
use waterui::shape::{Circle, ShapeExt as _};
use waterui::{Environment, Str, View, ViewExt as _};
use waterui_core::handler::{Handler, boxed_action};
use crate::color::{
InverseOnSurface, InverseSurface, OnPrimary, OnSecondaryContainer, OnSurfaceVariant,
OutlineVariant, Primary, SecondaryContainer,
};
use crate::semantics::interaction_style;
const ICON_BUTTON_STATE_LAYER_SIZE: f32 = 40.0;
const ICON_BUTTON_ICON_SIZE: f32 = 24.0;
const ICON_BUTTON_TOUCH_TARGET_SIZE: f32 = 48.0;
const ICON_BUTTON_OUTLINE_WIDTH: f32 = 1.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IconButtonSizeTokens {
pub container: f32,
pub state_layer: f32,
pub icon: f32,
pub outline_width: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum IconButtonSize {
#[default]
Small,
Medium,
Large,
}
impl IconButtonSize {
#[must_use]
pub const fn tokens(self) -> IconButtonSizeTokens {
match self {
Self::Small => IconButtonSizeTokens {
container: ICON_BUTTON_TOUCH_TARGET_SIZE,
state_layer: ICON_BUTTON_STATE_LAYER_SIZE,
icon: ICON_BUTTON_ICON_SIZE,
outline_width: ICON_BUTTON_OUTLINE_WIDTH,
},
Self::Medium => IconButtonSizeTokens {
container: 56.0,
state_layer: 56.0,
icon: 24.0,
outline_width: 1.0,
},
Self::Large => IconButtonSizeTokens {
container: 96.0,
state_layer: 96.0,
icon: 32.0,
outline_width: 2.0,
},
}
}
}
pub trait IconButtonVariantTokens: Default + 'static {
fn container_color() -> Color;
fn icon_color() -> Color;
#[must_use]
fn outline_color() -> Color {
OutlineVariant.into()
}
#[must_use]
fn outline_width() -> f32 {
0.0
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StandardIconButton;
#[derive(Debug, Clone, Copy, Default)]
pub struct FilledIconButton;
#[derive(Debug, Clone, Copy, Default)]
pub struct FilledTonalIconButton;
#[derive(Debug, Clone, Copy, Default)]
pub struct OutlinedIconButton;
impl IconButtonVariantTokens for StandardIconButton {
fn container_color() -> Color {
crate::color::Surface.with_opacity(0.0).into()
}
fn icon_color() -> Color {
OnSurfaceVariant.into()
}
}
impl IconButtonVariantTokens for FilledIconButton {
fn container_color() -> Color {
Primary.into()
}
fn icon_color() -> Color {
OnPrimary.into()
}
}
impl IconButtonVariantTokens for FilledTonalIconButton {
fn container_color() -> Color {
SecondaryContainer.into()
}
fn icon_color() -> Color {
OnSecondaryContainer.into()
}
}
impl IconButtonVariantTokens for OutlinedIconButton {
fn container_color() -> Color {
crate::color::Surface.with_opacity(0.0).into()
}
fn icon_color() -> Color {
OnSurfaceVariant.into()
}
fn outline_width() -> f32 {
ICON_BUTTON_OUTLINE_WIDTH
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SelectedStandardIconButton;
#[derive(Debug, Clone, Copy, Default)]
pub struct SelectedOutlinedIconButton;
impl IconButtonVariantTokens for SelectedStandardIconButton {
fn container_color() -> Color {
crate::color::Surface.with_opacity(0.0).into()
}
fn icon_color() -> Color {
Primary.into()
}
}
impl IconButtonVariantTokens for SelectedOutlinedIconButton {
fn container_color() -> Color {
InverseSurface.into()
}
fn icon_color() -> Color {
InverseOnSurface.into()
}
}
pub struct IconButton<Content, Action = fn(&Environment), Tokens = StandardIconButton> {
accessibility_label: Str,
content: Content,
action: Action,
size: IconButtonSize,
tokens: PhantomData<Tokens>,
}
impl<Content, Action, Tokens> Debug for IconButton<Content, Action, Tokens> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("IconButton")
.field("accessibility_label", &self.accessibility_label)
.finish_non_exhaustive()
}
}
impl<Content> IconButton<Content, fn(&Environment), StandardIconButton> {
#[must_use]
pub fn new(accessibility_label: impl Into<Str>, content: Content) -> Self {
Self {
accessibility_label: accessibility_label.into(),
content,
action: noop,
size: IconButtonSize::default(),
tokens: PhantomData,
}
}
}
impl<Content, Action, Tokens> IconButton<Content, Action, Tokens> {
#[must_use]
pub fn filled(self) -> IconButton<Content, Action, FilledIconButton> {
self.with_variant()
}
#[must_use]
pub fn filled_tonal(self) -> IconButton<Content, Action, FilledTonalIconButton> {
self.with_variant()
}
#[must_use]
pub fn outlined(self) -> IconButton<Content, Action, OutlinedIconButton> {
self.with_variant()
}
#[must_use]
pub fn selected(self) -> IconButton<Content, Action, SelectedStandardIconButton> {
self.with_variant()
}
#[must_use]
pub fn selected_outlined(self) -> IconButton<Content, Action, SelectedOutlinedIconButton> {
self.with_variant()
}
fn with_variant<NewTokens>(self) -> IconButton<Content, Action, NewTokens> {
IconButton {
accessibility_label: self.accessibility_label,
content: self.content,
action: self.action,
size: self.size,
tokens: PhantomData,
}
}
#[must_use]
pub const fn size(mut self, size: IconButtonSize) -> Self {
self.size = size;
self
}
#[must_use]
pub fn action<F, Args>(self, action: F) -> IconButton<Content, impl FnMut(&Environment), Tokens>
where
F: Handler<Args, ()> + 'static,
{
IconButton {
accessibility_label: self.accessibility_label,
content: self.content,
action: boxed_action(action),
size: self.size,
tokens: PhantomData,
}
}
}
impl<Content, Action, Tokens> View for IconButton<Content, Action, Tokens>
where
Content: View + 'static,
Action: FnMut(&Environment) + 'static,
Tokens: IconButtonVariantTokens,
{
fn body(self, _env: &Environment) -> impl View {
let mut action = self.action;
let size = self.size.tokens();
self.content
.foreground(Tokens::icon_color())
.size(size.icon, size.icon)
.padding_with((size.state_layer - size.icon) * 0.5)
.size(size.state_layer, size.state_layer)
.background(Circle.fill(Tokens::container_color()))
.border_with(
Border::new(Tokens::outline_color(), Tokens::outline_width())
.corner_radius(size.state_layer * 0.5),
)
.size(size.container, size.container)
.on_tap(move |env: Environment| action(&env))
.a11y_label(self.accessibility_label)
.a11y_role(AccessibilityRole::Button)
.a11y_children(AccessibilityChildren::ExcludeDescendants)
.install(interaction_style(
Tokens::icon_color(),
f64::from(size.state_layer * 0.5),
))
}
}
const fn noop(_env: &Environment) {}
#[must_use]
pub fn icon_button<Content>(
accessibility_label: impl Into<Str>,
content: Content,
) -> IconButton<Content>
where
Content: View + 'static,
{
IconButton::new(accessibility_label, content)
}
#[must_use]
pub fn filled_icon_button<Content>(
accessibility_label: impl Into<Str>,
content: Content,
) -> IconButton<Content, fn(&Environment), FilledIconButton>
where
Content: View + 'static,
{
icon_button(accessibility_label, content).filled()
}
#[must_use]
pub fn filled_tonal_icon_button<Content>(
accessibility_label: impl Into<Str>,
content: Content,
) -> IconButton<Content, fn(&Environment), FilledTonalIconButton>
where
Content: View + 'static,
{
icon_button(accessibility_label, content).filled_tonal()
}
#[must_use]
pub fn outlined_icon_button<Content>(
accessibility_label: impl Into<Str>,
content: Content,
) -> IconButton<Content, fn(&Environment), OutlinedIconButton>
where
Content: View + 'static,
{
icon_button(accessibility_label, content).outlined()
}
#[cfg(test)]
mod tests {
use super::{
ICON_BUTTON_ICON_SIZE, ICON_BUTTON_OUTLINE_WIDTH, ICON_BUTTON_STATE_LAYER_SIZE,
IconButtonSize, IconButtonVariantTokens, OutlinedIconButton,
};
#[test]
fn icon_button_size_scale_matches_compose_icon_button_tokens() {
let small = IconButtonSize::Small.tokens();
assert_eq!(small.container, 48.0);
assert_eq!(small.state_layer, 40.0);
assert_eq!(small.icon, 24.0);
let medium = IconButtonSize::Medium.tokens();
assert_eq!(medium.container, 56.0);
assert_eq!(medium.icon, 24.0);
assert_eq!(medium.outline_width, 1.0);
let large = IconButtonSize::Large.tokens();
assert_eq!(large.container, 96.0);
assert_eq!(large.icon, 32.0);
assert_eq!(large.outline_width, 2.0);
for size in [small, medium, large] {
assert!(size.container >= 48.0);
assert!(size.state_layer <= size.container);
assert!(size.icon < size.state_layer);
}
}
#[test]
fn icon_button_tokens_match_compose_icon_button_tokens() {
assert_eq!(ICON_BUTTON_STATE_LAYER_SIZE, 40.0);
assert_eq!(ICON_BUTTON_ICON_SIZE, 24.0);
}
#[test]
fn outlined_icon_button_tokens_match_compose_icon_button_tokens() {
assert_eq!(
OutlinedIconButton::outline_width(),
ICON_BUTTON_OUTLINE_WIDTH
);
}
#[test]
fn outlined_icon_button_border_uses_outline_variant() {
use super::{IconButtonVariantTokens, SelectedOutlinedIconButton};
use crate::{Material3, theme::colors::MaterialColorScheme};
use hydrolysis::Style as _;
use waterui::{Environment, Signal, color::ResolvedColor};
fn assert_resolves_to(actual: ResolvedColor, expected: ResolvedColor) {
assert_eq!(actual.red.to_bits(), expected.red.to_bits());
assert_eq!(actual.green.to_bits(), expected.green.to_bits());
assert_eq!(actual.blue.to_bits(), expected.blue.to_bits());
assert_eq!(actual.headroom.to_bits(), expected.headroom.to_bits());
assert_eq!(actual.opacity.to_bits(), expected.opacity.to_bits());
}
let scheme = MaterialColorScheme::baseline_light();
let mut env = Environment::new();
Material3::with_colors(scheme).install_tokens(&mut env);
for color in [
OutlinedIconButton::outline_color(),
SelectedOutlinedIconButton::outline_color(),
] {
assert_resolves_to(color.resolve(&env).get(), scheme.outline_variant.resolved());
}
}
}