Skip to main content

tessera_components/
icon_button.rs

1//! Icon actions with Expressive sizes, widths, and selection states.
2//!
3//! ## Usage
4//!
5//! Offer compact actions and toggles in toolbars, navigation, and media
6//! controls.
7use tessera_ui::{
8    Callback, Color, Dp, FrameNanosControl, Modifier, State,
9    accesskit::{Role, Toggled},
10    current_frame_nanos, receive_frame_nanos, remember, tessera, use_context,
11};
12
13use crate::{
14    alignment::Alignment,
15    animation::Animatable,
16    boxed::boxed,
17    modifier::{InteractionState, InteractionVisualState, ModifierExt as _},
18    painter::Painter,
19    shape_def::{RoundedCorner, Shape},
20    surface::{RipplePresentation, SurfaceStyle, surface},
21    theme::{ContentColor, MaterialColorScheme, MaterialTheme, content_color_for},
22};
23
24/// Visual emphasis of an icon action or toggle.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
26pub enum IconButtonVariant {
27    /// Transparent container with no outline.
28    Standard,
29    /// Primary container, or a surface container for an unselected toggle.
30    #[default]
31    Filled,
32    /// Secondary container with higher emphasis when selected.
33    FilledTonal,
34    /// Outlined container that becomes filled when selected.
35    Outlined,
36}
37
38/// Expressive visual dimensions of an icon button.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum IconButtonSize {
41    /// A 32dp container with a 20dp icon.
42    ExtraSmall,
43    /// A 40dp container with a 24dp icon.
44    #[default]
45    Small,
46    /// A 56dp container with a 24dp icon.
47    Medium,
48    /// A 96dp container with a 32dp icon.
49    Large,
50    /// A 136dp container with a 40dp icon.
51    ExtraLarge,
52}
53
54/// Horizontal proportions of an icon button's visual container.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum IconButtonWidth {
57    /// Reduced horizontal insets.
58    Narrow,
59    /// Equal visual width and height.
60    #[default]
61    Uniform,
62    /// Expanded horizontal insets.
63    Wide,
64}
65
66/// Resting shape of an icon button.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum IconButtonShape {
69    /// Fully rounded corners, changing to size-specific corners when selected.
70    #[default]
71    Round,
72    /// Size-specific corners, changing to fully rounded corners when selected.
73    Square,
74}
75
76/// Color source for standard and outlined icon buttons.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
78pub enum IconButtonColorStyle {
79    /// Recommended theme colors with a primary selected standard icon.
80    #[default]
81    Vibrant,
82    /// Inherit the surrounding content color for unselected icons and outlines.
83    Inherited,
84}
85
86impl IconButtonSize {
87    /// Returns the visual height, excluding minimum touch-target expansion.
88    pub const fn height(self) -> Dp {
89        Dp(match self {
90            Self::ExtraSmall => 32.0,
91            Self::Small => 40.0,
92            Self::Medium => 56.0,
93            Self::Large => 96.0,
94            Self::ExtraLarge => 136.0,
95        })
96    }
97
98    /// Returns the recommended icon size.
99    pub const fn icon_size(self) -> Dp {
100        Dp(match self {
101            Self::ExtraSmall => 20.0,
102            Self::Small | Self::Medium => 24.0,
103            Self::Large => 32.0,
104            Self::ExtraLarge => 40.0,
105        })
106    }
107
108    /// Returns the visual width for the requested proportions.
109    pub const fn width(self, width: IconButtonWidth) -> Dp {
110        let (narrow, uniform, wide) = match self {
111            Self::ExtraSmall => (28.0, 32.0, 40.0),
112            Self::Small => (32.0, 40.0, 52.0),
113            Self::Medium => (48.0, 56.0, 72.0),
114            Self::Large => (64.0, 96.0, 128.0),
115            Self::ExtraLarge => (104.0, 136.0, 184.0),
116        };
117        Dp(match width {
118            IconButtonWidth::Narrow => narrow,
119            IconButtonWidth::Uniform => uniform,
120            IconButtonWidth::Wide => wide,
121        })
122    }
123
124    /// Returns the outline thickness.
125    pub const fn outline_width(self) -> Dp {
126        Dp(match self {
127            Self::ExtraSmall | Self::Small | Self::Medium => 1.0,
128            Self::Large => 2.0,
129            Self::ExtraLarge => 3.0,
130        })
131    }
132
133    /// Returns the resting square corner radius.
134    pub const fn square_radius(self) -> Dp {
135        Dp(match self {
136            Self::ExtraSmall | Self::Small => 12.0,
137            Self::Medium => 16.0,
138            Self::Large | Self::ExtraLarge => 28.0,
139        })
140    }
141
142    /// Returns the pressed corner radius for either shape preset.
143    pub const fn pressed_radius(self) -> Dp {
144        Dp(match self {
145            Self::ExtraSmall | Self::Small => 8.0,
146            Self::Medium => 12.0,
147            Self::Large | Self::ExtraLarge => 16.0,
148        })
149    }
150}
151
152impl IconButtonBuilder {
153    /// Uses fully rounded corners in the unselected resting state.
154    pub fn round(&self) -> Self {
155        self.shape_style(IconButtonShape::Round)
156    }
157
158    /// Uses size-specific corners in the unselected resting state.
159    pub fn square(&self) -> Self {
160        self.shape_style(IconButtonShape::Square)
161    }
162
163    /// Applies the standard icon button preset.
164    pub fn standard(&self) -> Self {
165        self.variant(IconButtonVariant::Standard)
166    }
167
168    /// Applies the filled icon button preset.
169    pub fn filled(&self) -> Self {
170        self.variant(IconButtonVariant::Filled)
171    }
172
173    /// Applies the filled tonal icon button preset.
174    pub fn filled_tonal(&self) -> Self {
175        self.variant(IconButtonVariant::FilledTonal)
176    }
177
178    /// Applies the outlined icon button preset.
179    pub fn outlined(&self) -> Self {
180        self.variant(IconButtonVariant::Outlined)
181    }
182}
183
184#[derive(Clone, Copy, Debug, PartialEq)]
185struct IconButtonCorner {
186    fraction: f32,
187    radius: f32,
188}
189
190impl IconButtonCorner {
191    fn new(size: IconButtonSize, shape: IconButtonShape, selected: bool, pressed: bool) -> Self {
192        let (fraction, radius) = if pressed {
193            (0.0, size.pressed_radius().0 as f32)
194        } else if (shape == IconButtonShape::Round) != selected {
195            (0.5, 0.0)
196        } else {
197            (0.0, size.square_radius().0 as f32)
198        };
199        Self { fraction, radius }
200    }
201}
202
203struct IconButtonMotion {
204    fraction: Animatable,
205    radius: Animatable,
206    target: IconButtonCorner,
207    frame_nanos: u64,
208}
209
210impl IconButtonMotion {
211    fn new(target: IconButtonCorner, frame_nanos: u64) -> Self {
212        Self {
213            fraction: Animatable::spring(target.fraction, 40.0, 1.0),
214            radius: Animatable::spring(target.radius, 40.0, 1.0),
215            target,
216            frame_nanos,
217        }
218    }
219
220    fn retarget(&mut self, target: IconButtonCorner, frame_nanos: u64) {
221        self.advance(frame_nanos);
222        self.fraction.set_target(target.fraction);
223        self.radius.set_target(target.radius);
224        self.target = target;
225    }
226
227    fn advance(&mut self, frame_nanos: u64) {
228        if frame_nanos > self.frame_nanos {
229            let dt = (frame_nanos - self.frame_nanos) as f32 / 1_000_000_000.0;
230            self.fraction.tick(dt);
231            self.radius.tick(dt);
232            self.frame_nanos = frame_nanos;
233        }
234    }
235
236    fn is_idle(&self) -> bool {
237        self.fraction.is_idle() && self.radius.is_idle()
238    }
239
240    fn shape(&self) -> Shape {
241        let fraction = self.fraction.value();
242        // Capsule ends stay circular; ordinary corners use the shared
243        // superellipse curvature, smoothly interpolated during a morph.
244        let corner = RoundedCorner::relative(
245            fraction,
246            Dp(self.radius.value() as f64),
247            3.0 - fraction * 2.0,
248        );
249        Shape::RoundedRectangle {
250            top_left: corner,
251            top_right: corner,
252            bottom_right: corner,
253            bottom_left: corner,
254        }
255    }
256}
257
258#[derive(Clone, Copy, Debug, PartialEq)]
259struct IconButtonColors {
260    container: Color,
261    content: Color,
262    border: Option<Color>,
263}
264
265fn resolve_colors(
266    scheme: &MaterialColorScheme,
267    variant: IconButtonVariant,
268    selected: Option<bool>,
269    enabled: bool,
270    inherited: Option<Color>,
271) -> IconButtonColors {
272    let checked = selected == Some(true);
273    let outlined = variant == IconButtonVariant::Outlined && !checked;
274    let container = if !enabled {
275        if matches!(
276            variant,
277            IconButtonVariant::Filled | IconButtonVariant::FilledTonal
278        ) || (variant == IconButtonVariant::Outlined && checked)
279        {
280            scheme.on_surface.with_alpha(0.1)
281        } else {
282            Color::TRANSPARENT
283        }
284    } else {
285        match (variant, selected) {
286            (IconButtonVariant::Filled, Some(false)) => scheme.surface_container,
287            (IconButtonVariant::Filled, _) => scheme.primary,
288            (IconButtonVariant::FilledTonal, Some(true)) => scheme.secondary,
289            (IconButtonVariant::FilledTonal, _) => scheme.secondary_container,
290            (IconButtonVariant::Outlined, Some(true)) => scheme.inverse_surface,
291            _ => Color::TRANSPARENT,
292        }
293    };
294    let content = if !enabled {
295        let base = if matches!(
296            variant,
297            IconButtonVariant::Standard | IconButtonVariant::Outlined
298        ) {
299            inherited.unwrap_or(scheme.on_surface)
300        } else {
301            scheme.on_surface
302        };
303        base.with_alpha(0.38)
304    } else {
305        match (variant, selected) {
306            (IconButtonVariant::Filled, Some(false)) => scheme.on_surface_variant,
307            (IconButtonVariant::Filled, _) => scheme.on_primary,
308            (IconButtonVariant::FilledTonal, Some(true)) => scheme.on_secondary,
309            (IconButtonVariant::FilledTonal, _) => scheme.on_secondary_container,
310            (IconButtonVariant::Outlined, Some(true)) => scheme.inverse_on_surface,
311            (IconButtonVariant::Standard, Some(true)) => scheme.primary,
312            _ => inherited.unwrap_or(scheme.on_surface_variant),
313        }
314    };
315    let border = outlined.then(|| {
316        let base = inherited.unwrap_or(scheme.outline_variant);
317        if enabled { base } else { base.with_alpha(0.38) }
318    });
319    IconButtonColors {
320        container,
321        content,
322        border,
323    }
324}
325
326/// # icon_button
327///
328/// Display a compact action or controlled toggle with Expressive sizes and
329/// shapes.
330///
331/// ## Usage
332///
333/// Offer named icon actions and persistent choices in toolbars and media
334/// controls.
335///
336/// ## Parameters
337///
338/// - `variant` — visual emphasis, defaulting to filled.
339/// - `size` / `width` — visual dimensions, defaulting to small and uniform.
340/// - `shape_style` — round or square resting corners; selection swaps the
341///   shapes.
342/// - `selected` — controlled toggle state; omit for an ordinary action.
343/// - `icon` — optional vector or raster icon, centered at the size's icon
344///   dimensions.
345/// - `on_click` — activation callback; update `selected` here when using a
346///   toggle.
347/// - `enabled` — whether activation, focus, and interaction feedback are
348///   enabled.
349/// - `modifier` — outer constraints and node-local behavior.
350/// - `shape` — fixed shape override that disables automatic corner transitions.
351/// - `color_style` — vibrant or inherited colors for standard and outlined
352///   variants.
353/// - `color` / `content_color` — enabled container and icon color overrides.
354/// - `interaction_state` — optional shared pointer and focus state.
355/// - `presentation_ripple` — optional static circular ripple for pressed visual
356///   presentation.
357/// - `presentation_state` — optional visual interaction without changing input
358///   or focus.
359/// - `accessibility_label` / `accessibility_description` — action name and
360///   supporting help.
361///
362/// Interactive targets expand to at least 48dp when parent constraints permit.
363/// Disabled controls retain their target geometry and toggle state.
364///
365/// ## Examples
366///
367/// ```
368/// use tessera_components::{
369///     icon_button::icon_button, material_icons::filled, theme::material_theme,
370/// };
371/// use tessera_ui::{remember, tessera};
372///
373/// #[tessera]
374/// fn component() {
375///     let selected = remember(|| false);
376///     assert!(!selected.get());
377///     material_theme().child(move || {
378///         icon_button()
379///             .icon(filled::STAR_SVG)
380///             .filled_tonal()
381///             .selected(selected.get())
382///             .accessibility_label("Favorite")
383///             .on_click(move || selected.set(!selected.get()));
384///     });
385/// }
386/// # tessera_ui::testing::layout_test(component).run();
387/// ```
388#[tessera]
389pub fn icon_button(
390    variant: Option<IconButtonVariant>,
391    size: Option<IconButtonSize>,
392    width: Option<IconButtonWidth>,
393    shape_style: Option<IconButtonShape>,
394    selected: Option<bool>,
395    #[prop(into)] icon: Option<Painter>,
396    on_click: Option<Callback>,
397    enabled: Option<bool>,
398    modifier: Option<Modifier>,
399    shape: Option<Shape>,
400    color_style: Option<IconButtonColorStyle>,
401    color: Option<Color>,
402    content_color: Option<Color>,
403    interaction_state: Option<State<InteractionState>>,
404    presentation_state: Option<InteractionVisualState>,
405    presentation_ripple: Option<RipplePresentation>,
406    #[prop(into)] accessibility_label: Option<String>,
407    #[prop(into)] accessibility_description: Option<String>,
408) {
409    let scheme = use_context::<MaterialTheme>()
410        .expect("MaterialTheme must be provided")
411        .get()
412        .color_scheme;
413    let variant = variant.unwrap_or_default();
414    let size = size.unwrap_or_default();
415    let width = width.unwrap_or_default();
416    let enabled = enabled.unwrap_or(true);
417    let interactive = enabled && on_click.is_some();
418    let interaction_state = if let Some(state) = interaction_state {
419        state
420    } else {
421        remember(InteractionState::new)
422    };
423    if !interactive && interaction_state.with(|state| *state != InteractionState::new()) {
424        interaction_state.set(InteractionState::new());
425    }
426    let visual =
427        interaction_state.with(|state| state.with_presentation(presentation_state, enabled));
428    let target = if shape.is_some() {
429        IconButtonCorner {
430            fraction: 0.0,
431            radius: 0.0,
432        }
433    } else {
434        IconButtonCorner::new(
435            size,
436            shape_style.unwrap_or_default(),
437            selected.unwrap_or(false),
438            visual.is_pressed(),
439        )
440    };
441    let frame = current_frame_nanos();
442    let motion = remember(|| IconButtonMotion::new(target, frame));
443    if shape.is_some() {
444        if !motion.with(IconButtonMotion::is_idle) || motion.with(|motion| motion.target) != target
445        {
446            motion.set(IconButtonMotion::new(target, frame));
447        }
448    } else if motion.with(|motion| motion.target) != target {
449        motion.with_mut(|motion| motion.retarget(target, frame));
450    }
451    if !motion.with(IconButtonMotion::is_idle) {
452        receive_frame_nanos(move |frame| {
453            motion.with_mut(|motion| motion.advance(frame));
454            if motion.with(IconButtonMotion::is_idle) {
455                FrameNanosControl::Stop
456            } else {
457                FrameNanosControl::Continue
458            }
459        });
460    }
461    let inherited = if color_style.unwrap_or_default() == IconButtonColorStyle::Inherited {
462        Some(
463            use_context::<ContentColor>()
464                .map(|color| color.get().current)
465                .unwrap_or(scheme.on_surface),
466        )
467    } else {
468        None
469    };
470    let defaults = resolve_colors(&scheme, variant, selected, enabled, inherited);
471    let container = if enabled {
472        color.unwrap_or(defaults.container)
473    } else {
474        defaults.container
475    };
476    let content = if enabled {
477        content_color
478            .or_else(|| color.and_then(|color| content_color_for(color, &scheme)))
479            .unwrap_or(defaults.content)
480    } else {
481        defaults.content
482    };
483    let style = if let Some(border_color) = defaults.border {
484        SurfaceStyle::FilledOutlined {
485            fill_color: container,
486            border_color,
487            border_width: size.outline_width(),
488        }
489    } else {
490        SurfaceStyle::Filled { color: container }
491    };
492    surface()
493        .modifier(modifier.unwrap_or_default())
494        .shape(shape.unwrap_or_else(|| motion.with(IconButtonMotion::shape)))
495        .style(style)
496        .content_alignment(Alignment::Center)
497        .content_color(content)
498        .enabled(enabled)
499        .interaction_state(interaction_state)
500        .presentation_state_optional(presentation_state)
501        .presentation_ripple_optional(presentation_ripple)
502        .show_focus_indicator(true)
503        .ripple_color(content)
504        .accessibility_role(Role::Button)
505        .accessibility_toggled_optional(
506            selected.map(|value| if value { Toggled::True } else { Toggled::False }),
507        )
508        .accessibility_focusable(interactive)
509        .accessibility_label_optional(accessibility_label)
510        .accessibility_description_optional(accessibility_description)
511        .on_click_optional(on_click)
512        .child(move || {
513            let icon = icon.clone();
514            boxed()
515                .modifier(Modifier::new().size(size.width(width), size.height()))
516                .alignment(Alignment::Center)
517                .children(move || {
518                    if let Some(icon) = &icon {
519                        crate::icon::icon()
520                            .painter(icon.clone())
521                            .size(size.icon_size());
522                    }
523                });
524        });
525}
526
527#[cfg(test)]
528mod tests;