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

commit782ac1e895c3b57ea9dcb25a0e884280c1c773e8
parentf210ac6d3c
authorLucas Galante <[email protected]>
date2026-08-28 15:13
feat: the spinbox speaks the DE relief language

Under control_relief the spinbox drops its pre-relief chrome (opaque
border ring, opaque display and button fills) for transparent faces
where the relief IS the chrome: the control is a recessed well (the
TextBox language — the value sits on the well floor), the -/+ pair is
ONE flush inset run trough-ringed inside the well's right end (the
textpick picker's language), and the two buttons divide by an engraved
seam, not a wall pair (the breadcrumb's segment seams at miniature
scale). Hover becomes the 6% wash; editing keeps the caret plus an
accent hairline on the well floor. Spinbox::relief_parts is the shared
geometry — the widget's own paint and ParametersBg (whose rounded-quad
bridge keeps only flat prims) emit the same carves; the pane's
picker_troughs getter generalizes to troughs() and gains a grooves()
companion. The flat (non-relief) style is untouched.

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

 src/widget/container/parameters_bg.rs | 89 +++++++++++++++++++++++++-------
 src/widget/input/spinbox.rs           | 97 ++++++++++++++++++++++++++++++++++-
 2 files changed, 167 insertions(+), 19 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index fe95bf8..596e50b 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1204,6 +1204,15 @@ impl ParametersBg {
                     }
                 }
                 None
