armas_basic/components/
checkbox.rs1use 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
14pub struct CheckboxResponse {
16 pub response: Response,
18 pub changed: bool,
20}
21
22pub 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 #[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 #[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 #[must_use]
62 pub fn label(mut self, label: impl Into<String>) -> Self {
63 self.label = Some(label.into());
64 self
65 }
66
67 #[must_use]
69 pub fn description(mut self, description: impl Into<String>) -> Self {
70 self.description = Some(description.into());
71 self
72 }
73
74 #[must_use]
76 pub const fn disabled(mut self, disabled: bool) -> Self {
77 self.disabled = disabled;
78 self
79 }
80
81 pub fn show(&mut self, ui: &mut Ui, checked: &mut bool) -> CheckboxResponse {
83 let theme = ui.ctx().armas_theme();
84
85 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 if self.id.is_none() {
101 self.check_spring.value = if *checked { 1.0 } else { 0.0 };
102 }
103
104 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 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 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 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 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 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 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}