GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: a well with a flush run at its end is one field
A text row with its picker and a spinbox with its -/+ run were a recess
and a trough side by side, each turning its own corner at the seam, so
the outline broke there. Prim::Field draws them as one shape: one outline
whose outer half is the same profile all the way round, the inner half
blending from the well's floor to the run's face at the seam, and a step
wall along the seam.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 53 ++++++++-----
src/backend/window_runner.rs | 49 +++++++++++-
src/layout.rs | 21 ++++++
src/scene/paint.rs | 31 ++++++++
src/vk/shader2d.wgsl | 46 +++++++++++-
src/widget/container/parameters_bg.rs | 130 +++++++++++++++++++++-----------
src/widget/input/spinbox.rs | 135 ++++++++++++++++++++--------------
7 files changed, 347 insertions(+), 118 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ca60e6d..a08a273 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -317,24 +317,41 @@ cce-files does: a `PopoverCollector` keeps fills as plain rects, so the grown pl
loses its relief and its corners (`inset_plate` degrades to a flat fill there). The
designer's params pane went through one until the same day.
-### A text row's picker is the field's right end (since 2026-10-01)
-
-A `textpick` row in `ParametersBg` — a TextBox with a completion picker — is one field
-of two parts. The picker (a menu-button Dropdown, `PICK_W` wide) is a flush control
-plate over the whole band below the label, reaching the field's outer edge on the top,
-right and bottom as a dropdown trigger's plate does: square at the seam, the text box's
-radius outside (`Dropdown::set_radii`, and its ring in `troughs`). The TextBox stops at
-the seam with its well's right corners square (`TextBox::joined_right`, which `well()`
-and the pane's `reliefs` both read). Until then the picker was a button nested inside the
-box's well, its face stopping at the base of the well's wall, so it never reached the edge
-a dropdown's ▼ does. `textpick_rows_carry_a_picker` holds the layout.
-
-**A spinbox's -/+ run is its right end the same way** (the same day):
-`Spinbox::relief_parts` makes the run a flush plate over the whole band, out to the
-outer edge, its ring all round, square at the seam and the spinbox radius outside, and
-the value's well ends there square (`relief_parts` returns its radii, which the widget's
-paint and the pane's `reliefs` both use). The seam between - and + crosses the run's face
-between its ring's walls. It was nested inside a full-width well, as the picker was.
+### A well with a flush run at its end is ONE field (since 2026-10-01)
+
+`Prim::Field { rect, radii, depth, split, tint }` (`PaintCtx::field`) is a sunken well
+that ends in a flush run: left of `split` the interior one step down, right of it back at
+the surface's level, as a flush control plate's face. Two controls are drawn so: a
+`ParametersBg` textpick row (the TextBox and its completion picker, a menu-button Dropdown
+`PICK_W` wide) and a `Spinbox` (its value and its -/+ run). In both the run is the
+control's right end, reaching its outer edge on the top, right and bottom as a dropdown
+trigger's plate does.
+
+It is one prim because two did not work: a recess for the well and a trough for the run,
+side by side, each shade their OWN box, so at the seam each turns its own square corner
+and the strong line of the edge jumps — a recess is lit at its outer rim, a trough at its
+inner lip — and the outline reads broken exactly where the two meet. The field's outline
+is evaluated once (the whole rect, its own radii, `MODE_FIELD` in `shader2d.wgsl`). Its
+outer half is ONE profile all the way round — the recess's fall and shoulder — and only
+the inner half differs: on to the floor in the well, mirrored back up to the face in the
+run, blended across one wall width about the seam. Note the run's valley is NOT
+`Prim::Trough`'s, which fits its whole fall and rise into the wall's width, so its outer
+half is a compressed copy of a step that read differently from the well's beside it. The
+seam is the well's floor rising to the run's face — a step along `split` — fading to
+nothing at the outline, where both sides stand at half the step. Never grouped into a host
+plate (its profile is not a monotonic step); the overlay's host-box slot carries `split`.
+The legacy banded path and flat hosts (`layout.rs`'s bridge) draw the two-box form.
+
+The pieces that feed it: `ParametersBg::fields` (the pane's list, drawn after its troughs,
+hover-tinted like them; textpick rows and spinboxes with a run are in neither `reliefs`
+nor `troughs`), `TextBox::joined_right` (the box stops at the seam), `Dropdown::set_radii`
+(the picker's own raised paint, square at the seam), and `Spinbox::relief_parts`, which
+returns a `SpinRelief` — the outline as handed over, its radius and depth, and the run's
+`split` with the engraved -/+ seam; the widget's own paint carves it inside as every well
+is. `textpick_rows_carry_a_picker` and `a_spinbox_is_one_field_with_its_run_at_the_right_end`
+are the tests. Until the same day the picker and the run were nested INSIDE a full-width
+well, their faces stopping at the base of its wall, so they never reached the edge a
+dropdown's ▼ does.
### A row can open a submenu (since 2026-09-29)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 43fc11b..722da17 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1851,7 +1851,7 @@ fn prim_kind(p: &crate::scene::paint::Prim) -> &'static str {
P::Quad { .. } => "Quad", P::RoundedRect { .. } => "RoundedRect",
P::Border { .. } => "Border", P::Bevel { .. } => "Bevel",
P::Recess { .. } => "Recess", P::Boss { .. } => "Boss",
- P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Plate { .. } => "Plate",
+ P::Ridge { .. } => "Ridge", P::Trough { .. } => "Trough", P::Field { .. } => "Field", P::Plate { .. } => "Plate",
P::Arc { .. } => "Arc", P::ArcShaded { .. } => "ArcShaded",
P::Vector { .. } => "Vector", P::Circle { .. } => "Circle",
P::Sphere { .. } => "Sphere", P::Droplet { .. } => "Droplet",
@@ -2170,6 +2170,26 @@ pub fn tessellate_display_list(
made_plate = Some(*rect);
}
}
+ // A sunken well ending in a flush run (see `Prim::Field`): one
+ // outline, one overlay — never grouped, its profile is not a
+ // monotonic step. The cover quad inflates by half the wall, as a
+ // free carve's does; the host-box slot carries the seam's x
+ // (physical px), since nothing fades against a host here.
+ Prim::Field { rect, radii, depth, split, tint } if shader_plates => {
+ let infl = *depth * 0.5 + 2.0;
+ verts.extend(quad_vertices(
+ rect.x - infl, rect.y - infl,
+ rect.width + 2.0 * infl, rect.height + 2.0 * infl,
+ sw, sh, [0.0; 4],
+ ));
+ let mut p = plate_push_raised(rect, *radii, *depth, scale, plate_light, plate_mat, false, None);
+ p.mode = 16.0; // MODE_FIELD
+ if let Some(t) = tint {
+ p.specular_tint = [t[0], t[1], t[2], 1.0];
+ }
+ p.host = [*split * scale, 0.0, 0.0, 0.0];
+ plate = Some(p);
+ }
Prim::Recess { rect, radii, depth, edges, .. }
| Prim::Boss { rect, radii, depth, edges, .. }
| Prim::Ridge { rect, radii, depth, edges }
@@ -2522,6 +2542,33 @@ pub fn tessellate_display_list(
EdgeKind::Step, &mut verts,
);
}
+ Prim::Field { rect, radii, depth, split, .. } => {
+ // Legacy approximation: the two-box form `Prim::Field`
+ // replaced — the well to the seam a step down, the run past it
+ // the Trough arm's down-then-up stack. The banded machinery
+ // has no blended outline, and the legacy path exists only for
+ // A/B comparison.
+ let all = (true, true, true, true);
+ let lw = (*split - rect.x).max(0.0);
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, lw, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
+ EdgeKind::Step, &mut verts,
+ );
+ let (rx, rw) = (*split, (rect.x + rect.width - *split).max(0.0));
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rx, rect.y, rw, rect.height, (0.0, radii.1, radii.2, 0.0), half,
+ sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(half), all,
+ EdgeKind::Step, &mut verts,
+ );
+ let ir = (radii.1 - half).max(0.0);
+ push_bevel_edge_vertices_banded(
+ rx + half, rect.y + half, rw - *depth, rect.height - *depth, (0.0, ir, ir, 0.0), half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
+ EdgeKind::Step, &mut verts,
+ );
+ }
Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
}
diff --git a/src/layout.rs b/src/layout.rs
index 2cd165a..faefeed 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4342,6 +4342,27 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
pending_face = Some((rect, radii, fill));
}
}
+ // A flat host has no blended outline: the two-box form
+ // `Prim::Field` replaced — the well to the seam, the run's flush
+ // plate past it.
+ Prim::Field { rect, radii, depth, split, tint } => {
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Recess { tint },
+ x: rect.x,
+ y: rect.y,
+ w: (split - rect.x).max(0.0),
+ h: rect.height,
+ radii: (radii.0, 0.0, 0.0, radii.3),
+ depth,
+ edges: (true, true, true, true),
+ });
+ let r = radii.1.max(radii.2);
+ let (rx, rw) = (split, (rect.x + rect.width - split).max(0.0));
+ match tint {
+ Some(t) => pc.inset_plate_tinted([0.0; 4], rx, rect.y, rw, rect.height, r, depth, t),
+ None => pc.inset_plate([0.0; 4], rx, rect.y, rw, rect.height, r, depth),
+ }
+ }
Prim::Trough { rect, radii, depth, tint, .. } => {
let face = pending_face.take().map(|(_, _, fill)| fill).unwrap_or([0.0; 4]);
let (r1, r2, r3, r4) = radii;
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 85e620a..0cb92e8 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -580,6 +580,28 @@ pub enum Prim {
/// `tint` lights the rim like [`Prim::Recess`]'s — the focus treatment of a
/// flush control plate (`PaintCtx::control_plate`).
Trough { rect: Rect, radii: Radii, depth: f32, edges: (bool, bool, bool, bool), tint: Option<[f32; 3]> },
+ /// A sunken well that ends in a FLUSH run: one field, one outer contour.
+ /// Left of `split` (an x in the same space as `rect`) it is a
+ /// [`Prim::Recess`] — the interior one step down; right of it, a
+ /// [`Prim::Trough`] — a valley on the outline and the interior back at
+ /// the surface's level, as a flush control plate's. A parameter pane's
+ /// text row with its completion picker, a spinbox's value and its -/+ run.
+ ///
+ /// Why one prim and not the two it replaces, side by side: each of those
+ /// shades its OWN box, so at the seam the outline breaks — each box
+ /// turns its own square corner there, and the strong line of the edge
+ /// jumps from the recess's outer rim to the trough's inner lip. Here the
+ /// outline is evaluated once (the whole field, its own radii), its wall
+ /// blends from the step to the valley across a wall's width about the
+ /// seam — the two agree on the outer half, falling from the surface to
+ /// half the step, and part on the inner half — and the seam is the
+ /// well's floor rising to the run's face: a step wall along `split`,
+ /// fading to nothing where it meets the outer wall, the one place the
+ /// two sides stand at the same height.
+ ///
+ /// SDF path only; the legacy banded tessellation and flat hosts draw the
+ /// two-box form it replaced. `tint` lights it like a recess's.
+ Field { rect: Rect, radii: Radii, depth: f32, split: f32, tint: Option<[f32; 3]> },
/// The window's glass slab: a rounded fill plus a rolled, lit edge around its whole
/// perimeter, drawn at full size. Distinct from `Bevel`, which insets its fill by
/// `depth` — a plate must fill the window exactly, or the compositor's rounded window
@@ -1086,6 +1108,7 @@ impl PaintCtx {
Some(t) => self.trough_tinted(rect, radii, depth, t),
None => self.trough_edges(rect, radii, depth, edges),
},
+ Prim::Field { rect, radii, depth, split, tint } => self.field(rect, radii, depth, split, tint),
Prim::Plate { rect, radii, material, depth, shape } => {
self.plate_shaped(rect, radii, &material, depth, shape)
}
@@ -1527,6 +1550,14 @@ impl PaintCtx {
self.push(Prim::Trough { rect, radii, depth, edges, tint: Some(tint) });
}
+ /// A sunken well ending in a flush run at `split` — see [`Prim::Field`].
+ /// `tint` lights its rim (the focus and hover treatment).
+ pub fn field(&mut self, rect: Rect, radii: Radii, depth: f32, split: f32, tint: Option<[f32; 3]>) {
+ let split = split + self.offset.0;
+ let rect = self.apply_offset(rect);
+ self.push(Prim::Field { rect, radii, depth, split, tint });
+ }
+
/// Raise a rim along `rect`'s boundary — see `Prim::Ridge`. `depth` is the
/// full width of the bump (it straddles the outline by ±depth/2).
pub fn ridge(&mut self, rect: Rect, radii: Radii, depth: f32) {
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index ca9ac2e..1f5efd7 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -157,6 +157,7 @@ const MODE_DROPLET_SCRIM: i32 = 12; // flat feathered fill of the droplet silhou
const MODE_LATTICE: i32 = 13; // periodic well field: nearest-cell carve, one evaluation
const MODE_UNION: i32 = 14; // union of feature boxes carved/raised as one wall
const MODE_GROUT: i32 = 15; // flat colour outside a periodic field of rounded cells
+const MODE_FIELD: i32 = 16; // sunken well ending in a flush run: one outline (Prim::Field)
// Fillet modes rejoin the shared free-carve path as their flat equivalents.
const FILLET_TO_STEP: i32 = 4; // 6 -> RECESS, 7 -> BOSS
@@ -868,6 +869,10 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
fd = best;
fgd = bgrad;
eff = select(MODE_RECESS, MODE_BOSS, rrect_clip.p_radii.x > 0.5);
+ } else if (mode == MODE_FIELD) {
+ // The whole field's outline (p_rect / p_radii) as it stands; the
+ // profile branch below blends its wall and adds the seam.
+ eff = MODE_FIELD;
} else if (mode == MODE_FILLET_DOWN || mode == MODE_FILLET_UP) {
eff = mode - FILLET_TO_STEP;
let c = frag - rrect_clip.p_rect.xy;
@@ -885,7 +890,39 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
let cd = select(RECESS_DEPTH, window_info.relief_meta.x / t, window_info.relief_meta.x > 0.0);
var slope = 0.0;
var curv = 0.0;
- if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
+ // A second wall's slope and curvature, summed with the outline's: the
+ // seam of a MODE_FIELD.
+ var sv_seam = vec2f(0.0);
+ var curv_seam = 0.0;
+ if (eff == MODE_FIELD) {
+ // A well (the interior one step DOWN) left of the seam at
+ // p_host.x, a flush run (a valley on the outline, the interior back
+ // at the surface) right of it — one outline. The run's valley is
+ // NOT MODE_TROUGH's: that one fits its whole fall and rise into the
+ // wall's width, so its outer half is a compressed copy of a step and
+ // the edge read differently either side of the seam. Here the
+ // valley's outer half IS the well's — the same fall, the same
+ // shoulder — and its inner half that fall mirrored back up to the
+ // face. So the outline's outer half is one profile all the way round
+ // the field, and only the inner half, which the run's face really
+ // does rise from, blends across one wall width about the seam.
+ let split = rrect_clip.p_host.x;
+ let wf = smoothstep(split - 0.5 * t, split + 0.5 * t, frag.x);
+ let s_rec = -cd * carve_slope(u);
+ let c_rec = rrect_clip.p_mat.w * sin(u * TAU);
+ let s_tr = select(s_rec, cd * carve_slope(1.0 - u), u > 0.5);
+ let c_tr = rrect_clip.p_mat.w * sin(min(u, 1.0 - u) * TAU);
+ slope = mix(s_rec, s_tr, wf);
+ curv = mix(c_rec, c_tr, wf);
+ // The seam: the well's floor rising to the run's face — the well's
+ // own right wall, a step along x = split. Full inside the field,
+ // gone at the outline, where the outer wall stands both sides at
+ // half the step and there is nothing between them to climb.
+ let us = clamp((split - frag.x) / t + 0.5, 0.0, 1.0);
+ let fade = clamp(d / (0.5 * t), 0.0, 1.0);
+ sv_seam = vec2f(1.0, 0.0) * (-cd * carve_slope(us)) * fade;
+ curv_seam = rrect_clip.p_mat.w * sin(us * TAU) * fade;
+ } else if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
// Ridge bump: the carve profile mirrored about the boundary (rising
// outer half, falling inner half), amplitude halved so the wall tilt
// matches a step's despite the doubled profile rate. MODE_TROUGH is the
@@ -916,15 +953,16 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
// boss.
curv = -dir * rrect_clip.p_mat.w * sin(u * TAU);
}
- let sv = fgd * slope;
+ let sv = fgd * slope + sv_seam;
let n = normalize(vec3f(sv, 1.0));
let diff = PLATE_AMBIENT + (1.0 - PLATE_AMBIENT) * max(dot(n, l), 0.0);
let spec = roll_spec(sv);
// Fade the carve out across the host plate's perimeter roll (see p_host).
+ // A field's p_host carries its seam instead, and it fades against nothing.
let hb = rrect_clip.p_host;
let host_d = min(hb.z - abs(frag.x - hb.x), hb.w - abs(frag.y - hb.y));
- let att = clamp(host_d / t, 0.0, 1.0) * wedge;
- let v = (diff / flat_shade - 1.0 + curv + spec) * strength * att;
+ let att = select(clamp(host_d / t, 0.0, 1.0), 1.0, eff == MODE_FIELD) * wedge;
+ let v = (diff / flat_shade - 1.0 + curv + curv_seam + spec) * strength * att;
// p_spec_tint.w = 1 marks a tinted carve — the FOCUS treatment. The
// relief is the unfocused carve's, term for term; only its colours
// change: the light composites in the accent instead of white and the
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 4cca83f..18d43f2 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1775,12 +1775,12 @@ impl ParametersBg {
if w > 0.0 && h > 0.0 {
let r = crate::layout::spinbox_corner_radius();
let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
- match sb.inner().relief_parts(band) {
- Some((_, Some(((run, _, _, _), _)))) => {
- out.push((x, y + ty, run.x - x, h - ty, (r, 0.0, 0.0, r), depth, false, all));
+ // With its -/+ run the control is a field
+ // ([`Self::fields`]); without, a plain well.
+ if let Some(rel) = sb.inner().relief_parts(band) {
+ if rel.run.is_none() {
+ out.push((x, y + ty, w, h - ty, r4(r), depth, false, all));
}
- Some((_, None)) => out.push((x, y + ty, w, h - ty, r4(r), depth, false, all)),
- None => {}
}
}
}
@@ -1815,9 +1815,12 @@ impl ParametersBg {
let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
// A text box joined to its picker is square at the seam,
// as the box's own carve is (`TextBox::well`).
- let joined = is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right);
- let radii = if joined { (radius, 0.0, 0.0, radius) } else { r4(radius) };
- out.push((x, y + ty, ww, h - ty, radii, depth, raised, all));
+ // A text box joined to its picker is half of a field
+ // ([`Self::fields`]), drawn there.
+ if is_text_row(&p.2) && self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right) {
+ continue;
+ }
+ out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
}
}
out
@@ -1849,29 +1852,12 @@ impl ParametersBg {
if hidden[i] {
continue;
}
- if p.2.starts_with("textpick") {
- if let Some(d) = &self.choices[i] {
- // The picker's plate is the field's right end: its ring
- // all round, as a dropdown trigger's, square at the seam.
- let (bx, by, bw, bh) = d.rect();
- if bw > 0.0 && bh > 0.0 {
- let depth = crate::layout::bevel_width().min(bh * 0.2);
- let r = crate::layout::textbox_corner_radius();
- out.push((bx, by, bw, bh, (0.0, r, r, 0.0), depth, (true, true, true, true)));
- }
- }
- } else if p.2.starts_with("spinbox") {
- if let Some(sb) = &self.spinboxes[i] {
- let (x, y, w, h) = sb.rect();
- let ty = sb.label_strip();
- let band = Rect { x, y: y + ty, width: w, height: h - ty };
- if let Some((_, Some(((run, radii, rd, edges), _)))) =
- sb.inner().relief_parts(band)
- {
- out.push((run.x, run.y, run.width, run.height, radii, rd, edges));
- }
- }
- } else if p.2.starts_with("choice") {
+ // A textpick row's picker and a spinbox's -/+ run are the flush
+ // ends of fields ([`Self::fields`]), not troughs of their own.
+ if p.2.starts_with("textpick") || p.2.starts_with("spinbox") {
+ continue;
+ }
+ if p.2.starts_with("choice") {
// The dropdown trigger: the widget's own raised paint is one
// `inset_plate` on its content band — the same ring here, on
// the same band (top-label band excluded),
@@ -1894,6 +1880,54 @@ impl ParametersBg {
out
}
+ /// The rows that are ONE field ([`crate::scene::paint::Prim::Field`]):
+ /// a sunken well ending in a flush run, one outline round both —
+ /// `(x, y, w, h, radii, depth, split)`, drawn AFTER [`Self::troughs`].
+ /// A textpick row is its text box's well and its picker; a spinbox, its
+ /// value's well and its -/+ run. On the pane's own rule for wells and
+ /// troughs: the content band as the outline, the wall straddling it.
+ #[allow(clippy::type_complexity)]
+ pub fn fields(&self) -> Vec<(f32, 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(tb), Some(d)) = (&self.texts[i], &self.choices[i]) {
+ if !tb.inner().joined_right {
+ continue;
+ }
+ let (x, y, w, h) = tb.rect();
+ let (dx, _, dw, _) = d.rect();
+ let ty = tb.label_strip();
+ if w > 0.0 && h - ty > 0.0 && dw > 0.0 {
+ let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
+ let r = crate::layout::textbox_corner_radius();
+ out.push((x, y + ty, dx + dw - x, h - ty, (r, r, r, r), depth, dx));
+ }
+ }
+ } else if p.2.starts_with("spinbox") {
+ if let Some(sb) = &self.spinboxes[i] {
+ let (x, y, w, h) = sb.rect();
+ let ty = sb.label_strip();
+ let band = Rect { x, y: y + ty, width: w, height: h - ty };
+ if let Some(rel) = sb.inner().relief_parts(band) {
+ if let Some((split, _)) = rel.run {
+ let r = rel.radius;
+ out.push((band.x, band.y, band.width, band.height, (r, r, r, r), rel.depth, split));
+ }
+ }
+ }
+ }
+ }
+ 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).
@@ -1914,10 +1948,10 @@ impl ParametersBg {
let (x, y, w, h) = sb.rect();
let ty = sb.label_strip();
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));
+ if let Some(rel) = sb.inner().relief_parts(band) {
+ if let Some((_, (sa, sb2, sw, host))) = rel.run {
+ out.push((sa, sb2, sw, rel.depth, host));
+ }
}
}
}
@@ -2109,6 +2143,11 @@ impl Paint for ParametersBg {
ctx.trough_edges(rect, radii, td, tedges);
}
}
+ for (fx, fy, fw, fh, radii, fd, split) in self.fields() {
+ let rect = Rect { x: fx, y: fy, width: fw, height: fh };
+ let tint = hovered(fx, fy, fw, fh).then_some(Self::HOVER_TINT);
+ ctx.field(rect, radii, fd, split, tint);
+ }
for (ga, gb, gw, gd, ghost) in self.grooves() {
ctx.groove(ga, gb, gw, gd, ghost);
}
@@ -3874,12 +3913,18 @@ mod tests {
assert_eq!((dy, dy + dh), (ty + label, ty + th), "the picker spans the field's band, edge to edge");
assert!(p.texts[0].as_ref().unwrap().inner().joined_right, "the box is square at the seam");
assert!(!p.texts[1].as_ref().unwrap().inner().joined_right, "a plain text row is not");
- let (_, radii, _, _) = p.texts[0].as_ref().unwrap().inner().well().expect("a well");
- assert_eq!((radii.1, radii.2), (0.0, 0.0), "the well's right corners are square");
- assert!(radii.0 > 0.0 && radii.3 > 0.0);
+ // ONE field: one outline round the box and the picker, its seam at
+ // the picker — not a well and a trough each turning its own corner
+ // there.
let rr = crate::layout::textbox_corner_radius();
- let ring = p.inner().troughs().into_iter().find(|t| t.0 == dx).expect("the picker's ring");
- assert_eq!(ring.4, (0.0, rr, rr, 0.0), "square at the seam, the field's radius outside");
+ let fields = p.inner().fields();
+ assert_eq!(fields.len(), 1, "{fields:?}");
+ let (fx, fy, fw, fh, fradii, _, split) = fields[0];
+ assert_eq!((fx, fy, fx + fw, fy + fh), (tx, dy, dx + dw, dy + dh), "the field spans box and picker");
+ assert_eq!(fradii, (rr, rr, rr, rr), "its own radius at all four corners");
+ assert_eq!(split, dx, "the seam is where the picker begins");
+ assert!(p.inner().troughs().iter().all(|t| t.0 != dx), "the picker draws no ring of its own");
+ assert!(p.inner().reliefs().iter().all(|r| !(r.0 == tx && r.2 == tw)), "nor the box a well of its own");
// Both text variants lay out at the same row height.
assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
@@ -4667,7 +4712,10 @@ mod tests {
};
// A carve's rect and tint, whatever its kind.
let carve = |prim: &Prim| match prim {
- Prim::Recess { rect, tint, .. } | Prim::Boss { rect, tint, .. } | Prim::Trough { rect, tint, .. } => Some((*rect, *tint)),
+ Prim::Recess { rect, tint, .. }
+ | Prim::Boss { rect, tint, .. }
+ | Prim::Trough { rect, tint, .. }
+ | Prim::Field { rect, tint, .. } => Some((*rect, *tint)),
_ => None,
};
let at_rest = prims(&p, &ctx);
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index ff4083f..578f88f 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -39,6 +39,20 @@ pub struct Spinbox {
}
/// The zone geometry shared by paint and input, derived from the content rect.
+/// A spinbox's relief: one field, its well and its -/+ run — see
+/// [`Spinbox::relief_parts`].
+#[derive(Clone, Copy, Debug)]
+pub struct SpinRelief {
+ /// The field's outline, as the host was handed it.
+ pub rect: Rect,
+ pub radius: f32,
+ pub depth: f32,
+ /// Where the -/+ run begins (an x), and the engraved seam dividing - from
+ /// + as `(top, bottom, width, host)` — `None` when the button zone has no
+ /// area, and the field is the well alone.
+ pub run: Option<(f32, ((f32, f32), (f32, f32), f32, Rect))>,
+}
+
struct SpinGeom {
x: f32,
y: f32,
@@ -131,36 +145,24 @@ impl Spinbox {
/// 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 value sits in a recessed well (the TextBox language) that ends
- /// at the -/+ run, square there,
- /// - the -/+ pair is ONE flush control plate, the control's right end:
- /// it reaches the control's outer edge on the top, right and bottom as
- /// a dropdown trigger's plate does, its ring all round, square where it
- /// meets the well and the control's radius outside — so the two read as
- /// one field with a seam between them, as a parameter pane's text row
- /// and its picker do. Until 2026-10-01 the run was nested INSIDE a
- /// full-width well, its face stopping at the base of the well's wall,
- /// so it never reached the edge a dropdown's does,
+ /// relief IS the chrome, and it is ONE field ([`crate::scene::paint::Prim::Field`]):
+ /// - the value sits in a sunken well (the TextBox language),
+ /// - the -/+ pair is a flush run, the field's right end — out to the
+ /// control's edge on the top, right and bottom as a dropdown trigger's
+ /// plate is — and the field's one outline runs round both, its wall
+ /// blending from the well's step to the run's valley at the seam, so
+ /// the edge does not break there. Until 2026-10-01 the run was nested
+ /// inside a full-width well (its face stopping at the base of the
+ /// well's wall), and for a few hours after that the well and the run
+ /// were two carves side by side, each turning its own corner at the
+ /// seam,
/// - 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, radii, depth)` — carved, radii top-left
- /// clockwise — plus the run
- /// `(rect, radii, depth, trough edges)` and seam `(top, bottom, width,
- /// host)` when the button zone is non-degenerate. `None` when the control
+ /// The field's outline is `rect` as given — a host carves it inside or
+ /// not by its own rule, as it does every well. `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, f32, f32), f32),
- Option<(
- (Rect, (f32, f32, f32, f32), f32, (bool, bool, bool, bool)),
- ((f32, f32), (f32, f32), f32, Rect),
- )>,
- )> {
+ pub fn relief_parts(&self, rect: Rect) -> Option<SpinRelief> {
let g = self.geom(rect);
if g.w <= 0.0 || g.h <= 0.0 {
return None;
@@ -169,27 +171,19 @@ impl Spinbox {
let depth = crate::layout::bevel_width().min(g.h * 0.2);
let whole = Rect { x: g.x, y: g.y, width: g.w, height: g.h };
if g.btn_w <= 0.0 || g.btn_h <= 0.0 {
- let (well_rect, well_radii) = crate::layout::carve_inside(whole, (radius, radius, radius, radius), depth);
- return Some(((well_rect, well_radii, depth), None));
+ return Some(SpinRelief { rect: whole, radius, depth, run: None });
}
// The run begins at the button hit zone and spans the whole band to
- // the control's right edge; the well is what is left of it.
+ // the control's right edge.
let split = g.split_dec + g.pad;
- let (well_rect, well_radii) = crate::layout::carve_inside(
- Rect { x: g.x, y: g.y, width: split - g.x, height: g.h },
- (radius, 0.0, 0.0, radius),
- depth,
- );
- let well = (well_rect, well_radii, depth);
- let run_rect = Rect { x: split, y: g.y, width: g.x + g.w - split, height: g.h };
- let run = (run_rect, (0.0, radius, radius, 0.0), depth, (true, true, true, true));
// Seam floor width: the breadcrumb's SEAM_WIDTH — a hair of flat
// floor so the crease doesn't alias into a dotted line. It sits on
// the -/+ hit boundary, not the painted run's midpoint, and crosses
- // the run's face between its ring's walls.
+ // the run's face between its outline's walls.
let sx = split + g.btn_w;
- let seam = ((sx, run_rect.y + depth), (sx, run_rect.y + run_rect.height - depth), 0.75, run_rect);
- Some((well, Some((run, seam))))
+ let host = Rect { x: split, y: g.y, width: g.x + g.w - split, height: g.h };
+ let seam = ((sx, g.y + depth), (sx, g.y + g.h - depth), 0.75, host);
+ Some(SpinRelief { rect: whole, radius, depth, run: Some((split, seam)) })
}
fn value_text(&self) -> String {
@@ -336,20 +330,20 @@ impl Paint for Spinbox {
// 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, _, _), (seam_top, _, _, _))) = buttons {
+ if let Some(rel) = self.relief_parts(rect) {
+ // Carved inside the control as every well is.
+ let r = rel.radius;
+ let (field, radii) = crate::layout::carve_inside(rel.rect, (r, r, r, r), rel.depth);
+ if let Some((split, _)) = rel.run {
let wash = [1.0, 1.0, 1.0, 0.06];
+ let sx = split + g.btn_w;
+ let (top, h) = (field.y, field.height);
if self.hover_dec {
- ctx.rounded_rect(
- Rect { x: run.x, y: run.y, width: seam_top.0 - run.x, height: run.height },
- radii.0,
- (true, false, false, true),
- wash,
- );
+ ctx.rounded_rect(Rect { x: split, y: top, width: sx - split, height: h }, 0.0, (false, false, false, false), wash);
}
if self.hover_inc {
ctx.rounded_rect(
- Rect { x: seam_top.0, y: run.y, width: run.x + run.width - seam_top.0, height: run.height },
+ Rect { x: sx, y: top, width: field.x + field.width - sx, height: h },
radii.1,
(false, true, true, false),
wash,
@@ -370,11 +364,12 @@ impl Paint for Spinbox {
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, wradii, wd) = well;
- ctx.recess(wr, wradii, wd);
- if let Some(((run, radii, rd, edges), (sa, sb, sw, host))) = buttons {
- ctx.trough_edges(run, radii, rd, edges);
- ctx.groove(sa, sb, sw, rd, host);
+ match rel.run {
+ Some((split, (sa, sb, sw, host))) => {
+ ctx.field(field, radii, rel.depth, split, None);
+ ctx.groove(sa, sb, sw, rel.depth, host);
+ }
+ None => ctx.recess(field, radii, rel.depth),
}
}
} else if rounded {
@@ -629,6 +624,38 @@ mod tests {
use super::*;
use crate::widget::{WidgetHost, UiContext};
+ /// The value's well and the -/+ run are ONE field: one outline round
+ /// both, its seam where the run begins — not a well and a trough side by
+ /// side, each turning its own corner there — with the engraved seam
+ /// between - and + on top.
+ #[test]
+ fn a_spinbox_is_one_field_with_its_run_at_the_right_end() {
+ use crate::scene::paint::{PaintCtx, Prim};
+ if !crate::layout::control_relief() {
+ return;
+ }
+ let sb = Spinbox::new(0, -100, 100, 1);
+ let rect = Rect { x: 10.0, y: 20.0, width: 200.0, height: 26.0 };
+ let rel = sb.inner().relief_parts(rect).expect("a relief");
+ assert_eq!(rel.rect, rect, "the outline is the control as handed over");
+ let (split, (sa, sb2, _, _)) = rel.run.expect("a -/+ run");
+ assert!(split > rect.x + rect.width * 0.5 && split < rect.x + rect.width, "the run is the right end");
+ assert!(sa.0 > split && sa.0 == sb2.0, "the -/+ seam crosses the run");
+ let mut pc = PaintCtx::new();
+ Paint::paint(sb.inner(), rect, &mut pc);
+ let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+ let fields: Vec<&Prim> = prims.iter().filter(|p| matches!(p, Prim::Field { .. })).collect();
+ assert_eq!(fields.len(), 1, "{prims:?}");
+ if let Prim::Field { split: s, .. } = fields[0] {
+ assert_eq!(*s, split);
+ }
+ assert!(
+ !prims.iter().any(|p| matches!(p, Prim::Recess { .. } | Prim::Trough { .. })),
+ "no well or ring of its own beside the field"
+ );
+ assert!(prims.iter().any(|p| matches!(p, Prim::Groove { .. })), "the -/+ seam");
+ }
+
#[test]
fn spinbox_button_zones_step_the_value() {
let mut ctx = UiContext::new();