+            } else if p.2.starts_with("spinbox") {
+                // The well recess only — the -/+ run's trough and its seam
+                // travel through [`Self::troughs`] / [`Self::grooves`] (this
+                // tuple speaks boss/recess). The generic push below matches
+                // the widget's own `relief_parts` well exactly: same side-
+                // label inset, same content band, same depth cap.
+                self.spinboxes[i]
+                    .as_ref()
+                    .map(|w| (w as &dyn WidgetHost, crate::layout::spinbox_corner_radius(), false))
             } else {
                 if let Some(s) = &self.sliders[i] {
                     let (x, y, w, h) = s.rect();
@@ -1238,31 +1247,72 @@ impl ParametersBg {
 
     /// The textpick picker buttons' trough rings — `(x, y, w, h, radii, depth)`
     /// for [`crate::scene::paint::PaintCtx::trough`], drawn by the host AFTER
-    /// [`Self::reliefs`]. The picker is a FLUSH inset control: its face stays
-    /// level with the well floor and a valley seam straddles its outline — the
-    /// dropdown trigger's (and the breadcrumb run's) inset language. It cannot
-    /// ride in [`Self::reliefs`], whose tuple only speaks boss/recess. The
-    /// radius is the well radius's parallel curve at the button's inset; the
-    /// depth matches the well's carve, so the seam and the wall read as one
-    /// family.
-    pub fn picker_troughs(&self) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32)> {
+    /// [`Self::reliefs`]. These are the rows' FLUSH inset controls — the
+    /// textpick picker button, and the spinbox's -/+ run: faces level with
+    /// their well floor, a valley seam straddling the outline — the dropdown
+    /// trigger's (and the breadcrumb run's) inset language. They cannot ride
+    /// in [`Self::reliefs`], whose tuple only speaks boss/recess. Radii are
+    /// the well radius's parallel curve at each control's inset; depths match
+    /// the well's carve, so seam and wall read as one family.
+    pub fn troughs(&self) -> Vec<(f32, f32, f32, f32, (f32, f32, f32, f32), f32)> {
+        if !self.visible || !crate::layout::control_relief() {
+            return Vec::new();
+        }
+        let mut out = Vec::new();
+        let hidden = self.hidden_rows();
+        for (i, p) in self.display_params.iter().enumerate() {
+            if hidden[i] {
+                continue;
+            }
+            if p.2.starts_with("textpick") {
+                if let (Some(d), Some(tb)) = (&self.choices[i], &self.texts[i]) {
+                    let (bx, by, bw, bh) = d.rect();
+                    let (_, _, _, th) = tb.rect();
+                    let ty = crate::widget::label_offset(tb);
+                    if bw > 0.0 && bh > 0.0 {
+                        let depth = crate::layout::bevel_width().min((th - ty) * 0.2);
+                        let r = (crate::layout::textbox_corner_radius() - depth).max(2.0);
+                        out.push((bx, by, bw, bh, (r, r, r, r), depth));
+                    }
+                }
+            } else if p.2.starts_with("spinbox") {
+                if let Some(sb) = &self.spinboxes[i] {
+                    let (x, y, w, h) = sb.rect();
+                    let ty = crate::widget::label_offset(sb);
+                    let band = Rect { x, y: y + ty, width: w, height: h - ty };
+                    if let Some((_, Some(((run, radii, rd), _)))) = sb.inner().relief_parts(band) {
+                        out.push((run.x, run.y, run.width, run.height, radii, rd));
+                    }
+                }
+            }
+        }
+        out
+    }
+
+    /// The engraved seams companion — `(a, b, width, depth, host)` for
+    /// [`crate::scene::paint::PaintCtx::groove`], drawn AFTER [`Self::troughs`]
+    /// (a groove engraves the surface the trough's control face provides).
+    /// Today: the seam dividing a spinbox's -/+ run into its two buttons —
+    /// the breadcrumb's segment-seam language at miniature scale.
+    #[allow(clippy::type_complexity)]
+    pub fn grooves(&self) -> Vec<((f32, f32), (f32, f32), f32, f32, Rect)> {
         if !self.visible || !crate::layout::control_relief() {
             return Vec::new();
         }
         let mut out = Vec::new();
         let hidden = self.hidden_rows();
         for (i, p) in self.display_params.iter().enumerate() {
-            if hidden[i] || !p.2.starts_with("textpick") {
+            if hidden[i] || !p.2.starts_with("spinbox") {
                 continue;
             }
-            if let (Some(d), Some(tb)) = (&self.choices[i], &self.texts[i]) {
-                let (bx, by, bw, bh) = d.rect();
-                let (_, _, _, th) = tb.rect();
-                let ty = crate::widget::label_offset(tb);
-                if bw > 0.0 && bh > 0.0 {
-                    let depth = crate::layout::bevel_width().min((th - ty) * 0.2);
-                    let r = (crate::layout::textbox_corner_radius() - depth).max(2.0);
-                    out.push((bx, by, bw, bh, (r, r, r, r), depth));
+            if let Some(sb) = &self.spinboxes[i] {
+                let (x, y, w, h) = sb.rect();
+                let ty = crate::widget::label_offset(sb);
+                let band = Rect { x, y: y + ty, width: w, height: h - ty };
+                if let Some((_, Some(((_, _, rd), (sa, sb2, sw, host))))) =
+                    sb.inner().relief_parts(band)
+                {
+                    out.push((sa, sb2, sw, rd, host));
                 }
             }
         }
@@ -1415,9 +1465,12 @@ impl Paint for ParametersBg {
                 ctx.recess_edges(Rect { x: rx, y: ry, width: rw, height: rh }, radii, rd, edges);
             }
         }
-        for (tx2, ty2, tw2, th2, radii, td) in self.picker_troughs() {
+        for (tx2, ty2, tw2, th2, radii, td) in self.troughs() {
             ctx.trough(Rect { x: tx2, y: ty2, width: tw2, height: th2 }, radii, td);
         }
+        for (ga, gb, gw, gd, ghost) in self.grooves() {
+            ctx.groove(ga, gb, gw, gd, ghost);
+        }
         for (fcx, fcy, fr, fd, fs) in self.section_fillets() {
             ctx.concave_fillet(fcx, fcy, fr, fd, fs, false);
         }
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 809abf8..c724fef 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -98,6 +98,58 @@ impl Spinbox {
         }
     }
 
+    /// The control's relief set under `control_relief`, shared by the widget's
+    /// own `paint` and flat hosts (`ParametersBg`) that must re-emit carves
+    /// (their rounded-quad bridge keeps only flat prims — the slider's
+    /// `track_relief` precedent). Faces are transparent in this style; the
+    /// relief IS the chrome:
+    /// - the whole control is a recessed well (the TextBox language — the
+    ///   value sits on the well floor),
+    /// - the -/+ pair is ONE flush inset run inside the well's right end,
+    ///   trough-ringed like the textpick picker and the dropdown trigger,
+    /// - the two buttons divide by an engraved seam, not a wall pair — the
+    ///   breadcrumb run's segment language at miniature scale.
+    ///
+    /// Returns the well `(rect, radius, depth)`, plus the run
+    /// `(rect, radii, depth)` and seam `(top, bottom, width, host)` when the
+    /// button zone is non-degenerate. `None` when the control has no area.
+    /// The caller gates on `control_relief`.
+    #[allow(clippy::type_complexity)]
+    pub fn relief_parts(
+        &self,
+        rect: Rect,
+    ) -> Option<(
+        (Rect, f32, f32),
+        Option<(
+            (Rect, (f32, f32, f32, f32), f32),
+            ((f32, f32), (f32, f32), f32, Rect),
+        )>,
+    )> {
+        let g = self.geom(rect);
+        if g.w <= 0.0 || g.h <= 0.0 {
+            return None;
+        }
+        let radius = crate::layout::spinbox_corner_radius();
+        let depth = crate::layout::bevel_width().min(g.h * 0.2);
+        let well = (Rect { x: g.x, y: g.y, width: g.w, height: g.h }, radius, depth);
+        if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
+            return Some((well, None));
+        }
+        // The run rides the existing button hit zones (inset `pad` from the
+        // control edges — within a hair of the trough's depth/2 straddle, so
+        // the ring's outer edge hugs the well outline; the picker lesson).
+        // Right corners follow the well radius's parallel curve at that
+        // inset; left corners stay tight — the run's left edge is interior.
+        let run_rect = Rect { x: g.split_dec + g.pad, y: g.btn_y, width: 2.0 * g.btn_w, height: g.btn_h };
+        let rr = (radius - g.pad).max(2.0);
+        let run = (run_rect, (2.0, rr, rr, 2.0), depth);
+        // Seam floor width: the breadcrumb's SEAM_WIDTH — a hair of flat
+        // floor so the crease doesn't alias into a dotted line.
+        let sx = run_rect.x + g.btn_w;
+        let seam = ((sx, run_rect.y), (sx, run_rect.y + run_rect.height), 0.75, run_rect);
+        Some((well, Some((run, seam))))
+    }
+
     fn value_text(&self) -> String {
         if self.editing {
             self.edit_buffer.clone()
@@ -219,7 +271,50 @@ impl Paint for Spinbox {
         let inc_col = if self.hover_inc { colors::spinbox_button_hover() } else { colors::spinbox_button() };
         let dec_col = if self.hover_dec { colors::spinbox_button_hover() } else { colors::spinbox_button() };
 
-        if rounded {
+        if crate::layout::control_relief() {
+            // The DE relief style: transparent faces, the relief is the
+            // chrome (see [`Self::relief_parts`]). Flat prims first — hover
+            // washes and the editing cue survive a flat host's rounded-quad
+            // bridge, the carves are re-emitted host-side.
+            if let Some((well, buttons)) = self.relief_parts(rect) {
+                if let Some(((run, radii, _), _)) = buttons {
+                    let wash = [1.0, 1.0, 1.0, 0.06];
+                    let half = Rect { x: run.x, y: run.y, width: run.width * 0.5, height: run.height };
+                    if self.hover_dec {
+                        ctx.rounded_rect(half, radii.0, (true, false, false, true), wash);
+                    }
+                    if self.hover_inc {
+                        ctx.rounded_rect(
+                            Rect { x: run.x + run.width * 0.5, ..half },
+                            radii.1,
+                            (false, true, true, false),
+                            wash,
+                        );
+                    }
+                }
+                if self.editing {
+                    // Editing cue: an accent hairline on the well floor under
+                    // the value, plus the caret — a flat stand-in for the
+                    // tinted-recess focus treatment the pane's relief tuple
+                    // cannot carry.
+                    let accent = colors::highlight_primary_color();
+                    ctx.quad(
+                        Rect { x: g.x + 4.0, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
+                        accent,
+                    );
+                    let char_width = 8.4;
+                    let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+                    let cursor_y = g.y + (g.h - 14.0) / 2.0;
+                    ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
+                }
+                let (wr, wrad, wd) = well;
+                ctx.recess(wr, (wrad, wrad, wrad, wrad), wd);
+                if let Some(((run, radii, rd), (sa, sb, sw, host))) = buttons {
+                    ctx.trough(run, radii, rd);
+                    ctx.groove(sa, sb, sw, rd, host);
+                }
+            }
+        } else if rounded {
             let rc = (true, true, true, true);
             let border_color = if self.editing {
                 [0.20, 0.50, 0.85, 1.0]