Skip to main content

armas_basic/components/
checkbox.rs

1//! Checkbox Component
2//!
3//! A standalone checkbox styled like shadcn/ui Checkbox.
4//! 16x16px square with 4px rounded corners, animated checkmark.
5
6use crate::animation::SpringAnimation;
7use crate::ext::ArmasContextExt;
8use crate::Theme;
9use egui::{vec2, Color32, CornerRadius, Response, Sense, Stroke, Ui, Vec2};
10
11const SIZE: f32 = 16.0;
12const CORNER_RADIUS: u8 = 4;
13
14/// Response from checkbox interaction
15pub struct CheckboxResponse {
16    /// The underlying egui response
17    pub response: Response,
18    /// Whether the checkbox state changed
19    pub changed: bool,
20}
21
22/// A checkbox component (shadcn/ui Checkbox)
23///
24/// # Example
25///
26/// ```ignore
27/// let mut checked = false;
28/// Checkbox::new()
29///     .label("Accept terms")
30///     .show(ui, &mut checked);
31/// ```
32pub struct Checkbox {
33    id: Option<egui::Id>,
34    label: Option<String>,
35    description: Option<String>,
36    disabled: bool,
37    check_spring: SpringAnimation,
38}
39
40impl Checkbox {
41    /// Create a new checkbox
42    #[must_use]
43    pub const fn new() -> Self {
44        Self {
45            id: None,
46            label: None,
47            description: None,
48            disabled: false,
49            check_spring: SpringAnimation::new(0.0, 0.0).params(2000.0, 55.0),
50        }
51    }
52
53    /// Set ID for state persistence
54    #[must_use]
55    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
56        self.id = Some(id.into());
57        self
58    }
59
60    /// Set a label
61    #[must_use]
62    pub fn label(mut self, label: impl Into<String>) -> Self {
63        self.label = Some(label.into());
64        self
65    }
66
67    /// Set a description
68    #[must_use]
69    pub fn description(mut self, description: impl Into<String>) -> Self {
70        self.description = Some(description.into());
71        self
72    }
73
74    /// Set disabled state
75    #[must_use]
76    pub const fn disabled(mut self, disabled: bool) -> Self {
77        self.disabled = disabled;
78        self
79    }
80
81    /// Show the checkbox
82    pub fn show(&mut self, ui: &mut Ui, checked: &mut bool) -> CheckboxResponse {
83        let theme = ui.ctx().armas_theme();
84
85        // Load state from memory if ID is set
86        if let Some(id) = self.id {
87            let state_id = id.with("checkbox_state");
88            let (stored_checked, stored_anim): (bool, f32) = ui.ctx().data_mut(|d| {
89                d.get_temp(state_id)
90                    .unwrap_or((*checked, if *checked { 1.0 } else { 0.0 }))
91            });
92            *checked = stored_checked;
93            self.check_spring.value = stored_anim;
94        }
95
96        let old_checked = *checked;
97
98        // Without an ID, the spring resets to 0.0 every frame.
99        // Snap it to match `checked` so it only animates on actual toggles.
100        if self.id.is_none() {
101            self.check_spring.value = if *checked { 1.0 } else { 0.0 };
102        }
103
104        // Update spring animation
105        let target = if *checked { 1.0 } else { 0.0 };
106        self.check_spring.set_target(target);
107        let dt = ui.input(|i| i.stable_dt);
108        self.check_spring.update(dt);
109
110        if !self.check_spring.is_settled(0.001, 0.001) {
111            ui.ctx().request_repaint();
112        }
113
114        let response = ui
115            .horizontal(|ui| {
116                let (rect, mut response) = ui.allocate_exact_size(
117                    Vec2::splat(SIZE),
118                    if self.disabled {
119                        Sense::hover()
120                    } else {
121                        Sense::click()
122                    },
123                );
124
125                if ui.is_rect_visible(rect) {
126                    self.draw(ui, rect, *checked, &theme);
127                }
128
129                if response.clicked() && !self.disabled {
130                    *checked = !*checked;
131                    response.mark_changed();
132                }
133
134                // Label and description
135                if self.label.is_some() || self.description.is_some() {
136                    ui.add_space(theme.spacing.sm);
137                    ui.vertical(|ui| {
138                        ui.spacing_mut().item_spacing.y = theme.spacing.xs;
139                        if let Some(label) = &self.label {
140                            let color = if self.disabled {
141                                theme.muted_foreground().linear_multiply(0.5)
142                            } else {
143                                theme.foreground()
144                            };
145                            ui.label(
146                                egui::RichText::new(label)
147                                    .size(theme.typography.base)
148                                    .color(color),
149                            );
150                        }
151                        if let Some(desc) = &self.description {
152                            ui.label(
153                                egui::RichText::new(desc)
154                                    .size(theme.typography.sm)
155                                    .color(theme.muted_foreground()),
156                            );
157                        }
158                    });
159                }
160
161                response
162            })
163            .inner;
164
165        // Save state
166        if let Some(id) = self.id {
167            let state_id = id.with("checkbox_state");
168            ui.ctx().data_mut(|d| {
169                d.insert_temp(state_id, (*checked, self.check_spring.value));
170            });
171        }
172
173        CheckboxResponse {
174            response,
175            changed: old_checked != *checked,
176        }
177    }
178
179    fn draw(&self, ui: &mut Ui, rect: egui::Rect, checked: bool, theme: &Theme) {
180        let painter = ui.painter();
181        let t = self.check_spring.value;
182
183        // Background — primary when checked, transparent when unchecked
184        let bg_color = if self.disabled {
185            theme.muted().gamma_multiply(0.5)
186        } else if checked {
187            theme.primary()
188        } else {
189            Color32::TRANSPARENT
190        };
191
192        painter.rect_filled(rect, CornerRadius::same(CORNER_RADIUS), bg_color);
193
194        // Border
195        let border_color = if self.disabled {
196            theme.border()
197        } else if checked {
198            theme.primary()
199        } else {
200            theme.input()
201        };
202
203        painter.rect_stroke(
204            rect,
205            CornerRadius::same(CORNER_RADIUS),
206            Stroke::new(1.0, border_color),
207            egui::StrokeKind::Inside,
208        );
209
210        // Focus ring on hover
211        let hover_response = ui.interact(rect, ui.id().with("cb_hover"), Sense::hover());
212        if hover_response.hovered() && !self.disabled {
213            painter.rect_stroke(
214                rect.expand(2.0),
215                CornerRadius::same(CORNER_RADIUS + 2),
216                Stroke::new(2.0, theme.ring()),
217                egui::StrokeKind::Outside,
218            );
219        }
220
221        // Checkmark — skip when the spring is near-zero, otherwise the
222        // 1.5-px stroke between collapsed vertices leaves a visible dot.
223        if t > 0.05 {
224            let center = rect.center();
225            let size = rect.height() * 0.5 * t;
226
227            let check_start = center + vec2(-size * 0.35, 0.0);
228            let check_middle = center + vec2(-size * 0.05, size * 0.3);
229            let check_end = center + vec2(size * 0.35, -size * 0.35);
230
231            let check_color = if self.disabled {
232                theme.muted_foreground()
233            } else {
234                theme.primary_foreground()
235            };
236
237            painter.line_segment([check_start, check_middle], Stroke::new(1.5, check_color));
238            painter.line_segment([check_middle, check_end], Stroke::new(1.5, check_color));
239        }
240    }
241}
242
243impl Default for Checkbox {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_checkbox_creation() {
255        let cb = Checkbox::new();
256        assert!(!cb.disabled);
257        assert!(cb.label.is_none());
258    }
259
260    #[test]
261    fn test_checkbox_builder() {
262        let cb = Checkbox::new()
263            .label("Accept terms")
264            .description("Required")
265            .disabled(true);
266
267        assert_eq!(cb.label, Some("Accept terms".to_string()));
268        assert_eq!(cb.description, Some("Required".to_string()));
269        assert!(cb.disabled);
270    }
271}