graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: a dialog's choice rows are the params pane's dropdowns
A choice row draws the toolkit Dropdown in its control band, and opening it
hands its options to one live Dropdown laid out on the row: the trigger
plate grows into the list and shrinks back, in the params pane's own style
and animation. The open plate is registered as an occluder after the dialog
(and after the frame's tree wipe), so the rows under it are hidden and its
labels are not. The runner gives the dropdown the press before the app, so
a press that finds it closed after being expanded lands the pick and is
swallowed. Replaces the context menu shown as a list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 65 +++++++----
src/app.rs | 22 ++--
src/dialog.rs | 350 ++++++++++++++++++++++++++++++++--------------------------
src/main.rs | 99 ++++++++++-------
src/render.rs | 10 ++
5 files changed, 315 insertions(+), 231 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 7a17205..4c45321 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3362,30 +3362,47 @@ selection stays where it was:
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, 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. `a_choice_row_is_a_dropdown` drives it by pointer and keys.
+ visualizer settings): the PARAMS PANE'S DROPDOWN (since 2026-10-01,
+ the toolkit `Dropdown`), in the control band the sliders and colour
+ wells use. Closed, a row draws `Dialog::dropdown_stamp` — one
+ `Dropdown` handed each row's options and selection as it is painted,
+ as the toggle and slider stamps are. A press on the row or Enter opens
+ `Dialog::dropdown`, the LIVE one (`State::open_dialog_dropdown`): it
+ takes the row's options, is laid out on the row's band
+ (`sync_dialog_dropdown`), focused and sent Enter, and its plate GROWS
+ out of the trigger into the list and shrinks back, the toolkit's own
+ animation and frosted style — painted after the rows
+ (`render_popover`), over them. Up/Down/Enter/Escape are the dropdown's
+ own; Tab closes it; Left/Right on a closed row still step it in place;
+ every pick lands through `land_dialog_choice`. Four things it took:
+ - **Text is painted twice** (`paint_retagged`): a trigger's text must
+ carry the DIALOG's bounds or the dialog's occluder clamps it away.
+ - **The open plate is an occluder registered AFTER the dialog's** —
+ after the slot registration in `collect_display_list`, and after its
+ `clear_hierarchy`, which wipes the widget tree: registered before
+ that, the id stayed in `active_popovers` but resolved to nothing, the
+ engine's clamp skipped it in silence, and the list's labels were
+ clamped while the rows under it showed through. The clamp lets an
+ occluder's own labels through only past the occluders registered
+ before it, so the order is the whole trick.
+ - **The runner hands the press to the dropdown first.** Every left
+ press goes to each registered popover whose hit test MISSES it
+ before the app is asked (`close_popovers_missed_by_press`), and the
+ dialog's claim covers the dropdown, so it always misses: the
+ dropdown has taken the press — picked a row, or closed — before
+ `handle_event` runs. `Dialog::dropdown_armed` remembers it was
+ expanded; a press that finds it not expanded while armed is one it
+ already took, and `dialog_dropdown_press` lands the pick and
+ swallows the press, so the row under the list is not pressed too.
+ The test's press does what the runner does, or it would not have
+ caught this.
+ - **Closing the dialog shuts it outright** (`open = false`), so no
+ shrinking plate is left reporting a popover over the panes.
+ `Dropdown::is_expanded` (cce-ui, the same day) is what tells a
+ shrinking dropdown from one taking input. For one day before this the
+ choice was the context menu shown as a list under the row, and before
+ that a click stepped the value between two chevrons.
+ `a_choice_row_is_a_dropdown` is the test.
- `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 0637a21..99e9cbb 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -2739,9 +2739,6 @@ 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>,
@@ -7702,7 +7699,6 @@ 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(),
@@ -9736,8 +9732,10 @@ 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;
+ // A wheel closes the dialog's open dropdown, whose list
+ // would otherwise ride a scroll it was not laid out for.
+ if self.dialog_dropdown_open() {
+ self.close_dialog_dropdown();
}
if self.dialog_visible() {
return self.dialog_mouse_wheel(*delta);
@@ -9951,11 +9949,19 @@ 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() || self.dialog_dropdown_open())
+ if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open() || self.playbar_menu_open())
&& cce_ui::widget::context_menu::cursor_moved(self.cursor_x, self.cursor_y)
{
changed = true;
}
+ // The dialog's open dropdown follows the pointer with its
+ // highlight, ahead of the dialog.
+ if self.dialog_dropdown_open() {
+ let ev = cce_ui::widget::Event::PointerMove { x: self.cursor_x, y: self.cursor_y, local_x: self.cursor_x, local_y: self.cursor_y };
+ if self.dialog_dropdown_event(&ev) {
+ changed = true;
+ }
+ }
// A held menu slider follows the pointer (cursor_moved moved
// it); land the value, and let nothing else read this motion
// as a drag of its own.
@@ -10165,7 +10171,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
// 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() {
+ if *btn_state == ElementState::Pressed && self.dialog_dropdown_takes_press() {
return self.dialog_dropdown_press(*button);
}
if self.dialog_visible() {
diff --git a/src/dialog.rs b/src/dialog.rs
index f27bba0..5ae6f03 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -306,9 +306,27 @@ 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>,
+ /// The choice rows' closed dropdowns: one toolkit `Dropdown`, handed
+ /// each row's options and selection as the row is painted, as the
+ /// toggle and slider stamps are.
+ dropdown_stamp: RefCell<Adapted<Dropdown>>,
+ /// The OPEN dropdown: a real toolkit `Dropdown` — the params pane's —
+ /// given the options of the row it serves (`dropdown_row`) when that
+ /// row is opened, laid out on the row's band, and handed the pointer
+ /// and the keys while it is open (`State::dialog_dropdown_event`). Its
+ /// plate grows out of the trigger into the list and back. Boxed and
+ /// never replaced, because the UI context holds a pointer to it while
+ /// it is registered as an occluder.
+ pub dropdown: Box<Adapted<Dropdown>>,
+ /// The row id the open dropdown serves.
+ pub dropdown_row: Option<String>,
+ /// Whether the dropdown was last seen expanded. The runner hands every
+ /// left press to an open popover it thinks the press MISSED before the
+ /// app sees the press, and the dialog's own claim covers the dropdown,
+ /// so every press reaches the dropdown that way first: a press that
+ /// finds it no longer expanded, while this is still set, is one the
+ /// dropdown has already taken (`State::dialog_dropdown_press`).
+ pub dropdown_armed: bool,
/// 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
@@ -368,7 +386,10 @@ impl Dialog {
hover_ctl: None,
activated: None,
activated_control: false,
- dropdown_open: None,
+ dropdown_stamp: RefCell::new(Dropdown::new(Vec::new(), 0)),
+ dropdown: Box::new(Dropdown::new(Vec::new(), 0)),
+ dropdown_row: None,
+ dropdown_armed: false,
occluding: true,
toggle_stamps: RefCell::new([off, on]),
slider_stamp: RefCell::new(slider_stamp),
@@ -668,31 +689,13 @@ 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;
+ /// The open dropdown's trigger, in window coordinates: its row's control
+ /// band, while the row is in view. What the live dropdown is laid out
+ /// on, so it opens from where the row's stamp was drawn.
+ pub fn dropdown_trigger(&self, rect: Rect) -> Option<Rect> {
+ let id = self.dropdown_row.as_deref()?;
+ let i = self.rows.iter().position(|r| r.id == id)?;
+ self.row_rect(rect, i).map(|r| self.slider_band_rect(r))
}
/// Where a row's control is, for the hover: the switch, the whole
@@ -709,10 +712,7 @@ impl Dialog {
}),
Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
- 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))
- }
+ Some(Control::Choice { .. }) => Some(self.slider_band_rect(r)),
None => None,
}
}
@@ -826,33 +826,23 @@ 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 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 ChoiceMark {
- down: u32,
- size: f32,
- gap: f32,
-}
-
-/// 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_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 (down, _, _) = cce_ui::upload_icon("chevron-down", px)?;
- Some(ChoiceMark { down, size, gap: (font_size * 0.3).round().max(3.0) })
+/// Paint `w` into `r` with its text re-emitted under the dialog's own bounds
+/// `own`. Painted twice, on purpose: a hosted widget's text has to carry the
+/// DIALOG's bounds or the occluder the dialog registers clamps it away, and a
+/// `PaintCtx` cannot be handed a prim back — the first pass lays down the
+/// geometry (its text lands inside the occluder and is clamped to nothing),
+/// the second is a scratch pass whose text alone is re-emitted retagged.
+fn paint_retagged(w: &dyn Paint, r: Rect, ctx: &mut PaintCtx, own: Option<[f32; 4]>) {
+ Paint::paint(w, r, ctx);
+ let mut scratch = PaintCtx::new();
+ Paint::paint(w, r, &mut scratch);
+ for item in scratch.finish().items {
+ if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
+ ctx.text_with(text, x, y, font_size, color, font, own);
+ }
+ }
}
-/// 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 —
@@ -1037,25 +1027,23 @@ impl Paint for Dialog {
// 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,
- };
+ // What the chord column shows: the chord. A choice row shows a
+ // dropdown in the control band instead, as a slider or a colour
+ // row does.
let (right_text, right_color) = match &row.control {
- Some(Control::Choice { options, index }) => {
- (options.get(*index).cloned().unwrap_or_default(), label_color)
- }
+ Some(Control::Choice { .. }) => (String::new(), 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) };
- // 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;
+ let right_w = text_w;
// The label's clip stops short of the chord column so a long
- // label is cut by it rather than running under it.
+ // label is cut by it rather than running under it — or short of
+ // the band, for a choice row.
let chord_right = r.x + r.width - 8.0 - ctl_col;
- let label_right = chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 };
+ let label_right = match &row.control {
+ Some(Control::Choice { .. }) => self.slider_band_rect(r).x - 12.0,
+ _ => chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
+ };
let label_x = r.x + 8.0;
ctx.text_with(
if row.truncate_head {
@@ -1070,27 +1058,8 @@ 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 + well,
- ty,
- font_size,
- right_color,
- Some(family.clone()),
- own,
- );
- }
- 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;
- ctx.image(a.down, Rect { x: chord_right - a.size, y, width: a.size, height: a.size }, alpha);
+ ctx.text_with(right_text, chord_right - right_w, ty, font_size, right_color, Some(family.clone()), own);
}
match &row.control {
Some(Control::Toggle(on)) => {
@@ -1149,11 +1118,34 @@ impl Paint for Dialog {
}
}
}
- Some(Control::Choice { .. }) | None => {}
+ Some(Control::Choice { options, index }) => {
+ // The params pane's dropdown: the live one while this
+ // row's is open (it grows out of this trigger, painted
+ // over the rows below), else the shared stamp.
+ let band = self.slider_band_rect(r);
+ if self.dropdown_row.as_deref() == Some(row.id.as_str()) && self.dropdown.open {
+ paint_retagged(self.dropdown.inner(), band, ctx, own);
+ } else {
+ let mut stamp = self.dropdown_stamp.borrow_mut();
+ stamp.inner_mut().options = options.clone();
+ stamp.inner_mut().selected = *index;
+ paint_retagged(stamp.inner(), band, ctx, own);
+ }
+ }
+ None => {}
}
}
});
+ // The open dropdown's plate, grown out of its trigger over the rows
+ // — after them, so it covers them. Its labels carry its own rect,
+ // which `State::collect_display_list` registers as an occluder
+ // AFTER the dialog's, so the dialog's labels under it are clamped
+ // and its own are not.
+ if self.dropdown.open && self.dropdown_trigger(rect).is_some() {
+ WidgetHost::render_popover(&*self.dropdown, ctx);
+ }
+
// The scrollbar's fore copy, over the rows, at the activity's fade:
// pills, as `ScrollRegion::push_scrollbar_prims` and cce-mail's body
// bar draw them. Driven by the fade rather than the latch so it
@@ -1208,7 +1200,11 @@ impl Input for Dialog {
picking |= Input::tick(sel.inner_mut(), dt, rect);
}
let colored = self.drain_color_selectors();
- moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored
+ // The open dropdown's grow and shrink: frames while it runs, and a
+ // landed close settled back to a closed trigger.
+ let band = self.dropdown_trigger(rect).unwrap_or(rect);
+ let unfolding = self.dropdown.open && Input::tick(self.dropdown.inner_mut(), dt, band);
+ moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || unfolding
}
/// The whole rect, always — this is what makes the dialog modal over what
@@ -1693,7 +1689,12 @@ impl State {
if !self.dialog_visible() {
return;
}
- self.close_dialog_dropdown();
+ // Shut, not animated: with the dialog gone there is nothing to
+ // shrink into, and a plate still reporting itself open would keep
+ // covering presses meant for the panes.
+ self.slots.dialog.dropdown.inner_mut().open = false;
+ self.slots.dialog.dropdown_row = None;
+ self.slots.dialog.dropdown_armed = false;
self.slots.dialog.set_visible(false);
if self.focused_widget == Some(DIALOG_IDX) {
self.focused_widget = None;
@@ -2472,10 +2473,10 @@ impl State {
}
}
- /// 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
+ /// Open row `id`'s dropdown: the params pane's own control. The live
+ /// `Dialog::dropdown` takes the row's options and selection, is laid out
+ /// on the row's band, and opens — its plate growing out of the trigger
+ /// into the list, the current option highlighted. A pick lands 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 };
@@ -2485,91 +2486,126 @@ impl State {
}
self.slots.dialog.selected = i;
self.slots.dialog.scroll_to_selected();
+ {
+ let dd = self.slots.dialog.dropdown.inner_mut();
+ dd.options = options;
+ dd.selected = index;
+ }
+ self.slots.dialog.dropdown_row = Some(id.to_string());
+ if !self.sync_dialog_dropdown() {
+ self.slots.dialog.dropdown_row = None;
+ return;
+ }
+ // Opened as the toolkit opens one from the keyboard: focused, then
+ // Enter, which unfolds it with the current option highlighted.
+ let dd_id = self.slots.dialog.dropdown.base().id();
+ cce_ui::widget::focus::set_focused_id(dd_id, Some(&mut self.ui_context));
+ let enter = Event::KeyInput(KeyEvent {
+ state: ElementState::Pressed,
+ logical_key: Key::Named(NamedKey::Enter),
+ text: None,
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ });
+ let ptr = &mut *self.slots.dialog.dropdown as *mut Adapted<Dropdown>;
+ unsafe {
+ (*ptr).handle_event(&enter, &mut self.ui_context);
+ }
+ self.slots.dialog.dropdown_armed = true;
+ }
+
+ /// Lay the live dropdown out on its row's band, as the dialog now
+ /// stands. False when there is no such row in view.
+ pub(crate) fn sync_dialog_dropdown(&mut self) -> bool {
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));
+ let Some(band) = self.slots.dialog.dropdown_trigger(Rect { x, y, width: w, height: h }) else { return false };
+ WidgetHost::set_rect(&mut *self.slots.dialog.dropdown, band.x, band.y, band.width, band.height);
+ true
}
+ /// Whether the dropdown is open and taking input — not while it
+ /// shrinks closed, when the dialog under it has the pointer again.
pub(crate) fn dialog_dropdown_open(&self) -> bool {
- self.dialog_dropdown.is_some() && cce_ui::widget::context_menu::is_visible()
+ self.dialog_visible() && self.slots.dialog.dropdown_row.is_some() && self.slots.dialog.dropdown.is_expanded()
}
+ /// Close the dropdown — animated, as an outside press closes it.
pub(crate) fn close_dialog_dropdown(&mut self) {
- if self.dialog_dropdown.take().is_some() {
- cce_ui::widget::context_menu::hide();
+ self.slots.dialog.dropdown_armed = false;
+ if self.slots.dialog.dropdown.open {
+ let ptr = &mut *self.slots.dialog.dropdown as *mut Adapted<Dropdown>;
+ unsafe {
+ (*ptr).handle_event(&Event::FocusOut, &mut self.ui_context);
+ }
}
- 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);
+ /// Hand `ev` to the open dropdown, and land what it picked. True when
+ /// it took the event; a press it does not take (one made while it
+ /// shrinks closed) goes on to the dialog.
+ pub(crate) fn dialog_dropdown_event(&mut self, ev: &Event) -> bool {
+ if !self.dialog_dropdown_open() {
+ return false;
}
+ self.sync_dialog_dropdown();
+ let ptr = &mut *self.slots.dialog.dropdown as *mut Adapted<Dropdown>;
+ let taken = unsafe { (*ptr).handle_event(ev, &mut self.ui_context) };
+ self.land_dialog_dropdown_pick();
+ taken
}
- /// 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.
+ /// Land what the dropdown picked, however the event that picked it
+ /// reached it, and note whether it is still expanded.
+ fn land_dialog_dropdown_pick(&mut self) {
+ let picked = {
+ let dd = self.slots.dialog.dropdown.inner_mut();
+ dd.take_change().then(|| dd.options.get(dd.selected).cloned()).flatten()
+ };
+ self.slots.dialog.dropdown_armed = self.slots.dialog.dropdown.is_expanded();
+ if let (Some(value), Some(id)) = (picked, self.slots.dialog.dropdown_row.clone()) {
+ self.land_dialog_choice(&id, &value);
+ }
+ }
+
+ /// Whether a press has to be the dropdown's: it is expanded, or it was
+ /// until this very press — which the runner then handed it first (see
+ /// `Dialog::dropdown_armed`).
+ pub(crate) fn dialog_dropdown_takes_press(&self) -> bool {
+ self.dialog_dropdown_open() || (self.dialog_visible() && self.slots.dialog.dropdown_armed)
+ }
+
+ /// A press while the dropdown is open: on a row it picks it, on the
+ /// trigger it closes it, anywhere else it closes it and is swallowed —
+ /// the dialog stays up, as under any menu's dismissing press.
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);
- }
+ if !self.dialog_dropdown_open() {
+ // The runner handed it the press already: a pick, or the close
+ // a press anywhere else makes. Land it, and the press is spent.
+ self.land_dialog_dropdown_pick();
+ self.slots.dialog.dropdown_armed = false;
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);
+ if button != MouseButton::Left {
+ self.close_dialog_dropdown();
return true;
}
- self.close_dialog_dropdown();
- false
+ let ev = Event::MouseButton { button, state: ElementState::Pressed, x, y, local_x: x, local_y: y };
+ self.dialog_dropdown_event(&ev);
+ true
}
/// A key while the dropdown is open: Up and Down walk it, Enter picks
- /// the highlighted option, Escape and Tab close it; nothing else
+ /// the highlighted option, Escape closes it, Tab closes it too; nothing
/// reaches the dialog behind it.
fn dialog_dropdown_key(&mut self, event: &KeyEvent) -> bool {
- if event.state != ElementState::Pressed {
+ if event.state == ElementState::Pressed && matches!(event.logical_key, Key::Named(NamedKey::Tab)) {
+ self.close_dialog_dropdown();
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(),
- _ => {}
- }
+ self.dialog_dropdown_event(&Event::KeyInput(event.clone()));
true
}
diff --git a/src/main.rs b/src/main.rs
index dae851a..6601e62 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7669,7 +7669,8 @@ mod tests {
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()]);
+ assert_eq!(state.slots.dialog.dropdown.options, vec!["Ramp".to_string(), "Vector".to_string()]);
+ assert_eq!(state.slots.dialog.dropdown.selected, 0);
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());
@@ -14606,16 +14607,20 @@ 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 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.
+ /// A choice row is the params pane's dropdown. Closed, its row draws
+ /// the toolkit `Dropdown` in the control band, the value's text carried
+ /// under the dialog's bounds so the dialog's occluder lets it through.
+ /// A press on it opens the live dropdown laid out on that band: its
+ /// plate grows out of the trigger into the list, registered as an
+ /// occluder AFTER the dialog so the rows under it are hidden and its
+ /// own labels are not. Up, Down and Enter walk and pick; a press on a
+ /// row picks it; Escape or a press elsewhere closes it, and the dialog
+ /// stays up.
#[test]
fn a_choice_row_is_a_dropdown() {
use cce_ui::scene::paint::Prim;
- use cce_ui::widget::context_menu;
+ use cce_ui::widget::WidgetHost;
+ use crate::dialog::setting_row_id;
use crate::slots::DIALOG_IDX;
use crate::window::{LocalPosition, WindowEvent};
use cce_ui::widget::{ElementState, MouseButton};
@@ -14628,68 +14633,78 @@ mod tests {
state.dialog_key_input(&typed(c));
}
let (dx, dy, dw, dh) = state.positions[DIALOG_IDX];
+ let own = [dx, dy, dx + dw, dy + dh];
let list = state.collect_display_list();
- let inside = |x: f32, y: f32| x >= dx && x <= dx + dw && y >= dy && y <= dy + dh;
- let (vx, vy, font) = list
+ let (vx, vy) = list
.items
.iter()
.find_map(|item| match &item.prim {
- Prim::Text { text, x, y, font_size, .. } if text == "mm" && inside(*x, *y) => Some((*x, *y, *font_size)),
+ // The trigger draws its text a cluster at a time.
+ Prim::Text { text, x, y, bounds: Some(b), .. } if text == "m" && *b == own && *x > dx + dw * 0.5 => Some((*x, *y)),
_ => None,
})
- .expect("the World Unit row shows its value");
- let marks: Vec<_> = list
- .items
- .iter()
- .filter_map(|item| match &item.prim {
- Prim::Image { rect, .. } if inside(rect.x, rect.y) => Some(*rect),
- _ => None,
- })
- .collect();
- 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");
+ .expect("the World Unit row's dropdown shows its value under the dialog's bounds");
- // A press on the value opens the dropdown under it.
+ // As the runner presses: a frame drawn (which registers the open
+ // dropdown), then the press handed to every open popover that the
+ // press MISSED by its hit test — which the dialog's own claim makes
+ // the dropdown's — and only then to the app.
let press = |state: &mut State, x: f32, y: f32| {
state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+ let _ = state.collect_display_list();
+ state.ui_context.close_popovers_missed_by_press(x, y);
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);
+ press(&mut state, vx + 2.0, vy + 4.0);
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");
+ let units = state.slots.dialog.dropdown.options.clone();
+ assert_eq!(units[state.slots.dialog.dropdown.selected], "mm");
+ let (tx, ty, tw, th) = state.slots.dialog.dropdown.rect();
+ assert!(vx >= tx && vx < tx + tw && vy >= ty - 4.0 && vy < ty + th, "laid out on the band its value was drawn in");
+
+ // The plate grows out of the trigger into the list.
+ let grown = |state: &State| state.slots.dialog.dropdown.popover_rect().map(|r| r.3).unwrap_or(0.0);
+ let first = grown(&state);
+ std::thread::sleep(std::time::Duration::from_millis(250));
+ state.tick_frame(0.25);
+ assert!(grown(&state) > first.max(th) + 24.0, "{} then {}", first, grown(&state));
+ // Registered after the dialog, so the rows under it are clamped
+ // and its labels are not.
+ let _ = state.collect_display_list();
+ let pops = &state.ui_context.active_popovers;
+ let dialog_at = pops.iter().position(|&p| p == state.slots.dialog.base().id()).expect("the dialog");
+ let dd_id = state.slots.dialog.dropdown.base().id();
+ let dd_at = pops.iter().position(|&p| p == dd_id).expect("the dropdown");
+ assert!(dd_at > dialog_at);
+ // And resolvable, which is what the engine's clamp walks: an id the
+ // tree has dropped is skipped in silence.
+ assert!(state.ui_context.tree.get_ptr(dd_id).is_some(), "the dropdown is in the widget tree");
// 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_eq!(state.world_unit.suffix(), units[1]);
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);
+ // A press on a row of the list picks that row.
+ state.open_dialog_dropdown(&setting_row_id("World Unit"));
+ let k = units.iter().position(|u| u == "in").expect("inches");
+ let (rx, ry, _, _) = state.slots.dialog.dropdown.popover_geom(cce_ui::scene::layout::Rect { x: tx, y: ty, width: tw, height: th });
+ press(&mut state, rx + 10.0, ry + k as f32 * 24.0 + 12.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.open_dialog_dropdown(&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"));
+ state.open_dialog_dropdown(&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!(!state.dialog_dropdown_open() && state.dialog_visible(), "a press off the list closes it alone");
assert_eq!(state.world_unit.suffix(), "in", "and picks nothing");
state.close_dialog();
+ assert!(!state.slots.dialog.dropdown.open, "a closed dialog leaves no plate behind");
}
/// A control in the palette lifts under the pointer: the row whose
diff --git a/src/render.rs b/src/render.rs
index e332b38..8ea245a 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -168,6 +168,16 @@ impl State {
let w = self.slots.get_dyn(i);
self.ui_context.register_widget(w.base().id(), w as *const (dyn WidgetHost + 'static) as *mut (dyn WidgetHost + 'static));
}
+ // The dialog's open dropdown, AFTER the dialog and after the wipe
+ // above (which would drop it from the tree, leaving an id the
+ // engine's clamp cannot resolve): the clamp lets an occluder's own
+ // labels through only past the occluders registered before it, so
+ // the dialog's labels under the list are clamped and the list's are
+ // not.
+ if self.dialog_visible() && self.slots.dialog.dropdown.open && self.sync_dialog_dropdown() {
+ let dd: &mut (dyn cce_ui::widget::WidgetHost + 'static) = &mut *self.slots.dialog.dropdown;
+ self.ui_context.register_popover(dd);
+ }
let mut pc = PaintCtx::new();
let mut visited = vec![false; WIDGET_COUNT];