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

commit14f32a24005785fe2c793367929765d290a7d401
parente92ee16afa
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 20:49
feat: a text row's picker is the field's right end

The completion picker beside a text box was a button nested inside the
box's well. It is a flush plate reaching the field's outer edge now, as a
dropdown trigger's does, with the text box square at the seam beside it.

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

 CLAUDE.md                             | 12 ++++++
 src/widget/container/parameters_bg.rs | 76 ++++++++++++++++++++++-------------
 src/widget/input/dropdown.rs          | 15 +++++++
 src/widget/input/text_box.rs          | 20 +++++----
 4 files changed, 87 insertions(+), 36 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index b6e5eda..da4b7b5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -317,6 +317,18 @@ 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 row can open a submenu (since 2026-09-29)
 
 `context_menu::set_row_submenu(idx, SubmenuSpec { options, header_count, sliders })`,
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index dfeca01..a7ce5bb 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -50,8 +50,9 @@ fn is_text_row(t: &str) -> bool {
     t == "text" || t.starts_with("textpick")
 }
 
-/// Width of a textpick row's picker button, nested inside the right end of
-/// the TextBox's recessed well.
+/// Width of a textpick row's picker button: the right end of the field,
+/// a flush control plate reaching the field's outer edge as a dropdown
+/// trigger's does, the text box's well ending at the seam beside it.
 const PICK_W: f32 = 24.0;
 
 pub struct ParametersBg {
@@ -1074,21 +1075,21 @@ impl ParametersBg {
             if let Some(d) = d_opt {
                 let r = rects[i];
                 if self.display_params[i].2.starts_with("textpick") {
-                    // The picker button nests INSIDE the text box's recessed
-                    // well (the box spans the full row): below the detached
-                    // label band, its face reaching exactly to the base of
-                    // the well's wall (inset = the carve depth) on the sides
-                    // it adjoins — there the WELL'S OWN BEVEL is the seam's
-                    // far side, and the trough ([`Self::troughs`]) carves
-                    // only the interior left edge. A ring encapsulated
-                    // within the bevel doubled the valley on the adjoining
-                    // sides.
+                    // The picker is the field's RIGHT END: a flush control
+                    // plate over the whole band below the detached label,
+                    // out to the field's outer edge on the top, right and
+                    // bottom as a dropdown trigger's plate is, square where
+                    // it meets the text box (whose well stops at the seam —
+                    // `TextBox::joined_right`) and the box's radius on the
+                    // outside, so the two read as one field. Until
+                    // 2026-10-01 it 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.
                     let label_top = if inline[i] { 0.0 } else { crate::layout::control_label_strip() };
                     let band_h = r.3 - label_top;
-                    let inset = crate::layout::bevel_width().min(band_h * 0.2);
-                    let by = r.1 + label_top + inset;
-                    let bh = (band_h - 2.0 * inset).max(8.0);
-                    d.set_rect(r.0 + r.2 - PICK_W - inset, by, PICK_W, bh);
+                    let rr = crate::layout::textbox_corner_radius();
+                    d.set_rect(r.0 + r.2 - PICK_W, r.1 + label_top, PICK_W, band_h);
+                    d.inner_mut().set_radii(Some((0.0, rr, rr, 0.0)));
                     // 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 {
@@ -1105,7 +1106,11 @@ impl ParametersBg {
         for (i, tb_opt) in self.texts.iter_mut().enumerate() {
             if let Some(tb) = tb_opt {
                 let r = rects[i];
-                tb.set_rect(r.0, r.1, r.2, r.3);
+                // A textpick row's box ends where its picker begins.
+                let joined = self.display_params[i].2.starts_with("textpick") && self.choices[i].is_some();
+                tb.inner_mut().joined_right = joined;
+                let w = if joined { (r.2 - PICK_W).max(0.0) } else { r.2 };
+                tb.set_rect(r.0, r.1, w, r.3);
             }
         }
         for (i, cb_opt) in self.toggles.iter_mut().enumerate() {
@@ -1793,7 +1798,11 @@ 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);
-                out.push((x, y + ty, ww, h - ty, r4(radius), depth, raised, all));
+                // 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));
             }
         }
         out
@@ -1826,15 +1835,14 @@ impl ParametersBg {
                 continue;
             }
             if p.2.starts_with("textpick") {
-                if let (Some(d), Some(tb)) = (&self.choices[i], &self.texts[i]) {
+                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();
-                    let (_, _, _, th) = tb.rect();
-                    let ty = tb.label_strip();
                     if bw > 0.0 && bh > 0.0 {
-                        let depth = crate::layout::bevel_width().min((th - ty) * 0.2);
-                        let r = (crate::layout::textbox_corner_radius() - depth).max(2.0);
-                        // Left edge only: top, right and bottom adjoin the well.
-                        out.push((bx, by, bw, bh, (r, r, r, r), depth, (false, false, false, true)));
+                        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") {
@@ -3840,13 +3848,23 @@ mod tests {
         assert!(p.choices[1].is_none(), "plain text has no picker");
         assert!(p.texts[2].is_some() && p.choices[2].is_none(), "no options, no picker");
 
-        // Layout: the box spans the full row; the picker button nests
-        // inside it (within the box's right end).
+        // Layout: the picker is the field's right end — the box stops at
+        // the seam, square there, and the picker reaches the field's outer
+        // 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);
-        assert!(dx > tx && dx + dw < tx + tw, "button inside the box horizontally");
-        assert!(dy > ty && dy + dh <= ty + th, "button inside the box vertically");
+        assert_eq!(dx, tx + tw, "the picker begins where the box ends");
+        let label = p.texts[0].as_ref().unwrap().label_strip();
+        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);
+        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");
 
         // Both text variants lay out at the same row height.
         assert_eq!(p.inner().row_height(0), p.inner().row_height(1));
@@ -3855,7 +3873,7 @@ mod tests {
         // the box width and hangs below it, not off the button sliver.
         let anchor = p.choices[0].as_ref().unwrap().popover_anchor.expect("anchor set");
         assert_eq!(anchor.x, tx);
-        assert_eq!(anchor.width, tw);
+        assert_eq!(anchor.width, tw + dw);
         let d = p.choices[0].as_ref().unwrap();
         let (px_, py_, pw, _ph) = d.popover_geom(crate::scene::layout::Rect {
             x: dx, y: dy, width: dw, height: dh,
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 717854b..5f98926 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -165,6 +165,11 @@ pub struct Dropdown {
     /// root plate container ancestor — the hook that keeps the adjustment after an app dissolves its
     /// root plate container (the walk finds nothing once the widget is parentless).
     corner_frame: Option<((f32, f32, f32, f32), f32, (bool, bool, bool, bool))>,
+    /// The raised trigger plate's corner radii, top-left clockwise, in place
+    /// of the configured radius on all four — a trigger that is one end of
+    /// a wider field (a parameter pane's completion picker, square where it
+    /// meets the text box, the field's own radius on the outside).
+    radii: Option<(f32, f32, f32, f32)>,
     /// Raised style: the closed control's background is an SDF-lit `Bevel`
     /// plate (fill + rolled lit edge) instead of a flat fill + border stroke.
     raised: Option<bool>,
@@ -233,6 +238,7 @@ impl Dropdown {
             label: None,
             hovered: false,
             corner_frame: None,
+            radii: None,
             raised: None,
             flat: false,
             face: None,
@@ -255,6 +261,12 @@ impl Dropdown {
     }
 
     /// Set (or clear) the app-owned concentric frame — see the `corner_frame` field docs.
+    /// [`Self::radii`]: the raised trigger plate's corners, or `None` for
+    /// the configured radius.
+    pub fn set_radii(&mut self, radii: Option<(f32, f32, f32, f32)>) {
+        self.radii = radii;
+    }
+
     pub fn set_corner_frame(&mut self, frame: Option<((f32, f32, f32, f32), f32, (bool, bool, bool, bool))>) {
         self.corner_frame = frame;
     }
@@ -623,6 +635,9 @@ impl Dropdown {
                     r4[3] = (pr - g_left).max(0.0) * cf;
                 }
             }
+            if let Some((a, b, c, d)) = self.radii {
+                r4 = [a, b, c, d];
+            }
             // The trigger is a flush control plate (groove ring down, beveled
             // lip back up, face level with the surface; a transparent raw
             // fill = edges only), its footprint the trigger's rect and its
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index a18e8d9..1513e57 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -58,6 +58,10 @@ pub struct TextBox {
     /// when the user's config turns multiline wrap off DE-wide.
     pub line_wrap_override: Option<bool>,
     pub draw_bg_border: bool,
+    /// The box's right end meets another control (a parameter pane's
+    /// completion picker): its well's right corners are square, so the two
+    /// read as one field with a seam between them.
+    pub joined_right: bool,
     pub text_color: Option<[u8; 3]>,
     pub font_size: f32,
     pub font_family: String,
@@ -137,6 +141,7 @@ impl TextBox {
             multiline: false,
             line_wrap_override: None,
             draw_bg_border: true,
+            joined_right: false,
             text_color: None,
             font_size: style_size,
             font_family: style_family.clone(),
@@ -1199,8 +1204,9 @@ impl TextBox {
         labels
     }
 
-    /// The well this TextBox carves, as `(rect, corner radius, depth, focus
-    /// tint)` — `None` when it draws no relief at all (square-cornered legacy
+    /// 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 SINGLE source for that geometry: `paint` carves it here, and the
@@ -1208,7 +1214,7 @@ impl TextBox {
     /// 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, Option<[f32; 3]>)> {
+    pub fn well(&self) -> Option<(Rect, (f32, f32, f32, f32), f32, Option<[f32; 3]>)> {
         let radius = crate::layout::textbox_corner_radius();
         if radius <= 0.0 || !self.recessed() || !self.draw_bg_border {
             return None;
@@ -1221,12 +1227,13 @@ impl TextBox {
             height: self.rect.height - top,
         };
         let depth = crate::layout::bevel_width().min(well.height * 0.2);
-        let (well, radii) = crate::layout::carve_inside(well, (radius, radius, radius, radius), depth);
+        let right = if self.joined_right { 0.0 } else { radius };
+        let (well, radii) = crate::layout::carve_inside(well, (radius, right, right, radius), depth);
         let tint = self.editing.then(|| {
             let hc = crate::color::highlight_primary_color();
             [hc[0], hc[1], hc[2]]
         });
-        Some((well, radii.0, depth, tint))
+        Some((well, radii, depth, tint))
     }
 }
 
@@ -1558,10 +1565,9 @@ impl Paint for TextBox {
                         bg_color,
                     );
                 }
-                if let Some((well, r, depth, tint)) = self.well() {
+                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.
-                    let radii = (r, r, r, r);
                     match tint {
                         Some(t) => ctx.recess_tinted(well, radii, depth, t),
                         None => ctx.recess(well, radii, depth),