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