GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: a field's button reaches the well, its glyphs in its middle
The ridge between a field's well and its run read as a strip of new
surface beside the button, not as a wider button. The seam is the
well's wall running into the run's lip again, and the run is laid out a
wall wider in its place: a textpick picker is PICK_W and a wall, its
arrow centred (Dropdown::center_arrow); a spinbox's run begins a wall
earlier, its face halved by the -/+ seam with each glyph in the middle
of its half. The well's floor ends where it did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 24 ++++++++-----
src/vk/shader2d.wgsl | 50 +++++++++++---------------
src/widget/container/parameters_bg.rs | 22 +++++++-----
src/widget/input/dropdown.rs | 31 +++++++++++++++-
src/widget/input/spinbox.rs | 66 +++++++++++++++++++++++++++--------
5 files changed, 132 insertions(+), 61 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index e9a85d1..5cd3d70 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -343,14 +343,22 @@ well's fall mirrored back up, and whose left corners are its right corners, so t
button has the same padding and rounding at both ends (since 2026-10-02; until then
the lip was the outline's own inner half and the face met the seam square). Where the
face's rounded corner leaves room by the straight outline is the valley's flat floor.
-The seam is the right side mirrored: the button's valley rises out of `split` to the
-surface (its outer half, as the outline's is on the right), and the well's own right
-wall, a whole wall further left, falls from there to the floor — a ridge at the
-surface's level between them, where the surface lies outside the button's right edge.
-Both fade out at the outline, which runs straight across. (For an hour the seam was the
-well's wall's inner half running straight into the lip, which read as one doubled line
-with half the gap of the button's right side.) A textpick row's TextBox ends a wall
-short of its picker accordingly, so its text stops at the well. Never grouped into a host
+The seam is the well's own right wall's inner half, from half the step at `split`
+down to the floor, meeting the face's lip where both stand at half the step; it fades
+out at the outline, which runs straight across. **The run is laid out a wall wider than
+its face**, so the BUTTON is what reaches the well and what it carries stands in its
+middle: a textpick picker is `PICK_W` plus a wall, its arrow centred
+(`Dropdown::center_arrow`), and a spinbox's run begins a wall before its flat layout's
+buttons (`SpinGeom::run_x`), its face halved at its middle by the -/+ seam with each
+glyph in the middle of its half — hit zones, washes, glyphs and relief all read
+`SpinGeom`. For a few hours on 2026-10-02 the seam was the right side mirrored instead
+— the button's valley rising to a ridge at the surface's level, the well's wall falling
+from it a whole wall further left — which read as a strip of new surface between the
+well and the button rather than a wider button; the well's floor ends where it did
+then, the button having taken the strip. (Before that, the face began half a wall from
+the seam with the arrow at the trigger's usual right-hand place, and its left side read
+narrower than its right.) A textpick row's TextBox ends where its picker begins, so its
+text stops at the well. 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.
diff --git a/src/vk/shader2d.wgsl b/src/vk/shader2d.wgsl
index eebe154..9cc9e0d 100644
--- a/src/vk/shader2d.wgsl
+++ b/src/vk/shader2d.wgsl
@@ -913,16 +913,16 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
// both ends: the same padding, the same rounding. Where the face's
// rounded corner leaves room by the straight outline, that is the
// valley's floor, flat.
- // * The SEAM, the right side mirrored: the button's valley rises out
- // of the seam to the surface (its outer half, as the outline's is
- // on the right), and the well's own right wall, a whole wall's
- // width further left, falls from there to the floor — a ridge at
- // the surface's level between the two, standing where the
- // surface lies outside the button's right edge. Until 2026-10-02
- // the face's lip ran straight on down into the well, so the left
- // of the button read as one doubled line with half the gap of
- // its right. Both walls fade out at the outline, which runs
- // straight across them.
+ // * The SEAM, the well's own right wall's inner half: from half the
+ // step at the seam down to the floor, meeting the face's lip at
+ // the seam where both stand at half the step. For a few hours on
+ // 2026-10-02 a ridge stood there instead, the button's valley
+ // rising to the surface and the well's wall falling from it a
+ // whole wall further left — the right side mirrored, which read as
+ // a strip of new surface between the well and the button. The
+ // button is that much wider now (its host lays it out from where
+ // the well's floor ends), so its padding about what it carries is
+ // even and nothing stands between it and the well.
//
// A field with no well (split left of it — a dropdown trigger, a
// button) is all run: the face inset from the outline all round,
@@ -942,11 +942,9 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
let rtr = max(rr.y - hw, 0.0);
let rbr = max(rr.z - hw, 0.0);
let face_r = select(vec4f(max(rr.x - hw, 0.0), rtr, rbr, max(rr.w - hw, 0.0)), vec4f(rtr, rtr, rbr, rbr), has_well);
- // The well's outline stands a whole wall left of the button's.
- let well_r = split - t;
// The outline: its outer half everywhere, its inner half over the
- // well alone (`over_well`, antialiased across the well's end).
- let over_well = 1.0 - smoothstep(well_r - 0.5, well_r + 0.5, frag.x);
+ // well alone (`over_well`, antialiased across the seam's pixel).
+ let over_well = 1.0 - smoothstep(split - 0.5, split + 0.5, frag.x);
let inner = select(1.0, over_well, u > 0.5);
slope = -cd * carve_slope(u) * inner;
curv = rrect_clip.p_mat.w * sin(u * TAU) * inner;
@@ -958,23 +956,15 @@ fn plate_shade(frag: vec2f, vcol: vec4f) -> vec4f {
sv_seam = gf.xy * (cd * carve_slope(1.0 - uf));
curv_seam = rrect_clip.p_mat.w * sin((1.0 - uf) * TAU);
}
- // The seam, faded out at the outline (outside the field there is
- // no well to fall into, and across the outline's band the valley
- // runs straight through).
+ // The seam: the well's right wall, its inner half, faded out at the
+ // outline (outside the field there is no well to fall into).
if (has_well) {
- let fade = clamp(d / hw, 0.0, 1.0);
- // The button's valley, its outer half: from the ridge down to
- // half the step at the seam, where the face's lip takes over.
- let ub = (frag.x - split) / t + 0.5;
- if (ub >= 0.0 && ub <= 0.5) {
- sv_seam = sv_seam + vec2f(-1.0, 0.0) * (-cd * carve_slope(ub)) * fade;
- curv_seam = curv_seam + rrect_clip.p_mat.w * sin(ub * TAU) * fade;
- }
- // The well's right wall, whole: from the ridge to the floor.
- let us = (well_r - frag.x) / t + 0.5;
- if (us >= 0.0 && us <= 1.0) {
- sv_seam = sv_seam + vec2f(1.0, 0.0) * (-cd * carve_slope(us)) * fade;
- curv_seam = curv_seam + rrect_clip.p_mat.w * sin(us * TAU) * fade;
+ let us = (split - frag.x) / t + 0.5;
+ if (us >= 0.5) {
+ let usc = min(us, 1.0);
+ let fade = clamp(d / hw, 0.0, 1.0);
+ sv_seam = sv_seam + vec2f(1.0, 0.0) * (-cd * carve_slope(usc)) * fade;
+ curv_seam = curv_seam + rrect_clip.p_mat.w * sin(usc * TAU) * fade;
}
}
} else if (eff == MODE_RIDGE || eff == MODE_TROUGH) {
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index eb6ef6f..85ff490 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1088,8 +1088,14 @@ impl ParametersBg {
let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
let band_h = r.3 - label_top;
let rr = crate::layout::textbox_corner_radius();
- d.set_rect(r.0 + r.2 - PICK_W, r.1 + label_top, PICK_W, band_h);
+ // The picker begins a wall before PICK_W: that is the
+ // seam, where the well's floor ends and its wall meets
+ // the button's lip, so the button is PICK_W wide where
+ // it is a face and the arrow sits in its middle.
+ let depth = crate::layout::bevel_width().min(band_h * 0.2);
+ d.set_rect(r.0 + r.2 - PICK_W - depth, r.1 + label_top, PICK_W + depth, band_h);
d.inner_mut().set_radii(Some((0.0, rr, rr, 0.0)));
+ d.inner_mut().center_arrow = true;
// The menu hangs off the WHOLE field, not the button
// sliver: anchor the popover to the box's well band.
d.popover_anchor = Some(Rect {
@@ -1111,9 +1117,7 @@ impl ParametersBg {
tb.inner_mut().joined_right = joined;
tb.set_rect(r.0, r.1, r.2, r.3);
if joined {
- // The box's well ends a whole wall short of the picker
- // (the field's seam: the well's wall, a ridge, the
- // picker's valley), so its text stops there too.
+ // The box ends where the picker begins, at the seam.
let depth = crate::layout::bevel_width().min((r.3 - tb.label_strip()) * 0.2);
tb.set_rect(r.0, r.1, (r.2 - PICK_W - depth).max(0.0), r.3);
}
@@ -3874,12 +3878,14 @@ mod tests {
// edge on the right, top and bottom, as a dropdown trigger does.
let (tx, ty, tw, th) = p.texts[0].as_ref().unwrap().rect();
let (dx, dy, dw, dh) = p.choices[0].as_ref().unwrap().rect();
- assert_eq!(dw, PICK_W);
- // The box stops a wall short of the picker: the field's seam is the
- // well's wall, a ridge and the picker's valley.
+ // The box ends where the picker begins, at the seam; the picker
+ // is a wall wider than PICK_W, the wall being the lip its face
+ // rises out of at the seam, so the face is PICK_W wide.
let label = p.texts[0].as_ref().unwrap().label_strip();
let depth = crate::layout::bevel_width().min((th - label) * 0.2);
- assert!((dx - (tx + tw + depth)).abs() < 1e-3, "the picker begins a wall past the box's end: {dx} vs {}", tx + tw + depth);
+ assert!((dw - (PICK_W + depth)).abs() < 1e-3, "the picker is PICK_W and a wall: {dw}");
+ assert!((dx - (tx + tw)).abs() < 1e-3, "the picker begins where the box ends: {dx} vs {}", tx + tw);
+ assert!(d.center_arrow, "the picker's arrow stands in its middle, its padding even");
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");
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 5f98926..d6dbe19 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -149,6 +149,10 @@ pub struct Dropdown {
/// parameter pane's textpick picker button nested in its TextBox), where
/// the menu should span the whole field, not the button sliver.
pub popover_anchor: Option<Rect>,
+ /// The ▼ stands in the MIDDLE of the trigger instead of at its right
+ /// end — for a trigger that carries nothing else (the textpick
+ /// picker), whose padding about the arrow should be even.
+ pub center_arrow: bool,
pub font_family: String,
pub custom_display_text: Option<String>,
pub open_upward: Option<bool>,
@@ -231,6 +235,7 @@ impl Dropdown {
just_changed: false,
parent_snapshot: None,
popover_anchor: None,
+ center_arrow: false,
font_family: "sans-serif".to_string(),
custom_display_text: None,
open_upward: None,
@@ -810,9 +815,16 @@ impl Dropdown {
// character by character toward `right_limit` and drops anything past
// 90% — an overflow treatment of its own, which a hard clip would
// fight rather than help.
+ // The glyph is drawn at the configured font's size (the runner
+ // reads it off the font string), so that is the size it is measured at.
+ let arrow_x = if self.center_arrow {
+ x + 0.5 * (w - text_advance("▼", &font_family, font_size))
+ } else {
+ x + w - 18.0
+ };
ctx.text_with(
"▼",
- x + w - 18.0,
+ arrow_x,
crate::layout::center_text_y(content.y, content.height, 10.0),
10.0,
[0x83, 0x83, 0x8a],
@@ -1642,6 +1654,23 @@ mod tests {
assert_ne!(last_char.color, expected_color); // color has shifted towards background
}
+ /// A trigger that carries nothing but its arrow (the textpick picker)
+ /// stands it in its middle: its padding about the arrow is even.
+ #[test]
+ fn a_centred_arrow_stands_in_the_middle_of_its_trigger() {
+ let arrow_x = |center: bool| {
+ let mut dd = Dropdown::new(vec!["a".to_string()], 0).with_custom_display_text("");
+ dd.inner_mut().center_arrow = center;
+ dd.set_rect(10.0, 10.0, 30.0, 24.0);
+ dd.own_text_labels().into_iter().find(|l| l.text == "▼").expect("the arrow").x
+ };
+ let (family, size) = crate::layout::control_label_font_detached_parsed();
+ let adv = super::text_advance("▼", &family, size);
+ let x = arrow_x(true);
+ assert!((x + 0.5 * adv - 25.0).abs() < 1e-3, "centred on the trigger's middle: {x} + {adv}/2");
+ assert_eq!(arrow_x(false), 10.0 + 30.0 - 18.0, "an ordinary trigger keeps it at its right end");
+ }
+
#[test]
fn test_dropdown_auto_width() {
let dummy = crate::context::UiContext::new();
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 578f88f..0f6eb3f 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -63,6 +63,18 @@ struct SpinGeom {
btn_h: f32,
btn_w: f32,
pad: f32,
+ /// The -/+ run: where it begins (under the relief, the field's seam,
+ /// where the well's floor ends and its wall meets the run's lip), the
+ /// line between - and +, and where it ends. Hit zones, hover washes,
+ /// glyphs and the relief all read these, so they cannot disagree.
+ run_x: f32,
+ seam_x: f32,
+ run_end: f32,
+ /// The middles of the - and + halves, where the glyphs stand.
+ dec_c: f32,
+ inc_c: f32,
+ /// Where the value's text is clipped: the end of the well's floor.
+ text_end: f32,
}
impl Spinbox {
@@ -128,16 +140,42 @@ impl Spinbox {
let x = rect.x;
let w = rect.width;
let pad = crate::layout::spinbox_button_padding();
+ let split_dec = x + w * 0.55;
+ let btn_w = ((w * 0.45 - 2.0 * pad).max(0.0)) / 2.0;
+ let flat_run = split_dec + pad;
+ let (run_x, seam_x, run_end, dec_c, inc_c, text_end) = if crate::layout::control_relief() && btn_w > 0.0 {
+ // The run's face is inset half a wall from its outline on every
+ // side (`Prim::Field`), so it is halved at its own middle and
+ // each glyph stands in the middle of its half: the padding about
+ // - and + is even. The run begins a wall before the flat
+ // layout's buttons, taking the wall a ridge stood on for a few
+ // hours on 2026-10-02 (the well's floor ends where it did then).
+ let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+ let hw = 0.5 * depth;
+ let run_x = flat_run - depth;
+ let end = x + w;
+ let seam = 0.5 * (run_x + end);
+ (run_x, seam, end, 0.5 * (run_x + hw + seam), 0.5 * (seam + end - hw), (run_x - hw).min(split_dec))
+ } else {
+ let seam = flat_run + btn_w;
+ (flat_run, seam, seam + btn_w, flat_run + 0.5 * btn_w, seam + 0.5 * btn_w, split_dec)
+ };
SpinGeom {
x,
y: rect.y,
w,
h: rect.height,
- split_dec: x + w * 0.55,
+ split_dec,
btn_y: rect.y + pad,
btn_h: (rect.height - 2.0 * pad).max(0.0),
- btn_w: ((w * 0.45 - 2.0 * pad).max(0.0)) / 2.0,
+ btn_w,
pad,
+ run_x,
+ seam_x,
+ run_end,
+ dec_c,
+ inc_c,
+ text_end,
}
}
@@ -175,12 +213,12 @@ impl Spinbox {
}
// The run begins at the button hit zone and spans the whole band to
// the control's right edge.
- let split = g.split_dec + g.pad;
+ let split = g.run_x;
// 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 outline's walls.
- let sx = split + g.btn_w;
+ let sx = g.seam_x;
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)) })
@@ -336,7 +374,7 @@ impl Paint for Spinbox {
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 sx = g.seam_x;
let (top, h) = (field.y, field.height);
if self.hover_dec {
ctx.rounded_rect(Rect { x: split, y: top, width: sx - split, height: h }, 0.0, (false, false, false, false), wash);
@@ -438,17 +476,17 @@ impl Paint for Spinbox {
// buttons begin (`split_dec`). The caret above is already clamped to
// that field; the text it belongs to was not, so a long value ran
// under the buttons and out of the control.
- let field = Some([g.x, g.y, g.split_dec, g.y + g.h]);
+ let field = Some([g.x, g.y, g.text_end, g.y + g.h]);
ctx.text_with(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color, None, field);
if let Some(ref unit) = self.unit {
ctx.text_with(unit.clone(), g.x + crate::layout::CONTROL_TEXT_INSET + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a], None, field);
}
if g.btn_w > 0.0 {
- let dec_center_x = g.split_dec + g.pad + g.btn_w * 0.5;
- let inc_center_x = g.split_dec + g.pad + g.btn_w * 1.5;
+ let dec_center_x = g.dec_c;
+ let inc_center_x = g.inc_c;
let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
- let dec_box = Some([g.split_dec + g.pad, g.y, g.split_dec + g.pad + g.btn_w, g.y + g.h]);
- let inc_box = Some([g.split_dec + g.pad + g.btn_w, g.y, g.split_dec + g.pad + 2.0 * g.btn_w, g.y + g.h]);
+ let dec_box = Some([g.run_x, g.y, g.seam_x, g.y + g.h]);
+ let inc_box = Some([g.seam_x, g.y, g.run_end, g.y + g.h]);
ctx.text_with("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color, None, dec_box);
ctx.text_with("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color, None, inc_box);
}
@@ -473,8 +511,8 @@ impl Input for Spinbox {
}
let g = self.geom(r);
let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
- let hd = in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775;
- let hi = in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad;
+ let hd = in_y && *px >= g.run_x && *px < g.seam_x;
+ let hi = in_y && *px >= g.seam_x && *px < g.run_end;
let changed = hd != self.hover_dec || hi != self.hover_inc;
self.hover_dec = hd;
self.hover_inc = hi;
@@ -483,10 +521,10 @@ impl Input for Spinbox {
Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x: px, y: py, .. } => {
let g = self.geom(ectx.rect);
let in_y = *py >= g.btn_y && *py < g.btn_y + g.btn_h;
- if in_y && *px >= g.split_dec + g.pad && *px < g.x + g.w * 0.775 {
+ if in_y && *px >= g.run_x && *px < g.seam_x {
self.step_by(-1);
true
- } else if in_y && *px >= g.x + g.w * 0.775 && *px < g.x + g.w - g.pad {
+ } else if in_y && *px >= g.seam_x && *px < g.run_end {
self.step_by(1);
true
} else if *px < g.split_dec {