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

commitedd293687551633b2f817aeb5f92e81b4f773f3c
parent373a416a5a
authorLucas Galante <[email protected]>
date2026-07-30 10:10
feat: opt-in 'slide' toggle style — half-width button gliding with the state

style.control.toggle.style = "slide" (per-app config key; default look
unchanged) replaces the rocker/gradient pill with a button half the widget
wide that slides between the left (off) and right (on) ends. The position
chases the state through Input::tick (~90ms exponential glide) after a
click; programmatic value syncs snap, so only user interaction animates.
The button's fill crossfades the off/on state colors as it travels, and
under control_relief it rides the track as a raised plateau — reaching
legacy-view hosts through the new slide_button getter (ParametersBg's
relief drain falls back to rocker_reliefs when the style is off). The
label lives centered in the free half, following the state, with wide
labels overflowing away from the button.

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

 src/layout.rs                         |  14 +++++
 src/widget/container/parameters_bg.rs |  12 +++-
 src/widget/input/checkbox.rs          | 110 +++++++++++++++++++++++++++++++---
 3 files changed, 127 insertions(+), 9 deletions(-)

diff --git a/src/layout.rs b/src/layout.rs
index 0c337bd..28d4371 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -105,6 +105,7 @@ fn flatten_json_to_flat_props(val: &serde_json::Value, prefix: &str, flat_props:
                 "style.control.textbox.background_edit_color" | "style.textbox.background_edit_color" | "style.data.textbox.background_edit_color" => "textbox_background_edit_color",
                 "style.control.textbox.multiline.line_wrap" | "style.textbox.multiline.line_wrap" | "style.data.textbox.multiline.line_wrap" => "textbox_line_wrap",
                 "style.control.textbox.multiline.border_width" | "style.textbox.multiline.border_width" | "style.data.textbox.multiline.border_width" => "textbox_multiline_border_width",
+                "style.control.toggle.style" => "toggle_style",
                 "style.control.toggle.height" => "toggle_height",
                 "style.control.toggle.border_width" => "toggle_border_width",
                 "style.control.toggle.disabled_color" => "toggle_disabled_color",
@@ -1757,6 +1758,19 @@ pub fn slider_corner_radius() -> f32 {
     get_style_registry().read().unwrap().get_float("slider_corner_radius").unwrap_or(4.0)
 }
 
+/// The toggle's render style: `style.control.toggle.style = "slide"` swaps the
+/// rocker/gradient pill for a half-width button that slides between the left
+/// (off) and right (on) ends of the widget (see `Toggle::paint`). Anything
+/// else — or unset — keeps the default look, so apps opt in per-config.
+pub fn toggle_slide() -> bool {
+    lazy_init_style_registry();
+    get_style_registry()
+        .read()
+        .unwrap()
+        .get_string("toggle_style")
+        .is_some_and(|s| s == "slide")
+}
+
 /// The slider's render style: `style.control.slider.style = "band"` swaps the
 /// track/fill/thumb for a thin full-range band that inflates smoothly at the
 /// value (see `Slider::paint`). Anything else — or unset — keeps the default
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 1f4b5f9..c13f9dd 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1105,8 +1105,16 @@ impl ParametersBg {
                         let ty = crate::widget::label_offset(t);
                         let rect = Rect { x, y: y + ty, width: w, height: h - ty };
                         let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-                        for (half, radii, walls, raised) in t.inner().rocker_reliefs(rect) {
-                            out.push((half.x, half.y, half.width, half.height, radii, depth, raised, walls));
+                        if let Some(btn) = t.inner().slide_button(rect) {
+                            // Slide style: the gliding half-width button is one
+                            // raised plateau (its fill arrives through the
+                            // rounded-quad view like the rocker's).
+                            let r = crate::layout::toggle_corner_radius();
+                            out.push((btn.x, btn.y, btn.width, btn.height, (r, r, r, r), depth, true, all));
+                        } else {
+                            for (half, radii, walls, raised) in t.inner().rocker_reliefs(rect) {
+                                out.push((half.x, half.y, half.width, half.height, radii, depth, raised, walls));
+                            }
                         }
                     }
                 }
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 0823d16..279e04f 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -231,6 +231,11 @@ pub struct Toggle {
     /// (see `rocker_reliefs` and the paint impl) — and the flat style's
     /// state gradient is dropped.
     raised: bool,
+    /// The slide style's animated button position, 0 (left/off) → 1 (right/on).
+    /// Chases `toggled` in `tick` after a click; programmatic state syncs
+    /// (`set_toggled`, `set_value_string`) snap it, so only user interaction
+    /// animates.
+    slide_t: f32,
 }
 
 impl Toggle {
@@ -243,6 +248,7 @@ impl Toggle {
             focused: false,
             justify: Justification::Center,
             raised: crate::layout::control_relief(),
+            slide_t: 0.0,
         })
     }
 
