1use std::cell::Cell;
15use std::rc::Rc;
16use std::sync::Arc;
17
18use crate::color::Color;
19use crate::draw_ctx::DrawCtx;
20use crate::event::{Event, EventResult, Key, MouseButton};
21use crate::geometry::{Rect, Size};
22use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
23use crate::text::Font;
24use crate::widget::Widget;
25use crate::widgets::label::Label;
26
27const BOX_SIZE: f64 = 16.0;
28const FOCUS_PAD: f64 = 2.0;
29const GAP: f64 = 8.0;
30const BOX_STROKE_WIDTH: f64 = 1.5;
31
32#[cfg_attr(feature = "reflect", derive(bevy_reflect::Reflect))]
35#[derive(Clone, Debug, Default)]
36pub struct CheckboxProps {
37 pub checked: bool,
38 pub font_size: f64,
39 pub label_color: Option<Color>,
41}
42
43pub struct Checkbox {
45 bounds: Rect,
46 children: Vec<Box<dyn Widget>>,
49 base: WidgetBase,
50 font: Arc<Font>,
51 pub props: CheckboxProps,
52 state_cell: Option<Rc<Cell<bool>>>,
56 hovered: bool,
57 focused: bool,
58 on_change: Option<Box<dyn FnMut(bool)>>,
59 indeterminate_fn: Option<Box<dyn Fn() -> bool>>,
64 label_text: String,
67}
68
69impl Checkbox {
70 pub fn new(label: impl Into<String>, font: Arc<Font>, checked: bool) -> Self {
71 let label_text: String = label.into();
72 let font_size = 14.0;
73 let label_widget = Label::new(&label_text, Arc::clone(&font)).with_font_size(font_size);
74 Self {
75 bounds: Rect::default(),
76 children: vec![Box::new(label_widget)],
77 base: WidgetBase::new(),
78 font,
79 props: CheckboxProps {
80 checked,
81 font_size,
82 label_color: None,
83 },
84 state_cell: None,
85 hovered: false,
86 focused: false,
87 on_change: None,
88 indeterminate_fn: None,
89 label_text,
90 }
91 }
92
93 pub fn with_font_size(mut self, size: f64) -> Self {
94 self.props.font_size = size;
95 self.children[0] =
96 Box::new(Label::new(&self.label_text, Arc::clone(&self.font)).with_font_size(size));
97 self
98 }
99 pub fn with_label_color(mut self, c: Color) -> Self {
100 self.props.label_color = Some(c);
101 self
102 }
103
104 pub fn with_state_cell(mut self, cell: Rc<Cell<bool>>) -> Self {
110 self.state_cell = Some(cell);
111 self
112 }
113
114 pub fn with_margin(mut self, m: Insets) -> Self {
115 self.base.margin = m;
116 self
117 }
118 pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
119 self.base.h_anchor = h;
120 self
121 }
122 pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
123 self.base.v_anchor = v;
124 self
125 }
126 pub fn with_min_size(mut self, s: Size) -> Self {
127 self.base.min_size = s;
128 self
129 }
130 pub fn with_max_size(mut self, s: Size) -> Self {
131 self.base.max_size = s;
132 self
133 }
134
135 pub fn on_change(mut self, cb: impl FnMut(bool) + 'static) -> Self {
136 self.on_change = Some(Box::new(cb));
137 self
138 }
139
140 pub fn with_indeterminate_fn(mut self, f: impl Fn() -> bool + 'static) -> Self {
147 self.indeterminate_fn = Some(Box::new(f));
148 self
149 }
150
151 #[inline]
153 fn is_indeterminate(&self) -> bool {
154 self.indeterminate_fn.as_ref().is_some_and(|f| f())
155 }
156
157 pub fn checked(&self) -> bool {
158 self.props.checked
159 }
160 pub fn set_checked(&mut self, v: bool) {
161 self.props.checked = v;
162 }
163
164 fn toggle(&mut self) {
165 let new_val = !self.effective_checked();
166 self.props.checked = new_val;
167 if let Some(ref cell) = self.state_cell {
168 cell.set(new_val);
169 }
170 if let Some(cb) = self.on_change.as_mut() {
171 cb(new_val);
172 }
173 }
174
175 #[inline]
178 fn effective_checked(&self) -> bool {
179 if let Some(ref cell) = self.state_cell {
180 cell.get()
181 } else {
182 self.props.checked
183 }
184 }
185
186 fn unchecked_colors(v: &crate::theme::Visuals, hovered: bool) -> (Color, Color) {
187 let luma = v.bg_color.r * 0.299 + v.bg_color.g * 0.587 + v.bg_color.b * 0.114;
188 if luma < 0.5 {
189 let fill = if hovered { v.widget_bg } else { v.window_fill };
190 (fill, Color::rgba(1.0, 1.0, 1.0, 0.34))
191 } else {
192 let fill = if hovered {
193 v.widget_bg_hovered
194 } else {
195 v.widget_bg
196 };
197 (fill, v.widget_stroke)
198 }
199 }
200}
201
202impl Widget for Checkbox {
203 fn type_name(&self) -> &'static str {
204 "Checkbox"
205 }
206 fn bounds(&self) -> Rect {
207 self.bounds
208 }
209 fn set_bounds(&mut self, b: Rect) {
210 self.bounds = b;
211 }
212 fn children(&self) -> &[Box<dyn Widget>] {
213 &self.children
214 }
215 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
216 &mut self.children
217 }
218
219 #[cfg(feature = "reflect")]
220 fn as_reflect(&self) -> Option<&dyn bevy_reflect::Reflect> {
221 Some(&self.props)
222 }
223 #[cfg(feature = "reflect")]
224 fn as_reflect_mut(&mut self) -> Option<&mut dyn bevy_reflect::Reflect> {
225 Some(&mut self.props)
226 }
227
228 fn is_focusable(&self) -> bool {
229 true
230 }
231
232 fn margin(&self) -> Insets {
233 self.base.margin
234 }
235 fn widget_base(&self) -> Option<&WidgetBase> {
236 Some(&self.base)
237 }
238 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
239 Some(&mut self.base)
240 }
241 fn h_anchor(&self) -> HAnchor {
242 self.base.h_anchor
243 }
244 fn v_anchor(&self) -> VAnchor {
245 self.base.v_anchor
246 }
247 fn min_size(&self) -> Size {
248 self.base.min_size
249 }
250 fn max_size(&self) -> Size {
251 self.base.max_size
252 }
253
254 fn layout(&mut self, available: Size) -> Size {
255 let box_slot_w = BOX_SIZE + FOCUS_PAD * 2.0;
256 let h = (BOX_SIZE + FOCUS_PAD * 2.0).max(self.props.font_size * 1.25);
257 let label_avail_w = (available.width - box_slot_w - GAP).max(0.0);
258 let s = self.children[0].layout(Size::new(label_avail_w, h));
259 let lx = if self.label_text.is_empty() {
260 box_slot_w
261 } else {
262 box_slot_w + GAP
263 };
264 let ly = (h - s.height) * 0.5;
265 self.children[0].set_bounds(Rect::new(lx, ly, s.width, s.height));
266 let natural_w = if self.label_text.is_empty() {
267 box_slot_w
268 } else {
269 box_slot_w + GAP + s.width
270 };
271 let w = natural_w.min(available.width);
272 self.bounds = Rect::new(0.0, 0.0, w, h);
273 Size::new(w, h)
274 }
275
276 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
277 let v = ctx.visuals();
278 let h = self.bounds.height;
279 let box_x = FOCUS_PAD;
280 let box_y = (h - BOX_SIZE) * 0.5;
281
282 if self.focused {
284 ctx.set_stroke_color(v.accent_focus);
285 ctx.set_line_width(2.0);
286 ctx.begin_path();
287 ctx.rounded_rect(
288 box_x - 1.5,
289 box_y - 1.5,
290 BOX_SIZE + 3.0,
291 BOX_SIZE + 3.0,
292 4.0,
293 );
294 ctx.stroke();
295 }
296
297 let checked = self.effective_checked();
298 let indeterminate = self.is_indeterminate();
299 let filled = checked || indeterminate;
302
303 let (unchecked_bg, unchecked_border) = Self::unchecked_colors(&v, self.hovered);
305 let bg = if filled { v.accent } else { unchecked_bg };
306 ctx.set_fill_color(bg);
307 ctx.begin_path();
308 ctx.rounded_rect(box_x, box_y, BOX_SIZE, BOX_SIZE, 3.0);
309 ctx.fill();
310
311 let border = if filled {
313 v.widget_stroke_active
314 } else {
315 unchecked_border
316 };
317 ctx.set_stroke_color(border);
318 ctx.set_line_width(BOX_STROKE_WIDTH);
319 ctx.begin_path();
320 let stroke_inset = BOX_STROKE_WIDTH * 0.5;
321 ctx.rounded_rect(
322 box_x + stroke_inset,
323 box_y + stroke_inset,
324 BOX_SIZE - BOX_STROKE_WIDTH,
325 BOX_SIZE - BOX_STROKE_WIDTH,
326 3.0,
327 );
328 ctx.stroke();
329
330 if indeterminate {
334 ctx.set_stroke_color(Color::white());
335 ctx.set_line_width(2.0);
336 ctx.begin_path();
337 ctx.move_to(box_x + 3.0, box_y + BOX_SIZE * 0.5);
338 ctx.line_to(box_x + BOX_SIZE - 3.0, box_y + BOX_SIZE * 0.5);
339 ctx.stroke();
340 } else if checked {
341 ctx.set_stroke_color(Color::white());
342 ctx.set_line_width(2.0);
343 ctx.begin_path();
344 let bx = box_x;
345 let by = box_y;
346 ctx.move_to(bx + 3.0, by + BOX_SIZE * 0.55);
347 ctx.line_to(bx + BOX_SIZE * 0.42, by + BOX_SIZE * 0.28);
348 ctx.line_to(bx + BOX_SIZE - 3.0, by + BOX_SIZE * 0.75);
349 ctx.stroke();
350 }
351
352 let label_color = self.props.label_color.unwrap_or(v.text_color);
354 self.children[0].set_label_color(label_color);
355 }
356
357 fn on_event(&mut self, event: &Event) -> EventResult {
358 match event {
359 Event::MouseMove { pos } => {
360 let was = self.hovered;
361 self.hovered = self.hit_test(*pos);
362 if was != self.hovered {
363 crate::animation::request_draw();
364 return EventResult::Consumed;
365 }
366 EventResult::Ignored
367 }
368 Event::MouseDown {
369 button: MouseButton::Left,
370 ..
371 } => EventResult::Consumed,
372 Event::MouseUp {
373 button: MouseButton::Left,
374 pos,
375 ..
376 } => {
377 if self.hit_test(*pos) {
378 self.toggle();
379 crate::animation::request_draw();
380 }
381 EventResult::Consumed
382 }
383 Event::KeyDown {
384 key: Key::Char(' '),
385 ..
386 } => {
387 self.toggle();
388 crate::animation::request_draw();
389 EventResult::Consumed
390 }
391 Event::FocusGained => {
392 let was = self.focused;
393 self.focused = true;
394 if !was {
395 crate::animation::request_draw();
396 EventResult::Consumed
397 } else {
398 EventResult::Ignored
399 }
400 }
401 Event::FocusLost => {
402 let was = self.focused;
403 self.focused = false;
404 if was {
405 crate::animation::request_draw();
406 EventResult::Consumed
407 } else {
408 EventResult::Ignored
409 }
410 }
411 _ => EventResult::Ignored,
412 }
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 const FONT_BYTES: &[u8] = include_bytes!("../../../demo/assets/CascadiaCode.ttf");
421
422 fn test_font() -> Arc<Font> {
423 Arc::new(Font::from_slice(FONT_BYTES).expect("font"))
424 }
425
426 #[test]
427 fn no_indeterminate_fn_reads_false() {
428 let cb = Checkbox::new("x", test_font(), false);
429 assert!(!cb.is_indeterminate());
430 }
431
432 #[test]
433 fn indeterminate_fn_drives_visual_state() {
434 let flag = Rc::new(Cell::new(true));
435 let f = Rc::clone(&flag);
436 let cb = Checkbox::new("x", test_font(), false).with_indeterminate_fn(move || f.get());
437 assert!(cb.is_indeterminate());
438 flag.set(false);
439 assert!(!cb.is_indeterminate());
440 }
441
442 #[test]
443 fn click_still_toggles_while_indeterminate() {
444 let cb = Checkbox::new("x", test_font(), false).with_indeterminate_fn(|| true);
446 let mut cb = cb;
447 assert!(!cb.effective_checked());
448 cb.toggle();
449 assert!(cb.effective_checked());
450 }
451}