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

commite8f1199bb5826cf4d1c9425f0c5a193440a84955
parent5b4e97cf6f
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 11:35
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]