GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: a text box is a field that is all well
A plain text box was a Prim::Recess, grouped into the plate under it as a
CSG feature; on the NVIDIA device a grouped recess draws a doubled
outline, where a textpick row's field beside it (never grouped) drew the
single edge. A text box is now a field whose seam is past its right end
(PaintCtx::well_field), in TextBox's paint and in the parameter pane's
text rows; the fallbacks draw it as one plain recess.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 12 +++++++++
src/backend/window_runner.rs | 48 +++++++++++++++++++++--------------
src/layout.rs | 29 ++++++++++++++++-----
src/scene/paint.rs | 16 ++++++++++++
src/widget/container/parameters_bg.rs | 41 ++++++++++++++++++++++--------
src/widget/input/dropdown.rs | 2 +-
src/widget/input/text_box.rs | 29 ++++++++++++++++++---
7 files changed, 136 insertions(+), 41 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8f0b9df..43173a9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -382,6 +382,18 @@ explicit valley and `cce-relief`'s preview. `a_dropdown_trigger_wears_the_runs_e
`focus_lights_the_plate_rim`, `a_breadcrumb_and_a_font_selector_wear_the_runs_edge` and
`an_inset_plate_is_a_field_that_is_all_run` are the tests.
+**A text box is a field that is all well** (since 2026-10-02): `PaintCtx::well_field`, a
+`Prim::Field` whose seam is `FIELD_RUN_ONLY` px past its right end (`field_well_only`),
+in `TextBox`'s own paint and in `ParametersBg::fields` for every text row without a
+picker. It was a `Prim::Recess`, which the runner GROUPS into a live host plate as a CSG
+feature, and on the NVIDIA device (`CCE_VK_DEVICE=discrete`, the designer's `gpu
+"discrete"`) a grouped recess drew a doubled outline where the field beside it — never
+grouped, its own overlay — drew the single edge; on the integrated GPU the two are
+identical to the pixel, so the difference shows only on the discrete card. Other grouped
+recesses (a spinbox with no run, a colour well, a toggle's track) still group. Both
+fallbacks draw an all-well field as one plain recess. `a_text_box_is_a_field_that_is_all_well`
+is the test.
+
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`
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 9fd99c5..41d1a3b 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2549,30 +2549,40 @@ pub fn tessellate_display_list(
// has no blended outline, and the legacy path exists only for
// A/B comparison.
let all = (true, true, true, true);
- if *split > rect.x {
+ // All well (`PaintCtx::well_field`): the plain step, its own
+ // corners, and no run.
+ if *split >= rect.x + rect.width {
push_bevel_edge_vertices_banded(
- rect.x, rect.y, *split - rect.x, rect.height, (radii.0, 0.0, 0.0, radii.3), *depth,
+ rect.x, rect.y, rect.width, rect.height, *radii, *depth,
sw, sh, [0.0; 4], no, -1.0, default_bevel_bands(*depth), all,
EdgeKind::Step, &mut verts,
);
+ } else {
+ if *split > rect.x {
+ push_bevel_edge_vertices_banded(
+ rect.x, rect.y, *split - rect.x, 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 = split.max(rect.x);
+ let rw = (rect.x + rect.width - rx).max(0.0);
+ // All run (`PaintCtx::inset_plate`'s edge): its own left corners.
+ let (l0, l3) = if *split > rect.x { (0.0, 0.0) } else { (radii.0, radii.3) };
+ let half = *depth * 0.5;
+ push_bevel_edge_vertices_banded(
+ rx, rect.y, rw, rect.height, (l0, radii.1, radii.2, l3), 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);
+ let il = if *split > rect.x { 0.0 } else { (radii.0 - half).max(0.0) };
+ push_bevel_edge_vertices_banded(
+ rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), half,
+ sw, sh, [0.0; 4], no, 1.0, default_bevel_bands(half), all,
+ EdgeKind::Step, &mut verts,
+ );
}
- let rx = split.max(rect.x);
- let rw = (rect.x + rect.width - rx).max(0.0);
- // All run (`PaintCtx::inset_plate`'s edge): its own left corners.
- let (l0, l3) = if *split > rect.x { (0.0, 0.0) } else { (radii.0, radii.3) };
- let half = *depth * 0.5;
- push_bevel_edge_vertices_banded(
- rx, rect.y, rw, rect.height, (l0, radii.1, radii.2, l3), 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);
- let il = if *split > rect.x { 0.0 } else { (radii.0 - half).max(0.0) };
- push_bevel_edge_vertices_banded(
- rx + half, rect.y + half, rw - *depth, rect.height - *depth, (il, ir, ir, il), 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 72c3bae..3d97d52 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4351,7 +4351,20 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
// All run and no well (`PaintCtx::inset_plate`'s edge): the
// plate alone, with the face that came before it.
let face = if split <= rect.x { pending_face.take().map(|(_, _, fill)| fill) } else { None }.unwrap_or([0.0; 4]);
- if split > rect.x {
+ // All well and no run (`PaintCtx::well_field`, a text box):
+ // the plain recess, its own corners.
+ if split >= rect.x + rect.width {
+ pc.relief_carve(&ReliefCarve {
+ kind: CarveKind::Recess { tint },
+ x: rect.x,
+ y: rect.y,
+ w: rect.width,
+ h: rect.height,
+ radii,
+ depth,
+ edges: (true, true, true, true),
+ });
+ } else if split > rect.x {
pc.relief_carve(&ReliefCarve {
kind: CarveKind::Recess { tint },
x: rect.x,
@@ -4363,12 +4376,14 @@ pub fn render_widget<T: WidgetHost + 'static>(pc: &mut dyn RenderTarget, w: &mut
edges: (true, true, true, true),
});
}
- let r = radii.1.max(radii.2);
- let rx = split.max(rect.x);
- let rw = (rect.x + rect.width - rx).max(0.0);
- match tint {
- Some(t) => pc.inset_plate_tinted(face, rx, rect.y, rw, rect.height, r, depth, t),
- None => pc.inset_plate(face, rx, rect.y, rw, rect.height, r, depth),
+ if split < rect.x + rect.width {
+ let r = radii.1.max(radii.2);
+ let rx = split.max(rect.x);
+ let rw = (rect.x + rect.width - rx).max(0.0);
+ match tint {
+ Some(t) => pc.inset_plate_tinted(face, rx, rect.y, rw, rect.height, r, depth, t),
+ None => pc.inset_plate(face, rx, rect.y, rw, rect.height, r, depth),
+ }
}
}
Prim::Trough { rect, radii, depth, tint, .. } => {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index c709792..848a08f 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -222,6 +222,12 @@ pub enum PlateStance {
/// seam and the seam's own wall land nowhere near the field.
pub const FIELD_RUN_ONLY: f32 = 1.0e4;
+/// The seam of a field that is all well and no run ([`PaintCtx::well_field`]):
+/// this far past the field's right end.
+pub fn field_well_only(rect: Rect) -> f32 {
+ rect.x + rect.width + FIELD_RUN_ONLY
+}
+
/// A control plate: the thing you press, at the control rung of the plate
/// ladder. One description for every control face — Button, Dropdown,
/// FontSelector, Breadcrumb, a ButtonStrip's selected plateau — so their
@@ -1566,6 +1572,16 @@ impl PaintCtx {
self.push(Prim::Trough { rect, radii, depth, edges, tint: Some(tint) });
}
+ /// A sunken well and nothing else: a [`Prim::Field`] that is all well,
+ /// its seam [`FIELD_RUN_ONLY`] px past its right end — the well a text
+ /// box is. It shades as a field's well does, an overlay of its own
+ /// rather than a feature grouped into the plate under it, which is the
+ /// look a textpick row's well had beside a plain text box's (since
+ /// 2026-10-02; a plain box was a `Prim::Recess`, grouped).
+ pub fn well_field(&mut self, rect: Rect, radii: Radii, depth: f32, tint: Option<[f32; 3]>) {
+ self.field(rect, radii, depth, field_well_only(rect), 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]>) {
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 36cb9ef..635a64d 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1732,9 +1732,9 @@ impl ParametersBg {
}
// (control, its configured corner radius, raised vs recessed)
let ctl: Option<(&dyn WidgetHost, f32, bool)> = if is_text_row(&p.2) {
- // The textpick picker is NOT in this list: with it the row is
- // a field ([`Self::fields`]) — a well ending in a flush run.
- self.texts[i].as_ref().map(|w| (w as &dyn WidgetHost, crate::layout::textbox_corner_radius(), false))
+ // A text row is a field ([`Self::fields`]): with its picker a
+ // well ending in a flush run, without one all well.
+ None
} else if p.2.starts_with("choice") {
// The dropdown trigger is a FLUSH control, a field that is all
// run ([`Self::fields`]). A boss here read as a raised island
@@ -1817,11 +1817,6 @@ impl ParametersBg {
// The top-label band stays outside the relief like every other host.
let ty = w.label_strip();
let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
- // 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));
}
}
@@ -1848,7 +1843,24 @@ impl ParametersBg {
if hidden[i] {
continue;
}
- if p.2.starts_with("textpick") {
+ let joined = self.texts[i].as_ref().is_some_and(|t| t.inner().joined_right);
+ if is_text_row(&p.2) && !joined {
+ // A plain text row (a textpick with no candidates included):
+ // a field that is all well, so it shades as the well of a
+ // textpick row beside it does — an overlay of its own, where
+ // as a recess in [`Self::reliefs`] it was grouped into the
+ // pane's plate and read flatter (until 2026-10-02).
+ if let Some(tb) = &self.texts[i] {
+ let (x, y, w, h) = tb.rect();
+ let ty = tb.label_strip();
+ if w > 0.0 && h - ty > 0.0 {
+ let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
+ let r = crate::layout::textbox_corner_radius();
+ let band = Rect { x, y: y + ty, width: w, height: h - ty };
+ out.push((x, y + ty, w, h - ty, (r, r, r, r), depth, crate::scene::paint::field_well_only(band)));
+ }
+ }
+ } else if p.2.starts_with("textpick") {
if let (Some(tb), Some(d)) = (&self.texts[i], &self.choices[i]) {
if !tb.inner().joined_right {
continue;
@@ -3910,8 +3922,17 @@ mod tests {
// there.
let rr = crate::layout::textbox_corner_radius();
let fields = p.inner().fields();
- assert_eq!(fields.len(), 1, "{fields:?}");
+ assert_eq!(fields.len(), 3, "{fields:?}");
let (fx, fy, fw, fh, fradii, _, split) = fields[0];
+ // The plain row and the textpick with nothing to pick are fields
+ // too, all well: their seam past their right end.
+ for (i, f) in fields[1..].iter().enumerate() {
+ let (x, _, w, _, radii, _, s) = *f;
+ let (bx, _, bw, _) = p.texts[i + 1].as_ref().unwrap().rect();
+ assert_eq!((x, w), (bx, bw), "row {}'s field is its box", i + 1);
+ assert_eq!(radii, (rr, rr, rr, rr));
+ assert!(s >= x + w + 100.0, "all well: {s}");
+ }
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");
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 76dda88..9944913 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -1124,7 +1124,7 @@ impl Paint for Dropdown {
// A menu that replaces the trigger has no band to redraw on.
if !self.menu_replaces_trigger {
let (tx, ty) = (rect.x, rect.y);
- let (tw, th) = (rect.width, rect.height);
+ let th = rect.height;
let band_bounds = Some([ux, uy, ux + uw, uy + uh]);
let font = crate::layout::control_label_font_detached();
let text_y = crate::layout::align_text_y(ty, th, 12.0, 0.0);
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 1513e57..50704db 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1568,10 +1568,9 @@ impl Paint for TextBox {
if let Some((well, radii, depth, tint)) = self.well() {
// Focus lights the well's rim in the highlight accent (with
// the shader's complementary shadow) — the TreeList treatment.
- match tint {
- Some(t) => ctx.recess_tinted(well, radii, depth, t),
- None => ctx.recess(well, radii, depth),
- }
+ // A field that is all well (`PaintCtx::well_field`), the
+ // well a textpick row's box is half of.
+ ctx.well_field(well, radii, depth, tint);
}
}
@@ -1826,6 +1825,28 @@ unsafe impl Sync for TextBox {}
mod tests {
use super::*;
+ /// A text box's well is a field that is all well — the well a textpick
+ /// row's box is half of — and not a recess, which grouped into the plate
+ /// under it and read flatter beside the textpick's.
+ #[test]
+ fn a_text_box_is_a_field_that_is_all_well() {
+ use crate::scene::paint::{PaintCtx, Prim};
+ let mut tb = TextBox::new("x".to_string()).with_recessed(true);
+ tb.set_rect(10.0, 10.0, 200.0, 26.0);
+ let Some((well, radii, depth, _)) = tb.well() else {
+ return; // a square-cornered config draws no well
+ };
+ let mut pc = PaintCtx::new();
+ Paint::paint(tb.inner(), Rect { x: 10.0, y: 10.0, width: 200.0, height: 26.0 }, &mut pc);
+ let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+ assert!(
+ prims.iter().any(|p| matches!(p, Prim::Field { rect, radii: r, depth: d, split, .. }
+ if *rect == well && *r == radii && *d == depth && *split >= rect.x + rect.width + 100.0)),
+ "{prims:?}"
+ );
+ assert!(!prims.iter().any(|p| matches!(p, Prim::Recess { .. })), "no recess of its own");
+ }
+
/// The multiline caret/click math reads shaped per-line offsets; a caret
/// must land exactly where it is drawn, on every column of every line.
#[test]