GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: every dropdown's arrow lines up with a textpick picker's
A trigger centres its arrow in an arrow slot at its right end, which is
exactly a textpick picker's width (ARROW_SLOT and a relief wall), so the
picker's centred arrow and every other trigger's stand in one column. It
stood 18 px in from the right end, two pixels off the picker's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 5 ++++-
src/widget/container/parameters_bg.rs | 30 +++++++++++++++++++------
src/widget/input/dropdown.rs | 41 ++++++++++++++++++++++++++---------
3 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 5cd3d70..8f0b9df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -348,7 +348,10 @@ down to the floor, meeting the face's lip where both stand at half the step; it
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
+(`Dropdown::center_arrow`) — which is a trigger's ARROW SLOT (`dropdown::arrow_slot`),
+the right end every dropdown centres its ▼ in, so the picker's arrow and every other
+trigger's stand in one column (since the same day; they stood 18 px in from the right
+end, two pixels off the picker's; `a_pickers_arrow_lines_up_with_a_dropdowns`) — 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
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 85ff490..36cb9ef 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -53,7 +53,7 @@ fn is_text_row(t: &str) -> bool {
/// 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;
+const PICK_W: f32 = crate::widget::input::dropdown::ARROW_SLOT;
pub struct ParametersBg {
/// A row VALUE changed inside `tick` (a picker stream folded into its
@@ -1088,12 +1088,12 @@ 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();
- // 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);
+ // The picker is a trigger's arrow slot: PICK_W and a
+ // wall, the wall being the lip its face rises out of at
+ // the seam. Its arrow in its middle is then where every
+ // other trigger's arrow is, in its slot.
+ let pick_w = crate::widget::input::dropdown::arrow_slot(band_h);
+ 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)));
d.inner_mut().center_arrow = true;
// The menu hangs off the WHOLE field, not the button
@@ -3859,6 +3859,22 @@ mod tests {
/// The textpick text-row variant: a TextBox AND a menu-button Dropdown
/// share the row — the picker takes a right-edge sliver, its options come
/// from the type string, and a plain text row builds no picker.
+ /// A textpick row's picker centres its arrow, and every other dropdown
+ /// row's arrow stands in the same column: the picker is a trigger's
+ /// arrow slot (`dropdown::arrow_slot`).
+ #[test]
+ fn a_pickers_arrow_lines_up_with_a_dropdowns() {
+ let p = panel_with(&[
+ ("Name", "mass", "textpick:Norm,UV,Pos,Col"),
+ ("Type", "Float", "choice:Float,Float3,Int"),
+ ]);
+ let arrow = |i: usize| {
+ let d = p.choices[i].as_ref().unwrap();
+ d.own_text_labels().into_iter().find(|l| l.text == "▼").expect("an arrow").x
+ };
+ assert!((arrow(0) - arrow(1)).abs() < 1e-3, "picker {} vs dropdown {}", arrow(0), arrow(1));
+ }
+
#[test]
fn textpick_rows_carry_a_picker() {
let p = panel_with(&[
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index d6dbe19..76dda88 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -50,6 +50,18 @@ fn monospace_cell_width(font_family: &str, font_size: f32) -> f32 {
((w_m20 - w_m10) / 10.0).max(1.0)
}
+/// Width of the ARROW SLOT, a trigger's right end in which its ▼ is
+/// centred: `ARROW_SLOT` and a relief wall at this band height. A textpick
+/// picker (`ParametersBg`) is exactly this wide with its arrow in its middle,
+/// so its arrow lines up with every other trigger's in a column of rows.
+/// The wall is the lip a flush run's face rises out of at a field's seam.
+pub fn arrow_slot(band_h: f32) -> f32 {
+ ARROW_SLOT + crate::layout::bevel_width().min(band_h * 0.2)
+}
+
+/// The arrow slot less its wall — a picker's face.
+pub const ARROW_SLOT: f32 = 24.0;
+
/// The advance width `paint_text` will actually lay `text` out to.
///
/// This is the single source of truth shared by the sizing pass (`content_width` /
@@ -149,9 +161,9 @@ 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.
+ /// The ▼ stands in the MIDDLE of the trigger instead of in the arrow
+ /// slot 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>,
@@ -727,6 +739,17 @@ impl Dropdown {
}
}
+ /// Where the ▼ is drawn on a trigger band: centred in the arrow slot
+ /// at its right end ([`arrow_slot`]), or in the whole band under
+ /// `center_arrow`. Measured at the size the glyph is drawn at — the
+ /// configured font's, which the runner reads off the font string.
+ /// Until 2026-10-02 it stood 18 px in from the right end, and the
+ /// picker's centred arrow stood two pixels left of every other.
+ fn arrow_x(&self, band: Rect, font_family: &str, font_size: f32) -> f32 {
+ let slot = if self.center_arrow { band.width } else { arrow_slot(band.height).min(band.width) };
+ band.x + band.width - 0.5 * (slot + text_advance("▼", font_family, font_size))
+ }
+
/// Emit the selected-text (per-character fade against the right edge) and the ▼ arrow —
/// the legacy `text_labels` body minus the control label (the adapter's base-label
/// machinery draws that, with the +4px `detached_label_inset`).
@@ -817,11 +840,7 @@ impl Dropdown {
// 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
- };
+ let arrow_x = self.arrow_x(content, &font_family, font_size);
ctx.text_with(
"▼",
arrow_x,
@@ -1118,9 +1137,10 @@ impl Paint for Dropdown {
&font,
band_bounds,
);
+ let (family, size) = crate::layout::control_label_font_detached_parsed();
pc.text_with_font_and_bounds(
"▼",
- tx + tw - 18.0,
+ self.arrow_x(rect, &family, size),
crate::layout::center_text_y(ty, th, 10.0),
10.0,
[0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0],
@@ -1668,7 +1688,8 @@ mod tests {
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");
+ let slot = super::arrow_slot(24.0);
+ assert!((arrow_x(false) + 0.5 * adv - (40.0 - 0.5 * slot)).abs() < 1e-3, "an ordinary trigger centres it in the slot at its right end");
}
#[test]