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