git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit29edae6c44b1b9050f8de3e310b5174a182a87f1
parent5c84a50d28
authorLucas Galante <[email protected]>
date2026-07-21 17:46
feat: rocker toggles — state half raised, other recessed, gradient gone

Under control_relief the Toggle renders as a rocker switch: the state
half (where the flat style paints its gradient — top when on, bottom
when off) is a raised boss owning the seam wall, the other half a
recess skipping it, so the middle reads as one step down
(Toggle::rocker_reliefs, shared by paint and hosts). The state gradient
survives only as the flat style. ParametersBg::reliefs entries carry
per-corner radii and wall edges now so the rocker halves flow through
the legacy relief view.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/widget/container/parameters_bg.rs | 34 ++++++++++----
 src/widget/input/checkbox.rs          | 86 +++++++++++++++++++++++++++--------
 2 files changed, 94 insertions(+), 26 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index c44c666..b1f5028 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -911,9 +911,13 @@ impl ParametersBg {
     /// Returned unclipped; the host clips to the pane's scroll viewport and
     /// draws these AFTER the flat quads, so the walls' shading modulates the
     /// fills they cross (the order the widgets' own paints use). Tuple:
-    /// (x, y, w, h, radius, depth, raised) — raised maps to `PaintCtx::boss`,
-    /// flat to `PaintCtx::recess`.
-    pub fn reliefs(&self) -> Vec<(f32, f32, f32, f32, f32, f32, bool)> {
+    /// (x, y, w, h, per-corner radii, depth, raised, walls) — raised maps to
+    /// `PaintCtx::boss_edges`, flat to `recess_edges`; the toggles' rocker
+    /// halves are why radii/walls are per-entry.
+    #[allow(clippy::type_complexity)]
+    pub fn reliefs(
+        &self,
+    ) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32, bool, (bool, bool, bool, bool))> {
         if !self.visible || !crate::layout::control_relief() {
             return Vec::new();
         }
@@ -923,13 +927,15 @@ impl ParametersBg {
         // carve a recess (the flat outline+fillet path is the non-relief
         // style). The neck joining them in the outline style has no carved
         // equivalent — the tab and body read as two wells.
+        let all = (true, true, true, true);
+        let r4 = |r: f32| (r, r, r, r);
         for (title, content) in self.section_boxes() {
             let (tx, ty, tw, th) = title;
             let depth = crate::layout::bevel_width().min(th * 0.2);
-            out.push((tx, ty, tw, th, SECTION_R, depth, false));
+            out.push((tx, ty, tw, th, r4(SECTION_R), depth, false, all));
             if let Some((cx, cy, cw, ch)) = content {
                 let depth = crate::layout::bevel_width().min(ch * 0.2);
-                out.push((cx, cy, cw, ch, SECTION_R, depth, false));
+                out.push((cx, cy, cw, ch, r4(SECTION_R), depth, false, all));
             }
         }
 
@@ -946,7 +952,19 @@ impl ParametersBg {
             } else if p.2 == "button" {
                 self.buttons[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::button_corner_radius(), true))
             } else if p.2 == "toggle" || p.2 == "checkbox" {
-                self.toggles[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::toggle_corner_radius(), true))
+                // The rocker halves, exactly the widget's own paint.
+                if let Some(t) = &self.toggles[i] {
+                    let (x, y, w, h) = t.rect();
+                    if w > 0.0 && h > 0.0 {
+                        let depth = crate::layout::bevel_width().min(h * 0.2);
+                        for (half, radii, edges, up) in
+                            t.inner().rocker_reliefs(Rect { x, y, width: w, height: h })
+                        {
+                            out.push((half.x, half.y, half.width, half.height, radii, depth, up, edges));
+                        }
+                    }
+                }
+                None
             } else {
                 if let Some(s) = &self.sliders[i] {
                     let (x, y, w, h) = s.rect();
@@ -957,7 +975,7 @@ impl ParametersBg {
                     if let Some((rx, ry, rw, rh, rr, rd)) =
                         s.inner().track_relief(Rect { x, y: y + ty, width: w, height: h - ty })
                     {
-                        out.push((rx, ry, rw, rh, rr, rd, false));
+                        out.push((rx, ry, rw, rh, r4(rr), rd, false, all));
                     }
                 }
                 None
@@ -973,7 +991,7 @@ impl ParametersBg {
                 let lx = w.label_x_offset();
                 let ty = crate::widget::label_offset(w);
                 let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
-                out.push((x + lx, y + ty, ww - lx, h - ty, radius, depth, raised));
+                out.push((x + lx, y + ty, ww - lx, h - ty, r4(radius), depth, raised, all));
             }
         }
         out
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 2daed8d..706043a 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -226,8 +226,9 @@ pub struct Toggle {
     /// Where the label sits across the pill. Mirrors `Button::justify` — same enum, same
     /// 8px edge inset — so the two read as one control set wherever they share a column.
     justify: Justification,
-    /// Raised style: the background is an SDF-lit `Bevel` plate (fill + rolled
-    /// lit edge) instead of a flat fill; the state gradient composites on top.
+    /// Raised style: the pill renders as a rocker — the state half raised, the
+    /// other recessed (`rocker_reliefs`) — and the flat style's state gradient
+    /// is dropped.
     raised: bool,
 }
 
@@ -263,6 +264,34 @@ impl Toggle {
             colors::toggle_off_color()
         }
     }
+
+    /// The rocker's two relief halves over `rect`: (half rect, per-corner
+    /// radii, walls, raised). The STATE half — where the flat style paints its
+    /// gradient (top when on, bottom when off) — is raised; the other half is
+    /// recessed. The raised half owns the seam wall (all four edges), the
+    /// recessed half skips it, so the middle reads as one step down. Shared by
+    /// `paint` and hosts on the legacy relief views (`ParametersBg::reliefs`).
+    pub fn rocker_reliefs(
+        &self,
+        rect: Rect,
+    ) -> [(Rect, (f32, f32, f32, f32), (bool, bool, bool, bool), bool); 2] {
+        let r = crate::layout::toggle_corner_radius();
+        let half_h = rect.height / 2.0;
+        let top = Rect { x: rect.x, y: rect.y, width: rect.width, height: half_h };
+        let bottom =
+            Rect { x: rect.x, y: rect.y + half_h, width: rect.width, height: rect.height - half_h };
+        if self.toggled {
+            [
+                (top, (r, r, 0.0, 0.0), (true, true, true, true), true),
+                (bottom, (0.0, 0.0, r, r), (false, true, true, true), false),
+            ]
+        } else {
+            [
+                (bottom, (0.0, 0.0, r, r), (true, true, true, true), true),
+                (top, (r, r, 0.0, 0.0), (true, true, false, true), false),
+            ]
+        }
+    }
 }
 
 impl Adapted<Toggle> {
@@ -322,14 +351,24 @@ impl Paint for Toggle {
         let bg = colors::toggle_bg_color();
 
         if self.raised {
-            // One lit Bevel plate owns fill and edge; the state gradient below
-            // composites over it. Transparent fill degrades to a Boss (edges
-            // only — the plate below is the face).
-            let depth = crate::layout::bevel_width().min(h * 0.2);
+            // The rocker: the state half raised, the other recessed — the
+            // physical read of the flat style's state gradient, which this
+            // style drops entirely. The halves' outer walls trace the pill
+            // silhouette, so no full-pill bevel is drawn under them.
             if bg[3] > 0.001 {
-                ctx.bevel(rect, (radius, radius, radius, radius), bg, depth);
-            } else {
-                ctx.boss(rect, (radius, radius, radius, radius), depth);
+                if radius > 0.0 {
+                    ctx.rounded_rect(rect, radius, (true, true, true, true), bg);
+                } else {
+                    ctx.quad(rect, bg);
+                }
+            }
+            let depth = crate::layout::bevel_width().min(h * 0.2);
+            for (half, radii, edges, up) in self.rocker_reliefs(rect) {
+                if up {
+                    ctx.boss_edges(half, radii, depth, edges);
+                } else {
+                    ctx.recess_edges(half, radii, depth, edges);
+                }
             }
         } else if radius > 0.0 {
             ctx.rounded_rect(rect, radius, (true, true, true, true), bg);
@@ -344,7 +383,7 @@ impl Paint for Toggle {
         // rounded corners. The band alphas follow a perceptual curve, not a straight ramp —
         // see `colors::perceptual_fade_alpha`. The bands composite onto the bg quad above,
         // so that is the backdrop the curve is solved against.
-        let grad = self.gradient_color();
+        let grad = if self.raised { [0.0; 4] } else { self.gradient_color() };
         let half = h / 2.0;
         if half > 0.0 && grad[3] > 0.0 {
             let steps = (half.ceil() as usize).clamp(4, 32);
@@ -531,20 +570,31 @@ mod tests {
         ctx.register_widget(id, ptr);
         WidgetHost::set_rect(&mut t, 0.0, 0.0, 60.0, 30.0);
 
-        // Geometry is config-dependent (rounded vs square toggle); assert the invariant
-        // that holds in both: the gradient half flips with the state.
-        let before: Vec<_> = WidgetHost::all_rounded_quads(&t, &ctx);
-        let before_quads = WidgetHost::extra_quads(&t);
+        // Geometry is config-dependent (rounded vs square, gradient vs rocker);
+        // assert the invariant that holds in all styles: the state side flips —
+        // the flat style's gradient half, the relief style's raised half.
+        let painted = |t: &Adapted<Toggle>| {
+            let mut pc = crate::scene::paint::PaintCtx::new();
+            crate::widget::Paint::paint(
+                t.inner(),
+                Rect { x: 0.0, y: 0.0, width: 60.0, height: 30.0 },
+                &mut pc,
+            );
+            pc.finish()
+                .items
+                .into_iter()
+                .map(|i| format!("{:?}", i.prim))
+                .collect::<Vec<_>>()
+        };
+        let before = painted(&t);
 
         assert!(ctx.propagate_event(&click_at(30.0, 15.0), id), "toggle consumed the click");
         assert!(t.toggled());
         assert!(t.take_click());
 
-        let after: Vec<_> = WidgetHost::all_rounded_quads(&t, &ctx);
-        let after_quads = WidgetHost::extra_quads(&t);
         assert!(
-            before != after || before_quads != after_quads,
-            "toggling changes the emitted geometry (gradient switches halves)",
+            painted(&t) != before,
+            "toggling changes the emitted geometry (state side switches halves)",
         );
 
         // preferred_height forwards the legacy toggle height.