git.lucas.co / cce-ui
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 }