@@ -252,6 +258,7 @@ impl Toggle {
 
     pub fn set_toggled(&mut self, v: bool) {
         self.toggled = v;
+        self.slide_t = if v { 1.0 } else { 0.0 };
     }
 
     pub fn toggled(&self) -> bool {
@@ -266,6 +273,37 @@ impl Toggle {
         }
     }
 
+    /// The slide style's button (config `style.control.toggle.style =
+    /// "slide"`): half the widget wide, gliding between the left (off) and
+    /// right (on) ends by the animated `slide_t`. `None` when the style is
+    /// off — legacy-view hosts fall back to `rocker_reliefs`.
+    pub fn slide_button(&self, rect: Rect) -> Option<Rect> {
+        if !crate::layout::toggle_slide() {
+            return None;
+        }
+        let bw = rect.width * 0.5;
+        Some(Rect {
+            x: rect.x + self.slide_t * (rect.width - bw),
+            y: rect.y,
+            width: bw,
+            height: rect.height,
+        })
+    }
+
+    /// The slide button's face color: the off/on state colors crossfaded by
+    /// the animated position, so the fill morphs while the button glides.
+    pub fn slide_button_color(&self) -> [f32; 4] {
+        let off = colors::toggle_off_color();
+        let on = colors::toggle_on_color();
+        let t = self.slide_t;
+        [
+            off[0] + (on[0] - off[0]) * t,
+            off[1] + (on[1] - off[1]) * t,
+            off[2] + (on[2] - off[2]) * t,
+            off[3] + (on[3] - off[3]) * t,
+        ]
+    }
+
     /// The rocker's two halves over `rect` as FLAT relief steps: (half rect,
     /// per-corner radii, (top, right, bottom, left) walls, raised). The state
     /// half (top when on, bottom when off) is a plateau raised out of the
@@ -380,8 +418,34 @@ impl Paint for Toggle {
         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
         let radius = crate::layout::toggle_corner_radius();
         let bg = colors::toggle_bg_color();
-
-        if self.raised {
+        let slide = crate::layout::toggle_slide();
+
+        if slide {
+            // The slide style: the widget is the track; a half-width button
+            // glides between its ends with the state (animated in `tick`).
+            // Under control_relief the button is a raised plateau riding the
+            // track (its beveled edges also reach legacy-view hosts through
+            // `slide_button` — see `ParametersBg::reliefs`).
+            if bg[3] > 0.001 {
+                if radius > 0.0 {
+                    ctx.rounded_rect(rect, radius, (true, true, true, true), bg);
+                } else {
+                    ctx.quad(rect, bg);
+                }
+            }
+            if let Some(btn) = self.slide_button(rect) {
+                let fill = self.slide_button_color();
+                if radius > 0.0 {
+                    ctx.rounded_rect(btn, radius, (true, true, true, true), fill);
+                } else {
+                    ctx.quad(btn, fill);
+                }
+                if self.raised {
+                    let depth = crate::layout::bevel_width().min(h * 0.2);
+                    ctx.boss_edges(btn, (radius, radius, radius, radius), depth, (true, true, true, true));
+                }
+            }
+        } else if self.raised {
             // The rocker: two FLAT half faces (see `rocker_reliefs`) — the
             // state half a raised plateau, the other recessed, hinge wall
             // open so they meet in a single step. Each face carries its
@@ -424,7 +488,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 = if self.raised { [0.0; 4] } else { self.gradient_color() };
+        let grad = if self.raised || slide { [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);
@@ -458,10 +522,22 @@ impl Paint for Toggle {
         if let Some(ref label) = self.label {
             let (font_fam, font_size) = crate::layout::control_label_font_parsed();
             let est_w = crate::widget::display::measure_text_width(label, &font_fam, font_size);
-            let tx = match self.justify {
-                Justification::Left => x + 8.0,
-                Justification::Right => x + w - est_w - 8.0,
-                Justification::Center => x + (w - est_w) / 2.0,
+            let tx = if slide {
+                // The label lives centered in the FREE half — the side the
+                // button has slid away from — so the two never fight. It
+                // follows the state, not the glide: the text hops at the
+                // click, the button catches up. A label wider than the half
+                // overflows AWAY from the button, never over it.
+                let half_w = w * 0.5;
+                let hx = if self.toggled { x } else { x + half_w };
+                let centered = hx + (half_w - est_w) / 2.0;
+                if self.toggled { centered.min(x + half_w - est_w - 4.0) } else { centered.max(hx + 4.0) }
+            } else {
+                match self.justify {
+                    Justification::Left => x + 8.0,
+                    Justification::Right => x + w - est_w - 8.0,
+                    Justification::Center => x + (w - est_w) / 2.0,
+                }
             };
             ctx.text(
                 label.clone(),
@@ -502,6 +578,25 @@ impl Input for Toggle {
         }
     }
 
+    /// Slide-style glide: the button position chases the state after a click
+    /// (~90ms exponential settle). Programmatic syncs snap instead — see
+    /// `set_toggled` / `set_value_string` — so only user interaction animates.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        if !crate::layout::toggle_slide() {
+            return false;
+        }
+        let target = if self.toggled { 1.0 } else { 0.0 };
+        let d = target - self.slide_t;
+        if d.abs() < 0.001 {
+            return false;
+        }
+        self.slide_t += d * (1.0 - (-dt * 22.0).exp());
+        if (target - self.slide_t).abs() < 0.005 {
+            self.slide_t = target;
+        }
+        true
+    }
+
     fn take_click(&mut self) -> bool {
         std::mem::take(&mut self.just_toggled)
     }
@@ -518,6 +613,7 @@ impl Input for Toggle {
         let Some(new_toggled) = parse_bool(val) else { return false };
         if self.toggled != new_toggled {
             self.toggled = new_toggled;
+            self.slide_t = if new_toggled { 1.0 } else { 0.0 };
             self.just_toggled = true;
             true
         } else {