git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit1073ea13f0531e60a1ca494776d09b5c795eb10e
parentd34d422252
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 13:02
feat: a dialog's choice rows are dropdowns

A choice row (World Unit, GPU, Node Wire Style, the visualizer settings)
shows its value in a well with a chevron-down, and a press or Enter opens
the toolkit's context menu under it as a dropdown: the options, the current
one marked and highlighted. Up/Down walk it, Enter picks, Escape or a press
off it closes it alone; the dialog stays up. Left/Right still step a choice
in place. Picks land through one land_dialog_choice the arrows share.

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

 CLAUDE.md     |  32 +++++---
 src/app.rs    |  14 +++-
 src/dialog.rs | 241 +++++++++++++++++++++++++++++++++++++++++++++++-----------
 src/main.rs   |  88 ++++++++++++++++-----
 4 files changed, 301 insertions(+), 74 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 01db31b..7a17205 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3361,17 +3361,31 @@ selection stays where it was:
   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
   range the pitch limits), landing through `set_zoom_percent`, which zooms
   about the cursor cell and re-reads the row, since `zoom` clamps.
-- `Choice` — a fixed set (World Unit). The current option reads in the
-  chord column between two arrows; Enter or a click steps to the next,
-  Left/Right either way. The arrows are cce-icons' `chevron-left` /
-  `chevron-right` (`choice_arrows`), in square boxes 0.8 of the row's font
-  size — the triangle then stands about a capital high — rasterized at
-  twice that. Until 2026-09-30 they were the text triangles `◂` `▸`, which
-  the dialog's font has no glyph for, so they drew as boxes. The right
-  column (values and chords alike) is measured by SHAPING
+- `Choice` — a fixed set (World Unit, GPU, Node Wire Style, the
+  visualizer settings): a DROPDOWN (since 2026-10-01). The current option
+  reads in the chord column in a well with cce-icons' `chevron-down` after
+  it (`choice_mark`, a square box 0.8 of the row's font size, rasterized
+  at twice that), and the well lifts under the pointer — `choice_span` is
+  the one rect the paint, the hover and the dropdown share. A press on the
+  row, or Enter, opens the toolkit's context menu as its dropdown
+  (`State::open_dialog_dropdown`): one row an option, the current one
+  marked `●` and highlighted, right-aligned under the well. It is a popup
+  ABOVE the dialog, so it runs past the plate's edge, and it is in front
+  of the dialog for input — `dialog_dropdown_press` ahead of
+  `dialog_mouse_input` (a press off it closes it alone and is swallowed),
+  `dialog_dropdown_wheel` ahead of the dialog's wheel, and
+  `dialog_dropdown_key` ahead of the dialog's keys: Up/Down walk it
+  (cce-ui's `context_menu::step_hovered`), Enter picks, Escape and Tab
+  close it. A pick lands through `land_dialog_choice`, the one write the
+  arrow keys use too — Left/Right still step a choice without opening it.
+  `State::dialog_dropdown` is the open row's id and its options' values.
+  Until then a click or Enter stepped to the next option and the value
+  stood between a left and a right chevron; before 2026-09-30 those were
+  the text triangles `◂` `▸`, which the dialog's font has no glyph for.
+  The right column (values and chords alike) is measured by SHAPING
   (`shaped_width`, as `Button::label_width` does): `measure_text_width`
   resolves the family through usvg and measured a value 8 px wider than it
-  drew, which left the right arrow twice as far from it as the left.
+  drew. `a_choice_row_is_a_dropdown` drives it by pointer and keys.
 - `Color` — a hex colour. Behind each colour row the
   dialog keeps one toolkit `ColorSelector` (`Dialog::colors`, by row id,
   kept across re-rankings so a query that drops the row does not kill its
diff --git a/src/app.rs b/src/app.rs
index 52dcad4..0637a21 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2739,6 +2739,9 @@ pub struct State {
     pub visualizers: Vec<crate::visualizer::Visualizer>,
     /// The visualizer the dialog's VisualizerEdit page is editing.
     pub vis_editing: Option<usize>,
+    /// The dialog row whose dropdown is open, and its options' values in
+    /// the order the menu lists them (`State::open_dialog_dropdown`).
+    pub dialog_dropdown: Option<(String, Vec<String>)>,
     /// The displayed scene's point attributes as last built, with their
     /// ranges: what the visualizer editor offers.
     pub scene_attributes: Vec<crate::visualizer::SceneAttribute>,
@@ -7699,6 +7702,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
             visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
             vis_editing: None,
+            dialog_dropdown: None,
             scene_attributes: Vec::new(),
             scene_base: None,
             scene_groups: Vec::new(),
@@ -9732,6 +9736,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // The dialog is modal: a wheel over it scrolls it, and a wheel
                 // anywhere else does nothing rather than scrolling — and
                 // focusing — the pane it is covering.
+                if self.dialog_dropdown_open() && self.dialog_dropdown_wheel(delta) {
+                    return true;
+                }
                 if self.dialog_visible() {
                     return self.dialog_mouse_wheel(*delta);
                 }
@@ -9944,7 +9951,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
 
                 // Track hover on the node/viewport/network context menus so
                 // the highlight follows.
-                if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open() || self.playbar_menu_open())
+                if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open() || self.playbar_menu_open() || self.dialog_dropdown_open())
                     && cce_ui::widget::context_menu::cursor_moved(self.cursor_x, self.cursor_y)
                 {
                     changed = true;
@@ -10156,6 +10163,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                 // convention every other floating surface in this app follows
                 // (the node menu, the plate corner menu) — and is swallowed
                 // rather than also acting on the pane it landed on.
+                // A choice row's dropdown is in front of the dialog: it takes
+                // the press first, and a press off it closes it alone.
+                if *btn_state == ElementState::Pressed && self.dialog_dropdown_open() {
+                    return self.dialog_dropdown_press(*button);
+                }
                 if self.dialog_visible() {
                     if let Some(handled) = self.dialog_mouse_input(*button, *btn_state) {
                         return handled;
diff --git a/src/dialog.rs b/src/dialog.rs
index e470fa0..f27bba0 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -77,8 +77,9 @@ pub enum Control {
     /// over the band turns it, a press on the band jumps to the pointer.
     /// A `dec` of zero snaps to whole numbers — the spinbox shape.
     Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
-    /// One of a fixed set: picking the row steps to the next option, the
-    /// arrows step either way.
+    /// One of a fixed set: picking the row opens a dropdown of the options
+    /// under its value ([`State::open_dialog_dropdown`]); the arrows step
+    /// either way without it.
     Choice { options: Vec<String>, index: usize },
     /// A colour, `#rrggbb`, drawn as the toolkit's colour selector: a hex
     /// well and a swatch that opens the picker.
@@ -305,6 +306,9 @@ pub struct Dialog {
     /// Whether the press that set `activated` was on the row's control
     /// (its switch) rather than on the rest of the row.
     activated_control: bool,
+    /// The row whose dropdown is open, so its trigger stays lifted while
+    /// the pointer is over the menu.
+    dropdown_open: Option<usize>,
     /// Whether the dialog is currently claiming its rect as an occluder — see
     /// [`Paint::popover`]. Lowered for the length of an event dispatch into
     /// the dialog, because the one claim serves two mechanisms that want
@@ -364,6 +368,7 @@ impl Dialog {
             hover_ctl: None,
             activated: None,
             activated_control: false,
+            dropdown_open: None,
             occluding: true,
             toggle_stamps: RefCell::new([off, on]),
             slider_stamp: RefCell::new(slider_stamp),
@@ -663,9 +668,37 @@ impl Dialog {
         Rect { x, y: r.y + 2.0, width: (right - x).max(10.0), height: ROW_H - 4.0 }
     }
 
+    /// A choice row's dropdown trigger in row rect `r`: the well around
+    /// its current value and the mark, right-aligned on the chord column —
+    /// what is painted, what the pointer lifts, and what the dropdown opens
+    /// under.
+    fn choice_span(&self, r: Rect, value: &str, family: &str, font_size: f32) -> Rect {
+        let text_w = if value.is_empty() { 0.0 } else { shaped_width(value, family, font_size) };
+        let mark_w = choice_mark(font_size).map_or(0.0, |a| a.size + a.gap);
+        let right = r.x + r.width - 8.0 - self.toggle_col() + CHOICE_WELL_PAD;
+        let width = text_w + mark_w + 2.0 * CHOICE_WELL_PAD;
+        Rect { x: right - width, y: r.y + 2.0, width, height: r.height - 4.0 }
+    }
+
+    /// Row `i`'s dropdown trigger in window coordinates, when it is a
+    /// choice row in view — where its dropdown opens.
+    pub fn choice_trigger(&self, rect: Rect, i: usize) -> Option<Rect> {
+        let r = self.row_rect(rect, i)?;
+        let Some(Control::Choice { options, index }) = &self.rows.get(i)?.control else { return None };
+        let (family, font_size) = cce_ui::layout::control_label_font_parsed();
+        Some(self.choice_span(r, options.get(*index).map(String::as_str).unwrap_or(""), &family, font_size))
+    }
+
+    /// Mark row `i` as the one whose dropdown is open (its trigger stays
+    /// lifted), or none.
+    pub fn set_dropdown_open(&mut self, i: Option<usize>) {
+        self.dropdown_open = i;
+    }
+
     /// Where a row's control is, for the hover: the switch, the whole
-    /// slider (readout lane included), the colour band. None for a row
-    /// with no control the pointer can lift.
+    /// slider (readout lane included), the colour band, a choice's
+    /// dropdown trigger. None for a row with no control the pointer can
+    /// lift.
     fn control_rect(&self, r: Rect, row: &Row) -> Option<Rect> {
         match &row.control {
             Some(Control::Toggle(_)) => Some(Rect {
@@ -676,7 +709,11 @@ impl Dialog {
             }),
             Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
             Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
-            Some(Control::Choice { .. }) | None => None,
+            Some(Control::Choice { options, index }) => {
+                let (family, font_size) = cce_ui::layout::control_label_font_parsed();
+                Some(self.choice_span(r, options.get(*index).map(String::as_str).unwrap_or(""), &family, font_size))
+            }
+            None => None,
         }
     }
 
@@ -789,30 +826,33 @@ fn shaped_width(text: &str, family: &str, font_size: f32) -> f32 {
         .unwrap_or_else(|| display::measure_text_width(text, family, font_size))
 }
 
-/// A choice row's two arrows: cce-icons' `chevron-left` / `chevron-right`,
-/// as image ids, drawn in a square box of `size` logical px with `gap` px
-/// between each and the value.
+/// A choice row's dropdown mark: cce-icons' `chevron-down`, as an image
+/// id, drawn in a square box of `size` logical px, `gap` px right of the
+/// value. Until 2026-10-01 a choice was a value between a left and a right
+/// chevron, stepped by a click; it is a dropdown now
+/// ([`State::open_dialog_dropdown`]), and the arrow keys still step it.
 #[derive(Clone, Copy)]
-struct ChoiceArrows {
-    left: u32,
-    right: u32,
+struct ChoiceMark {
+    down: u32,
     size: f32,
     gap: f32,
 }
 
-/// The arrows for a row set in `font_size`: the chevron's triangle is three
+/// The mark for a row set in `font_size`: the chevron's triangle is three
 /// quarters of its box, so a box of 0.8 of the font size makes the triangle
 /// about as tall as a capital. Rasterized at twice the box, so a scale-2
 /// output draws it at its own pixels and scale 1 minifies it cleanly.
 /// `None` when the icon set is missing.
-fn choice_arrows(font_size: f32) -> Option<ChoiceArrows> {
+fn choice_mark(font_size: f32) -> Option<ChoiceMark> {
     let size = (font_size * 0.8).round().max(6.0);
     let px = (size * 2.0).ceil() as u32;
-    let (left, _, _) = cce_ui::upload_icon("chevron-left", px)?;
-    let (right, _, _) = cce_ui::upload_icon("chevron-right", px)?;
-    Some(ChoiceArrows { left, right, size, gap: (font_size * 0.3).round().max(3.0) })
+    let (down, _, _) = cce_ui::upload_icon("chevron-down", px)?;
+    Some(ChoiceMark { down, size, gap: (font_size * 0.3).round().max(3.0) })
 }
 
+/// How far the dropdown's well reaches past the value and the mark.
+const CHOICE_WELL_PAD: f32 = 6.0;
+
 impl Paint for Dialog {
     /// `paint` authors geometry AND text, so the Text prims pass through
     /// `paint_self` verbatim instead of the single-font own-labels bridge —
@@ -992,26 +1032,26 @@ impl Paint for Dialog {
             let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
             let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
             // What the chord column shows: the chord, or a choice row's
-            // current option between its two arrows — a value, so it wears
-            // the label's colour rather than the chord's grey. The arrows
-            // are cce-icons' chevrons, text triangles only if the icon set
-            // is missing (the font has no glyph for those, and drew boxes).
-            let arrows = match &row.control {
-                Some(Control::Choice { .. }) => choice_arrows(font_size),
+            // current option and its dropdown mark — a value, so it wears
+            // the label's colour rather than the chord's grey — in a well
+            // that reads as the dropdown's trigger and lifts under the
+            // pointer. The mark is cce-icons' chevron, left out if the icon
+            // set is missing (the font has no glyph for a triangle).
+            let mark = match &row.control {
+                Some(Control::Choice { .. }) => choice_mark(font_size),
                 _ => None,
             };
             let (right_text, right_color) = match &row.control {
                 Some(Control::Choice { options, index }) => {
-                    let value = options.get(*index).map(String::as_str).unwrap_or("");
-                    let text = if arrows.is_some() { value.to_string() } else { format!("\u{25c2} {value} \u{25b8}") };
-                    (text, label_color)
+                    (options.get(*index).cloned().unwrap_or_default(), label_color)
                 }
                 _ => (row.chord.clone(), [0x85, 0x85, 0x92]),
             };
             let text_w = if right_text.is_empty() { 0.0 } else { shaped_width(&right_text, &family, font_size) };
-            // An arrow's box, and the gap between it and the value.
-            let arrow_w = arrows.map_or(0.0, |a| a.size + a.gap);
-            let right_w = text_w + 2.0 * arrow_w;
+            // The mark's box, and the gap between it and the value.
+            let arrow_w = mark.map_or(0.0, |a| a.size + a.gap);
+            let well = if mark.is_some() || matches!(row.control, Some(Control::Choice { .. })) { CHOICE_WELL_PAD } else { 0.0 };
+            let right_w = text_w + arrow_w + well;
             // The label's clip stops short of the chord column so a long
             // label is cut by it rather than running under it.
             let chord_right = r.x + r.width - 8.0 - ctl_col;
@@ -1030,10 +1070,15 @@ impl Paint for Dialog {
                 Some(family.clone()),
                 own,
             );
+            if let Some(Control::Choice { .. }) = &row.control {
+                let span = self.choice_span(r, &right_text, &family, font_size);
+                let lift = if self.hover_ctl == Some(i) || self.dropdown_open == Some(i) { 0.34 } else { 0.22 };
+                ctx.rounded_rect(span, ctrl_r, (true, true, true, true), [0.0, 0.0, 0.0, lift]);
+            }
             if text_w > 0.0 {
                 ctx.text_with(
                     right_text,
-                    chord_right - right_w + arrow_w,
+                    chord_right - right_w + well,
                     ty,
                     font_size,
                     right_color,
@@ -1041,13 +1086,11 @@ impl Paint for Dialog {
                     own,
                 );
             }
-            if let Some(a) = arrows {
-                // White glyphs, dimmed to the value's own brightness.
+            if let Some(a) = mark {
+                // A white glyph, dimmed to the value's own brightness.
                 let alpha = right_color[0] as f32 / 255.0;
                 let y = r.y + (r.height - a.size) * 0.5;
-                let at = |x: f32| Rect { x, y, width: a.size, height: a.size };
-                ctx.image(a.left, at(chord_right - right_w), alpha);
-                ctx.image(a.right, at(chord_right - a.size), alpha);
+                ctx.image(a.down, Rect { x: chord_right - a.size, y, width: a.size, height: a.size }, alpha);
             }
             match &row.control {
                 Some(Control::Toggle(on)) => {
@@ -1650,6 +1693,7 @@ impl State {
         if !self.dialog_visible() {
             return;
         }
+        self.close_dialog_dropdown();
         self.slots.dialog.set_visible(false);
         if self.focused_widget == Some(DIALOG_IDX) {
             self.focused_widget = None;
@@ -2299,6 +2343,10 @@ impl State {
             self.drain_dialog_clicks();
             return true;
         }
+        // An open dropdown has the keys next: it is in front of the rows.
+        if self.dialog_dropdown_open() {
+            return self.dialog_dropdown_key(event);
+        }
         // The dialog's own chord closes it, wherever the user has bound it —
         // asked for by id rather than hardcoded to Alt+D, so a rebind in
         // `input.kdl` keeps working both ways.
@@ -2405,17 +2453,124 @@ impl State {
                 }
                 let n = options.len() as i32;
                 let next = (index as i32 + dir).rem_euclid(n) as usize;
-                if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
-                    let label = label.to_string();
-                    self.apply_setting(&label, &options[next]);
-                } else if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
-                    let field = field.to_string();
-                    self.set_visualizer_field(i, &field, &options[next], true);
-                    self.refresh_dialog_controls();
+                self.land_dialog_choice(&id, &options[next]);
+            }
+            _ => {}
+        }
+    }
+
+    /// A choice row's new option, from its dropdown or an arrow key: a
+    /// setting row writes its setting, a visualizer's row its field.
+    pub(crate) fn land_dialog_choice(&mut self, id: &str, value: &str) {
+        if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
+            let label = label.to_string();
+            self.apply_setting(&label, value);
+        } else if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
+            let field = field.to_string();
+            self.set_visualizer_field(i, &field, value, true);
+            self.refresh_dialog_controls();
+        }
+    }
+
+    /// Open row `id`'s dropdown: the toolkit's context menu, one row an
+    /// option with the current one marked and highlighted, right-aligned
+    /// under the row's trigger. It is a popup above the dialog, so it can
+    /// run past the plate's edge. Picking a row lands it through
+    /// [`Self::land_dialog_choice`]; the dialog stays up.
+    pub(crate) fn open_dialog_dropdown(&mut self, id: &str) {
+        let Some(i) = self.slots.dialog.rows.iter().position(|r| r.id == id) else { return };
+        let Some(Control::Choice { options, index }) = self.slots.dialog.rows[i].control.clone() else { return };
+        if options.is_empty() {
+            return;
+        }
+        self.slots.dialog.selected = i;
+        self.slots.dialog.scroll_to_selected();
+        let (x, y, w, h) = self.positions[DIALOG_IDX];
+        let Some(trigger) = self.slots.dialog.choice_trigger(Rect { x, y, width: w, height: h }, i) else { return };
+        let labels: Vec<String> =
+            options.iter().enumerate().map(|(k, o)| format!("{} {o}", if k == index { "●" } else { "○" })).collect();
+        let target = self.slots.dialog.base().id();
+        // Shown once to learn its width, then where it goes: its right edge
+        // on the trigger's, its top on the trigger's bottom.
+        cce_ui::widget::context_menu::show(trigger.x, trigger.y + trigger.height, labels.clone(), 0, target);
+        let (_, menu_w, _) = cce_ui::widget::context_menu::natural_geometry();
+        let left = (trigger.x + trigger.width - menu_w).max(0.0);
+        cce_ui::widget::context_menu::show(left, trigger.y + trigger.height, labels, 0, target);
+        cce_ui::widget::context_menu::set_hovered_item(Some(index));
+        self.slots.dialog.set_dropdown_open(Some(i));
+        self.dialog_dropdown = Some((id.to_string(), options));
+    }
+
+    pub(crate) fn dialog_dropdown_open(&self) -> bool {
+        self.dialog_dropdown.is_some() && cce_ui::widget::context_menu::is_visible()
+    }
+
+    pub(crate) fn close_dialog_dropdown(&mut self) {
+        if self.dialog_dropdown.take().is_some() {
+            cce_ui::widget::context_menu::hide();
+        }
+        self.slots.dialog.set_dropdown_open(None);
+    }
+
+    /// Land option `k` of the open dropdown, and close it.
+    fn pick_dialog_dropdown(&mut self, k: usize) {
+        let Some((id, options)) = self.dialog_dropdown.clone() else { return };
+        self.close_dialog_dropdown();
+        if let Some(value) = options.get(k) {
+            self.land_dialog_choice(&id, value);
+        }
+    }
+
+    /// A press while the dropdown is open: on a row it picks it, anywhere
+    /// else it closes the dropdown and is swallowed — the dialog under it
+    /// stays up, as any menu's dismissing press leaves what is under it.
+    pub(crate) fn dialog_dropdown_press(&mut self, button: MouseButton) -> bool {
+        let (x, y) = (self.cursor_x, self.cursor_y);
+        if button == MouseButton::Left && cce_ui::widget::context_menu::hit_test(x, y) {
+            if let Some(k) = cce_ui::widget::context_menu::row_at(x, y) {
+                self.pick_dialog_dropdown(k);
+            }
+            return true;
+        }
+        self.close_dialog_dropdown();
+        true
+    }
+
+    /// A wheel while the dropdown is open: over it, it scrolls a list too
+    /// long for the screen; anywhere else it closes it, and the dialog
+    /// takes the wheel.
+    pub(crate) fn dialog_dropdown_wheel(&mut self, delta: &MouseScrollDelta) -> bool {
+        if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
+            cce_ui::widget::context_menu::mouse_wheel(delta, self.cursor_x, self.cursor_y);
+            return true;
+        }
+        self.close_dialog_dropdown();
+        false
+    }
+
+    /// A key while the dropdown is open: Up and Down walk it, Enter picks
+    /// the highlighted option, Escape and Tab close it; nothing else
+    /// reaches the dialog behind it.
+    fn dialog_dropdown_key(&mut self, event: &KeyEvent) -> bool {
+        if event.state != ElementState::Pressed {
+            return true;
+        }
+        match &event.logical_key {
+            Key::Named(NamedKey::ArrowDown) => {
+                cce_ui::widget::context_menu::step_hovered(1);
+            }
+            Key::Named(NamedKey::ArrowUp) => {
+                cce_ui::widget::context_menu::step_hovered(-1);
+            }
+            Key::Named(NamedKey::Enter) => {
+                if let Some(k) = cce_ui::widget::context_menu::hovered_item() {
+                    self.pick_dialog_dropdown(k);
                 }
             }
+            Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Tab) => self.close_dialog_dropdown(),
             _ => {}
         }
+        true
     }
 
     /// A slider row's value arriving from a drag, a wheel or an arrow: the
@@ -2527,7 +2682,7 @@ impl State {
                         self.set_visualizer_field(i, &field, if on { "false" } else { "true" }, true);
                         self.refresh_dialog_controls();
                     }
-                    Some(Control::Choice { .. }) => self.nudge_dialog_selection(1),
+                    Some(Control::Choice { .. }) => self.open_dialog_dropdown(&id),
                     _ => {}
                 },
             }
@@ -2551,7 +2706,7 @@ impl State {
                         let v = if on { "false" } else { "true" };
                         self.apply_setting(s.label, v);
                     }
-                    Some(Control::Choice { .. }) => self.nudge_dialog_selection(1),
+                    Some(Control::Choice { .. }) => self.open_dialog_dropdown(&id),
                     _ => {}
                 }
                 return;
diff --git a/src/main.rs b/src/main.rs
index b534142..dae851a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7663,11 +7663,15 @@ mod tests {
         assert_ne!(colours(&state), plain, "the ramp is on the scene");
         assert_eq!(serde_json::to_string(&state.fs_root).unwrap(), nodes_before, "no node in the graph");
 
-        // Enter on Mode steps it to Vector: Vector's rows, and lines drawn.
+        // Mode's dropdown, Down, Enter: Vector's rows, and lines drawn.
         let mode_row = state.slots.dialog.rows.iter().position(|r| r.id == field("mode")).unwrap();
         state.slots.dialog.selected = mode_row;
         let lines_before = state.overlay_normal_verts.len();
         state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        assert!(state.dialog_dropdown_open(), "a choice opens its dropdown");
+        assert_eq!(cce_ui::widget::context_menu::options(), vec!["● Ramp".to_string(), "○ Vector".to_string()]);
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowDown)));
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
         assert!(state.visualizers[0].is_vector());
         assert!(has(&state, "scale") && !has(&state, "ramp"), "Vector's rows");
         assert_eq!(state.slots.dialog.selected_id(), Some(field("mode").as_str()), "the selection stays");
@@ -14602,13 +14606,19 @@ mod tests {
     /// ranked with the commands, so a query finds a colour the way it finds
     /// a command. There is no second half: Tab in this mode does nothing,
     /// and nothing draws a strip.
-    /// A choice row's value stands between two arrows, and they are
-    /// cce-icons' chevrons — not the text triangles they were, which the
-    /// font has no glyph for — in square boxes sized from the row's font.
-    #[test]
-    fn a_choice_rows_arrows_are_cce_icons_the_size_of_its_text() {
+    /// A choice row is a dropdown: its value sits in a well with one
+    /// chevron-down after it — cce-icons', square, sized from the row's
+    /// font — and a press on it opens a menu of the options under it,
+    /// right-aligned on the well, the current one marked and highlighted.
+    /// Up, Down and Enter walk and pick; a press on a row picks it; Escape
+    /// or a press elsewhere closes the menu and leaves the dialog up.
+    #[test]
+    fn a_choice_row_is_a_dropdown() {
         use cce_ui::scene::paint::Prim;
+        use cce_ui::widget::context_menu;
         use crate::slots::DIALOG_IDX;
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton};
         let mut state = State::new(false);
         state.resize(1600.0, 900.0, 1.0);
         state.rebuild_positions();
@@ -14620,7 +14630,7 @@ mod tests {
         let (dx, dy, dw, dh) = state.positions[DIALOG_IDX];
         let list = state.collect_display_list();
         let inside = |x: f32, y: f32| x >= dx && x <= dx + dw && y >= dy && y <= dy + dh;
-        let value = list
+        let (vx, vy, font) = list
             .items
             .iter()
             .find_map(|item| match &item.prim {
@@ -14628,11 +14638,7 @@ mod tests {
                 _ => None,
             })
             .expect("the World Unit row shows its value");
-        assert!(
-            !list.items.iter().any(|item| matches!(&item.prim, Prim::Text { text, .. } if text.contains('\u{25c2}') || text.contains('\u{25b8}'))),
-            "no text triangles: the font has no glyph for them"
-        );
-        let arrows: Vec<_> = list
+        let marks: Vec<_> = list
             .items
             .iter()
             .filter_map(|item| match &item.prim {
@@ -14640,14 +14646,50 @@ mod tests {
                 _ => None,
             })
             .collect();
-        let (vx, _, font) = value;
-        let left = arrows.iter().find(|r| r.x + r.width <= vx).expect("an arrow ahead of the value");
-        let right = arrows.iter().find(|r| r.x > vx).expect("an arrow after the value");
-        for r in [left, right] {
-            assert_eq!(r.width, r.height, "square");
-            assert_eq!(r.width, (font * 0.8).round(), "sized from the row's font");
-        }
-        assert_eq!(left.y, right.y, "on one line");
+        assert_eq!(marks.len(), 1, "one mark: {marks:?}");
+        let mark = marks[0];
+        assert!(mark.x > vx, "after the value");
+        assert_eq!(mark.width, mark.height, "square");
+        assert_eq!(mark.width, (font * 0.8).round(), "sized from the row's font");
+
+        // A press on the value opens the dropdown under it.
+        let press = |state: &mut State, x: f32, y: f32| {
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+            state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        };
+        press(&mut state, vx + 2.0, vy + font * 0.5);
+        assert!(state.dialog_dropdown_open(), "the press opened the dropdown");
+        let options = context_menu::options();
+        assert_eq!(options.first().map(String::as_str), Some("● mm"), "{options:?}");
+        assert!(options[1..].iter().all(|o| o.starts_with("○ ")));
+        assert_eq!(context_menu::hovered_item(), Some(0), "the current option is highlighted");
+        let (_, menu_w, _) = context_menu::natural_geometry();
+        assert!((context_menu::x() + menu_w - (mark.x + mark.width + 6.0)).abs() < 1.0, "right-aligned on the trigger");
+        assert!(context_menu::y() > vy, "under the row");
+
+        // Down, Enter: the next unit, the dialog still up.
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowDown)));
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        assert_eq!(state.world_unit.suffix(), options[1].trim_start_matches("○ "));
+        assert!(!state.dialog_dropdown_open() && state.dialog_visible());
+
+        // A press on a row of the menu picks that row.
+        state.open_dialog_dropdown(&crate::dialog::setting_row_id("World Unit"));
+        let pick = context_menu::options().iter().position(|o| o.ends_with(" in")).expect("inches");
+        press(&mut state, context_menu::x() + 8.0, context_menu::row_y(pick) + 4.0);
+        assert_eq!(state.world_unit.suffix(), "in");
+        assert!(state.dialog_visible());
+
+        // Escape closes the dropdown alone; a press off it does too.
+        state.open_dialog_dropdown(&crate::dialog::setting_row_id("World Unit"));
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+        assert!(!state.dialog_dropdown_open() && state.dialog_visible());
+        state.open_dialog_dropdown(&crate::dialog::setting_row_id("World Unit"));
+        press(&mut state, dx + 20.0, dy + dh - 20.0);
+        assert!(!state.dialog_dropdown_open() && state.dialog_visible(), "a press off the menu closes it alone");
+        assert_eq!(state.world_unit.suffix(), "in", "and picks nothing");
+        state.close_dialog();
     }
 
     /// A control in the palette lifts under the pointer: the row whose
@@ -14791,7 +14833,11 @@ mod tests {
         state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowLeft)));
         assert_eq!(state.world_unit, before, "left arrow steps it back");
         state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
-        assert_ne!(state.world_unit, before, "Enter steps a choice too");
+        assert!(state.dialog_dropdown_open(), "Enter opens a choice's dropdown");
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowDown)));
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+        assert_ne!(state.world_unit, before, "Down and Enter pick the next option");
+        assert!(!state.dialog_dropdown_open());
         assert!(state.dialog_visible(), "and keeps the dialog up");
 
         let scale_row = state.slots.dialog.rows.iter().position(|r| r.id == setting_row_id("Group Marker Size")).unwrap();