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

commite9dcccc97381532515ea7459070dc3506e08a4dd
parent0a7f85f913
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:44
refactor: a field is built by its form

scene::paint::Field is the object a text box, a flush control plate, a
text row with its picker, a spinbox and a toggle all are, built by the
form it takes: Field::well, ::run, ::ending_in_run(split),
::sliding_run(width, t), or ::spanning(a, b), the general form. It is
painted by PaintCtx::field(&Field), which replaces field(rect, radii,
depth, split, tint) and field_run: no host spells the FIELD_RUN_ONLY
sentinel any more, only Field::prim_span. A field with no run paints a
Prim::Recess, as a plain well always has, so it still groups into its
plate.

TextBox::well and Toggle::field hand out a Field, Toggle::run is gone
into it, and ParametersBg::fields is a Vec<Field> in place of an
eight-number tuple.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             |  42 +++++++---
 src/scene/paint.rs                    | 147 ++++++++++++++++++++++++++++------
 src/widget/container/parameters_bg.rs |  71 +++++++---------
 src/widget/input/checkbox.rs          |  77 ++++++++----------
 src/widget/input/spinbox.rs           |   7 +-
 src/widget/input/text_box.rs          |  22 ++---
 6 files changed, 234 insertions(+), 132 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 56a00ab..cedeb0e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -406,16 +406,32 @@ A dropdown, a button, a text row with its picker, a spinbox and a toggle are ONE
 object: a well cut into a plate with a flush plate, the RUN, standing in it, one
 outline round both (`Prim::Field`). They differ only in where the run is:
 
