GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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),