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(800.0, 30.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        // Update spring animation
99        let target = if *checked { 1.0 } else { 0.0 };
100        self.check_spring.set_target(target);
101        let dt = ui.input(|i| i.stable_dt);
102        self.check_spring.update(dt);
103
104        if !self.check_spring.is_settled(0.001, 0.001) {
105            ui.ctx().request_repaint();
106        }
107
108        let response = ui
109            .horizontal(|ui| {
110                let (rect, mut response) = ui.allocate_exact_size(
111                    Vec2::splat(SIZE),
112                    if self.disabled {
113                        Sense::hover()
114                    } else {
115                        Sense::click()
116                    },
117                );
118
119                if ui.is_rect_visible(rect) {
120                    self.draw(ui, rect, *checked, &theme);
121                }
122
123                if response.clicked() && !self.disabled {
124                    *checked = !*checked;
125                    response.mark_changed();
126                }
127
128                // Label and description
129                if self.label.is_some() || self.description.is_some() {
130                    ui.add_space(theme.spacing.sm);
131                    ui.vertical(|ui| {
132                        ui.spacing_mut().item_spacing.y = theme.spacing.xs;
133                        if let Some(label) = &self.label {
134                            let color = if self.disabled {
135                                theme.muted_foreground().linear_multiply(0.5)
136                            } else {
137                                theme.foreground()
138                            };
139                            ui.label(
140                                egui::RichText::new(label)
141                                    .size(theme.typography.base)
142                                    .color(color),
143                            );
144                        }
145                        if let Some(desc) = &self.description {
146                            ui.label(
147                                egui::RichText::new(desc)
148                                    .size(theme.typography.sm)
149                                    .color(theme.muted_foreground()),
150                            );
151                        }
152                    });
153                }
154
155                response
156            })
157            .inner;
158
159        // Save state
160        if let Some(id) = self.id {
161            let state_id = id.with("checkbox_state");
162            ui.ctx().data_mut(|d| {
163                d.insert_temp(state_id, (*checked, self.check_spring.value));
164            });
165        }
166
167        CheckboxResponse {
168            response,
169            changed: old_checked != *checked,
170        }
171    }
172
173    fn draw(&self, ui: &mut Ui, rect: egui::Rect, checked: bool, theme: &Theme) {
174        let painter = ui.painter();
175        let t = self.check_spring.value;
176
177        // Background — primary when checked, transparent when unchecked
178        let bg_color = if self.disabled {
179            theme.muted().gamma_multiply(0.5)
180        } else if checked {
181            theme.primary()
182        } else {
183            Color32::TRANSPARENT
184        };
185
186        painter.rect_filled(rect, CornerRadius::same(CORNER_RADIUS), bg_color);
187
188        // Border
189        let border_color = if self.disabled {
190            theme.border()
191        } else if checked {
192            theme.primary()
193        } else {
194            theme.input()
195        };
196
197        painter.rect_stroke(
198            rect,
199            CornerRadius::same(CORNER_RADIUS),
200            Stroke::new(1.0, border_color),
201            egui::StrokeKind::Inside,
202        );
203
204        // Focus ring on hover
205        let hover_response = ui.interact(rect, ui.id().with("cb_hover"), Sense::hover());
206        if hover_response.hovered() && !self.disabled {
207            painter.rect_stroke(
208                rect.expand(2.0),
209                CornerRadius::same(CORNER_RADIUS + 2),
210                Stroke::new(2.0, theme.ring()),
211                egui::StrokeKind::Outside,
212            );
213        }
214
215        // Checkmark
216        if t > 0.0 {
217            let center = rect.center();
218            let size = rect.height() * 0.5 * t;
219
220            let check_start = center + vec2(-size * 0.35, 0.0);
221            let check_middle = center + vec2(-size * 0.05, size * 0.3);
222            let check_end = center + vec2(size * 0.35, -size * 0.35);
223
224            let check_color = if self.disabled {
225                theme.muted_foreground()
226            } else {
227                theme.primary_foreground()
228            };
229
230            painter.line_segment([check_start, check_middle], Stroke::new(1.5, check_color));
231            painter.line_segment([check_middle, check_end], Stroke::new(1.5, check_color));
232        }
233    }
234}
235
236impl Default for Checkbox {
237    fn default() -> Self {
238        Self::new()
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_checkbox_creation() {
248        let cb = Checkbox::new();
249        assert!(!cb.disabled);
250        assert!(cb.label.is_none());
251    }
252
253    #[test]
254    fn test_checkbox_builder() {
255        let cb = Checkbox::new()
256            .label("Accept terms")
257            .description("Required")
258            .disabled(true);
259
260        assert_eq!(cb.label, Some("Accept terms".to_string()));
261        assert_eq!(cb.description, Some("Required".to_string()));
262        assert!(cb.disabled);
263    }
264}