-| form | run | well |
-| --- | --- | --- |
-| flush control plate (dropdown trigger, button, breadcrumb run, …) | the whole field | none |
-| text row with its picker, spinbox with its -/+ run | the right end | left of it |
-| toggle | half the field: the left end off, the right end on | the other half; both sides mid-glide |
-| plain text box | none (a `Prim::Recess`, for grouping — see below) | the whole field |
-
-So the run is a span, `split` to `end` (`PaintCtx::field_run`; `PaintCtx::field`
-is the run reaching the right end, `end` `FIELD_RUN_ONLY` past it). Where the run
-stops short of the right end, a well lies to its right too, and `MODE_FIELD`
+| form | `Field::` constructor | run | well |
+| --- | --- | --- | --- |
+| text box | `well` | none | the whole field |
+| flush control plate (dropdown trigger, button, breadcrumb run, …) | `run` | the whole field | none |
+| text row with its picker, spinbox with its -/+ run | `ending_in_run(split)` | the right end | left of it |
+| toggle | `sliding_run(width, t)` | half the field: the left end off, the right end on | the other half; both sides mid-glide |
+
+**`scene::paint::Field` is the object** (since the same day), and
+`PaintCtx::field(&Field)` the one way to paint one: the outline (rect and radii,
+the carve's boundary — a widget takes it through `carve_inside`), the wall width,
+the run's span clamped to the outline (`run_span`, `None` for a well;
+`Field::spanning(a, b)` is the general form the others are), and a rim `tint`
+(`with_tint`). What builds a field asks for it by its FORM, never by numbers that
+encode one — the old `field(rect, radii, depth, split, tint)` took a seam put
+`FIELD_RUN_ONLY` px off the field to mean "no well", and every host spelled that
+sentinel itself. Now only `Field::prim_span` does, turning a run that reaches an
+end of the field (within the shader's half pixel) into the prim's encoding. A
+field with NO run paints a `Prim::Recess`, as a plain well always has, because a
+recess groups into the plate under it and a `Prim::Field` never does — so the
+text box is a form of the object without changing what it draws. The widgets
+hand theirs out: `TextBox::well`, `Toggle::field`, and `ParametersBg::fields` is
+a `Vec<Field>` (the pane tints the hovered one); `PaintCtx::inset_plate` is a
+face and `Field::run`.
+
+`Prim::Field`'s run is a span, `split` to `end`. Where it stops short of the
+right end, a well lies to its right too, and `MODE_FIELD`
 mirrors everything it does on the left: the face is inset half a wall from that
 seam with the corners of the run's LEFT end, and the seam is that well's left
 wall's inner half. A run reaching the right end takes the same path through the
@@ -423,9 +439,9 @@ shader as before, term for term, so every field that was drawn is drawn as it wa
 The host-box slot carries both ends (`p_host.x`, `.y`); the legacy banded path and
 the flat-host bridge draw a well box either side of the run.
 
-**The Toggle is drawn so** (`Toggle::field` the outline, `Toggle::run` the span,
-`Toggle::face` what the relief-off path lights; `ParametersBg::fields` asks for
-the same, and its `reliefs` no longer carries toggles). Until this it was a recess
+**The Toggle is drawn so** (`Toggle::field`, its sliding field, lit while focused;
+`Toggle::face` what the relief-off path lights; `ParametersBg::fields` takes the
+same, and its `reliefs` no longer carries toggles). Until this it was a recess
 with a raised `Boss` on its floor: the one control whose nested plate stood ABOVE
 the surface where every other stood flush with it, and whose outline turned its
 own corner round the plate instead of running round the whole control. The run
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index cd21398..7b59a39 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -217,13 +217,111 @@ pub enum PlateStance {
     Flat,
 }
 
-/// How far left of a [`Prim::Field`] its seam is put to make it all run and
-/// no well ([`PaintCtx::inset_plate`]): far enough that the blend across the
-/// seam and the seam's own wall land nowhere near the field. A run's `end`
-/// is put as far past the field's right edge to reach that edge
-/// ([`PaintCtx::field`]).
+/// How far past a [`Prim::Field`]'s end its run's end is put to say the run
+/// REACHES that end and there is no well on that side: far enough that the
+/// blend across the seam and the seam's own wall land nowhere near the
+/// field. An encoding of the prim's; a [`Field`] says it by its form.
 pub const FIELD_RUN_ONLY: f32 = 1.0e4;
 
+/// How near a run's end may come to the field's end and still be taken as
+/// reaching it — the shader's own tolerance (half a pixel, `MODE_FIELD`).
+const FIELD_REACH: f32 = 0.5;
+
+/// A FIELD: a well cut into a plate with a flush plate, the RUN, standing
+/// in it, one outline round both. One object in every form a control takes
+/// — they differ only in where the run is:
+///
+/// | form | constructor | run | well |
+/// | --- | --- | --- | --- |
+/// | text box | [`Field::well`] | none | the whole field |
+/// | flush control plate (dropdown, button, …) | [`Field::run`] | the whole field | none |
+/// | text row with its picker, spinbox | [`Field::ending_in_run`] | the right end | left of it |
+/// | toggle | [`Field::sliding_run`] | part of the field, anywhere | either side of it |
+///
+/// Painted by [`PaintCtx::field`], as a [`Prim::Field`] — except a field
+/// with no run, which is a [`Prim::Recess`]: that is what a plain well has
+/// always been, and a recess groups into the plate under it where a field
+/// prim never does. The rect is the field's OUTLINE (the carve's boundary;
+/// a widget takes it through [`crate::layout::carve_inside`] from its
+/// footprint), and `depth` the wall width.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub struct Field {
+    pub rect: Rect,
+    pub radii: Radii,
+    pub depth: f32,
+    /// The run's span in x, clamped to the outline; `None` for a well.
+    run: Option<(f32, f32)>,
+    /// Lights the rim — the focus and hover treatment.
+    pub tint: Option<[f32; 3]>,
+}
+
+impl Field {
+    /// All well, no run: a text box.
+    pub fn well(rect: Rect, radii: Radii, depth: f32) -> Self {
+        Field { rect, radii, depth, run: None, tint: None }
+    }
+
+    /// All run, no well: the edge of a flush control plate — a dropdown
+    /// trigger, a button ([`PaintCtx::inset_plate`]).
+    pub fn run(rect: Rect, radii: Radii, depth: f32) -> Self {
+        Self::spanning(rect, radii, depth, rect.x, rect.x + rect.width)
+    }
+
+    /// A well ending in a run from `split` to the field's right end: a text
+    /// row with its completion picker, a spinbox with its -/+ run.
+    pub fn ending_in_run(rect: Rect, radii: Radii, depth: f32, split: f32) -> Self {
+        Self::spanning(rect, radii, depth, split, rect.x + rect.width)
+    }
+
+    /// A run `width` wide, at `t` of its travel along the field: 0 is the
+    /// left end, 1 the right, a well either side between — a toggle, whose
+    /// run glides from off to on.
+    pub fn sliding_run(rect: Rect, radii: Radii, depth: f32, width: f32, t: f32) -> Self {
+        let width = width.clamp(0.0, rect.width);
+        let x = rect.x + t.clamp(0.0, 1.0) * (rect.width - width);
+        Self::spanning(rect, radii, depth, x, x + width)
+    }
+
+    /// A run from `a` to `b`, anywhere in the field — the general form the
+    /// others are; clamped to the outline.
+    pub fn spanning(rect: Rect, radii: Radii, depth: f32, a: f32, b: f32) -> Self {
+        let (l, r) = (rect.x, rect.x + rect.width);
+        let a = a.clamp(l, r);
+        let b = b.clamp(a, r);
+        Field { rect, radii, depth, run: Some((a, b)), tint: None }
+    }
+
+    /// The rim lit in `tint` (focus, hover), or not.
+    pub fn with_tint(mut self, tint: Option<[f32; 3]>) -> Self {
+        self.tint = tint;
+        self
+    }
+
+    /// Where the run is, `(left, right)` within the outline; `None` for a
+    /// field that is all well.
+    pub fn run_span(&self) -> Option<(f32, f32)> {
+        self.run
+    }
+
+    /// Whether any of the field is well — false for a field that is all run.
+    pub fn has_well(&self) -> bool {
+        match self.run {
+            None => true,
+            Some((a, b)) => a > self.rect.x + FIELD_REACH || b < self.rect.x + self.rect.width - FIELD_REACH,
+        }
+    }
+
+    /// The run as [`Prim::Field`] encodes it, `(split, end)`: an end that
+    /// reaches the field's is put [`FIELD_RUN_ONLY`] past it.
+    fn prim_span(&self) -> Option<(f32, f32)> {
+        let (a, b) = self.run?;
+        let (l, r) = (self.rect.x, self.rect.x + self.rect.width);
+        let split = if a <= l + FIELD_REACH { l - FIELD_RUN_ONLY } else { a };
+        let end = if b >= r - FIELD_REACH { r + FIELD_RUN_ONLY } else { b };
+        Some((split, end))
+    }
+}
+
 /// 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
