1use cranpose_core::{NodeId, rememberKeyed};
4use cranpose_ui_graphics::{Brush, Color, VectorPath};
5use cranpose_ui_layout::Alignment;
6
7use crate::{
8 Modifier, SemanticsWidgetRole, Size, composable,
9 interaction::{MutableInteractionSource, rememberMutableInteractionSource},
10 widgets::{Box, BoxSpec},
11};
12
13const ICON_VIEW_BOX: f32 = 24.0;
15
16pub const DEFAULT_ICON_SIZE: f32 = 24.0;
18
19pub const MINIMUM_TOUCH_TARGET: f32 = 48.0;
25
26#[derive(Clone, Copy, Debug, PartialEq)]
28pub struct IconSpec {
29 pub size: f32,
31 pub tint: Option<Color>,
34}
35
36impl Default for IconSpec {
37 fn default() -> Self {
38 Self {
39 size: DEFAULT_ICON_SIZE,
40 tint: None,
41 }
42 }
43}
44
45impl IconSpec {
46 pub const fn sized(size: f32) -> Self {
48 Self { size, tint: None }
49 }
50
51 pub const fn with_size(mut self, size: f32) -> Self {
53 self.size = size;
54 self
55 }
56
57 pub const fn with_tint(mut self, tint: Color) -> Self {
59 self.tint = Some(tint);
60 self
61 }
62}
63
64#[composable]
70pub fn Icon(
71 path: &'static str,
72 content_description: Option<String>,
73 size: f32,
74 color: Color,
75) -> NodeId {
76 IconWith(
77 Modifier::empty(),
78 path,
79 IconSpec::sized(size).with_tint(color),
80 content_description,
81 )
82}
83
84#[composable]
86pub fn IconWith(
87 modifier: Modifier,
88 path: &'static str,
89 spec: IconSpec,
90 content_description: Option<String>,
91) -> NodeId {
92 let parsed = rememberKeyed(path, |value| VectorPath::parse(value).ok());
93 let size = spec.size;
94 let tint = spec.tint;
95 let modifier = modifier
96 .size(Size::new(size, size))
97 .draw_behind(move |scope| {
98 if let Some(path) = &parsed {
99 let scaled = path.scaled(size / ICON_VIEW_BOX);
100 match tint {
101 Some(tint) => scope.draw_vector_path(&scaled, Brush::solid(tint)),
102 None => {
103 scope.draw_vector_path(&scaled, Brush::solid(Color(0.0, 0.0, 0.0, 1.0)));
104 }
105 }
106 }
107 });
108 let modifier = match content_description {
109 Some(description) => modifier.semantics_spec(
110 cranpose_foundation::SemanticsSpec::new()
111 .content_description(description)
112 .role(SemanticsWidgetRole::Image),
113 ),
114 None => modifier,
115 };
116 Box(modifier, BoxSpec::default(), || {})
117}
118
119#[derive(Clone, Copy, Debug, Default, PartialEq)]
121pub struct IconButtonColors {
122 pub background: Option<Color>,
124 pub pressed_background: Option<Color>,
126 pub disabled_background: Option<Color>,
128}
129
130impl IconButtonColors {
131 pub const fn with_background(mut self, color: Color) -> Self {
133 self.background = Some(color);
134 self
135 }
136
137 pub const fn with_pressed_background(mut self, color: Color) -> Self {
139 self.pressed_background = Some(color);
140 self
141 }
142
143 pub const fn with_disabled_background(mut self, color: Color) -> Self {
145 self.disabled_background = Some(color);
146 self
147 }
148
149 fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
150 if !enabled {
151 return self.disabled_background.or(self.background);
152 }
153 if pressed {
154 return self.pressed_background.or(self.background);
155 }
156 self.background
157 }
158}
159
160#[derive(Clone, Copy, Debug, PartialEq)]
162pub struct IconButtonSpec {
163 pub enabled: bool,
166 pub touch_target: f32,
169 pub colors: IconButtonColors,
171}
172
173impl Default for IconButtonSpec {
174 fn default() -> Self {
175 Self {
176 enabled: true,
177 touch_target: MINIMUM_TOUCH_TARGET,
178 colors: IconButtonColors::default(),
179 }
180 }
181}
182
183impl IconButtonSpec {
184 pub const fn with_enabled(mut self, enabled: bool) -> Self {
186 self.enabled = enabled;
187 self
188 }
189
190 pub fn with_touch_target(mut self, size: f32) -> Self {
193 self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
194 self
195 }
196
197 pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
199 self.colors = colors;
200 self
201 }
202
203 pub fn resolved_touch_target(&self) -> f32 {
205 self.touch_target.max(MINIMUM_TOUCH_TARGET)
206 }
207}
208
209#[composable]
211pub fn IconButton<F>(
212 modifier: Modifier,
213 content_description: impl Into<String>,
214 on_click: impl Fn() + 'static,
215 content: F,
216) -> NodeId
217where
218 F: FnMut() + 'static,
219{
220 IconButtonWith(
221 modifier,
222 content_description,
223 IconButtonSpec::default(),
224 None,
225 on_click,
226 content,
227 )
228}
229
230#[composable]
237pub fn IconButtonWith<F>(
238 modifier: Modifier,
239 content_description: impl Into<String>,
240 spec: IconButtonSpec,
241 interaction_source: Option<MutableInteractionSource>,
242 on_click: impl Fn() + 'static,
243 content: F,
244) -> NodeId
245where
246 F: FnMut() + 'static,
247{
248 let description = content_description.into();
249 let enabled = spec.enabled;
250 let target = spec.resolved_touch_target();
251 let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
252 let pressed = source.collectIsPressedAsState().get();
253
254 let mut modifier = modifier
255 .size(Size::new(target, target))
256 .press_interaction_source(source)
257 .semantics(move |config| {
258 config.content_description = Some(description.clone());
259 config.role = Some(SemanticsWidgetRole::Button);
260 config.enabled = enabled;
261 config.is_clickable = enabled;
262 });
263 if let Some(surface) = spec.colors.surface(enabled, pressed) {
264 modifier = modifier.background(surface);
265 }
266 if enabled {
267 modifier = modifier.clickable(move |_| on_click());
268 }
269
270 Box(
271 modifier,
272 BoxSpec::default().content_alignment(Alignment::CENTER),
273 content,
274 )
275}
276
277#[cfg(test)]
278#[path = "tests/icon_tests.rs"]
279mod tests;