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(800.0, 30.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 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 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 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 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 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 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 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}