@@ -1121,7 +1219,9 @@ 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, end, tint } => self.field_run(rect, radii, depth, (split, end), tint),
+            Prim::Field { rect, radii, depth, split, end, tint } => {
+                self.field(&Field::spanning(rect, radii, depth, split, end).with_tint(tint))
+            }
             Prim::Plate { rect, radii, material, depth, shape } => {
                 self.plate_shaped(rect, radii, &material, depth, shape)
             }
@@ -1444,7 +1544,7 @@ impl PaintCtx {
         // triggers had been switched to the run's edge one by one the day
         // before, and the apps' own flush plates (the calendar's, cce-cloud's,
         // cce-files', the system interface's) kept the trough until here.
-        self.field(rect, radii, depth, rect.x - FIELD_RUN_ONLY, None);
+        self.field(&Field::run(rect, radii, depth));
     }
 
     /// [`inset_plate`](Self::inset_plate) with the rim lit — the focused flush
@@ -1454,7 +1554,7 @@ impl PaintCtx {
         if let Some(face) = face.filter(|m| m.tint[3] > 0.001) {
             self.border(rect, radii, face.fill(PlateRole::Nested), [0.0; 4], 0.0);
         }
-        self.field(rect, radii, depth, rect.x - FIELD_RUN_ONLY, Some(tint));
+        self.field(&Field::run(rect, radii, depth).with_tint(Some(tint)));
     }
 
     /// A canvas well's floor — the opening you look into or draw in (a
@@ -1573,21 +1673,22 @@ impl PaintCtx {
         self.push(Prim::Trough { rect, radii, depth, edges, tint: Some(tint) });
     }
 
