GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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 {