GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/checkbox.rs (30.1K)
1 //! Narrow-trait `Checkbox` and `Toggle` (Phase 5e — first interactive widgets off `WidgetHost`).
2 //!
3 //! Both are inline-label widgets: they paint their own label (with hover/focus-dependent color)
4 //! inside their rect, so they track `hovered`/`focused` themselves from the `MouseEnter`/
5 //! `MouseLeave`/`FocusIn`/`FocusOut` events the adapter forwards — the migration shape for the
6 //! state that becomes `Animated<f32>` in RFC §3.6.
7 //!
8 //! Geometry parity: `paint` emits the same conditional geometry as the legacy `extra_quads` /
9 //! `extra_arcs` / `all_rounded_quads` overrides did, in the matching prim kinds, so the adapter's
10 //! per-prim reverse bridges reproduce the legacy getters byte-for-byte.
11
12 use crate::colors;
13 use crate::scene::layout::Rect;
14 use crate::scene::paint::PaintCtx;
15 use crate::widget::{
16 Adapted, ElementState, Event, EventCtx, Input, Justification, Layout, MouseButton, Paint,
17 };
18
19 /// A rect shrunk by `g` on every side, its uniform corner radius shrunk to
20 /// match so the inner silhouette stays concentric with the outer one.
21 fn inset(rect: Rect, radius: f32, g: f32) -> (Rect, f32) {
22 (
23 Rect {
24 x: rect.x + g,
25 y: rect.y + g,
26 width: (rect.width - 2.0 * g).max(0.0),
27 height: (rect.height - 2.0 * g).max(0.0),
28 },
29 (radius - g).max(0.0),
30 )
31 }
32
33 fn parse_bool(val: &str) -> Option<bool> {
34 match val.trim().to_lowercase().as_str() {
35 "true" | "1" | "yes" | "on" => Some(true),
36 "false" | "0" | "no" | "off" => Some(false),
37 _ => None,
38 }
39 }
40
41 /// A ring-and-dot check mark with an optional label to its right: a 14px ring in
42 /// the dim text colour, filled with a dot in the toggle-on colour when checked —
43 /// the mark cce-list's rows have always drawn (they call `paint_round_mark` for
44 /// it). Standalone, the mark fills the rect.
45 pub struct Checkbox {
46 checked: bool,
47 just_clicked: bool,
48 pub just_changed: bool,
49 label: Option<String>,
50 hovered: bool,
51 focused: bool,
52 }
53
54 impl Checkbox {
55 /// The labelled mark's radius: a 14px disc.
56 pub const ROUND_RADIUS: f32 = 7.0;
57
58 pub fn new() -> Adapted<Checkbox> {
59 Adapted::new(Checkbox {
60 checked: false,
61 just_clicked: false,
62 just_changed: false,
63 label: None,
64 hovered: false,
65 focused: false,
66 })
67 }
68
69 pub fn set_checked(&mut self, checked: bool) {
70 self.checked = checked;
71 }
72
73 pub fn checked(&self) -> bool {
74 self.checked
75 }
76
77 /// The mark, centred on (`cx`, `cy`): a dim ring, and a solid dot in the
78 /// toggle-on colour when checked. Shared with hosts that draw their own rows
79 /// (cce-list) so a list's marks and a `Checkbox` agree pixel for pixel.
80 pub fn paint_round_mark(ctx: &mut PaintCtx, cx: f32, cy: f32, radius: f32, checked: bool) {
81 Self::paint_round_mark_ringed(ctx, cx, cy, radius, checked, colors::TEXT_DIM);
82 }
83
84 /// [`Checkbox::paint_round_mark`] with the ring in `ring` — the mark's
85 /// silhouette lit in the highlight colour is its keyboard-focus ring (a
86 /// mark is not a plate, so it has no rim to tint; the ring it already
87 /// draws is the silhouette).
88 pub fn paint_round_mark_ringed(ctx: &mut PaintCtx, cx: f32, cy: f32, radius: f32, checked: bool, ring: [f32; 4]) {
89 ctx.border(
90 Rect { x: cx - radius, y: cy - radius, width: 2.0 * radius, height: 2.0 * radius },
91 (radius, radius, radius, radius),
92 [0.0, 0.0, 0.0, 0.0],
93 ring,
94 1.5,
95 );
96 if checked {
97 ctx.circle(cx, cy, radius - 3.0, colors::TOGGLE_ON);
98 }
99 }
100
101 /// Hover state, also settable directly for immediate-mode hosts that do their own
102 /// hit-testing instead of routing `MouseEnter`/`MouseLeave` (json_layout).
103 pub fn hovered(&self) -> bool {
104 self.hovered
105 }
106
107 pub fn set_hovered(&mut self, hovered: bool) {
108 self.hovered = hovered;
109 }
110 }
111
112 impl Layout for Checkbox {
113 fn inline_label(&self) -> bool {
114 true
115 }
116 }
117
118 impl Paint for Checkbox {
119 fn color(&self) -> [f32; 4] {
120 // The mark is painted; the widget itself has no background.
121 [0.0, 0.0, 0.0, 0.0]
122 }
123
124 fn widget_font(&self) -> Option<String> {
125 Some(crate::layout::control_label_font())
126 }
127
128 fn sync_label(&mut self, label: &str) {
129 self.label = Some(label.to_string());
130 }
131
132 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
133 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
134 // The mark leads and the label follows, a list row's reading order.
135 // Standalone, the mark fills the rect.
136 let r = if self.label.is_some() { Self::ROUND_RADIUS } else { (w.min(h) / 2.0).max(1.0) };
137 let (cx, cy) = if self.label.is_some() { (x + r, y + h / 2.0) } else { (x + w / 2.0, y + h / 2.0) };
138 // Focused: the mark's own ring lit in the highlight colour.
139 let ring = if self.focused { crate::color::highlight_primary_color() } else { colors::TEXT_DIM };
140 Self::paint_round_mark_ringed(ctx, cx, cy, r, self.checked, ring);
141 if let Some(ref label) = self.label {
142 let (_, font_size) = crate::layout::control_label_font_parsed();
143 let ty = crate::layout::align_text_y(y, h, font_size, 0.0);
144 ctx.text_with(
145 label.clone(),
146 cx + r + 8.0,
147 ty,
148 font_size,
149 colors::control_label_color_for_state(self.hovered, self.focused),
150 None,
151 // The label is caller text and the box is caller-sized; a
152 // control has no business drawing past its own rect.
153 Some([x, y, x + w, y + h]),
154 );
155 }
156 }
157 }
158
159 impl Input for Checkbox {
160 fn focus_role(&self) -> crate::widget::FocusRole {
161 crate::widget::FocusRole::Plate
162 }
163 fn on_event(&mut self, event: &Event, _ectx: &mut EventCtx) -> bool {
164 match event {
165 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
166 // Already hit-gated by the adapter.
167 self.checked = !self.checked;
168 self.just_clicked = true;
169 self.just_changed = true;
170 true
171 }
172 Event::MouseEnter => {
173 self.hovered = true;
174 false
175 }
176 Event::MouseLeave => {
177 self.hovered = false;
178 false
179 }
180 Event::FocusIn => {
181 self.focused = true;
182 false
183 }
184 Event::FocusOut => {
185 self.focused = false;
186 false
187 }
188 Event::KeyInput(key_event) => {
189 // A focused plate is pressed by Enter / Space, as a Button is.
190 if !self.focused || key_event.state != ElementState::Pressed {
191 return false;
192 }
193 match key_event.logical_key {
194 crate::widget::Key::Named(crate::widget::NamedKey::Enter)
195 | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
196 self.checked = !self.checked;
197 self.just_clicked = true;
198 self.just_changed = true;
199 true
200 }
201 _ => false,
202 }
203 }
204 _ => false,
205 }
206 }
207
208 fn opens_context_menu(&self) -> bool {
209 true
210 }
211
212 fn take_click(&mut self) -> bool {
213 std::mem::take(&mut self.just_clicked)
214 }
215
216 fn take_change(&mut self) -> bool {
217 std::mem::take(&mut self.just_changed)
218 }
219
220 fn value_string(&self) -> Option<String> {
221 Some(self.checked.to_string())
222 }
223
224 fn set_value_string(&mut self, val: &str) -> bool {
225 let Some(new_checked) = parse_bool(val) else { return false };
226 if self.checked != new_checked {
227 self.checked = new_checked;
228 self.just_changed = true;
229 true
230 } else {
231 false
232 }
233 }
234
235 fn value(&self) -> i32 {
236 if self.checked { 1 } else { 0 }
237 }
238 }
239
240 /// A plate that slides in a well: the toggle's footprint is a track carved
241 /// into the plate it sits on, and a half-width control plate stands on that
242 /// well's floor at the left (off) or right (on) end, gliding between them.
243 ///
244 /// That is the ONE toggle style. The rocker — two flat half faces with a
245 /// hinge between them, the state half tipped out toward the light — is gone,
246 /// and with it the per-widget and per-config style switch it was chosen by
247 /// (`style.control.toggle.style`, `Toggle::with_slide`).
248 #[derive(Debug, Clone)]
249 pub struct Toggle {
250 toggled: bool,
251 just_toggled: bool,
252 label: Option<String>,
253 hovered: bool,
254 focused: bool,
255 /// Where the label sits across the track. Mirrors `Button::justify` — same enum, same
256 /// 8px edge inset — so the two read as one control set wherever they share a column.
257 justify: Justification,
258 /// Relief style: the track is a real carved well and the glider a raised
259 /// plate standing in it. Without it the track falls back to the hairline
260 /// frame every well shares and the plate to a lit face — see `paint`.
261 raised: Option<bool>,
262 /// The glider's animated position along its well, 0 (left/off) → 1 (right/on).
263 /// Chases `toggled` in `tick` after a click; programmatic state syncs
264 /// (`set_toggled`, `set_value_string`) snap it, so only user interaction
265 /// animates.
266 slide_t: f32,
267 }
268
269 impl Toggle {
270 /// The style in force: the per-widget override (`with_raised`) when set, else
271 /// the DE's `control_relief`, read live so a runtime switch
272 /// (`layout::set_control_relief`) restyles every control at once.
273 fn raised(&self) -> bool {
274 self.raised.unwrap_or_else(crate::layout::control_relief)
275 }
276
277 pub fn new() -> Adapted<Toggle> {
278 Adapted::new(Toggle {
279 toggled: false,
280 just_toggled: false,
281 label: None,
282 hovered: false,
283 focused: false,
284 justify: Justification::Center,
285 raised: None,
286 slide_t: 0.0,
287 })
288 }
289
290 pub fn set_label(&mut self, label: &str) {
291 self.label = Some(label.to_string());
292 }
293
294 pub fn set_toggled(&mut self, v: bool) {
295 self.toggled = v;
296 self.slide_t = if v { 1.0 } else { 0.0 };
297 }
298
299 pub fn toggled(&self) -> bool {
300 self.toggled
301 }
302
303 /// The well the glider lives in: the toggle's whole footprint carved one
304 /// step down. Taken through [`crate::layout::carve_inside`], so the walls
305 /// stay inside the rect and the gap beside a toggle is the gap, exactly as
306 /// a TextBox's well is taken. `(rect, per-corner radii, wall width)`.
307 ///
308 /// The SINGLE source for the track geometry: `paint` carves it here and
309 /// [`Toggle::slide_plate`] measures the floor from it.
310 pub fn well(&self, rect: Rect) -> (Rect, crate::scene::paint::Radii, f32) {
311 let r = crate::layout::toggle_corner_radius();
312 let depth = crate::layout::bevel_width().min(rect.height * 0.2);
313 let (well, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
314 (well, radii, depth)
315 }
316
317 /// The sliding plate, as `(footprint, corner radius, wall width)`: half the
318 /// well's floor wide, gliding between the floor's ends by the animated
319 /// `slide_t` — left is off, right is on.
320 ///
321 /// It stands ON the floor — the flat region inside the well's walls, which
322 /// start half a wall in from the well's own boundary — and its roll abuts
323 /// that floor's edge instead of shading over the well's wall. Its corners
324 /// run concentric with the well's.
325 ///
326 /// Its wall is HALF the well's. A plate in a well is the shallower part of
327 /// the pair, and at a control's height it has to be: a toggle is 24px, a
328 /// well wall 4.8, and two full-depth walls stacked leave the boss no flat
329 /// top at all — its own walls meet in the middle and the plate reads as a
330 /// ridge drawn across the track rather than a thing standing in it.
331 pub fn slide_plate(&self, rect: Rect) -> (Rect, f32, f32) {
332 let (well, radii, wd) = self.well(rect);
333 let (floor, floor_r) = inset(well, radii.0, wd * 0.5);
334 let pd = (wd * 0.5).max(1.0);
335 let (travel, pr) = inset(floor, floor_r, pd * 0.5);
336 let pw = travel.width * 0.5;
337 let plate = Rect {
338 x: travel.x + self.slide_t * (travel.width - pw),
339 y: travel.y,
340 width: pw,
341 height: travel.height,
342 };
343 (plate, pr, pd)
344 }
345
346 /// The carves this Toggle paints: the track's well (a `Recess`) and the
347 /// glider standing in it (a `Boss`, the faceless raised plate
348 /// [`crate::scene::paint::PaintCtx::control_plate`] emits) — in that order,
349 /// the order `paint` emits them.
350 ///
351 /// Exists because a Toggle paints NO fill in any state: on a legacy-view
352 /// host (`ParametersBg::reliefs`) these carves are the ENTIRE control, and
353 /// without them the row is a bare label. Empty without `raised` styling,
354 /// where the frame and the lit face stand in for them.
355 pub fn flat_carves(&self, rect: Rect) -> Vec<crate::layout::ReliefCarve> {
356 use crate::layout::{CarveKind, ReliefCarve};
357 if !self.raised() {
358 return Vec::new();
359 }
360 let all = (true, true, true, true);
361 let (well, well_radii, depth) = self.well(rect);
362 let (plate, pr, pd) = self.slide_plate(rect);
363 let (boss, boss_radii) = crate::layout::carve_inside(plate, (pr, pr, pr, pr), pd);
364 vec![
365 ReliefCarve {
366 kind: CarveKind::Recess { tint: None },
367 x: well.x,
368 y: well.y,
369 w: well.width,
370 h: well.height,
371 radii: well_radii,
372 depth,
373 edges: all,
374 },
375 ReliefCarve {
376 kind: CarveKind::Boss { tint: None },
377 x: boss.x,
378 y: boss.y,
379 w: boss.width,
380 h: boss.height,
381 radii: boss_radii,
382 depth: pd,
383 edges: all,
384 },
385 ]
386 }
387 }
388
389 impl Adapted<Toggle> {
390 pub fn with_left_align(mut self, left_align: bool) -> Self {
391 self.justify = if left_align { Justification::Left } else { Justification::Center };
392 self
393 }
394
395 pub fn with_justify(mut self, justify: Justification) -> Self {
396 self.justify = justify;
397 self
398 }
399
400 /// Raised style: see the `raised` field.
401 pub fn with_raised(mut self, raised: bool) -> Self {
402 self.raised = Some(raised);
403 self
404 }
405 }
406
407 impl Layout for Toggle {
408 fn inline_label(&self) -> bool {
409 true
410 }
411
412 fn intrinsic_size(&self) -> Option<crate::scene::layout::Size> {
413 // Legacy `preferred_height`; width comes from the container.
414 Some(crate::scene::layout::Size::new(0.0, crate::layout::toggle_height()))
415 }
416 }
417
418 impl Paint for Toggle {
419 /// No fill of its own: a toggle is worked out of the plate it sits on, so
420 /// the plate's material (tint, blur, whatever it is) shows through and the
421 /// state reads from light and relief alone — see [`Paint::paint`].
422 fn color(&self) -> [f32; 4] {
423 [0.0, 0.0, 0.0, 0.0]
424 }
425
426 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
427 let r = crate::layout::toggle_corner_radius();
428 if r > 0.0 {
429 Some((r, (true, true, true, true)))
430 } else {
431 None
432 }
433 }
434
435 fn widget_font(&self) -> Option<String> {
436 Some(crate::layout::control_label_font())
437 }
438
439 fn sync_label(&mut self, label: &str) {
440 self.label = Some(label.to_string());
441 }
442
443 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
444 use crate::scene::paint::{ControlPlate, PlateStance};
445 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
446
447 // A toggle paints NO fill of its own: it is worked out of the plate it
448 // sits on, so the plate's own material (tint, blur, whatever it is)
449 // shows through both the well's floor and the glider's face, and the
450 // state reads from light and relief alone — the DE's transparent-face
451 // convention (closed dropdowns, inset troughs). The state colors this
452 // used to tint with (enabled/disabled/background) are retired with the
453 // rest of the toggle's palette.
454 let (well, well_radii, depth) = self.well(rect);
455 let (plate, plate_r, plate_depth) = self.slide_plate(rect);
456 if self.raised() {
457 // The track is a WELL — the same recess a TextBox carves — and the
458 // glider is a control plate standing on its floor, raised faceless
459 // (a `Boss`, the surface below as its face). Its position IS the
460 // read: left off, right on, animated in `tick`. Both also reach
461 // legacy-view hosts through `flat_carves` (see
462 // `ParametersBg::reliefs`), which must emit them in this order.
463 ctx.recess(well, well_radii, depth);
464 let focus = if self.focused { Some(ControlPlate::focus_tint()) } else { None };
465 ctx.control_plate(
466 &ControlPlate::control(plate, plate_r, PlateStance::Raised, None)
467 .with_depth(plate_depth)
468 .with_tint(focus),
469 );
470 } else {
471 // Relief off: a well is its frame — the one hairline every well
472 // falls back to, lit while focused, exactly `well_rim`'s flat arm —
473 // and the plate standing in it is a lit face, the neutral overlay
474 // the DE gives a surface it cannot carve (what the rocker's halves
475 // wore). Scaled by the relief strength, as that lighting was.
476 //
477 // The plate's overlay is a ROUNDED RECT on purpose: the legacy
478 // reverse bridge reads only those, never a `Border`, so a
479 // legacy-view host (`ParametersBg`, whose carves are gated on
480 // relief) still shows which end the plate is at.
481 let bw = crate::layout::toggle_border_width().max(1.0);
482 ctx.border(well, well_radii, [0.0; 4], colors::well_frame_color(self.hovered, self.focused), bw);
483 let lit = (0.16 * (crate::layout::bevel_depth() / 0.15)).clamp(0.0, 0.5);
484 ctx.rounded_rect(plate, plate_r, (true, true, true, true), [1.0, 1.0, 1.0, lit]);
485 }
486
487 if let Some(ref label) = self.label {
488 let (font_fam, font_size) = crate::layout::control_label_font_parsed();
489 let est_w = crate::widget::display::measure_text_width(label, &font_fam, font_size);
490 let tx = match self.justify {
491 Justification::Left => x + crate::layout::CONTROL_TEXT_INSET,
492 Justification::Right => x + w - est_w - crate::layout::CONTROL_TEXT_INSET,
493 Justification::Center => x + (w - est_w) / 2.0,
494 };
495 // The label never moves with the state. When the glider covers it
496 // the text shows through: the plate carries no face of its own, and
497 // the glyphs land in the engine's later text pass either way.
498 //
499 // Focus is the glider plate's own lit rim (`ControlPlate::with_tint`)
500 // — the ring every other plate wears, which the rocker's partial
501 // carves could not — so the label stays the label.
502 ctx.text_with(
503 label.clone(),
504 tx,
505 crate::layout::align_text_y(y, h, font_size, 0.0),
506 font_size,
507 colors::control_label_color_for_state(self.hovered, false),
508 None,
509 Some([x, y, x + w, y + h]),
510 );
511 }
512 }
513 }
514
515 impl Input for Toggle {
516 fn focus_role(&self) -> crate::widget::FocusRole {
517 crate::widget::FocusRole::Plate
518 }
519 fn on_event(&mut self, event: &Event, _ectx: &mut EventCtx) -> bool {
520 match event {
521 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
522 self.toggled = !self.toggled;
523 self.just_toggled = true;
524 true
525 }
526 Event::MouseEnter => {
527 self.hovered = true;
528 false
529 }
530 Event::MouseLeave => {
531 self.hovered = false;
532 false
533 }
534 Event::FocusIn => {
535 self.focused = true;
536 false
537 }
538 Event::FocusOut => {
539 self.focused = false;
540 false
541 }
542 Event::KeyInput(key_event) => {
543 // A focused plate is pressed by Enter / Space, as a Button is.
544 if !self.focused || key_event.state != ElementState::Pressed {
545 return false;
546 }
547 match key_event.logical_key {
548 crate::widget::Key::Named(crate::widget::NamedKey::Enter)
549 | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
550 self.toggled = !self.toggled;
551 self.just_toggled = true;
552 true
553 }
554 _ => false,
555 }
556 }
557 _ => false,
558 }
559 }
560
561 /// The glide: the plate's position in its well chases the state after a
562 /// click (~90ms exponential settle). Programmatic syncs snap instead — see
563 /// `set_toggled` / `set_value_string` — so only user interaction animates.
564 fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
565 let target = if self.toggled { 1.0 } else { 0.0 };
566 let d = target - self.slide_t;
567 if d.abs() < 0.001 {
568 return false;
569 }
570 if !crate::motion::enabled() {
571 self.slide_t = target;
572 return true;
573 }
574 self.slide_t += d * (1.0 - (-dt * 22.0).exp());
575 if (target - self.slide_t).abs() < 0.005 {
576 self.slide_t = target;
577 }
578 true
579 }
580
581 fn take_click(&mut self) -> bool {
582 std::mem::take(&mut self.just_toggled)
583 }
584
585 fn take_change(&mut self) -> bool {
586 std::mem::take(&mut self.just_toggled)
587 }
588
589 fn value_string(&self) -> Option<String> {
590 Some(self.toggled.to_string())
591 }
592
593 fn set_value_string(&mut self, val: &str) -> bool {
594 let Some(new_toggled) = parse_bool(val) else { return false };
595 if self.toggled != new_toggled {
596 self.toggled = new_toggled;
597 self.slide_t = if new_toggled { 1.0 } else { 0.0 };
598 self.just_toggled = true;
599 true
600 } else {
601 false
602 }
603 }
604 }
605
606 #[cfg(test)]
607 mod tests {
608 use super::*;
609 use crate::widget::{WidgetHost, UiContext};
610
611 fn click_at(x: f32, y: f32) -> Event {
612 Event::MouseButton {
613 button: MouseButton::Left,
614 state: ElementState::Pressed,
615 x,
616 y,
617 local_x: x,
618 local_y: y,
619 }
620 }
621
622 #[test]
623 fn checkbox_click_toggles_and_polls_like_legacy() {
624 let mut ctx = UiContext::new();
625 let mut cb = Checkbox::new();
626 let (id, ptr) = (cb.id(), cb.as_ptr_mut());
627 ctx.register_widget(id, ptr);
628 WidgetHost::set_rect(&mut cb, 0.0, 0.0, 20.0, 20.0);
629
630 assert!(ctx.propagate_event(&click_at(10.0, 10.0), id), "in-rect click consumed");
631 assert!(cb.checked(), "click checked it");
632 assert!(cb.take_click(), "take_click reads once");
633 assert!(!cb.take_click(), "...then clears");
634 assert!(cb.take_change());
635
636 assert!(!ctx.propagate_event(&click_at(100.0, 100.0), id), "miss is not consumed");
637 assert!(cb.checked(), "miss does not toggle");
638 }
639
640 #[test]
641 fn checkbox_value_string_round_trip() {
642 let mut cb = Checkbox::new();
643 assert_eq!(cb.get_value_string(), Some("false".to_string()));
644 assert!(cb.set_value_string("on"));
645 assert!(cb.checked());
646 assert_eq!(cb.value(), 1);
647 assert!(!cb.set_value_string("on"), "unchanged value reports false");
648 assert!(!cb.set_value_string("junk"), "unparsable reports false");
649 assert!(cb.take_change(), "set_value_string marked the change");
650 }
651
652 /// A labelled checkbox paints its ring-and-dot mark on the left and the label after
653 /// it: no quads at all, one circle through the bridge once checked.
654 #[test]
655 fn checkbox_paints_ring_and_dot() {
656 let ctx = UiContext::new();
657 let mut cb = Checkbox::new().with_label("Enable");
658 WidgetHost::set_rect(&mut cb, 0.0, 0.0, 200.0, 24.0);
659
660 assert!(WidgetHost::extra_quads(&cb).is_empty());
661 assert!(WidgetHost::extra_circles(&cb).is_empty());
662
663 cb.inner_mut().set_checked(true);
664 let circles = WidgetHost::extra_circles(&cb);
665 assert_eq!(circles.len(), 1);
666 let r = Checkbox::ROUND_RADIUS;
667 assert_eq!(circles[0], (r, 12.0, r - 3.0, colors::TOGGLE_ON));
668
669 // Label text comes through the prim-derived text bridge, after the mark.
670 let labels = cb.own_text_labels();
671 assert_eq!(labels.len(), 1);
672 assert_eq!(labels[0].text, "Enable");
673 assert_eq!(labels[0].x, 2.0 * r + 8.0);
674
675 // Inline label => no set_rect inflation.
676 assert_eq!(WidgetHost::rect(&cb), (0.0, 0.0, 200.0, 24.0));
677 let _ = &ctx;
678 }
679
680 #[test]
681 fn toggle_click_glides_the_plate_across_its_well() {
682 let mut ctx = UiContext::new();
683 let mut t = Toggle::new();
684 let (id, ptr) = (t.id(), t.as_ptr_mut());
685 ctx.register_widget(id, ptr);
686 WidgetHost::set_rect(&mut t, 0.0, 0.0, 60.0, 30.0);
687
688 let rect = Rect { x: 0.0, y: 0.0, width: 60.0, height: 30.0 };
689 let painted = |t: &Adapted<Toggle>| {
690 let mut pc = crate::scene::paint::PaintCtx::new();
691 crate::widget::Paint::paint(t.inner(), rect, &mut pc);
692 pc.finish().items.into_iter().map(|i| format!("{:?}", i.prim)).collect::<Vec<_>>()
693 };
694 let before = painted(&t);
695 let plate_x = |t: &Adapted<Toggle>| t.inner().slide_plate(rect).0.x;
696 let left = plate_x(&t);
697
698 assert!(ctx.propagate_event(&click_at(30.0, 15.0), id), "toggle consumed the click");
699 assert!(t.toggled());
700 assert!(t.take_click());
701
702 // A click sets the target; the plate GLIDES there (`tick`), so the
703 // geometry only moves once time passes — the rocker's halves used to
704 // swap on the press itself.
705 assert_eq!(plate_x(&t), left, "the click alone does not move the plate");
706 for _ in 0..60 {
707 crate::widget::Input::tick(t.inner_mut(), 1.0 / 60.0, rect);
708 }
709 assert!(plate_x(&t) > left, "the plate glided toward the on end");
710 assert!(painted(&t) != before, "toggling changes the emitted geometry");
711
712 // preferred_height forwards the legacy toggle height.
713 assert_eq!(WidgetHost::preferred_height(&t), Some(crate::layout::toggle_height()));
714 }
715
716 /// The plate stands ON the well's floor, clear of its walls by one wall
717 /// width on every side, and travels between the floor's ends: off is flush
718 /// left, on is flush right, and it never reaches outside the toggle's rect.
719 #[test]
720 fn toggle_plate_lives_inside_the_well() {
721 let rect = Rect { x: 10.0, y: 4.0, width: 120.0, height: 24.0 };
722 let mut t = Toggle::new();
723
724 let (well, _, depth) = t.inner().well(rect);
725 assert!(well.x >= rect.x && well.y >= rect.y, "the well carves inside the rect");
726
727 // The floor is the flat region inside the well's walls; the plate's own
728 // (half-depth) roll abuts its edge, so the plate is inset one more
729 // half-wall of its own from there.
730 let (off, _, pd) = t.inner().slide_plate(rect);
731 assert!((pd - depth * 0.5).abs() < 1e-4, "the plate's wall is half the well's");
732 let edge = depth * 0.5 + pd * 0.5;
733 assert!((off.x - (well.x + edge)).abs() < 1e-4, "off sits at the travel's left end");
734 assert!((off.y - (well.y + edge)).abs() < 1e-4, "and clear of the floor's top");
735 assert!(
736 off.height > pd * 2.0,
737 "the plate keeps a flat top: a boss no taller than its own wall is a ridge",
738 );
739
740 t.set_toggled(true); // programmatic syncs snap, so this is the on-end geometry
741 let (on, _, _) = t.inner().slide_plate(rect);
742 assert!(on.x > off.x, "on is to the right of off");
743 assert!(
744 (on.x + on.width - (well.x + well.width - edge)).abs() < 1e-4,
745 "on sits flush against the travel's right end",
746 );
747 assert!(on.x + on.width <= rect.x + rect.width, "and never leaves the toggle's rect");
748 assert!((off.width * 2.0 - (well.width - 2.0 * edge)).abs() < 1e-4, "half the travel wide");
749 }
750
751 /// The carves a legacy-view host re-emits (`ParametersBg::reliefs`) are the
752 /// well then the plate, in the order `paint` emits them — and nothing at
753 /// all with relief off, where the flat frames stand in.
754 #[test]
755 fn toggle_flat_carves_are_the_well_then_the_plate() {
756 use crate::layout::CarveKind;
757 let rect = Rect { x: 0.0, y: 0.0, width: 120.0, height: 24.0 };
758 let t = Toggle::new().with_raised(true);
759 let carves = t.inner().flat_carves(rect);
760 assert_eq!(carves.len(), 2);
761 assert!(matches!(carves[0].kind, CarveKind::Recess { .. }), "the track's well first");
762 assert!(matches!(carves[1].kind, CarveKind::Boss { .. }), "then the plate standing in it");
763 assert!(carves.iter().all(|c| c.edges == (true, true, true, true)), "both are full rings");
764
765 let flat = Toggle::new().with_raised(false);
766 assert!(flat.inner().flat_carves(rect).is_empty(), "relief off carves nothing");
767 }
768
769 #[test]
770 fn toggle_set_label_via_deref_reaches_paint() {
771 let mut t = Toggle::new();
772 WidgetHost::set_rect(&mut t, 0.0, 0.0, 60.0, 30.0);
773 t.set_label("ON"); // the network.rs pattern: live label updates through Deref
774 let labels = t.own_text_labels();
775 assert_eq!(labels.len(), 1);
776 assert_eq!(labels[0].text, "ON");
777 }
778 }