-    /// A sunken well ending in a flush run at `split`, the run reaching the
-    /// field's right end — 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 end = rect.x + rect.width + FIELD_RUN_ONLY;
-        self.field_run(rect, radii, depth, (split, end), tint);
-    }
-
-    /// A sunken well with a flush run from `run.0` to `run.1` — see
-    /// [`Prim::Field`]: the general form, a well on whichever sides of the
-    /// run the field extends past it.
-    pub fn field_run(&mut self, rect: Rect, radii: Radii, depth: f32, run: (f32, f32), tint: Option<[f32; 3]>) {
-        let (split, end) = (run.0 + self.offset.0, run.1 + self.offset.0);
-        let rect = self.apply_offset(rect);
-        self.push(Prim::Field { rect, radii, depth, split, end, tint });
+    /// Paint a [`Field`] — see it for the forms. One with a run is a
+    /// [`Prim::Field`]; one that is all well a [`Prim::Recess`], which groups
+    /// into the plate under it.
+    pub fn field(&mut self, field: &Field) {
+        let Field { rect, radii, depth, tint, .. } = *field;
+        match field.prim_span() {
+            None => match tint {
+                Some(t) => self.recess_tinted(rect, radii, depth, t),
+                None => self.recess(rect, radii, depth),
+            },
+            Some((split, end)) => {
+                let (split, end) = (split + self.offset.0, end + self.offset.0);
+                let rect = self.apply_offset(rect);
+                self.push(Prim::Field { rect, radii, depth, split, end, tint });
+            }
+        }
     }
 
     /// Raise a rim along `rect`'s boundary — see `Prim::Ridge`. `depth` is the
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 86eb249..dc3c0b3 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -36,6 +36,7 @@ use crate::colors;
 use crate::scene::layout::Rect;
 use crate::scene::paint::PaintCtx;
 use crate::widget::display::{Float3, TextLabel};
+use crate::scene::paint::Field;
 use crate::widget::input::{Button, ColorSelector, Dropdown, Ramp, Slider, Spinbox, TextBox, Toggle};
 use crate::widget::{
     Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Key, Layout, MouseButton,
@@ -1810,21 +1811,19 @@ impl ParametersBg {
         out
     }
 
-    /// The rows that are ONE field ([`crate::scene::paint::Prim::Field`]):
-    /// a sunken well holding a flush run, one outline round both —
-    /// `(x, y, w, h, radii, depth, split, end)`, the run from `split` to
-    /// `end`, drawn AFTER [`Self::reliefs`]. A textpick row is its text box's
-    /// well and its picker; a spinbox, its value's well and its -/+ run (both
-    /// runs reaching the right end, `end` [`crate::scene::paint::FIELD_RUN_ONLY`]
-    /// past it); a dropdown or button row, a field that is all run (its seam
-    /// as far to the left); a toggle, a well with its run at the end its
-    /// state says (`Toggle::field` and `Toggle::run`). So every flush control
-    /// in the pane has the same edge. They replaced
+    /// The rows that are ONE field with a run ([`Field`]: a sunken well
+    /// holding a flush run, one outline round both), drawn AFTER
+    /// [`Self::reliefs`]. A textpick row is its text box's well ending in its
+    /// picker, a spinbox its value's well ending in its -/+ run
+    /// ([`Field::ending_in_run`]); a dropdown or button row, a field that is
+    /// all run ([`Field::run`]); a toggle, its own sliding field
+    /// (`Toggle::field`). So every flush control in the pane has the same
+    /// edge. The plain wells are not here — they are [`Self::reliefs`], which
+    /// group into the pane's plate. They replaced
     /// `troughs()` on 2026-10-01. 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, f32)> {
-        use crate::scene::paint::FIELD_RUN_ONLY;
+    pub fn fields(&self) -> Vec<Field> {
         if !self.visible || !crate::layout::control_relief() {
             return Vec::new();
         }
@@ -1845,7 +1844,8 @@ impl ParametersBg {
                     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, dx + dw + FIELD_RUN_ONLY));
+                        let outline = Rect { x, y: y + ty, width: dx + dw - x, height: h - ty };
+                        out.push(Field::ending_in_run(outline, (r, r, r, r), depth, dx));
                     }
                 }
             } else if p.2 == "button" {
@@ -1858,7 +1858,7 @@ impl ParametersBg {
                     if w > 0.0 && h - ty > 0.0 {
                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
                         let r = crate::layout::button_corner_radius();
-                        out.push((x, y + ty, w, h - ty, (r, r, r, r), depth, x - FIELD_RUN_ONLY, x + w + FIELD_RUN_ONLY));
+                        out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
                     }
                 }
             } else if p.2.starts_with("choice") {
@@ -1873,7 +1873,7 @@ impl ParametersBg {
                     if w > 0.0 && h - ty > 0.0 {
                         let depth = crate::layout::bevel_width().min((h - ty) * 0.2);
                         let r = crate::layout::dropdown_corner_radius();
-                        out.push((x, y + ty, w, h - ty, (r, r, r, r), depth, x - FIELD_RUN_ONLY, x + w + FIELD_RUN_ONLY));
+                        out.push(Field::run(Rect { x, y: y + ty, width: w, height: h - ty }, (r, r, r, r), depth));
                     }
                 }
             } else if p.2.starts_with("spinbox") {
@@ -1884,31 +1884,21 @@ impl ParametersBg {
                     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,
-                                band.x + band.width + FIELD_RUN_ONLY,
-                            ));
+                            out.push(Field::ending_in_run(band, (r, r, r, r), rel.depth, split));
                         }
                     }
                 }
             } else if p.2 == "toggle" || p.2 == "checkbox" {
-                // The toggle's own field (`Toggle::field`, `Toggle::run`) —
+                // The toggle's own field (`Toggle::field`) —
                 // exactly what its paint draws, on its band. It paints no
                 // fill, so this IS the control.
                 if let Some(t) = &self.toggles[i] {
                     let (x, y, w, h) = t.rect();
                     let ty = t.label_strip();
                     if w > 0.0 && h - ty > 0.0 {
+                        // The pane's hover is the tint here, as on every field.
                         let band = Rect { x, y: y + ty, width: w, height: h - ty };
-                        let (f, radii, depth) = t.inner().field(band);
-                        let (a, b) = t.inner().run(band);
-                        out.push((f.x, f.y, f.width, f.height, radii, depth, a, b));
+                        out.push(t.inner().field(band).with_tint(None));
                     }
                 }
             }
