Skip to main content

repose_material/material3/
icon_button.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4
5use repose_core::*;
6use repose_ui::{Box, ViewExt};
7
8use super::util::{apply_m3_clickable_ex, icon_content_with_color, with_button_semantics};
9use super::*;
10
11/// Color slots for icon buttons (Compose `IconButtonColors`).
12#[derive(Clone, Copy, Debug)]
13pub struct IconButtonColors {
14    pub container_color: Color,
15    pub content_color: Color,
16    pub disabled_container_color: Color,
17    pub disabled_content_color: Color,
18}
19
20impl IconButtonColors {
21    pub fn container(&self, enabled: bool) -> Color {
22        if enabled {
23            self.container_color
24        } else {
25            self.disabled_container_color
26        }
27    }
28    pub fn content(&self, enabled: bool) -> Color {
29        if enabled {
30            self.content_color
31        } else {
32            self.disabled_content_color
33        }
34    }
35
36    /// When caller only sets container, derive a contrasting content color
37    /// (Compose `contentColorFor` + local fallback). Prevents white-on-white.
38    pub fn ensuring_contrast(mut self) -> Self {
39        // Transparent container: content is drawn on parent; keep content as-is.
40        if self.container_color.3 == 0 {
41            return self;
42        }
43        let paired = content_color_for(self.container_color);
44        // If content is missing contrast vs container, replace with paired/fallback.
45        let cl = self.content_color.relative_luminance();
46        let bl = self.container_color.relative_luminance();
47        if (cl - bl).abs() < 0.25 {
48            self.content_color = paired;
49        }
50        let dcl = self.disabled_content_color.relative_luminance();
51        let dbl = self.disabled_container_color.relative_luminance();
52        if self.disabled_container_color.3 != 0 && (dcl - dbl).abs() < 0.25 {
53            self.disabled_content_color = theme().on_surface.with_alpha_f32(0.38);
54        }
55        self
56    }
57}
58
59#[derive(Clone, Debug)]
60pub struct IconButtonConfig {
61    pub modifier: Modifier,
62    pub enabled: bool,
63    pub colors: IconButtonColors,
64    pub container_size: Option<f32>,
65    pub interaction_source: Option<MutableInteractionSource>,
66    pub shape_radius: Option<f32>,
67}
68
69impl Default for IconButtonConfig {
70    fn default() -> Self {
71        Self {
72            modifier: Modifier::new(),
73            enabled: true,
74            colors: IconButtonColors {
75                container_color: Color::TRANSPARENT,
76                content_color: Color::TRANSPARENT,
77                disabled_container_color: Color::TRANSPARENT,
78                disabled_content_color: Color::TRANSPARENT,
79            },
80            container_size: None,
81            interaction_source: None,
82            shape_radius: None,
83        }
84    }
85}
86
87fn is_default_colors(c: &IconButtonColors) -> bool {
88    c.container_color == Color::TRANSPARENT
89        && c.disabled_container_color == Color::TRANSPARENT
90        && c.content_color == Color::TRANSPARENT
91        && c.disabled_content_color == Color::TRANSPARENT
92}
93
94fn resolve_colors(
95    config: &IconButtonConfig,
96    variant_defaults: IconButtonColors,
97) -> IconButtonColors {
98    if is_default_colors(&config.colors) {
99        return variant_defaults.ensuring_contrast();
100    }
101    let mut c = config.colors;
102    if c.content_color == Color::TRANSPARENT {
103        c.content_color = if c.container_color.3 == 0 {
104            IconButtonDefaults::content_color()
105        } else {
106            content_color_for(c.container_color)
107        };
108    }
109    if c.disabled_content_color == Color::TRANSPARENT {
110        c.disabled_content_color = theme().on_surface.with_alpha_f32(0.38);
111    }
112    c.ensuring_contrast()
113}
114
115#[allow(clippy::too_many_arguments)]
116fn icon_button_render(
117    icon: View,
118    on_click: impl Fn() + 'static,
119    config: &IconButtonConfig,
120    colors: IconButtonColors,
121    sz: f32,
122    bg: Option<Color>,
123    bdr: Option<(f32, Color)>,
124    state_colors: StateColors,
125    ripple_bounded: bool,
126) -> View {
127    let is_enabled = config.enabled;
128    let content_color = colors.content(is_enabled);
129    let radius = config.shape_radius.unwrap_or(sz * 0.5);
130    let touch = IconButtonDefaults::MIN_INTERACTIVE_SIZE.max(sz);
131
132    let outer = Modifier::new()
133        .size(touch, touch)
134        .align_items(AlignItems::CENTER)
135        .justify_content(JustifyContent::CENTER)
136        .then(config.modifier.clone());
137
138    let mut inner = Modifier::new()
139        .size(sz, sz)
140        .clip_rounded(radius)
141        .state_colors(state_colors)
142        .align_items(AlignItems::CENTER)
143        .justify_content(JustifyContent::CENTER);
144
145    if let Some(bg_color) = bg {
146        inner = inner.background(bg_color);
147    }
148    if let Some((w, c)) = bdr {
149        inner = inner.border(w, c, radius);
150    }
151
152    let source: Rc<MutableInteractionSource> = config
153        .interaction_source
154        .clone()
155        .map(Rc::new)
156        .unwrap_or_else(|| remember(MutableInteractionSource::new));
157
158    let (bounded, r) = if ripple_bounded {
159        (true, None)
160    } else {
161        (false, Some(IconButtonDefaults::STATE_LAYER_RADIUS))
162    };
163
164    inner = apply_m3_clickable_ex(
165        inner,
166        &source,
167        content_color,
168        is_enabled,
169        on_click,
170        bounded,
171        r,
172    );
173    inner = with_button_semantics(inner, is_enabled);
174
175    let icon = icon_content_with_color(content_color, icon);
176
177    Box(outer).child(Box(inner).child(icon))
178}
179
180/// M3 standard Icon Button (transparent container).
181pub fn IconButton(icon: View, on_click: impl Fn() + 'static, config: IconButtonConfig) -> View {
182    let colors = resolve_colors(&config, IconButtonDefaults::colors());
183    let sz = config
184        .container_size
185        .unwrap_or(IconButtonDefaults::CONTAINER_SIZE);
186    let cc = colors.content(config.enabled);
187    icon_button_render(
188        icon,
189        on_click,
190        &config,
191        colors,
192        sz,
193        None, // transparent container — no fill
194        None,
195        StateColors {
196            default: Color::TRANSPARENT,
197            hovered: Color::TRANSPARENT,
198            focused: Color::TRANSPARENT,
199            pressed: Color::TRANSPARENT,
200            dragged: cc.with_alpha_f32(0.12),
201            disabled: Color::TRANSPARENT,
202        },
203        false,
204    )
205}
206
207pub fn FilledIconButton(
208    icon: View,
209    on_click: impl Fn() + 'static,
210    config: IconButtonConfig,
211) -> View {
212    let th = theme();
213    let colors = resolve_colors(&config, IconButtonDefaults::filled_colors());
214    let is_enabled = config.enabled;
215    let sz = config
216        .container_size
217        .unwrap_or(IconButtonDefaults::FILLED_CONTAINER_SIZE);
218    let bg = colors.container(is_enabled);
219    let content_color = colors.content(is_enabled);
220    icon_button_render(
221        icon,
222        on_click,
223        &config,
224        colors,
225        sz,
226        Some(bg),
227        None,
228        StateColors {
229            default: Color::TRANSPARENT,
230            hovered: Color::TRANSPARENT,
231            focused: Color::TRANSPARENT,
232            pressed: Color::TRANSPARENT,
233            dragged: content_color.with_alpha_f32(0.12),
234            disabled: th.on_surface.with_alpha_f32(0.12),
235        },
236        true,
237    )
238}
239
240pub fn FilledTonalIconButton(
241    icon: View,
242    on_click: impl Fn() + 'static,
243    config: IconButtonConfig,
244) -> View {
245    let th = theme();
246    let colors = resolve_colors(&config, IconButtonDefaults::filled_tonal_colors());
247    let is_enabled = config.enabled;
248    let sz = config
249        .container_size
250        .unwrap_or(IconButtonDefaults::FILLED_CONTAINER_SIZE);
251    let bg = colors.container(is_enabled);
252    let content_color = colors.content(is_enabled);
253    icon_button_render(
254        icon,
255        on_click,
256        &config,
257        colors,
258        sz,
259        Some(bg),
260        None,
261        StateColors {
262            default: Color::TRANSPARENT,
263            hovered: Color::TRANSPARENT,
264            focused: Color::TRANSPARENT,
265            pressed: Color::TRANSPARENT,
266            dragged: content_color.with_alpha_f32(0.12),
267            disabled: th.on_surface.with_alpha_f32(0.12),
268        },
269        true,
270    )
271}
272
273pub fn OutlinedIconButton(
274    icon: View,
275    on_click: impl Fn() + 'static,
276    config: IconButtonConfig,
277) -> View {
278    let th = theme();
279    let colors = resolve_colors(&config, IconButtonDefaults::outlined_colors());
280    let sz = config
281        .container_size
282        .unwrap_or(IconButtonDefaults::CONTAINER_SIZE);
283    let border_color = if config.enabled {
284        th.outline
285    } else {
286        th.on_surface.with_alpha_f32(0.12)
287    };
288    let cc = colors.content(config.enabled);
289    icon_button_render(
290        icon,
291        on_click,
292        &config,
293        colors,
294        sz,
295        None,
296        Some((1.0, border_color)),
297        StateColors {
298            default: Color::TRANSPARENT,
299            hovered: Color::TRANSPARENT,
300            focused: Color::TRANSPARENT,
301            pressed: Color::TRANSPARENT,
302            dragged: cc.with_alpha_f32(0.12),
303            disabled: Color::TRANSPARENT,
304        },
305        true,
306    )
307}