1use std::rc::Rc;
4
5use cranpose_core::{NodeId, rememberKeyed};
6use cranpose_ui_graphics::{Brush, Color, VectorPath};
7use cranpose_ui_layout::Alignment;
8
9use crate::{
10 Modifier, SemanticsWidgetRole, Size, composable,
11 interaction::{MutableInteractionSource, rememberMutableInteractionSource},
12 widgets::{Box, BoxSpec},
13};
14
15const ICON_VIEW_BOX: f32 = 24.0;
17
18pub const DEFAULT_ICON_SIZE: f32 = 24.0;
20
21pub const MINIMUM_TOUCH_TARGET: f32 = 48.0;
27
28#[derive(Clone, Copy, Debug, PartialEq)]
30pub struct IconSpec {
31 pub size: f32,
33 pub tint: Option<Color>,
36}
37
38impl Default for IconSpec {
39 fn default() -> Self {
40 Self {
41 size: DEFAULT_ICON_SIZE,
42 tint: None,
43 }
44 }
45}
46
47impl IconSpec {
48 pub const fn sized(size: f32) -> Self {
50 Self { size, tint: None }
51 }
52
53 pub const fn with_size(mut self, size: f32) -> Self {
55 self.size = size;
56 self
57 }
58
59 pub const fn with_tint(mut self, tint: Color) -> Self {
61 self.tint = Some(tint);
62 self
63 }
64}
65
66#[composable]
72pub fn Icon(
73 path: &'static str,
74 content_description: Option<String>,
75 size: f32,
76 color: Color,
77) -> NodeId {
78 IconWith(
79 Modifier::empty(),
80 path,
81 IconSpec::sized(size).with_tint(color),
82 content_description,
83 )
84}
85
86#[composable]
88pub fn IconWith(
89 modifier: Modifier,
90 path: &'static str,
91 spec: IconSpec,
92 content_description: Option<String>,
93) -> NodeId {
94 let size = spec.size;
95 let scaled = rememberKeyed((path, size), |&(path, size)| {
96 icon_path(path).map(|parsed| Rc::new(parsed.scaled(size / ICON_VIEW_BOX)))
97 });
98 let tint = spec.tint.unwrap_or(Color(0.0, 0.0, 0.0, 1.0));
99 let modifier = modifier
100 .size(Size::new(size, size))
101 .draw_behind(move |scope| {
102 if let Some(path) = &scaled {
103 scope.draw_vector_path(path, Brush::solid(tint));
104 }
105 });
106 let modifier = match content_description {
107 Some(description) => modifier.semantics_spec(
108 cranpose_foundation::SemanticsSpec::new()
109 .content_description(description)
110 .role(SemanticsWidgetRole::Image),
111 ),
112 None => modifier,
113 };
114 Box(modifier, BoxSpec::default(), || {})
115}
116
117fn icon_path(path: &'static str) -> Option<Rc<VectorPath>> {
120 crate::thread_state::with(|state| {
121 let mut paths = state.icon_paths.borrow_mut();
122 if let Some((_, parsed)) = paths.iter().find(|(known, _)| *known == path) {
123 return parsed.clone();
124 }
125 let parsed = VectorPath::parse(path).ok().map(Rc::new);
126 paths.push((path, parsed.clone()));
127 parsed
128 })
129}
130
131#[derive(Clone, Copy, Debug, Default, PartialEq)]
133pub struct IconButtonColors {
134 pub background: Option<Color>,
136 pub pressed_background: Option<Color>,
138 pub disabled_background: Option<Color>,
140}
141
142impl IconButtonColors {
143 pub const fn with_background(mut self, color: Color) -> Self {
145 self.background = Some(color);
146 self
147 }
148
149 pub const fn with_pressed_background(mut self, color: Color) -> Self {
151 self.pressed_background = Some(color);
152 self
153 }
154
155 pub const fn with_disabled_background(mut self, color: Color) -> Self {
157 self.disabled_background = Some(color);
158 self
159 }
160
161 fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
162 if !enabled {
163 return self.disabled_background.or(self.background);
164 }
165 if pressed {
166 return self.pressed_background.or(self.background);
167 }
168 self.background
169 }
170}
171
172#[derive(Clone, Copy, Debug, PartialEq)]
174pub struct IconButtonSpec {
175 pub enabled: bool,
178 pub touch_target: f32,
181 pub colors: IconButtonColors,
183}
184
185impl Default for IconButtonSpec {
186 fn default() -> Self {
187 Self {
188 enabled: true,
189 touch_target: MINIMUM_TOUCH_TARGET,
190 colors: IconButtonColors::default(),
191 }
192 }
193}
194
195impl IconButtonSpec {
196 pub const fn with_enabled(mut self, enabled: bool) -> Self {
198 self.enabled = enabled;
199 self
200 }
201
202 pub fn with_touch_target(mut self, size: f32) -> Self {
205 self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
206 self
207 }
208
209 pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
211 self.colors = colors;
212 self
213 }
214
215 pub fn resolved_touch_target(&self) -> f32 {
217 self.touch_target.max(MINIMUM_TOUCH_TARGET)
218 }
219}
220
221#[composable]
223pub fn IconButton<F>(
224 modifier: Modifier,
225 content_description: impl Into<String>,
226 on_click: impl Fn() + 'static,
227 content: F,
228) -> NodeId
229where
230 F: FnMut() + 'static,
231{
232 IconButtonWith(
233 modifier,
234 content_description,
235 IconButtonSpec::default(),
236 None,
237 on_click,
238 content,
239 )
240}
241
242#[composable]
249pub fn IconButtonWith<F>(
250 modifier: Modifier,
251 content_description: impl Into<String>,
252 spec: IconButtonSpec,
253 interaction_source: Option<MutableInteractionSource>,
254 on_click: impl Fn() + 'static,
255 content: F,
256) -> NodeId
257where
258 F: FnMut() + 'static,
259{
260 let description = content_description.into();
261 let enabled = spec.enabled;
262 let target = spec.resolved_touch_target();
263 let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
264 let pressed = source.collectIsPressedAsState().get();
265
266 let mut modifier = modifier
267 .size(Size::new(target, target))
268 .press_interaction_source(source)
269 .stable_semantics(move |config| {
270 config.content_description = Some(description.clone());
271 config.role = Some(SemanticsWidgetRole::Button);
272 config.enabled = enabled;
273 config.is_clickable = enabled;
274 });
275 if let Some(surface) = spec.colors.surface(enabled, pressed) {
276 modifier = modifier.background(surface);
277 }
278 if enabled {
279 modifier = modifier.clickable(move |_| on_click());
280 }
281
282 Box(
283 modifier,
284 BoxSpec::default().content_alignment(Alignment::CENTER),
285 content,
286 )
287}
288
289#[cfg(test)]
290#[path = "tests/icon_tests.rs"]
291mod tests;