@@ -2123,10 +2113,10 @@ impl Paint for ParametersBg {
                 (false, false) => ctx.recess_edges(rect, radii, rd, edges),
             }
         }
-        for (fx, fy, fw, fh, radii, fd, split, end) 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_run(rect, radii, fd, (split, end), tint);
+        for field in self.fields() {
+            let r = field.rect;
+            let tint = hovered(r.x, r.y, r.width, r.height).then_some(Self::HOVER_TINT);
+            ctx.field(&field.with_tint(tint));
         }
         for (ga, gb, gw, gd, ghost) in self.grooves() {
             ctx.groove(ga, gb, gw, gd, ghost);
@@ -3920,10 +3910,10 @@ mod tests {
         let rr = crate::layout::textbox_corner_radius();
         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");
+        let f = fields[0].rect;
+        assert_eq!((f.x, f.y, f.x + f.width, f.y + f.height), (tx, dy, dx + dw, dy + dh), "the field spans box and picker");
+        assert_eq!(fields[0].radii, (rr, rr, rr, rr), "its own radius at all four corners");
+        assert_eq!(fields[0].run_span(), Some((dx, dx + dw)), "the run is the picker, to the field's end");
         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.
@@ -3949,7 +3939,7 @@ mod tests {
     /// spinbox. Never a trough, whose outer half differed.
     #[test]
     fn a_dropdown_trigger_wears_the_runs_edge() {
-        use crate::scene::paint::{PaintCtx, Prim, FIELD_RUN_ONLY};
+        use crate::scene::paint::{PaintCtx, Prim};
         if !crate::layout::control_relief() {
             return;
         }
@@ -3959,9 +3949,8 @@ mod tests {
         let ty = d.label_strip();
         let fields = p.inner().fields();
         assert_eq!(fields.len(), 1, "{fields:?}");
-        let (fx, fy, fw, fh, _, _, split, _) = fields[0];
-        assert_eq!((fx, fy, fw, fh), (x, y + ty, w, h - ty), "on the trigger's band");
-        assert_eq!(split, x - FIELD_RUN_ONLY, "all run, no well");
+        assert_eq!(fields[0].rect, Rect { x, y: y + ty, width: w, height: h - ty }, "on the trigger's band");
+        assert!(!fields[0].has_well(), "all run, no well");
         let mut pc = PaintCtx::new();
         Paint::paint(d.inner(), Rect { x, y: y + ty, width: w, height: h - ty }, &mut pc);
         let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 20cca87..e96ca19 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -11,7 +11,7 @@
 
 use crate::colors;
 use crate::scene::layout::Rect;
-use crate::scene::paint::PaintCtx;
+use crate::scene::paint::{Field, PaintCtx};
 use crate::widget::{
     Adapted, ElementState, Event, EventCtx, Input, Justification, Layout, MouseButton, Paint,
 };
@@ -317,33 +317,27 @@ impl Toggle {
         self.toggled
     }
 
-    /// The field's outline: the toggle's whole footprint, carved one step
-    /// down. Taken through [`crate::layout::carve_inside`], so the walls stay
-    /// inside the rect and the gap beside a toggle is the gap, exactly as a
-    /// TextBox's well and a spinbox's field are taken. `(rect, per-corner
-    /// radii, wall width)`.
+    /// The toggle as the [`Field`] it is, in its sliding form: the whole
+    /// footprint carved one step down — taken through
+    /// [`crate::layout::carve_inside`], so the walls stay inside the rect and
+    /// the gap beside a toggle is the gap, exactly as a TextBox's well and a
+    /// spinbox's field are taken — holding a run half its width, placed by
+    /// the animated `slide_t`: flush with the left end off, the right end on,
+    /// a well either side between. Lit while focused.
     ///
-    /// The SINGLE source for the toggle's geometry: `paint` draws it here,
-    /// [`Toggle::run`] places the run in it, and a host that draws the relief
-    /// itself (`ParametersBg::fields`) asks for both.
-    pub fn field(&self, rect: Rect) -> (Rect, crate::scene::paint::Radii, f32) {
+    /// As a text row's picker is, the run is laid out a wall wider than the
+    /// face it carries (the field insets the face half a wall from the
+    /// outline and from the seam), so the face reaches the well.
+    ///
+    /// The SINGLE source for the toggle's geometry: `paint` draws it, the
+    /// relief-off paint lights its [`Toggle::face`], and a host that draws
+    /// the relief itself (`ParametersBg::fields`) takes it whole.
+    pub fn field(&self, rect: Rect) -> Field {
         let r = crate::layout::toggle_corner_radius();
         let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-        let (field, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
-        (field, radii, depth)
-    }
-
-    /// The flush run, as the xs it spans in [`Toggle::field`]: half the field
-    /// wide, placed by the animated `slide_t` — flush with the field's left
-    /// end off, its right end on, a well on both sides between. As a text
-    /// row's picker is, the run is laid out a wall wider than the face it
-    /// carries (the field insets the face half a wall from the outline and
-    /// from the seam), so the face reaches the well.
-    pub fn run(&self, rect: Rect) -> (f32, f32) {
-        let (field, _, _) = self.field(rect);
-        let w = field.width * 0.5;
-        let x = field.x + self.slide_t * (field.width - w);
-        (x, x + w)
+        let (outline, radii) = crate::layout::carve_inside(rect, (r, r, r, r), depth);
+        let focus = self.focused.then(crate::scene::paint::ControlPlate::focus_tint);
+        Field::sliding_run(outline, radii, depth, outline.width * 0.5, self.slide_t).with_tint(focus)
     }
 
     /// The run's FACE — what stands at the surface's level inside the
@@ -351,10 +345,10 @@ impl Toggle {
     /// the field's less that. `(rect, corner radius)`. The relief-off paint
     /// lights this rect, where the field cannot be carved.
     pub fn face(&self, rect: Rect) -> (Rect, f32) {
-        let (field, radii, depth) = self.field(rect);
-        let (a, b) = self.run(rect);
-        let run = Rect { x: a, y: field.y, width: b - a, height: field.height };
-        inset(run, radii.0, depth * 0.5)
+        let f = self.field(rect);
+        let (a, b) = f.run_span().unwrap_or((f.rect.x, f.rect.x));
+        let run = Rect { x: a, y: f.rect.y, width: b - a, height: f.rect.height };
+        inset(run, f.radii.0, f.depth * 0.5)
     }
 }
 
@@ -413,7 +407,6 @@ impl Paint for Toggle {
     }
 
     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
-        use crate::scene::paint::ControlPlate;
         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
 
         // A toggle paints NO fill of its own: it is worked out of the plate it
@@ -423,16 +416,15 @@ impl Paint for Toggle {
         // convention (closed dropdowns, inset troughs). The state colors this
         // used to tint with (enabled/disabled/background) are retired with the
         // rest of the toggle's palette.
-        let (field, field_radii, depth) = self.field(rect);
+        let field = self.field(rect);
         if self.raised() {
             // ONE field — the well and the flush run in it under one outline,
             // as a text row's picker and a spinbox's -/+ run are drawn. The
             // run's position IS the read: left off, right on, animated in
             // `tick`. A host that draws the relief itself
-            // (`ParametersBg::fields`) asks for the same `field` and `run`.
-            // Focus lights the field's rim, the ring every field wears.
-            let focus = self.focused.then(ControlPlate::focus_tint);
-            ctx.field_run(field, field_radii, depth, self.run(rect), focus);
+            // (`ParametersBg::fields`) takes the same `field`. Focus lights
+            // the field's rim, the ring every field wears.
+            ctx.field(&field);
         } else {
             // Relief off: a well is its frame — the one hairline every well
             // falls back to, lit while focused, exactly `well_rim`'s flat arm —
@@ -444,7 +436,7 @@ impl Paint for Toggle {
             // reverse bridge reads only those, never a `Border`, so a
             // legacy-view host still shows which end the run is at.
             let bw = crate::layout::toggle_border_width().max(1.0);
-            ctx.border(field, field_radii, [0.0; 4], colors::well_frame_color(self.hovered, self.focused), bw);
+            ctx.border(field.rect, field.radii, [0.0; 4], colors::well_frame_color(self.hovered, self.focused), bw);
             let (face, face_r) = self.face(rect);
             let lit = (0.16 * (crate::layout::bevel_depth() / 0.15)).clamp(0.0, 0.5);
             ctx.rounded_rect(face, face_r, (true, true, true, true), [1.0, 1.0, 1.0, lit]);
@@ -658,7 +650,7 @@ mod tests {
             pc.finish().items.into_iter().map(|i| format!("{:?}", i.prim)).collect::<Vec<_>>()
         };
         let before = painted(&t);
-        let plate_x = |t: &Adapted<Toggle>| t.inner().run(rect).0;
+        let plate_x = |t: &Adapted<Toggle>| t.inner().field(rect).run_span().unwrap().0;
         let left = plate_x(&t);
 
         assert!(ctx.propagate_event(&click_at(30.0, 15.0), id), "toggle consumed the click");
@@ -688,7 +680,7 @@ mod tests {
         use crate::scene::paint::{Prim, FIELD_RUN_ONLY};
         let rect = Rect { x: 10.0, y: 4.0, width: 120.0, height: 24.0 };
         let mut t = Toggle::new().with_raised(true);
-        let (field, radii, depth) = t.inner().field(rect);
+        let Field { rect: field, radii, depth, .. } = t.inner().field(rect);
         assert!(field.x >= rect.x && field.y >= rect.y, "the field carves inside the rect");
         let (fl, fr) = (field.x, field.x + field.width);
 
@@ -701,20 +693,21 @@ mod tests {
         assert_eq!(off.len(), 1, "one prim, the field: {off:?}");
         let Prim::Field { rect: r, radii: rr, depth: d, split, end, tint } = off[0] else { panic!("{off:?}") };
         assert_eq!((r, rr, d, tint), (field, radii, depth, None));
-        assert_eq!(split, fl, "off: the run reaches the field's left end — no well there");
+        assert!(split <= fl - FIELD_RUN_ONLY + 1.0, "off: the run reaches the field's left end — no well there");
         assert!((end - (fl + field.width * 0.5)).abs() < 1e-4, "half the field, the well beyond it");
 
         t.set_toggled(true); // programmatic syncs snap, so this is the on-end geometry
         let Prim::Field { split, end, .. } = fields(&t)[0] else { panic!() };
-        assert!((split - (fl + field.width * 0.5)).abs() < 1e-4 && (end - fr).abs() < 1e-4, "on: the right half");
+        assert!((split - (fl + field.width * 0.5)).abs() < 1e-4, "on: the right half");
+        assert!(end >= fr + FIELD_RUN_ONLY - 1.0, "reaching the field's right end");
 
         t.slide_t = 0.5;
         let Prim::Field { split, end, .. } = fields(&t)[0] else { panic!() };
         assert!(split > fl + 1.0 && end < fr - 1.0, "mid-glide, a well either side of the run");
-        assert!(end < fr + FIELD_RUN_ONLY, "and the run's end is its own, not the field's");
+        assert!(end < fr, "and the run's end is its own, not the field's");
 
         let (face, face_r) = t.inner().face(rect);
-        let (a, b) = t.inner().run(rect);
+        let (a, b) = t.inner().field(rect).run_span().unwrap();
         assert!((face.x - (a + depth * 0.5)).abs() < 1e-4 && (face.x + face.width - (b - depth * 0.5)).abs() < 1e-4);
         assert!((face_r - (radii.0 - depth * 0.5).max(0.0)).abs() < 1e-4, "concentric with the field");
 
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 0f6eb3f..77ced27 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -402,12 +402,15 @@ 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]);
                 }
+                // A field in its two forms: the value's well ending in the
+                // -/+ run, or (no room for the buttons) all well.
+                use crate::scene::paint::Field;
                 match rel.run {
                     Some((split, (sa, sb, sw, host))) => {
-                        ctx.field(field, radii, rel.depth, split, None);
+                        ctx.field(&Field::ending_in_run(field, radii, rel.depth, split));
                         ctx.groove(sa, sb, sw, rel.depth, host);
                     }
-                    None => ctx.recess(field, radii, rel.depth),
+                    None => ctx.field(&Field::well(field, radii, rel.depth)),
                 }
             }
         } else if rounded {
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 1513e57..485f71a 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1204,17 +1204,20 @@ impl TextBox {
         labels
     }
 
-    /// The well this TextBox carves, as `(rect, corner radii, depth, focus
-    /// tint)` — radii top-left, top-right, bottom-right, bottom-left, the
-    /// right two square when the box is [`Self::joined_right`] — `None` when it draws no relief at all (square-cornered legacy
-    /// geometry, `control_relief` off, or a box that draws no background).
+    /// The well this TextBox carves: a [`crate::scene::paint::Field`] that is
+    /// all well, lit while editing — its radii top-left, top-right,
+    /// bottom-right, bottom-left, the right two square when the box is
+    /// [`Self::joined_right`] (its host draws the field that joins it to a
+    /// run, `ParametersBg::fields`). `None` when it draws no relief at all
+    /// (square-cornered legacy geometry, `control_relief` off, or a box that
+    /// draws no background).
     ///
     /// The SINGLE source for that geometry: `paint` carves it here, and the
     /// flat-path bridge in `layout::render_widget` re-offers the same rect
     /// through [`crate::layout::RenderTarget::recess`] for hosts that consume
     /// `all_quads` and so never see the carve. A second copy of this math in
     /// the bridge is exactly how the two would drift apart.
-    pub fn well(&self) -> Option<(Rect, (f32, f32, f32, f32), f32, Option<[f32; 3]>)> {
+    pub fn well(&self) -> Option<crate::scene::paint::Field> {
         let radius = crate::layout::textbox_corner_radius();
         if radius <= 0.0 || !self.recessed() || !self.draw_bg_border {
             return None;
@@ -1233,7 +1236,7 @@ impl TextBox {
             let hc = crate::color::highlight_primary_color();
             [hc[0], hc[1], hc[2]]
         });
-        Some((well, radii, depth, tint))
+        Some(crate::scene::paint::Field::well(well, radii, depth).with_tint(tint))
     }
 }
 
@@ -1565,13 +1568,10 @@ impl Paint for TextBox {
                         bg_color,
                     );
                 }
-                if let Some((well, radii, depth, tint)) = self.well() {
+                if let Some(field) = 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),
-                    }
+                    ctx.field(&field);
                 }
             }