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

src/dialog.rs (146.9K)

   1 //! The dialog (`Alt+D`, `Ctrl+P`, `Tab`): one filterable list of everything
   2 //! the app can be asked to do or to show differently.
   3 //!
   4 //! Every registry command is a row, and so is every display setting
   5 //! `DesignSettings` persists — a colour, a size, a unit — each carrying its
   6 //! control inline: a switch, a slider, a choice, a colour well. Until
   7 //! 2026-09-24 the settings were a second HALF behind a tab strip, a
   8 //! `ParametersBg` laid out inside the plate: two lists behind one chord, one
   9 //! of which could not be filtered, with the tab strip and its section
  10 //! headers as the only way to tell them apart. A setting is something you
  11 //! ask the app for by name, exactly as a command is, so it ranks in the same
  12 //! list and the strip is gone.
  13 //!
  14 //! This widget owns the FRAME — plate, query line, row list — and paints the
  15 //! rows' controls from the toolkit's own stamps (`Toggle`, `Slider`) and
  16 //! hosted `ColorSelector`s, so a slider in the dialog is the same slider as
  17 //! a slider in the params pane. The values behind the setting rows are the
  18 //! live `State` fields — see [`SETTINGS`] and [`Owner`].
  19 //!
  20 //! App-owned on the narrow traits wrapped in `Adapted<Dialog>`, like
  21 //! [`crate::playbar::Playbar`], and a subtree painter for the same reason:
  22 //! `paint` authors geometry AND text, so `append_frame_text` skips the slot.
  23 //! Geometry helpers take the laid-out `rect` rather than caching one, again
  24 //! like the playbar — the designer computes the same rects from
  25 //! `positions[DIALOG_IDX]` when it needs them outside a paint.
  26 
  27 use std::cell::RefCell;
  28 
  29 use cce_ui::colors;
  30 use cce_ui::scene::layout::Rect;
  31 use cce_ui::scene::paint::{PaintCtx, Prim};
  32 use cce_ui::widget::*;
  33 
  34 /// What the dialog was opened to do.
  35 ///
  36 /// One widget, two entry points, because a picker and a settings list are
  37 /// the same plate with the same keys — what differs is what a row MEANS.
  38 /// Splitting them into two widgets is how an app ends up with two filterable
  39 /// lists that behave differently, which is the thing this dialog replaced.
  40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  41 pub enum Mode {
  42     /// `Alt+D` / `Ctrl+P`: the commands and the settings, one list.
  43     Commands,
  44     /// `Tab` in the network pane: one list of node templates, and a pick
  45     /// that instantiates one at the grid cursor. Tab is what opened it, so
  46     /// Tab closes it again.
  47     AddNode,
  48     /// The node menu's Rename and the `rename_node` command: the query
  49     /// line is the NAME, opened holding the one the node has, and the one
  50     /// row says what Enter will do with it.
  51     Rename,
  52     /// The `group_markers` command: the scene's point groups, a switch
  53     /// each, marking the group's members in the viewport while it is on.
  54     /// The palette TURNS INTO this list, the way it turns into the node
  55     /// list — one plate, one filter.
  56     Groups,
  57 }
  58 
  59 /// The control a row carries, drawn over its right end and worked in place —
  60 /// the dialog stays up while any of these is used, since a setting you can
  61 /// only touch once before the panel vanishes is a button with extra steps.
  62 #[derive(Debug, Clone, PartialEq)]
  63 pub enum Control {
  64     /// A switch: a toggle command's state, or a boolean setting. Picking
  65     /// the row flips it.
  66     Toggle(bool),
  67     /// A slider over `min..=max`, read out to `dec` decimals with `suffix`;
  68     /// the arrows nudge it by `step` while the row is selected, the wheel
  69     /// over the band turns it, a press on the band jumps to the pointer.
  70     /// A `dec` of zero snaps to whole numbers — the spinbox shape.
  71     Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
  72     /// One of a fixed set: picking the row opens a dropdown of the options
  73     /// under its value ([`State::open_dialog_dropdown`]); the arrows step
  74     /// either way without it.
  75     Choice { options: Vec<String>, index: usize },
  76     /// A colour, `#rrggbb`, drawn as the toolkit's colour selector: a hex
  77     /// well and a swatch that opens the picker.
  78     Color { hex: String },
  79 }
  80 
  81 impl Control {
  82     /// The value a setting row writes back, in the string the setting's
  83     /// writer takes — the same encodings the params pane uses.
  84     pub fn value_string(&self) -> String {
  85         match self {
  86             Control::Toggle(on) => if *on { "true" } else { "false" }.to_string(),
  87             Control::Slider { value, dec, .. } => format!("{:.*}", dec, value),
  88             Control::Choice { options, index } => options.get(*index).cloned().unwrap_or_default(),
  89             Control::Color { hex, .. } => hex.clone(),
  90         }
  91     }
  92 
  93     /// Clamp `v` into a slider's range, snapping to whole numbers when the
  94     /// readout shows none.
  95     fn quantize(&self, v: f32) -> f32 {
  96         match self {
  97             Control::Slider { min, max, dec, .. } => {
  98                 let v = v.clamp(min.min(*max), max.max(*min));
  99                 if *dec == 0 { v.round() } else { v }
 100             }
 101             _ => v,
 102         }
 103     }
 104 
 105     /// Slider `k` of the control, as (value, min, max): a slider's one, a
 106     /// float2's two.
 107     fn slider_part(&self, k: usize) -> Option<(f32, f32, f32)> {
 108         match self {
 109             Control::Slider { value, min, max, .. } if k == 0 => Some((*value, *min, *max)),
 110             _ => None,
 111         }
 112     }
 113 
 114     /// Move slider `k` to `v`, clamped and snapped as the control says.
 115     fn set_slider_part(&mut self, k: usize, v: f32) {
 116         let q = self.quantize(v);
 117         match self {
 118             Control::Slider { value, .. } if k == 0 => *value = q,
 119             _ => {}
 120         }
 121     }
 122 }
 123 
 124 /// One row: what picking it means, plus what to draw.
 125 #[derive(Debug, Clone)]
 126 pub struct Row {
 127     /// What the app does with this row: a command id or a setting row id
 128     /// in [`Mode::Commands`], a node template's name in [`Mode::AddNode`].
 129     /// Owned rather than `&'static str` because a template name is read
 130     /// off disk.
 131     pub id: String,
 132     pub label: String,
 133     /// The chord as a human reads it, empty when there is none. Drawn in its
 134     /// own right-hand column so the dialog teaches the keyboard rather than
 135     /// replacing it — which the `cce-cloud` palette could only approximate by
 136     /// padding the label out, since all it could send was one line of text.
 137     pub chord: String,
 138     /// The row's control, if it is one — see [`Control`]. `None` for a
 139     /// command that runs and is done.
 140     pub control: Option<Control>,
 141     /// Truncate the label on the LEFT when it does not fit, rather than on
 142     /// the right: the tail of a path is what identifies it, and a row that
 143     /// cut `/home/me/projects/thing` down to `/home/me/pro...` would name
 144     /// every project in the directory equally badly.
 145     pub truncate_head: bool,
 146 }
 147 
 148 impl Row {
 149     /// A row with no control: a command, a template, a path.
 150     pub fn plain(id: impl Into<String>, label: impl Into<String>, chord: impl Into<String>) -> Row {
 151         Row { id: id.into(), label: label.into(), chord: chord.into(), control: None, truncate_head: false }
 152     }
 153 
 154     /// The switch's state, for a toggle row.
 155     pub fn toggle(&self) -> Option<bool> {
 156         match self.control {
 157             Some(Control::Toggle(on)) => Some(on),
 158             _ => None,
 159         }
 160     }
 161 
 162     /// The slider's value, for a slider row.
 163     pub fn slider_value(&self) -> Option<f32> {
 164         match self.control {
 165             Some(Control::Slider { value, .. }) => Some(value),
 166             _ => None,
 167         }
 168     }
 169 
 170     /// Whether the row's control is made of sliders: a slider, or a
 171     /// float2's two.
 172     fn is_slider(&self) -> bool {
 173         matches!(self.control, Some(Control::Slider { .. }))
 174     }
 175 
 176     fn is_color(&self) -> bool {
 177         matches!(self.control, Some(Control::Color { .. }))
 178     }
 179 }
 180 
 181 /// The dialog's outer size. Fixed rather than proportional: it is a focused
 182 /// list, and a list that grows with the window turns into a wall of rows with
 183 /// the one you want somewhere in it.
 184 const DIALOG_W: f32 = 520.0;
 185 /// The plate's height, clamped to the window by [`layout_in`].
 186 ///
 187 /// 420 until 2026-09-23, which was sized for a Settings half of thirteen
 188 /// rows. Retiring the root meta node moved everything its four utility
 189 /// subnets held into that table — it is nearer thirty now — and a list that
 190 /// shows eight of them is a list you scroll rather than read.
 191 const DIALOG_H: f32 = 640.0;
 192 
 193 const PAD: f32 = 12.0;
 194 /// The query line's height: the toolkit's text box height, since the line
 195 /// is the dialog's text field.
 196 fn query_h() -> f32 {
 197     cce_ui::layout::textbox_height()
 198 }
 199 pub const ROW_H: f32 = 24.0;
 200 /// A toggle row's switch: the toolkit's `Toggle`, at the row's height less a
 201 /// hair of air, and about twice as wide as tall — the proportion the params
 202 /// pane's toggles have.
 203 pub const TOGGLE_W: f32 = 36.0;
 204 const TOGGLE_H: f32 = ROW_H - 4.0;
 205 /// How far in from the row's right end a slider or colour row's BAND begins.
 206 /// It runs from there out to the CHORD column's right edge, so it ends
 207 /// exactly where every other row's key binding ends and the toggle column
 208 /// stays clear — a band that stopped short of the chords read as a control
 209 /// someone had forgotten to finish. Not reserved on the other rows: a
 210 /// control row has no chord, so it borrows the chord column rather than
 211 /// pushing every chord in the list left by half the plate.
 212 pub const SLIDER_W: f32 = 180.0;
 213 /// The readout's width and its gap from the band. It sits to the LEFT of the
 214 /// band, because the band's right end is spoken for. The readout is drawn by
 215 /// the dialog, not by the toolkit slider's own: the dialog claims its rect as
 216 /// a text occluder, and the clamp lets through only text carrying the
 217 /// dialog's exact bounds (see `Dialog::popover`), so the stamp's readout
 218 /// would paint and never show.
 219 const READOUT_W: f32 = 60.0;
 220 const READOUT_GAP: f32 = 8.0;
 221 /// Gap between the query line and the list.
 222 const GAP: f32 = 8.0;
 223 
 224 /// The dialog's rect inside a `width` x `height` window: centered
 225 /// horizontally, and a little above centre vertically so the list grows into
 226 /// the window's roomier half rather than down over the status bar.
 227 pub fn layout_in(width: f32, height: f32) -> (f32, f32, f32, f32) {
 228     let w = DIALOG_W.min((width - 2.0 * PAD).max(200.0));
 229     let h = DIALOG_H.min((height - 2.0 * PAD).max(160.0));
 230     let x = ((width - w) * 0.5).max(0.0).round();
 231     let y = ((height - h) * 0.4).max(0.0).round();
 232     (x, y, w, h)
 233 }
 234 
 235 /// The least height an anchored plate shrinks to before it moves up
 236 /// instead: a query line and a dozen rows.
 237 const ANCHORED_MIN_H: f32 = 340.0;
 238 
 239 /// The dialog's rect when it opens where something else stood — the network
 240 /// menu's Add Node, which turns the menu into the list. The top-left corner
 241 /// is the anchor's wherever the plate fits there: short of room below, it
 242 /// gives up height down to [`ANCHORED_MIN_H`] and only then rises, and it is
 243 /// pulled in from the right edge. The width is the centred plate's.
 244 pub fn layout_at(width: f32, height: f32, ax: f32, ay: f32) -> (f32, f32, f32, f32) {
 245     let (_, _, w, full_h) = layout_in(width, height);
 246     let x = ax.min(width - PAD - w).max(0.0).round();
 247     let below = height - PAD - ay;
 248     let (y, h) = if below >= full_h {
 249         (ay, full_h)
 250     } else if below >= ANCHORED_MIN_H.min(full_h) {
 251         (ay, below)
 252     } else {
 253         let h = ANCHORED_MIN_H.min(full_h);
 254         ((height - PAD - h).max(0.0), h)
 255     };
 256     (x, y.round(), w, h.floor())
 257 }
 258 
 259 /// The query line, at the top of the plate.
 260 fn query_rect(rect: Rect) -> Rect {
 261     Rect { x: rect.x + PAD, y: rect.y + PAD, width: (rect.width - 2.0 * PAD).max(0.0), height: query_h() }
 262 }
 263 
 264 /// The list's viewport.
 265 fn list_rect(rect: Rect) -> Rect {
 266     let q = query_rect(rect);
 267     let top = q.y + q.height + GAP;
 268     Rect { x: q.x, y: top, width: q.width, height: (rect.y + rect.height - PAD - top).max(0.0) }
 269 }
 270 
 271 /// How many rows the list can show at once, for a dialog of this size.
 272 pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
 273     (list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
 274 }
 275 
 276 /// The dialog turning into what it now shows — from the menu it replaced,
 277 /// or from the list it was in another mode — animated as the context
 278 /// menu's page turns are (`context_menu::TURN_MS`): the plate on its way
 279 /// from `from` to its own rect, the rows sliding in and coming up.
 280 #[derive(Debug, Clone, Copy)]
 281 pub struct DialogTurn {
 282     /// The plate it turned from, when that was another size: a menu.
 283     pub from: Option<Rect>,
 284     pub start: std::time::Instant,
 285     /// +1 forward (the rows come in from the right), -1 back.
 286     pub dir: f32,
 287 }
 288 
 289 pub struct Dialog {
 290     pub mode: Mode,
 291     /// The turn being animated, if one is.
 292     pub turning: Option<DialogTurn>,
 293     /// Where the plate's top-left corner goes, when it opened in place of
 294     /// something else (see [`layout_at`]); `None` centres it.
 295     pub anchor: Option<(f32, f32)>,
 296     /// What has been typed into the filter.
 297     pub query: String,
 298     /// The filtered, ranked rows — rebuilt by the app whenever `query`
 299     /// changes, never here: ranking needs the registry AND the focused pane,
 300     /// and this widget knows neither.
 301     pub rows: Vec<Row>,
 302     /// Which row Enter would run. Kept in range by [`Dialog::set_rows`].
 303     pub selected: usize,
 304     /// The list's scroll offset in px (0 = row 0 flush with the list top),
 305     /// the drawn value: rows paint at `i * ROW_H - scroll_px`, clipped to the
 306     /// list. Driven by `scroll_motion` — the DE's one wheel→offset model, so
 307     /// a notch glides and a trackpad tracks and coasts — and jumped by the
 308     /// keyboard (`scroll_to_selected`). Until 2026-09-20 this was a ROW
 309     /// index: every wheel event rounded to whole rows, so a trackpad's small
 310     /// deltas did nothing until one crossed half a row and then jumped it —
 311     /// the choppy commands list.
 312     pub scroll_px: f32,
 313     scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion,
 314     /// The list's scrollbar, in the DE's sink-behind idiom (cce-mail's
 315     /// body bar, `ScrollRegion` with `sink_behind`): pills at the list's
 316     /// right edge that fade in on a scroll, stay while hovered or dragged,
 317     /// and fade out after the hold. Sunk, it is not drawn and takes no
 318     /// input — a press on its lane reaches the row beneath.
 319     sb_activity: cce_ui::widget::ScrollbarActivity,
 320     sb_dragging: bool,
 321     /// Where in the thumb the drag grabbed it, so the thumb does not jump
 322     /// to centre itself under the pointer.
 323     sb_drag_offset: f32,
 324     /// How many rows fit — pushed in from the layout, since `on_event` and the
 325     /// app's key handling both need it and neither has the rect to hand.
 326     page: usize,
 327     hover_row: Option<usize>,
 328     /// The row whose CONTROL the pointer is over — its switch, its slider
 329     /// (readout lane included), its colour well — so the control lifts
 330     /// under the pointer as the same control does in the params pane. The
 331     /// toggle and slider stamps are set from it as each row is painted; a
 332     /// colour row's selector is a widget of its own and is told by
 333     /// `MouseEnter` / `MouseLeave` as the pointer crosses its band.
 334     hover_ctl: Option<usize>,
 335     /// A row the pointer activated, drained by the app.
 336     activated: Option<String>,
 337     /// The choice rows' closed dropdowns: one toolkit `Dropdown`, handed
 338     /// each row's options and selection as the row is painted, as the
 339     /// toggle and slider stamps are.
 340     dropdown_stamp: RefCell<Adapted<Dropdown>>,
 341     /// The OPEN dropdown: a real toolkit `Dropdown` — the params pane's —
 342     /// given the options of the row it serves (`dropdown_row`) when that
 343     /// row is opened, laid out on the row's band, and handed the pointer
 344     /// and the keys while it is open (`State::dialog_dropdown_event`). Its
 345     /// plate grows out of the trigger into the list and back. Boxed and
 346     /// never replaced, because the UI context holds a pointer to it while
 347     /// it is registered as an occluder.
 348     /// An [`Embedded`] child, the context's once the dialog is (the designer reaches it
 349     /// through [`State::dialog_dd`]); invisible while closed, so it is no Tab stop and
 350     /// nothing hits it.
 351     pub dropdown: Embedded<Adapted<Dropdown>>,
 352     /// The row id the open dropdown serves.
 353     pub dropdown_row: Option<String>,
 354     /// Whether the dropdown was last seen expanded. The runner hands every
 355     /// left press to an open popover it thinks the press MISSED before the
 356     /// app sees the press, and the dialog's own claim covers the dropdown,
 357     /// so every press reaches the dropdown that way first: a press that
 358     /// finds it no longer expanded, while this is still set, is one the
 359     /// dropdown has already taken (`State::dialog_dropdown_press`).
 360     pub dropdown_armed: bool,
 361     /// Whether the dialog is currently claiming its rect as an occluder — see
 362     /// [`Paint::popover`]. Lowered for the length of an event dispatch into
 363     /// the dialog, because the one claim serves two mechanisms that want
 364     /// opposite answers.
 365     occluding: bool,
 366     /// The switch a toggle row draws, off and on — the toolkit's own
 367     /// `Toggle`, painted by hand into the row, so a switch in the dialog IS
 368     /// the switch in the params pane. Two stamps rather than one set per row
 369     /// because `paint` takes `&self`, and building a widget per row per
 370     /// frame would be silly.
 371     toggle_stamps: RefCell<[Adapted<Toggle>; 2]>,
 372     /// The slider every slider row draws — the toolkit's own `Slider`, so a
 373     /// slider in the dialog IS the slider in the params pane. One stamp for
 374     /// all of them, set to each row's range and value as it is painted;
 375     /// interior mutability because `paint` takes `&self`.
 376     slider_stamp: RefCell<Adapted<Slider>>,
 377     /// The row whose band a press took hold of, while the pointer is moving
 378     /// it — the app drives this through its widget-drag protocol
 379     /// (`draggable` and the `drag_*` hooks), so the drag survives the
 380     /// pointer leaving the plate.
 381     slider_drag: Option<usize>,
 382     /// Which of the dragged row's sliders the press took: 0 for a slider,
 383     /// either end of a float2.
 384     slider_part: usize,
 385     /// The float2 end under the pointer, while its row's control is
 386     /// hovered — the one that lifts.
 387     hover_part: usize,
 388     /// The band's (x, width) captured at the press, so a drag keeps
 389     /// mapping the pointer while the row scrolls under it.
 390     slider_track: (f32, f32),
 391     /// The row and value the pointer moved a slider to, drained by the app.
 392     slider_change: Option<(String, f32)>,
 393     /// One toolkit colour selector per colour row, by row id — real widgets,
 394     /// not stamps, because each carries state of its own: a hex edit in
 395     /// progress, a picker process streaming values. Kept across
 396     /// re-rankings so a query that drops the row does not kill its picker.
 397     colors: Vec<(String, Adapted<ColorSelector>)>,
 398     /// Colour rows the selectors changed, `(row id, hex)`, drained by the app.
 399     color_changes: Vec<(String, String)>,
 400 }
 401 
 402 impl Dialog {
 403     pub fn new() -> Adapted<Dialog> {
 404         let mut off = Toggle::new();
 405         off.set_toggled(false);
 406         let mut on = Toggle::new();
 407         on.set_toggled(true);
 408         let mut slider_stamp = Slider::new().with_readout(false);
 409         slider_stamp.set_scroll(false);
 410         let mut d = Adapted::new(Dialog {
 411             mode: Mode::Commands,
 412             turning: None,
 413             anchor: None,
 414             query: String::new(),
 415             rows: Vec::new(),
 416             selected: 0,
 417             scroll_px: 0.0,
 418             scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion::new(),
 419             sb_activity: cce_ui::widget::ScrollbarActivity::new(),
 420             sb_dragging: false,
 421             sb_drag_offset: 0.0,
 422             page: 1,
 423             hover_row: None,
 424             hover_ctl: None,
 425             activated: None,
 426             dropdown_stamp: RefCell::new(Dropdown::new(Vec::new(), 0)),
 427             dropdown: Embedded::new({
 428                 let mut dd = Dropdown::new(Vec::new(), 0);
 429                 dd.set_visible(false);
 430                 dd
 431             }),
 432             dropdown_row: None,
 433             dropdown_armed: false,
 434             occluding: true,
 435             toggle_stamps: RefCell::new([off, on]),
 436             slider_stamp: RefCell::new(slider_stamp),
 437             slider_drag: None,
 438             slider_part: 0,
 439             hover_part: 0,
 440             slider_track: (0.0, 1.0),
 441             slider_change: None,
 442             colors: Vec::new(),
 443             color_changes: Vec::new(),
 444         });
 445         d.set_visible(false);
 446         d
 447     }
 448 
 449     /// Record how many rows fit, from the laid-out rect. Re-clamps the
 450     /// offset to the new range and nothing more: this runs on EVERY
 451     /// relayout (`layout_dialog`, off `rebuild_positions`, which the frame
 452     /// tick reaches whenever anything animates), and snapping to the
 453     /// selection here undid every wheel and finger scroll within a frame
 454     /// (2026-09-20 — the list "would not scroll at all"). Keeping the
 455     /// selection in view is the keyboard's job: `move_selection` and
 456     /// `scroll_to_selected` at the call sites that change it.
 457     pub fn set_page(&mut self, page: usize) {
 458         self.page = page.max(1);
 459         self.set_scroll_px(self.scroll_px);
 460     }
 461 
 462     /// How many rows fit — what PageUp/PageDown step by.
 463     pub fn page_len(&self) -> usize {
 464         self.page.max(1)
 465     }
 466 
 467     /// Claim, or stop claiming, the dialog's rect as an occluder.
 468     pub fn set_occluding(&mut self, on: bool) {
 469         self.occluding = on;
 470     }
 471 
 472     /// The furthest the list scrolls: the last page flush with the bottom.
 473     fn max_scroll_px(&self) -> f32 {
 474         ((self.rows.len() as f32 - self.page.max(1) as f32) * ROW_H).max(0.0)
 475     }
 476 
 477     /// The first row with any part in view.
 478     fn first_row(&self) -> usize {
 479         (self.scroll_px / ROW_H).floor().max(0.0) as usize
 480     }
 481 
 482     /// Jump the list to `px` (clamped) — the keyboard's move, not a glide.
 483     fn set_scroll_px(&mut self, px: f32) {
 484         self.scroll_px = px.clamp(0.0, self.max_scroll_px());
 485         self.scroll_motion.y.jump_to(self.scroll_px);
 486     }
 487 
 488     /// The scrollbar's geometry — `(sb_x, track_y, sb_w, track_h, thumb_y,
 489     /// thumb_h)`, mirroring `ScrollRegion::scrollbar_geom` as cce-mail's
 490     /// body bar does — or `None` when the rows fit and there is no bar. The
 491     /// one source for paint, the press and the drag. The bar rides the
 492     /// plate's CENTRE line, as both of cce-mail's bars ride theirs — over
 493     /// the rows, reserving no lane, in front only while raised; the track
 494     /// stops 4px short at each end like every toolkit bar.
 495     pub fn scrollbar_geom(&self, rect: Rect) -> Option<(f32, f32, f32, f32, f32, f32)> {
 496         let max_scroll = self.max_scroll_px();
 497         if max_scroll <= 0.0 {
 498             return None;
 499         }
 500         let list = list_rect(rect);
 501         let sb_w = cce_ui::layout::centred_scrollbar_width();
 502         let sb_x = rect.x + (rect.width - sb_w) * 0.5;
 503         let track_y = list.y + 4.0;
 504         let track_h = (list.height - 8.0).max(0.0);
 505         let content_h = self.rows.len() as f32 * ROW_H;
 506         let visible_ratio = list.height / content_h.max(1.0);
 507         let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
 508         let thumb_y = track_y + (self.scroll_px / max_scroll) * (track_h - thumb_h);
 509         Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h))
 510     }
 511 
 512     fn over_scrollbar(&self, rect: Rect, px: f32, py: f32) -> bool {
 513         self.scrollbar_geom(rect).is_some_and(|(sb_x, track_y, sb_w, track_h, _, _)| {
 514             px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
 515         })
 516     }
 517 
 518     /// A left press on the bar's strip (±4px slop, like `ScrollRegion`):
 519     /// grab the thumb where it was clicked, or jump the track there and
 520     /// drag from the thumb's centre. A sunk bar is not drawn and takes no
 521     /// input — the press falls through to the row beneath.
 522     fn sb_press(&mut self, rect: Rect, px: f32, py: f32) -> bool {
 523         if !self.sb_activity.raised() {
 524             return false;
 525         }
 526         let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) else {
 527             return false;
 528         };
 529         if px < sb_x - 4.0 || px > sb_x + sb_w + 4.0 || py < track_y || py > track_y + track_h {
 530             return false;
 531         }
 532         self.sb_dragging = true;
 533         let click_offset = py - thumb_y;
 534         if (0.0..=thumb_h).contains(&click_offset) {
 535             self.sb_drag_offset = click_offset;
 536         } else {
 537             self.sb_drag_offset = thumb_h / 2.0;
 538             self.sb_drag_to(rect, py);
 539         }
 540         true
 541     }
 542 
 543     fn sb_drag_to(&mut self, rect: Rect, py: f32) -> bool {
 544         let Some((_, track_y, _, track_h, _, thumb_h)) = self.scrollbar_geom(rect) else {
 545             return false;
 546         };
 547         let target = py - self.sb_drag_offset;
 548         let ratio = if track_h - thumb_h > 0.0 { ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0) } else { 0.0 };
 549         let old = self.scroll_px;
 550         self.set_scroll_px(ratio * self.max_scroll_px());
 551         (self.scroll_px - old).abs() > 0.01
 552     }
 553 
 554     /// Whether the bar is raised — for the app's press cascade and tests.
 555     pub fn scrollbar_raised(&self) -> bool {
 556         self.sb_activity.raised()
 557     }
 558 
 559     /// A row's rect in the list, `Some` while any part of it is in view
 560     /// (the paint clips to the list, so a partly scrolled row draws cut).
 561     fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
 562         let list = list_rect(rect);
 563         let offset = i as f32 * ROW_H - self.scroll_px;
 564         if offset + ROW_H <= 0.0 || offset >= list.height {
 565             return None;
 566         }
 567         Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
 568     }
 569 
 570     /// How far the turn in progress has gone, eased; `None` when none is.
 571     pub fn turn_progress(&self) -> Option<f32> {
 572         let t = self.turning.as_ref()?;
 573         let raw = t.start.elapsed().as_secs_f32() * 1000.0 / cce_ui::widget::context_menu::turn_ms();
 574         (raw < 1.0).then(|| cce_ui::widget::context_menu::turn_ease(raw))
 575     }
 576 
 577     /// The plate as it is drawn on `rect`: there, or on its way there from
 578     /// the plate it turned from.
 579     pub fn drawn_rect(&self, rect: Rect) -> Rect {
 580         match (self.turn_progress(), self.turning.and_then(|t| t.from)) {
 581             (Some(e), Some(f)) => Rect {
 582                 x: f.x + (rect.x - f.x) * e,
 583                 y: f.y + (rect.y - f.y) * e,
 584                 width: f.width + (rect.width - f.width) * e,
 585                 height: f.height + (rect.height - f.height) * e,
 586             },
 587             _ => rect,
 588         }
 589     }
 590 
 591     /// The row under `(x, y)` in a dialog laid out on `rect`.
 592     pub fn row_index_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
 593         self.row_at(rect, x, y)
 594     }
 595 
 596     fn row_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
 597         let list = list_rect(rect);
 598         if x < list.x || x >= list.x + list.width || y < list.y || y >= list.y + list.height {
 599             return None;
 600         }
 601         let i = ((y - list.y + self.scroll_px) / ROW_H).floor();
 602         (i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
 603     }
 604 
 605     /// Keep `selected` inside the scrolled window.
 606     pub fn scroll_to_selected(&mut self) {
 607         let view = self.page.max(1) as f32 * ROW_H;
 608         let top = self.selected as f32 * ROW_H;
 609         let bottom = top + ROW_H;
 610         if top < self.scroll_px {
 611             self.set_scroll_px(top);
 612             self.sb_activity.bump();
 613         } else if bottom > self.scroll_px + view {
 614             self.set_scroll_px(bottom - view);
 615             self.sb_activity.bump();
 616         }
 617     }
 618 
 619     pub fn move_selection(&mut self, delta: i32) {
 620         if self.rows.is_empty() {
 621             self.selected = 0;
 622             self.set_scroll_px(0.0);
 623             return;
 624         }
 625         let n = self.rows.len() as i32;
 626         // Wrapping, not clamping: a list you can fall off the bottom of makes
 627         // the last row harder to reach than the first, and this one is short.
 628         self.selected = (self.selected as i32 + delta).rem_euclid(n) as usize;
 629         self.scroll_to_selected();
 630     }
 631 
 632     /// What Enter would pick.
 633     pub fn selected_id(&self) -> Option<&str> {
 634         self.rows.get(self.selected).map(|r| r.id.as_str())
 635     }
 636 
 637     /// The selected row's control, if it has one.
 638     pub fn selected_control(&self) -> Option<&Control> {
 639         self.rows.get(self.selected).and_then(|r| r.control.as_ref())
 640     }
 641 
 642     pub fn take_activated(&mut self) -> Option<String> {
 643         self.activated.take()
 644     }
 645 
 646     /// Put the scroll back where it was after a re-rank, clamped to the
 647     /// list the rows now make.
 648     pub fn restore_scroll_px(&mut self, px: f32) {
 649         self.set_scroll_px(px);
 650     }
 651 
 652     /// Replace one row's control in place — the app re-reads a control from
 653     /// the live value after applying it, without touching the ranking, the
 654     /// selection or the scroll.
 655     pub fn set_control(&mut self, id: &str, control: Option<Control>) {
 656         if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
 657             row.control = control;
 658         }
 659         self.sync_color_selectors();
 660     }
 661 
 662     /// Move a slider row to a value, clamped and snapped as the row says.
 663     pub fn set_slider_value(&mut self, i: usize, v: f32) {
 664         self.set_slider_part(i, 0, v);
 665     }
 666 
 667     /// Move slider `k` of row `i` — a float2's either end — to a value.
 668     pub fn set_slider_part(&mut self, i: usize, k: usize, v: f32) {
 669         if let Some(c) = self.rows.get_mut(i).and_then(|r| r.control.as_mut()) {
 670             c.set_slider_part(k, v);
 671         }
 672     }
 673 
 674     pub fn take_slider_change(&mut self) -> Option<(String, f32)> {
 675         self.slider_change.take()
 676     }
 677 
 678     /// Say what row `i`'s sliders now hold, for the app to land.
 679     fn note_slider_change(&mut self, i: usize) {
 680         let Some(row) = self.rows.get(i) else { return };
 681         match row.control {
 682             Some(Control::Slider { value, .. }) => self.slider_change = Some((row.id.clone(), value)),
 683             _ => {}
 684         }
 685     }
 686 
 687     /// Whether a press has taken hold of a slider — the app arms its
 688     /// widget drag on this.
 689     pub fn slider_dragging(&self) -> bool {
 690         self.slider_drag.is_some()
 691     }
 692 
 693     /// The colour selector behind a colour row, if that row has one.
 694     pub fn color_selector(&self, id: &str) -> Option<&Adapted<ColorSelector>> {
 695         self.colors.iter().find(|(k, _)| k == id).map(|(_, s)| s)
 696     }
 697 
 698     /// The colour selector whose hex well is being typed into, if any — the
 699     /// app hands it the keyboard ahead of the filter.
 700     pub fn editing_color(&mut self) -> Option<&mut Adapted<ColorSelector>> {
 701         self.colors.iter_mut().find(|(_, s)| s.inner().editing).map(|(_, s)| s)
 702     }
 703 
 704     pub fn take_color_changes(&mut self) -> Vec<(String, String)> {
 705         std::mem::take(&mut self.color_changes)
 706     }
 707 
 708     /// Give every colour row a selector, and set each from its row — unless
 709     /// the selector is mid-edit, when the buffer is the user's and the row's
 710     /// value is what it was opened on. A value the APP put in is not a
 711     /// change to report back, so the flag it raises is dropped here.
 712     fn sync_color_selectors(&mut self) {
 713         for row in &self.rows {
 714             let Some(Control::Color { hex }) = &row.control else { continue };
 715             let k = match self.colors.iter().position(|(k, _)| *k == row.id) {
 716                 Some(k) => k,
 717                 None => {
 718                     self.colors.push((row.id.clone(), ColorSelector::new([0; 3])));
 719                     self.colors.len() - 1
 720                 }
 721             };
 722             let sel = &mut self.colors[k].1;
 723             if !sel.inner().editing {
 724                 sel.set_value_string(hex);
 725                 let _ = sel.take_change();
 726             }
 727         }
 728     }
 729 
 730     /// Collect what the selectors changed — a picker line, a hex committed
 731     /// by Enter — as `(row id, hex)`, and move the rows to match.
 732     fn drain_color_selectors(&mut self) -> bool {
 733         let mut changed = false;
 734         for (id, sel) in &mut self.colors {
 735             if !sel.take_change() {
 736                 continue;
 737             }
 738             let Some(hex) = sel.get_value_string() else { continue };
 739             if let Some(Control::Color { hex: h, .. }) =
 740                 self.rows.iter_mut().find(|r| r.id == *id).and_then(|r| r.control.as_mut())
 741             {
 742                 *h = hex.clone();
 743             }
 744             self.color_changes.push((id.clone(), hex));
 745             changed = true;
 746         }
 747         changed
 748     }
 749 
 750     /// The switch column's width: reserved on EVERY row as soon as any row
 751     /// has a toggle, so the chord column keeps a straight edge.
 752     fn toggle_col(&self) -> f32 {
 753         if self.rows.iter().any(|r| r.toggle().is_some()) { TOGGLE_W + 12.0 } else { 0.0 }
 754     }
 755 
 756     /// The band a slider or colour row draws its control over — so the
 757     /// pointer maps to the value where the band is drawn. It ends at the
 758     /// chord column's right edge, not the row's, which is why it needs the
 759     /// roster rather than the rect alone.
 760     fn slider_band_rect(&self, r: Rect) -> Rect {
 761         let x = r.x + r.width - 8.0 - SLIDER_W;
 762         let right = r.x + r.width - 8.0 - self.toggle_col();
 763         Rect { x, y: r.y + 2.0, width: (right - x).max(10.0), height: ROW_H - 4.0 }
 764     }
 765 
 766     /// The open dropdown's trigger, in window coordinates: its row's control
 767     /// band, while the row is in view. What the live dropdown is laid out
 768     /// on, so it opens from where the row's stamp was drawn.
 769     pub fn dropdown_trigger(&self, rect: Rect) -> Option<Rect> {
 770         let id = self.dropdown_row.as_deref()?;
 771         let i = self.rows.iter().position(|r| r.id == id)?;
 772         self.row_rect(rect, i).map(|r| self.slider_band_rect(r))
 773     }
 774 
 775     /// Where a row's control is, for the hover: the switch, the whole
 776     /// slider (readout lane included), the colour band, a choice's
 777     /// dropdown trigger. None for a row with no control the pointer can
 778     /// lift.
 779     fn control_rect(&self, r: Rect, row: &Row) -> Option<Rect> {
 780         match &row.control {
 781             Some(Control::Toggle(_)) => Some(Rect {
 782                 x: r.x + r.width - 8.0 - TOGGLE_W,
 783                 y: r.y + (r.height - TOGGLE_H) * 0.5,
 784                 width: TOGGLE_W,
 785                 height: TOGGLE_H,
 786             }),
 787             Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
 788             Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
 789             Some(Control::Choice { .. }) => Some(self.slider_band_rect(r)),
 790             None => None,
 791         }
 792     }
 793 
 794     /// The row whose control is under the pointer.
 795     pub fn hovered_control(&self) -> Option<usize> {
 796         self.hover_ctl
 797     }
 798 
 799     /// The whole slider control: the band plus the readout lane ahead of it.
 800     /// This is what the pointer tests against, so the wheel turns the slider
 801     /// over the readout too.
 802     fn slider_rect(&self, r: Rect) -> Rect {
 803         let b = self.slider_band_rect(r);
 804         Rect { x: b.x - READOUT_W - READOUT_GAP, width: b.width + READOUT_W + READOUT_GAP, ..b }
 805     }
 806 
 807     /// A slider row's sliders, each as (band, the whole control): one for a
 808     /// slider, two for a float2.
 809     fn slider_parts(&self, r: Rect, row: &Row) -> Vec<(Rect, Rect)> {
 810         match row.control {
 811             Some(Control::Slider { .. }) => vec![(self.slider_band_rect(r), self.slider_rect(r))],
 812             _ => Vec::new(),
 813         }
 814     }
 815 
 816     /// Which of row `i`'s sliders is under `x`, testing the band alone when
 817     /// `band_only` (a press) and band and readout together otherwise (the
 818     /// wheel, the hover).
 819     fn slider_part_at(&self, r: Rect, i: usize, x: f32, band_only: bool) -> Option<(usize, Rect)> {
 820         let row = self.rows.get(i)?;
 821         self.slider_parts(r, row).into_iter().enumerate().find_map(|(k, (band, whole))| {
 822             let s = if band_only { band } else { whole };
 823             (x >= s.x && x < s.x + s.width).then_some((k, band))
 824         })
 825     }
 826 
 827     /// Row `i`'s slider bands in window coordinates, while the row is in
 828     /// view inside the dialog at `(x, y, w, h)` — for the suite to press
 829     /// on where the pointer would.
 830     #[cfg(test)]
 831     pub fn slider_bands(&self, (x, y, w, h): (f32, f32, f32, f32), i: usize) -> Vec<Rect> {
 832         let rect = Rect { x, y, width: w, height: h };
 833         let Some(r) = self.row_rect(rect, i) else { return Vec::new() };
 834         self.slider_parts(r, &self.rows[i]).into_iter().map(|(band, _)| band).collect()
 835     }
 836 
 837     /// Step slider `k` of row `i` by wheel notches: 2% of the range each,
 838     /// the toolkit slider's own rate, up meaning more — the sign the
 839     /// viewport's zoom wheel has.
 840     fn scroll_slider(&mut self, i: usize, k: usize, notches: f32) -> bool {
 841         let Some((cur, min, max)) = self.rows.get(i).and_then(|r| r.control.as_ref()).and_then(|c| c.slider_part(k)) else {
 842             return false;
 843         };
 844         self.set_slider_part(i, k, cur + notches * 0.02 * (max - min));
 845         let Some((now, ..)) = self.rows[i].control.as_ref().and_then(|c| c.slider_part(k)) else { return false };
 846         if (now - cur).abs() < 1e-6 {
 847             return false;
 848         }
 849         self.note_slider_change(i);
 850         true
 851     }
 852 
 853     /// Put the dragged slider where the pointer is along the captured band.
 854     /// Jumps, rather than dragging relative to a grab: the band has no thumb
 855     /// to grab, and a click on a scale should mean "this much".
 856     fn slide_to(&mut self, px: f32) -> bool {
 857         let Some(i) = self.slider_drag else { return false };
 858         let k = self.slider_part;
 859         let Some((old, min, max)) = self.rows.get(i).and_then(|r| r.control.as_ref()).and_then(|c| c.slider_part(k)) else {
 860             return false;
 861         };
 862         let (tx, tw) = self.slider_track;
 863         let t = ((px - tx) / tw).clamp(0.0, 1.0);
 864         self.set_slider_part(i, k, min + t * (max - min));
 865         let Some((now, ..)) = self.rows[i].control.as_ref().and_then(|c| c.slider_part(k)) else { return false };
 866         self.note_slider_change(i);
 867         now != old
 868     }
 869 
 870     /// Swap in a freshly ranked row list, keeping the selection in range.
 871     ///
 872     /// The selection goes back to the top rather than trying to follow the
 873     /// row it was on: the rows are re-ranked by the query, so "the same row"
 874     /// after a keystroke is a different one, and the best match being
 875     /// preselected is the whole point of ranking them.
 876     pub fn set_rows(&mut self, rows: Vec<Row>) {
 877         self.rows = rows;
 878         self.selected = 0;
 879         self.set_scroll_px(0.0);
 880         self.sync_color_selectors();
 881     }
 882 
 883     /// Hand a press or wheel on a colour row's band to its selector: the well
 884     /// begins a hex edit, the swatch opens the picker. The selector's rect is
 885     /// the band, set here because the band moves with the scroll.
 886     fn color_event(&mut self, i: usize, band: Rect, event: &Event, ectx: &mut EventCtx) -> bool {
 887         let id = self.rows[i].id.clone();
 888         let Some(k) = self.colors.iter().position(|(k, _)| *k == id) else { return false };
 889         let Some(ui) = ectx.ui.as_deref_mut() else { return false };
 890         let sel = &mut self.colors[k].1;
 891         WidgetHost::set_rect(sel, band.x, band.y, band.width, band.height);
 892         let taken = sel.handle_event(event, ui);
 893         self.drain_color_selectors();
 894         taken
 895     }
 896 }
 897 
 898 impl Layout for Dialog {
 899     /// Above every pane: the dialog is modal in practice — a press inside it never reaches what it
 900     /// covers — so it has to be drawn that way too.
 901     fn z_order(&self) -> i32 {
 902         900
 903     }
 904 
 905     /// The live dropdown into the context with the dialog. Not linked: the designer routes
 906     /// to it itself (`State::dialog_dropdown_event`).
 907     fn register_embedded_children(&mut self, _host_id: WidgetId, ctx: &mut UiContext) {
 908         self.dropdown.attach(ctx);
 909     }
 910 
 911     fn release_embedded_children(&mut self, ctx: &mut UiContext) {
 912         self.dropdown.detach(ctx);
 913     }
 914 }
 915 
 916 /// How wide `text` draws: shaped by the frame's own font system, as
 917 /// `Button::label_width` measures. `measure_text_width` resolves the family
 918 /// through usvg's font database and measured a choice's value some 8 px
 919 /// wider than it drew, which put the right arrow twice as far from it as
 920 /// the left; it is the fallback for a font system that shapes nothing.
 921 pub(crate) fn shaped_width(text: &str, family: &str, font_size: f32) -> f32 {
 922     cce_ui::geometry_font_system()
 923         .lock()
 924         .ok()
 925         .and_then(|mut fs| {
 926             cce_ui::backend::window_runner::shaped_cluster_offsets(&mut fs, text, font_size, Some(family))
 927                 .last()
 928                 .map(|&(_, total)| total)
 929         })
 930         .filter(|&w| w > 0.0)
 931         .unwrap_or_else(|| display::measure_text_width(text, family, font_size))
 932 }
 933 
 934 /// Paint `w` into `r` with its text re-emitted under the dialog's own bounds
 935 /// `own`. Painted twice, on purpose: a hosted widget's text has to carry the
 936 /// DIALOG's bounds or the occluder the dialog registers clamps it away, and a
 937 /// `PaintCtx` cannot be handed a prim back — the first pass lays down the
 938 /// geometry (its text lands inside the occluder and is clamped to nothing),
 939 /// the second is a scratch pass whose text alone is re-emitted retagged.
 940 fn paint_retagged(w: &dyn Paint, r: Rect, ctx: &mut PaintCtx, own: Option<[f32; 4]>) {
 941     Paint::paint(w, r, ctx);
 942     let mut scratch = PaintCtx::new();
 943     Paint::paint(w, r, &mut scratch);
 944     for item in scratch.finish().items {
 945         if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
 946             ctx.text_with(text, x, y, font_size, color, font, own);
 947         }
 948     }
 949 }
 950 
 951 impl Paint for Dialog {
 952     /// `paint` authors geometry AND text, so the Text prims pass through
 953     /// `paint_self` verbatim instead of the single-font own-labels bridge —
 954     /// the chord column needs its own family and its own clip bounds.
 955     fn paints_own_subtree(&self) -> bool {
 956         true
 957     }
 958 
 959     /// The dialog IS a menu plate: `Material::menu` — `style.surface.menu`'s
 960     /// colour, opacity and compression — the material the context menus
 961     /// wear, so the palette and a right-click menu are configured in one
 962     /// block and cannot drift apart. Until 2026-09-28 it was the parameter
 963     /// plate's fill with a backdrop compression of its own (a constant, and
 964     /// a `surface.dialog` config key), an in-app override the menus did not
 965     /// share.
 966     fn color(&self) -> [f32; 4] {
 967         cce_ui::scene::Material::menu().fill(cce_ui::scene::PlateRole::Nested)
 968     }
 969 
 970     /// A menu has no border: its edge is the rolled perimeter
 971     /// `context_menu::paint_menu_plate` draws.
 972     fn solid_border(&self) -> Option<([f32; 4], f32)> {
 973         None
 974     }
 975 
 976     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
 977         let r = cce_ui::layout::menu_corner_radius();
 978         (r > 0.0).then_some((r, (true, true, true, true)))
 979     }
 980 
 981     /// The dialog's whole rect, as an occluder.
 982     ///
 983     /// Text is not painted in display-list order — the engine collects every
 984     /// Text prim and lays them all out at the end — so a plate drawn over a
 985     /// label does not hide it, whatever the z. What hides it is the
 986     /// popover-occlusion clamp, which reads `UiContext::active_popovers`; the
 987     /// designer registers every visible widget whose `popover_rect` is `Some`,
 988     /// so claiming one here is how the graph's node labels and the viewport's
 989     /// readouts stop bleeding through the plate.
 990     ///
 991     /// The clamp exempts text whose OWN bounds coincide with the occluder, so
 992     /// everything drawn inside the dialog carries these exact bounds and does
 993     /// its own truncating — the hosted colour selectors' text included, which
 994     /// is re-emitted retagged (see `paint`).
 995     ///
 996     /// **`occluding` exists because one claim serves two mechanisms that want
 997     /// opposite answers.** `UiContext::is_coordinate_covered` reads the same
 998     /// `popover_rect` — off every REGISTERED widget, not only the ones in
 999     /// `active_popovers` — to decide that a press has landed under something
1000     /// else. With the claim standing, every control inside the dialog is
1001     /// covered by the plate it is drawn on and nothing can be clicked.
1002     /// `State::dispatch_uncovered` lowers the flag for the length of a
1003     /// dispatch into the dialog and puts it straight back.
1004     fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
1005         // While it turns, the plate as it is drawn: the labels are cut there.
1006         let r = self.drawn_rect(rect);
1007         self.occluding.then_some((r.x, r.y, r.width, r.height))
1008     }
1009 
1010     /// Without a context the live dropdown is reached only while the dialog holds it; a
1011     /// dialog in the context paints through `paint_ui`, which reaches it there.
1012     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
1013         self.paint_with(self.dropdown.here(), rect, ctx);
1014     }
1015 
1016     fn paint_ui(&self, ui: &UiContext, rect: Rect, ctx: &mut PaintCtx) {
1017         self.paint_with(Some(self.dropdown.get(ui)), rect, ctx);
1018     }
1019 }
1020 
1021 impl Dialog {
1022     /// The dialog's paint, with the live dropdown when it can be reached.
1023     fn paint_with(&self, dropdown: Option<&Adapted<Dropdown>>, rect: Rect, ctx: &mut PaintCtx) {
1024         if rect.width <= 0.0 || rect.height <= 0.0 {
1025             return;
1026         }
1027         let open = dropdown.is_some_and(|d| d.open);
1028         let (family, font_size) = cce_ui::layout::control_label_font_parsed();
1029         let accent = colors::highlight_primary_color();
1030         let tint = [accent[0], accent[1], accent[2]];
1031         let depth = colors::plate_bevel_width();
1032         let ctrl_r = cce_ui::layout::control_corner_radius();
1033         let radii = (ctrl_r, ctrl_r, ctrl_r, ctrl_r);
1034         // The occlusion-clamp exemption (see `popover`): every label in here
1035         // carries the dialog's own rect, so none of them is clipped away by
1036         // the occluder the dialog itself registers. The cost is that bounds
1037         // no longer trim an overlong label, so the rows truncate by hand.
1038         let own = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
1039         let cols_for = |width: f32| -> usize {
1040             (width / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize
1041         };
1042         let fit = |text: &str, width: f32| -> String {
1043             if width <= 0.0 {
1044                 return String::new();
1045             }
1046             display::truncate_tail(text, cols_for(width))
1047         };
1048         // The same budget, cut from the other end — a path's tail is what
1049         // identifies it.
1050         let fit_head = |text: &str, width: f32| -> String {
1051             if width <= 0.0 {
1052                 return String::new();
1053             }
1054             display::truncate_head(text, cols_for(width))
1055         };
1056 
1057         // --- The query line: a well, like the text rows in the params pane.
1058         // The caret is a plain rule and does not blink: the dialog owns the
1059         // keyboard outright while it is open, so there is no focus to signal.
1060         // In AddNode the hint says what this opening of the plate is for;
1061         // there is no title band, so the two openings are the same height.
1062         let q = query_rect(rect);
1063         ctx.rounded_rect(q, ctrl_r, (true, true, true, true), [0.0, 0.0, 0.0, 0.22]);
1064         let qty = cce_ui::layout::align_text_y(q.y, q.height, font_size, 0.0);
1065         let qtx = q.x + 8.0;
1066         let q_w = q.width - 16.0;
1067         if self.query.is_empty() {
1068             let hint = fit(
1069                 match self.mode {
1070                     Mode::Commands => "Type to filter commands and settings",
1071                     Mode::AddNode => "Add Node: type to filter nodes",
1072                     Mode::Rename => "Rename: type the node's name",
1073                     Mode::Groups => "Group Markers: type to filter groups",
1074                 },
1075                 q_w,
1076             );
1077             ctx.text_with(hint, qtx, qty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
1078         } else {
1079             // Head-truncated: what matters while typing is the end of the
1080             // query, which is where the caret is.
1081             let shown = display::truncate_head(&self.query, (q_w / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize);
1082             ctx.text_with(shown, qtx, qty, font_size, [0xe6, 0xe6, 0xee], Some(family.clone()), own);
1083         }
1084         let caret_x = qtx + display::measure_text_width(&self.query, &family, font_size) + 1.0;
1085         // The query owns the keyboard while the palette is up: say so, so a
1086         // touch raises the on-screen keyboard. The field stands in for the
1087         // caret (and is off by the slide while the palette turns — brief, and
1088         // the board does not care where).
1089         cce_ui::text_input::claim(q.x, q.y, q.width, q.height);
1090         if caret_x < q.x + q.width - 4.0 {
1091             ctx.quad(
1092                 Rect { x: caret_x, y: q.y + 6.0, width: 1.0, height: q.height - 12.0 },
1093                 [accent[0], accent[1], accent[2], 0.9],
1094             );
1095         }
1096 
1097         // --- The rows. The chord column is right-aligned against the list's
1098         // right edge rather than padded out to a fixed width: the label is
1099         // what gets read, so it is the label that keeps the stable left edge.
1100         //
1101         // The switches get a column of their own at the far right, reserved
1102         // for EVERY row as soon as any row has one, so the chord column keeps
1103         // a straight edge whether or not the row beside it toggles. Without
1104         // the reservation the chords step left on toggle rows and the column
1105         // reads as ragged, which is worse than the strip of air it costs.
1106         let list = list_rect(rect);
1107         let toggle_col = self.toggle_col();
1108         if self.rows.is_empty() {
1109             let ty = cce_ui::layout::align_text_y(list.y, ROW_H, font_size, 0.0);
1110             let empty = match self.mode {
1111                 Mode::Commands => "No matching command or setting",
1112                 Mode::AddNode => "No matching node",
1113                 Mode::Rename => "No node to rename",
1114                 Mode::Groups => "No point group in the scene",
1115             };
1116             ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
1117             return;
1118         }
1119         ctx.clip(list, |ctx| {
1120         for i in self.first_row()..self.rows.len() {
1121             let Some(r) = self.row_rect(rect, i) else { break };
1122             let row = &self.rows[i];
1123             // The highlight stops short of the switch column. A switch has
1124             // no face of its own — it is carved out of whatever it stands
1125             // on, the DE's convention — and carved out of the selection's
1126             // tinted bevel it vanished outright: the selected row, the one
1127             // row whose state Enter is about to flip, was the one row whose
1128             // state could not be read. On the plate it reads like the rest.
1129             // A slider or colour row's control is wider than the toggle
1130             // column and takes the chord column's place on that one row.
1131             let ctl_col = if let Some(c) = row.is_slider().then(|| self.control_rect(r, row)).flatten() {
1132                 (r.x + r.width) - c.x + 4.0
1133             } else if row.is_color() {
1134                 (r.x + r.width) - self.slider_band_rect(r).x + 4.0
1135             } else {
1136                 toggle_col
1137             };
1138             let hl = Rect { width: (r.width - ctl_col).max(0.0), ..r };
1139             if i == self.selected {
1140                 ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
1141                 ctx.bevel_tinted(hl, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
1142             } else if self.hover_row == Some(i) {
1143                 ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
1144             }
1145             let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
1146             let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
1147             // What the chord column shows: the chord. A choice row shows a
1148             // dropdown in the control band instead, as a slider or a colour
1149             // row does.
1150             let (right_text, right_color) = match &row.control {
1151                 Some(Control::Choice { .. }) => (String::new(), label_color),
1152                 _ => (row.chord.clone(), [0x85, 0x85, 0x92]),
1153             };
1154             // A row that leads to another list ends in the page mark
1155             // (`refresh_dialog_rows`): drawn as the `chevron-right` glyph a
1156             // menu's page row wears, never as the character. Likewise the
1157             // back mark that leads a label (a visualizer's Back row) is the
1158             // `chevron-left` glyph, as a menu page's back band is.
1159             let (right_text, leads) = match right_text.strip_suffix(PAGE_MARK) {
1160                 Some(t) => (t.trim_end().to_string(), true),
1161                 None => (right_text, false),
1162             };
1163             let (label, back) = match row.label.strip_prefix(BACK_MARK) {
1164                 Some(rest) => (rest.trim_start(), true),
1165                 None => (row.label.as_str(), false),
1166             };
1167             let chev = (font_size * 0.8).round();
1168             let text_w = if right_text.is_empty() { 0.0 } else { shaped_width(&right_text, &family, font_size) };
1169             let right_w = text_w + if !leads { 0.0 } else if text_w > 0.0 { chev + GLYPH_GAP } else { chev };
1170             // The label's clip stops short of the chord column so a long
1171             // label is cut by it rather than running under it — or short of
1172             // the band, for a choice row.
1173             let chord_right = r.x + r.width - 8.0 - ctl_col;
1174             let label_right = match &row.control {
1175                 Some(Control::Choice { .. }) => self.slider_band_rect(r).x - 12.0,
1176                 _ => chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
1177             };
1178             let glyph_y = r.y + (r.height - chev) * 0.5;
1179             let glyph_color = |c: [u8; 3]| [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0];
1180             if back {
1181                 let at = Rect { x: r.x + 8.0, y: glyph_y, width: chev, height: chev };
1182                 ctx.icon("chevron-left", at, glyph_color(label_color));
1183             }
1184             let label_x = r.x + 8.0 + if back { chev + GLYPH_GAP } else { 0.0 };
1185             ctx.text_with(
1186                 if row.truncate_head {
1187                     fit_head(label, label_right - label_x)
1188                 } else {
1189                     fit(label, label_right - label_x)
1190                 },
1191                 label_x,
1192                 ty,
1193                 font_size,
1194                 label_color,
1195                 Some(family.clone()),
1196                 own,
1197             );
1198             if text_w > 0.0 {
1199                 ctx.text_with(right_text, chord_right - right_w, ty, font_size, right_color, Some(family.clone()), own);
1200             }
1201             if leads {
1202                 let at = Rect { x: chord_right - chev, y: glyph_y, width: chev, height: chev };
1203                 ctx.icon("chevron-right", at, glyph_color(right_color));
1204             }
1205             match &row.control {
1206                 Some(Control::Toggle(on)) => {
1207                     let tr = Rect {
1208                         x: r.x + r.width - 8.0 - TOGGLE_W,
1209                         y: r.y + (r.height - TOGGLE_H) * 0.5,
1210                         width: TOGGLE_W,
1211                         height: TOGGLE_H,
1212                     };
1213                     let mut stamps = self.toggle_stamps.borrow_mut();
1214                     stamps[*on as usize].set_hovered(self.hover_ctl == Some(i));
1215                     Paint::paint(&*stamps[*on as usize], tr, ctx);
1216                 }
1217                 Some(Control::Slider { value, min, max, dec, suffix, .. }) => {
1218                     let band = self.slider_band_rect(r);
1219                     {
1220                         let mut stamp = self.slider_stamp.borrow_mut();
1221                         stamp.set_range(*min, *max);
1222                         stamp.set_scaled_value(*value);
1223                         stamp.set_hovered(self.hover_ctl == Some(i));
1224                         Paint::paint(&**stamp, band, ctx);
1225                     }
1226                     // The readout, right-aligned in its lane ahead of the
1227                     // band — the dialog's own text, so it clears the
1228                     // occlusion clamp.
1229                     let readout = format!("{:.*}{}", *dec, value, suffix);
1230                     let rw = display::measure_text_width(&readout, &family, font_size);
1231                     ctx.text_with(
1232                         readout,
1233                         band.x - READOUT_GAP - rw,
1234                         ty,
1235                         font_size,
1236                         label_color,
1237                         Some(family.clone()),
1238                         own,
1239                     );
1240                 }
1241                 Some(Control::Color { .. }) => {
1242                     if let Some(sel) = self.color_selector(&row.id) {
1243                         let band = self.slider_band_rect(r);
1244                         // Painted twice, on purpose. The selector's hex text
1245                         // has to carry the DIALOG's bounds or the occluder
1246                         // the dialog registers clamps it away, and a
1247                         // `PaintCtx` cannot be handed a prim back: the first
1248                         // pass lays down the well and the swatch (its text
1249                         // lands inside the occluder and is clamped to
1250                         // nothing), the second is a scratch pass whose text
1251                         // alone is re-emitted retagged.
1252                         Paint::paint(&**sel, band, ctx);
1253                         let mut scratch = PaintCtx::new();
1254                         Paint::paint(&**sel, band, &mut scratch);
1255                         for item in scratch.finish().items {
1256                             if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
1257                                 ctx.text_with(text, x, y, font_size, color, font, own);
1258                             }
1259                         }
1260                     }
1261                 }
1262                 Some(Control::Choice { options, index }) => {
1263                     // The params pane's dropdown: the live one while this
1264                     // row's is open (it grows out of this trigger, painted
1265                     // over the rows below), else the shared stamp.
1266                     let band = self.slider_band_rect(r);
1267                     if let Some(dd) = dropdown.filter(|_| open && self.dropdown_row.as_deref() == Some(row.id.as_str())) {
1268                         paint_retagged(dd.inner(), band, ctx, own);
1269                     } else {
1270                         let mut stamp = self.dropdown_stamp.borrow_mut();
1271                         stamp.inner_mut().options = options.clone();
1272                         stamp.inner_mut().selected = *index;
1273                         paint_retagged(stamp.inner(), band, ctx, own);
1274                     }
1275                 }
1276                 None => {}
1277             }
1278         }
1279         });
1280 
1281         // The open dropdown's plate, grown out of its trigger over the rows
1282         // — after them, so it covers them. Its labels carry its own rect,
1283         // which `State::collect_display_list` registers as an occluder
1284         // AFTER the dialog's, so the dialog's labels under it are clamped
1285         // and its own are not.
1286         if let Some(dd) = dropdown.filter(|_| open && self.dropdown_trigger(rect).is_some()) {
1287             WidgetHost::render_popover(dd, ctx);
1288         }
1289 
1290         // The scrollbar's fore copy, over the rows, at the activity's fade:
1291         // pills, as `ScrollRegion::push_scrollbar_prims` and cce-mail's body
1292         // bar draw them. Driven by the fade rather than the latch so it
1293         // draws all the way out. The dialog's plate is the host's, so there
1294         // is no under-plate copy to show through while sunk — sunk is
1295         // simply not drawn, as cce-mail's body bar over its opaque window.
1296         let a = self.sb_activity.fade().clamp(0.0, 1.0);
1297         if a > 0.001 {
1298             if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) {
1299                 let dim = |mut c: [f32; 4]| {
1300                     c[3] *= a;
1301                     c
1302                 };
1303                 let all = (true, true, true, true);
1304                 ctx.rounded_rect(
1305                     Rect { x: sb_x, y: track_y, width: sb_w, height: track_h },
1306                     sb_w.min(track_h) * 0.5,
1307                     all,
1308                     dim(cce_ui::color::scrollbar_track_color()),
1309                 );
1310                 ctx.rounded_rect(
1311                     Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
1312                     sb_w.min(thumb_h) * 0.5,
1313                     all,
1314                     dim(cce_ui::color::scrollbar_thumb_color()),
1315                 );
1316             }
1317         }
1318     }
1319 }
1320 
1321 impl Input for Dialog {
1322     /// Advance the list's glide / coast (see `scroll_px`), and poll the
1323     /// colour selectors — a picker streams its values through a reader
1324     /// thread that only a tick can see.
1325     fn tick(&mut self, dt: f32, rect: Rect) -> bool {
1326         let max = self.max_scroll_px();
1327         self.scroll_motion.reconcile(0.0, self.scroll_px);
1328         let moved = self.scroll_motion.tick(dt, cce_ui::widget::Bounds::max(0.0), cce_ui::widget::Bounds::max(max));
1329         if moved {
1330             self.scroll_px = self.scroll_motion.y.pos();
1331         }
1332         // The bar's raise/sink latch and its fade: frames keep coming while
1333         // the hold runs and while the fore copy is still chasing the latch,
1334         // so the sink actually renders instead of freezing mid-fade.
1335         let visible = self.scrollbar_geom(rect).is_some();
1336         let flipped = self.sb_activity.tick(dt, visible, self.sb_dragging);
1337         let fade = self.sb_activity.fade();
1338         let fading = if self.sb_activity.raised() { fade < 1.0 } else { fade > 0.0 };
1339         let mut picking = false;
1340         for (_, sel) in &mut self.colors {
1341             picking |= Input::tick(sel.inner_mut(), dt, rect);
1342         }
1343         let colored = self.drain_color_selectors();
1344         // The open dropdown's grow and shrink is ticked in `tick_ctx`: it is the context's.
1345         let turning = self.turn_progress().is_some();
1346         moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || turning
1347     }
1348 
1349     /// The open dropdown's grow and shrink: frames while it runs, and a landed close settled
1350     /// back to a closed trigger — then out of sight again, so a closed one is no Tab stop.
1351     fn tick_ctx(&mut self, dt: f32, ectx: &mut EventCtx) -> bool {
1352         let rect = ectx.rect;
1353         let Some(ui) = ectx.ui.as_deref_mut() else { return false };
1354         let band = self.dropdown_trigger(rect).unwrap_or(rect);
1355         let dd = self.dropdown.get_mut(ui);
1356         let unfolding = dd.open && Input::tick(dd.inner_mut(), dt, band);
1357         let open = dd.open;
1358         if dd.visible() != open {
1359             dd.set_visible(open);
1360         }
1361         unfolding
1362     }
1363 
1364     /// The whole rect, always — this is what makes the dialog modal over what
1365     /// it covers: the designer's press cascade asks the dialog first and a hit
1366     /// never falls through to the pane underneath.
1367     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
1368         x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
1369     }
1370 
1371     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1372         let rect = ectx.rect;
1373         match event {
1374             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
1375                 // The raised bar is in front of the rows; sunk, it is not
1376                 // there and the press reaches the row.
1377                 if self.sb_press(rect, *x, *y) {
1378                     return true;
1379                 }
1380                 if let Some(i) = self.row_at(rect, *x, *y) {
1381                     self.selected = i;
1382                     let r = self.row_rect(rect, i);
1383                     if self.rows[i].is_slider() {
1384                         // On the band: take hold and jump there. On the
1385                         // rest of the row — the readout lane included:
1386                         // selected, and nothing to run. A press tests the
1387                         // BAND rather than the whole control, or a click
1388                         // on the readout would jump the value to the end
1389                         // of the range nearest it.
1390                         if let Some((k, band)) = r.and_then(|r| self.slider_part_at(r, i, *x, true)) {
1391                             self.slider_track = (band.x, band.width);
1392                             self.slider_drag = Some(i);
1393                             self.slider_part = k;
1394                             self.slide_to(*x);
1395                         }
1396                         return true;
1397                     }
1398                     if self.rows[i].is_color() {
1399                         // On the band: the selector's — a hex edit or the
1400                         // picker. Elsewhere on the row: selected, nothing
1401                         // to run.
1402                         if let Some(r) = r {
1403                             let s = self.slider_band_rect(r);
1404                             if *x >= s.x && *x < s.x + s.width {
1405                                 self.color_event(i, s, event, ectx);
1406                             }
1407                         }
1408                         return true;
1409                     }
1410                     self.activated = self.rows.get(i).map(|r| r.id.clone());
1411                     return true;
1412                 }
1413                 // Inside the plate but on no control: consumed anyway, so the
1414                 // press cannot reach the pane the dialog is covering.
1415                 true
1416             }
1417             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
1418                 if self.slider_drag.take().is_some() {
1419                     return true;
1420                 }
1421                 if std::mem::take(&mut self.sb_dragging) {
1422                     // The release starts the hold before the bar sinks.
1423                     self.sb_activity.bump();
1424                     return true;
1425                 }
1426                 false
1427             }
1428             Event::PointerMove { x, y, .. } => {
1429                 if self.slider_drag.is_some() {
1430                     return self.slide_to(*x);
1431                 }
1432                 if self.sb_dragging {
1433                     return self.sb_drag_to(rect, *y);
1434                 }
1435                 self.sb_activity.set_hover(self.over_scrollbar(rect, *x, *y));
1436                 let row = self.row_at(rect, *x, *y);
1437                 let ctl = row.filter(|&i| {
1438                     self.row_rect(rect, i)
1439                         .and_then(|r| self.control_rect(r, &self.rows[i]))
1440                         .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height)
1441                 });
1442                 let part = ctl
1443                     .and_then(|i| self.row_rect(rect, i).and_then(|r| self.slider_part_at(r, i, *x, false)))
1444                     .map_or(0, |(k, _)| k);
1445                 let changed = row != self.hover_row || ctl != self.hover_ctl || part != self.hover_part;
1446                 self.hover_row = row;
1447                 self.hover_part = part;
1448                 if ctl != self.hover_ctl {
1449                     // A colour well is a widget of its own: told the way
1450                     // the runner tells any widget.
1451                     for (i, entering) in [(self.hover_ctl, false), (ctl, true)] {
1452                         let Some(i) = i else { continue };
1453                         if self.rows.get(i).is_some_and(Row::is_color) {
1454                             if let Some(band) = self.row_rect(rect, i).map(|r| self.slider_band_rect(r)) {
1455                                 let ev = if entering { Event::MouseEnter } else { Event::MouseLeave };
1456                                 self.color_event(i, band, &ev, ectx);
1457                             }
1458                         }
1459                     }
1460                     self.hover_ctl = ctl;
1461                 }
1462                 changed
1463             }
1464             Event::MouseWheel { delta, x, y, .. } => {
1465                 if self.rows.is_empty() {
1466                     return false;
1467                 }
1468                 // Over a slider row's control the wheel turns the slider,
1469                 // not the list — the rest of the row still scrolls.
1470                 if let Some(i) = self.row_at(rect, *x, *y) {
1471                     if let Some((k, _)) = self.row_rect(rect, i).and_then(|r| self.slider_part_at(r, i, *x, false)) {
1472                         // Up is more, for a wheel and for a finger alike
1473                         // (`value_notches_y`).
1474                         return self.scroll_slider(i, k, delta.value_notches_y());
1475                     }
1476                 }
1477                 // The DE scroll model: a notch is one row and glides there, a
1478                 // trackpad tracks 1:1 and coasts on the lift (`tick` advances).
1479                 let max = self.max_scroll_px();
1480                 self.scroll_motion.reconcile(0.0, self.scroll_px);
1481                 let moved = self.scroll_motion.apply(
1482                     delta,
1483                     (ROW_H, ROW_H),
1484                     cce_ui::widget::Bounds::max(0.0),
1485                     cce_ui::widget::Bounds::max(max),
1486                 );
1487                 self.scroll_px = self.scroll_motion.y.pos();
1488                 // A scroll raises the bar and starts its hold.
1489                 self.sb_activity.bump();
1490                 moved || self.scroll_motion.is_animating()
1491             }
1492             _ => false,
1493         }
1494     }
1495 
1496     // A slider drag rides the app's widget-drag protocol (armed by
1497     // `State::dialog_mouse_input` once a press has taken a band), so the
1498     // pointer keeps moving the value after it leaves the plate, as a params
1499     // pane slider's does.
1500     fn draggable(&self, _rect: Rect) -> bool {
1501         self.slider_drag.is_some()
1502     }
1503     fn is_dragging(&self) -> bool {
1504         self.slider_drag.is_some()
1505     }
1506     fn drag_begin(&mut self, px: f32, _py: f32, _rect: Rect) {
1507         self.slide_to(px);
1508     }
1509     fn drag_update(&mut self, px: f32, _py: f32, _rect: Rect) -> bool {
1510         self.slide_to(px)
1511     }
1512     fn drag_end(&mut self) {
1513         self.slider_drag = None;
1514     }
1515 }
1516 
1517 // ---------------------------------------------------------------------------
1518 // The designer's half: what the dialog shows, and what choosing a row does.
1519 // ---------------------------------------------------------------------------
1520 
1521 use crate::app::State;
1522 use crate::command::Context;
1523 use crate::slots::DIALOG_IDX;
1524 
1525 /// The list's zoom row: not a registry command but a control — a slider
1526 /// over the network zoom, present only while the network pane is focused,
1527 /// since zoom is that pane's and a slider for a pane you are not looking at
1528 /// would be a strange thing to offer. Picking it runs nothing; dragging it,
1529 /// or the arrow keys while it is selected, zoom in place with the dialog
1530 /// up, the way the toggle rows stay up.
1531 pub const ZOOM_ROW_ID: &str = "zoom_level";
1532 
1533 /// The list's other non-command row: the open project's PATH, with its file
1534 /// name in the chord column the way a command's chord sits there — the
1535 /// palette's readout of what is being edited. Picking it copies the path to
1536 /// the clipboard, which is the one thing anyone wants a path on screen for.
1537 /// It heads the list, where it reads as the document the rest of the
1538 /// commands act on.
1539 pub const PATH_ROW_ID: &str = "project_path";
1540 
1541 /// Prefix of a recent-project row's id; the rest is the path.
1542 ///
1543 /// The recent list was the Main utility node's "Open" dropdown, and it went
1544 /// with that node — leaving `State::recent_files` written on every save and
1545 /// read by nothing. It is a list of documents, so it belongs where the open
1546 /// document's own path already is: rows under the path row, each opening its
1547 /// project. Ranked against the path text like everything else.
1548 pub const RECENT_ROW_PREFIX: &str = "recent:";
1549 /// The one row of [`Mode::Rename`].
1550 pub const RENAME_ROW_ID: &str = "rename:";
1551 /// A camera NODE of the current level, as a palette row: picking it looks
1552 /// through that camera. The Default Camera is a registry command instead
1553 /// (`default_camera`), there being always exactly one.
1554 pub const CAMERA_ROW_PREFIX: &str = "camera:";
1555 
1556 /// A row of [`Mode::Groups`]: the prefix, then the group's name.
1557 pub const GROUP_ROW_PREFIX: &str = "group:";
1558 
1559 /// How many recent projects the list offers. `recent_files` keeps ten; five
1560 /// is what fits above the commands without the palette reading as a file
1561 /// manager, and a query narrows the rest.
1562 pub const RECENT_ROW_LIMIT: usize = 5;
1563 
1564 /// Prefix of a setting row's id; the rest is the [`Setting`]'s label, which
1565 /// is unique across the table (`dialog_settings_labels_are_unique`).
1566 pub const SETTING_ROW_PREFIX: &str = "setting:";
1567 
1568 pub fn setting_row_id(label: &str) -> String {
1569     format!("{SETTING_ROW_PREFIX}{label}")
1570 }
1571 
1572 /// The setting a row id names, if it is a setting row.
1573 pub fn setting_of_row(id: &str) -> Option<&'static Setting> {
1574     let label = id.strip_prefix(SETTING_ROW_PREFIX)?;
1575     SETTINGS.iter().find(|s| s.label == label)
1576 }
1577 
1578 /// Where a setting row's value lives.
1579 ///
1580 /// It used to be neither the live `State` fields nor `DesignSettings`: both
1581 /// were DOWNSTREAM of the root meta node, whose utility subnets were copied
1582 /// over live state on every param change, so a write straight to
1583 /// `State::grid_thickness` survived exactly until the next one. With that
1584 /// node retired the live field IS the value, and it is the one kind of owner
1585 /// left. (A toggle the command registry owns went when the settings joined
1586 /// the commands list: those toggles ARE command rows there, and a second row
1587 /// for each would have listed every switch twice. A param on the ACTIVE
1588 /// camera, the Camera Pivot Size row's, went on 2026-09-30: no camera node
1589 /// has the param, so the row only ever wrote the live field, which the
1590 /// viewport menu's slider sets.)
1591 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1592 pub enum Owner {
1593     /// A display setting the app owns outright: a live field on `State`,
1594     /// persisted by `DesignSettings` into `state.kdl`. Named by the key
1595     /// `settings_field_*` dispatch on.
1596     Field(&'static str),
1597 }
1598 
1599 /// The control a [`Setting`] row draws.
1600 ///
1601 /// A bare Rust field carries no type and no range the way a param did, so
1602 /// the table spells it; the row's [`Control`] is built from it and the live
1603 /// value. The value strings are the params pane's encodings, so a setting
1604 /// reads and writes the way a node parameter of the same shape does.
1605 #[derive(Debug, Clone, Copy, PartialEq)]
1606 pub enum Ctl {
1607     Toggle,
1608     /// `#rrggbb`.
1609     Color,
1610     /// A whole number over `min..=max`. The stored float is scaled by
1611     /// `unit` (thousandths for Grid Thickness, tenths for Origin Size),
1612     /// which is the convention those params already used.
1613     Spin { min: f32, max: f32, unit: f32 },
1614     /// A float slider, `min..=max`, shown to `dec` decimals.
1615     Slider { min: f32, max: f32, dec: usize },
1616     /// A fixed set of strings.
1617     Choice(&'static [&'static str]),
1618 }
1619 
1620 /// One setting row of the list.
1621 pub struct Setting {
1622     /// What the dialog calls it — and the row's identity: the row id is
1623     /// the label under [`SETTING_ROW_PREFIX`], and the writeback resolves
1624     /// it back to this row. Unique across the table.
1625     pub label: &'static str,
1626     pub owner: Owner,
1627     pub ctl: Ctl,
1628 }
1629 
1630 impl Setting {
1631     /// A row over a live field.
1632     const fn field(label: &'static str, key: &'static str, ctl: Ctl) -> Self {
1633         Setting { label, owner: Owner::Field(key), ctl }
1634     }
1635 }
1636 
1637 /// The setting rows, in the order an empty query lists them.
1638 ///
1639 /// Scope is exactly what `DesignSettings` persists: the DISPLAY state, which
1640 /// is the part of the app's configuration that is a preference rather than
1641 /// part of a project. The four utility subnets under the root meta node
1642 /// (`main`, `view`, `guides`, `render`) held these values until 2026-09-23,
1643 /// and every one of them that was reachable only by selecting one of those
1644 /// nodes is a row here or a command in the registry. Anything left out would
1645 /// not be "hidden in the node tree", it would be gone —
1646 /// `every_retired_subnet_setting_is_reachable` is the backstop.
1647 ///
1648 /// The TOGGLES those subnets held (Show Grid, Show Wireframe, Square Aspect
1649 /// …) are not here: each is a registry command with a switch on its own
1650 /// row, and this table lists what is not a command. Still deliberately NOT
1651 /// here either: the pane-visibility toggles (commands too), the active
1652 /// camera (the viewport menubar's own menu, whose entries are the camera
1653 /// NODES and so cannot be a fixed table), and keybindings, which this DE
1654 /// edits as `input.kdl` on purpose.
1655 pub const SETTINGS: &[Setting] = &[
1656     Setting::field("Background Color", "bg_color", Ctl::Color),
1657     Setting::field("World Unit", "world_unit", Ctl::Choice(&["mm", "cm", "m", "in"])),
1658     // How the network's node wires run — not the wireframe's wires below.
1659     Setting::field("Node Wire Style", "node_wire_style", Ctl::Choice(&["Orthogonal", "Rounded", "Bezier", "Straight"])),
1660     // Takes effect at the next launch — the renderer's device is chosen
1661     // once, when it is created (`app::apply_gpu_preference`).
1662     Setting::field("GPU", "gpu", Ctl::Choice(crate::app::GPU_CHOICES)),
1663     Setting::field("Geometry Opacity", "geo_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
1664     // The colour applies only in single-colour mode (off, the wires carry
1665     // the geometry's vertex colours) — so a colour edit turns that mode on,
1666     // or a colour set here looks ignored.
1667     Setting::field("Wireframe Color", "wire_color", Ctl::Color),
1668     // The wires' own opacity, in both colour modes; Geometry Opacity is the
1669     // polygons'. It was the wire colour's alpha until 2026-09-25.
1670     Setting::field("Wire Opacity", "wire_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
1671     Setting::field("Wire Thickness", "wire_width", Ctl::Slider { min: 1.0, max: 8.0, dec: 1 }),
1672     // The Selected-Group markers' radius, in world units.
1673     Setting::field("Group Marker Size", "group_marker_size", Ctl::Slider { min: 0.0, max: crate::app::GROUP_MARKER_SIZE_MAX, dec: 3 }),
1674     // The pull arrows' length over the true displacement; 1 is the vector
1675     // as it is.
1676     Setting::field("Pull Arrow Scale", "pull_arrow_scale", Ctl::Slider { min: 0.25, max: 10.0, dec: 2 }),
1677     // In world units, as Group Marker Size is, and as the viewport menu's
1678     // slider always read it: until 2026-09-29 this row was a spin in
1679     // THOUSANDTHS (5..100), so the two sizes read as different numbers for
1680     // one radius, and 0.025 typed here landed on the spin's floor.
1681     Setting::field("Point Marker Size", "point_marker_size", Ctl::Slider { min: 0.005, max: 0.1, dec: 3 }),
1682     Setting::field("Point Marker Color", "point_marker_color", Ctl::Color),
1683     Setting::field("Grid Color", "grid_color", Ctl::Color),
1684     Setting::field("Grid Thickness", "grid_thickness", Ctl::Spin { min: 2.0, max: 200.0, unit: 1000.0 }),
1685     Setting::field("Origin Size", "origin_size", Ctl::Spin { min: 1.0, max: 50.0, unit: 10.0 }),
1686 ];
1687 
1688 /// The page mark, as a menu's page row wears it, and the back mark a menu
1689 /// page's back band leads with. Both ride the row's TEXT (the chord and the
1690 /// label) and are drawn as cce-icons chevrons by the row painter.
1691 pub use cce_ui::widget::context_menu::{BACK_MARK, PAGE_MARK};
1692 
1693 /// The gap between a row's chevron glyph and the text beside it — the
1694 /// toolkit menu's own.
1695 const GLYPH_GAP: f32 = 6.0;
1696 
1697 /// Whether a row of the dialog in `mode` turns it into another list: the
1698 /// palette's Group Markers. A press runs it, as before; a side swipe forward
1699 /// with the pointer on it does too. (Attribute Visualizers led into the
1700 /// dialog's visualizer list until 2026-10-06; they are edited in the params
1701 /// HUD now, and the row closes the palette and opens them there.)
1702 pub fn dialog_row_leads(mode: Mode, id: &str) -> bool {
1703     match mode {
1704         Mode::Commands => matches!(id, "group_markers"),
1705         _ => false,
1706     }
1707 }
1708 
1709 impl State {
1710     pub fn dialog_visible(&self) -> bool {
1711         self.ui_context[self.slots.dialog].visible()
1712     }
1713 
1714     pub fn toggle_dialog(&mut self) {
1715         if self.dialog_visible() && self.ui_context[self.slots.dialog].mode == Mode::Commands {
1716             self.close_dialog();
1717         } else {
1718             self.open_dialog();
1719         }
1720     }
1721 
1722     /// Open the dialog on its commands-and-settings list — `Alt+D`, and what
1723     /// `Ctrl+P` reaches instead of spawning a popup process.
1724     pub fn open_dialog(&mut self) {
1725         self.open_dialog_in(Mode::Commands);
1726     }
1727 
1728     /// The add-node palette: the same plate, one list, and a pick that
1729     /// instantiates a template at the grid cursor.
1730     ///
1731     /// Was a `cce-cloud --dmenu` popup — a second process with its own
1732     /// window, fed one line of text per row and answering with one line back.
1733     /// It could not show a chord in a column of its own, could not be styled
1734     /// with the app, and put a second filterable list in front of the user
1735     /// that looked nothing like the first.
1736     pub fn open_node_palette(&mut self) {
1737         if self.dialog_visible() && self.ui_context[self.slots.dialog].mode == Mode::AddNode {
1738             self.close_dialog();
1739             return;
1740         }
1741         self.open_dialog_in(Mode::AddNode);
1742     }
1743 
1744     /// The add-node palette opened where the network menu stood, by that
1745     /// menu's Add Node row: the menu TRANSFORMS into the list, as the
1746     /// palette transforms into Group Markers. The two plates are one
1747     /// material (`paint_menu_plate`), so the list taking the menu's corner
1748     /// is what makes it read as the same plate grown, not a second one
1749     /// arriving across the window.
1750     pub fn open_node_palette_at(&mut self, x: f32, y: f32) {
1751         self.open_dialog_from(Mode::AddNode, crate::menu_page::MenuOrigin::Network, (x, y));
1752     }
1753 
1754     /// The dialog in `mode`, turned to from the menu `origin` by one of its
1755     /// page rows: its top-left where the menu's was, and a swipe back shows
1756     /// the menu again (see `crate::menu_page`).
1757     ///
1758     /// The plate grows out of the menu just put down: its size is what the
1759     /// context menu still holds after the hide.
1760     pub fn open_dialog_from(&mut self, mode: Mode, origin: crate::menu_page::MenuOrigin, at: (f32, f32)) {
1761         use cce_ui::widget::context_menu;
1762         let from = Rect { x: at.0, y: at.1, width: context_menu::w(), height: context_menu::h() };
1763         self.close_dialog();
1764         self.open_dialog_anchored(mode, Some(at));
1765         self.dialog_from = Some(origin);
1766         self.ui_context[self.slots.dialog].turning = Some(DialogTurn { from: Some(from), start: std::time::Instant::now(), dir: 1.0 });
1767     }
1768 
1769     /// Turn the open dialog to `mode`, as the row or key that leads there
1770     /// does.
1771     pub(crate) fn open_dialog_mode(&mut self, mode: Mode) {
1772         self.open_dialog_in(mode);
1773     }
1774 
1775     /// Wire the node just added (the level's last) into the wire from the
1776     /// node `src_id` to `dest_id`, and say so. A node with no Input — a
1777     /// generator — is left unwired: it cannot sit mid-chain.
1778     fn splice_new_node(&mut self, src_id: &str, dest_id: &str) {
1779         let dir = self.current_dir();
1780         let (Some(new), Some(src)) = (dir.children.last(), dir.children.iter().find(|c| c.id == src_id)) else { return };
1781         let (new_id, new_name, src_name) = (new.id.clone(), new.name.clone(), src.name.clone());
1782         if crate::app::splice_into_wire(self.current_dir_mut(), &new_id, src_name.clone(), dest_id) {
1783             self.sync_nodes();
1784             self.rebuild_scene_geometry();
1785             self.sync_parameters_pane();
1786             let dest = self.current_dir().children.iter().find(|c| c.id == dest_id).map(|c| c.name.clone()).unwrap_or_default();
1787             self.update_status_text(&format!("Added {new_name} between {src_name} and {dest}."));
1788         }
1789     }
1790 
1791     /// Open the dialog to rename the node in `slot` of the current level.
1792     /// The query line is the name: it opens holding the one the node has,
1793     /// so a rename that changes a letter is a letter typed.
1794     pub fn open_rename_dialog(&mut self, slot: usize) {
1795         self.open_rename_dialog_from(slot, None);
1796     }
1797 
1798     /// [`Self::open_rename_dialog`], turned to from a menu's Rename row
1799     /// standing at a corner, when `from` says so.
1800     pub fn open_rename_dialog_from(&mut self, slot: usize, from: Option<(crate::menu_page::MenuOrigin, (f32, f32))>) {
1801         let Some((id, name)) = self.current_dir().children.get(slot).map(|n| (n.id.clone(), n.name.clone())) else {
1802             self.update_status_text("Select a node to rename.");
1803             return;
1804         };
1805         self.rename_target = Some(id);
1806         match from {
1807             Some((origin, at)) => self.open_dialog_from(Mode::Rename, origin, at),
1808             None => self.open_dialog_in(Mode::Rename),
1809         }
1810         self.ui_context[self.slots.dialog].query = name;
1811         self.refresh_dialog_rows();
1812     }
1813 
1814     /// The Group Markers list: the `group_markers` command. From the
1815     /// palette this is the palette transformed — the same plate, with the
1816     /// groups where the commands were.
1817     pub fn open_group_markers_dialog(&mut self) {
1818         self.open_dialog_in(Mode::Groups);
1819     }
1820 
1821     /// What renaming the dialog's node to `typed` would do: the name it
1822     /// has and the one it would get, or why not. None when the node is
1823     /// gone.
1824     pub fn rename_outcome(&self, typed: &str) -> Option<Result<(String, String), String>> {
1825         let id = self.rename_target.as_ref()?;
1826         let node = crate::viewer_state::find_node_by_id(&self.fs_root, id)?;
1827         Some(self.rename_check(id, typed).map(|new| (node.name.clone(), new)))
1828     }
1829 
1830     fn open_dialog_in(&mut self, mode: Mode) {
1831         self.open_dialog_anchored(mode, None);
1832     }
1833 
1834     fn open_dialog_anchored(&mut self, mode: Mode, anchor: Option<(f32, f32)>) {
1835         // A dialog that is up and turns to another mode stays where it
1836         // stands, turned to from where it was: the mode it leaves goes on
1837         // the trail a swipe back follows — or, when the mode it turns to IS
1838         // the trail's last, it is going back, and comes off. Opened afresh
1839         // it has neither, and whoever opened it from a menu says so after.
1840         let was = self.dialog_visible().then_some(self.ui_context[self.slots.dialog].mode);
1841         self.ui_context[self.slots.dialog].turning = None;
1842         match was {
1843             Some(prev) if anchor.is_none() => {
1844                 // The same plate turned to another list: the rows slide in,
1845                 // from the right going on, from the left coming back.
1846                 let dir = if self.dialog_trail.last() == Some(&mode) {
1847                     self.dialog_trail.pop();
1848                     Some(-1.0)
1849                 } else if prev != mode {
1850                     self.dialog_trail.push(prev);
1851                     Some(1.0)
1852                 } else {
1853                     None
1854                 };
1855                 self.ui_context[self.slots.dialog].turning = dir.map(|dir| DialogTurn { from: None, start: std::time::Instant::now(), dir });
1856             }
1857             _ => {
1858                 self.ui_context[self.slots.dialog].anchor = anchor;
1859                 self.dialog_trail.clear();
1860                 self.dialog_from = None;
1861             }
1862         }
1863         // Always with an empty query: a dialog that reopens holding the last
1864         // search has to be cleared before it can be used, which is a step
1865         // every single time to save one occasionally.
1866         self.ui_context[self.slots.dialog].mode = mode;
1867         self.ui_context[self.slots.dialog].query.clear();
1868         self.ui_context[self.slots.dialog].set_visible(true);
1869         self.refresh_dialog_rows();
1870         self.rebuild_positions();
1871         self.apply_layout();
1872         // Modal: the panes lose the pointer while it is up.
1873         self.broadcast_pointer();
1874         self.update_status_text(match mode {
1875             Mode::Commands => "Dialog: type to filter commands and settings, Escape closes.",
1876             Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
1877             Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
1878             Mode::Groups => "Group Markers: Enter or a click marks a group's points in the scene, Escape closes.",
1879         });
1880     }
1881 
1882     pub fn close_dialog(&mut self) {
1883         if !self.dialog_visible() {
1884             return;
1885         }
1886         // Shut, not animated: with the dialog gone there is nothing to
1887         // shrink into, and a plate still reporting itself open would keep
1888         // covering presses meant for the panes.
1889         let dd = self.dialog_dd();
1890         self.ui_context[dd].inner_mut().open = false;
1891         self.ui_context[dd].set_visible(false);
1892         self.ui_context[self.slots.dialog].dropdown_row = None;
1893         self.ui_context[self.slots.dialog].dropdown_armed = false;
1894         self.ui_context[self.slots.dialog].set_visible(false);
1895         self.dialog_from = None;
1896         self.dialog_trail.clear();
1897         if self.focused_widget == Some(DIALOG_IDX) {
1898             self.focused_widget = None;
1899         }
1900         self.rebuild_positions();
1901         self.apply_layout();
1902         // The pane under the pointer hovers again without a motion.
1903         self.broadcast_pointer();
1904     }
1905 
1906     /// Re-rank the row list against the current query, for whichever mode is
1907     /// up.
1908     ///
1909     /// Commands and settings rank together through the one
1910     /// [`crate::command::fuzzy_rank`], with the focused pane's commands
1911     /// partitioned to the front as [`crate::command::palette_entries`] does
1912     /// (a setting belongs to no pane, so it ranks among the rest). Node
1913     /// templates rank through the same function, so typing means the same
1914     /// thing in every list; they carry no chord, so the column is simply
1915     /// empty for them.
1916     pub fn refresh_dialog_rows(&mut self) {
1917         let query = self.ui_context[self.slots.dialog].query.clone();
1918         let rows: Vec<Row> = match self.ui_context[self.slots.dialog].mode {
1919             // One row, which is what Enter will do: the name as it will be
1920             // written, or why it will not be.
1921             Mode::Rename => match self.rename_outcome(&query) {
1922                 Some(Ok((old, new))) => vec![Row::plain(RENAME_ROW_ID, format!("Rename {old} to {new}"), "")],
1923                 Some(Err(why)) => vec![Row::plain(RENAME_ROW_ID, why, "")],
1924                 None => vec![],
1925             },
1926             Mode::Commands => {
1927                 let cmds = crate::command::COMMANDS;
1928                 // The level's camera nodes rank among the commands, as
1929                 // the viewport's: "Camera: camera1".
1930                 let cameras: Vec<String> = self.camera_names().into_iter().skip(1).collect();
1931                 let camera_labels: Vec<String> = cameras.iter().map(|n| format!("Camera: {n}")).collect();
1932                 let mut labels: Vec<&str> = cmds.iter().map(|c| c.label).collect();
1933                 labels.extend(SETTINGS.iter().map(|s| s.label));
1934                 labels.extend(camera_labels.iter().map(|l| l.as_str()));
1935                 let contexts: Vec<Context> = cmds
1936                     .iter()
1937                     .map(|c| c.context)
1938                     .chain(SETTINGS.iter().map(|_| Context::Always))
1939                     .chain(cameras.iter().map(|_| Context::Viewport))
1940                     .collect();
1941                 let ranked = crate::command::rank_with_focus(&query, &labels, &contexts, self.focused_context());
1942                 let mut rows: Vec<Row> = ranked
1943                     .into_iter()
1944                     .map(|i| {
1945                         if i < cmds.len() {
1946                             let c = &cmds[i];
1947                             Row {
1948                                 id: c.id.to_string(),
1949                                 label: c.label.to_string(),
1950                                 chord: self
1951                                     .shortcut_manager
1952                                     .chord_for(c.id)
1953                                     .map(|s| s.describe())
1954                                     .unwrap_or_default(),
1955                                 control: self.command_toggle_state(c.id).map(Control::Toggle),
1956                                 truncate_head: false,
1957                             }
1958                         } else if i < cmds.len() + SETTINGS.len() {
1959                             self.setting_row(&SETTINGS[i - cmds.len()])
1960                         } else {
1961                             let at = i - cmds.len() - SETTINGS.len();
1962                             let active = cameras[at] == self.active_camera;
1963                             Row {
1964                                 id: format!("{CAMERA_ROW_PREFIX}{}", cameras[at]),
1965                                 label: camera_labels[at].clone(),
1966                                 // The column a chord would use says which
1967                                 // camera the viewport is looking through.
1968                                 chord: if active { "active".to_string() } else { String::new() },
1969                                 control: None,
1970                                 truncate_head: false,
1971                             }
1972                         }
1973                     })
1974                     .collect();
1975                 // The open project's path heads the list — ranked against
1976                 // the path text, so typing any part of it (the project's
1977                 // name included, that being the tail) finds or drops the row
1978                 // like any other. Absent when no project is loaded: the
1979                 // bundled `default_project.json` leaves `loaded_project_path`
1980                 // None on purpose, and a row offering to copy a path to a
1981                 // versioned file in the source tree would be a trap.
1982                 if let Some((path, name)) = self.project_path_readout() {
1983                     if !crate::command::fuzzy_rank(&query, &[path.as_str()]).is_empty() {
1984                         rows.insert(
1985                             0,
1986                             Row { id: PATH_ROW_ID.to_string(), label: path, chord: name, control: None, truncate_head: true },
1987                         );
1988                     }
1989                 }
1990                 // The recent projects, under the path row — the open
1991                 // document, then the ones before it. The project already
1992                 // open is not offered again.
1993                 let open_now = self.loaded_project_path.clone();
1994                 let recent: Vec<std::path::PathBuf> = self
1995                     .recent_files
1996                     .iter()
1997                     .filter(|p| Some(*p) != open_now.as_ref())
1998                     .take(RECENT_ROW_LIMIT)
1999                     .cloned()
2000                     .collect();
2001                 for path in recent.iter().rev() {
2002                     let text = path.to_string_lossy().to_string();
2003                     if crate::command::fuzzy_rank(&query, &[text.as_str()]).is_empty() {
2004                         continue;
2005                     }
2006                     let name = path
2007                         .file_name()
2008                         .map(|n| n.to_string_lossy().to_string())
2009                         .unwrap_or_default();
2010                     rows.insert(
2011                         0,
2012                         Row {
2013                             id: format!("{RECENT_ROW_PREFIX}{text}"),
2014                             label: text,
2015                             chord: name,
2016                             control: None,
2017                             // Same reason as the path row: the tail of a
2018                             // path is what identifies it.
2019                             truncate_head: true,
2020                         },
2021                     );
2022                 }
2023                 // The zoom slider heads the network pane's list, ranked like
2024                 // a row labelled "Zoom" so a query still finds (or drops) it.
2025                 if self.focused_context() == Context::Network
2026                     && !crate::command::fuzzy_rank(&query, &["Zoom"]).is_empty()
2027                 {
2028                     rows.insert(
2029                         0,
2030                         Row {
2031                             id: ZOOM_ROW_ID.to_string(),
2032                             label: "Zoom".to_string(),
2033                             chord: String::new(),
2034                             control: Some(self.zoom_control()),
2035                             truncate_head: false,
2036                         },
2037                     );
2038                 }
2039                 rows
2040             }
2041             // The scene's point groups, ranked by name, a switch each and the
2042             // member count in the chord column.
2043             Mode::Groups => {
2044                 let names: Vec<&str> = self.scene_groups.iter().map(|(n, _)| n.as_str()).collect();
2045                 crate::command::fuzzy_rank(&query, &names)
2046                     .into_iter()
2047                     .map(|i| {
2048                         let (name, members) = &self.scene_groups[i];
2049                         Row {
2050                             id: format!("{GROUP_ROW_PREFIX}{name}"),
2051                             label: name.clone(),
2052                             chord: format!("{} point{}", members.len(), if members.len() == 1 { "" } else { "s" }),
2053                             control: Some(Control::Toggle(self.group_marked(name))),
2054                             truncate_head: false,
2055                         }
2056                     })
2057                     .collect()
2058             }
2059             Mode::AddNode => {
2060                 // The templates that may stand at this level: at the root
2061                 // the Geometry node, cameras and pages; inside a geometry
2062                 // node everything but the first two (`context::placement`).
2063                 // The settings directories that refused geometry once were
2064                 // the root meta node's utility subnets, and they are gone.
2065                 let here = crate::context::context_at(&self.current_path);
2066                 let offered: Vec<&str> = self
2067                     .node_templates
2068                     .iter()
2069                     .filter(|t| crate::context::fits(crate::context::placement(&t.node.node_type), here))
2070                     .map(|t| t.label.as_str())
2071                     .collect();
2072                 crate::command::fuzzy_rank(&query, &offered)
2073                     .into_iter()
2074                     .map(|i| Row::plain(offered[i], offered[i], ""))
2075                     .collect()
2076             }
2077         };
2078         // A row that turns the dialog into another list says so where a
2079         // menu's page row does, at its right end.
2080         let mode = self.ui_context[self.slots.dialog].mode;
2081         let rows = rows
2082             .into_iter()
2083             .map(|mut r| {
2084                 if dialog_row_leads(mode, &r.id) {
2085                     r.chord = if r.chord.is_empty() { PAGE_MARK.to_string() } else { format!("{}  {PAGE_MARK}", r.chord) };
2086                 }
2087                 r
2088             })
2089             .collect();
2090         self.ui_context[self.slots.dialog].set_rows(rows);
2091     }
2092 
2093     /// The open project's path and its file name, for the palette's path row
2094     /// — `None` when no project is loaded.
2095     ///
2096     /// The name is `file_name()`, which is the same thing the WINDOW TITLE
2097     /// shows, so the palette and the title bar cannot disagree about what is
2098     /// open. A project is a DIRECTORY holding `state.json`, so that name is
2099     /// the directory's; the bundled `default_project.json` is the one single
2100     /// file, and it never gets here because loading it leaves
2101     /// `loaded_project_path` None — the app's own position is that nothing is
2102     /// loaded, and Set As Default says the same.
2103     pub fn project_path_readout(&self) -> Option<(String, String)> {
2104         let path = self.loaded_project_path.as_ref()?;
2105         let name = path.file_name()?.to_string_lossy().into_owned();
2106         Some((path.to_string_lossy().into_owned(), name))
2107     }
2108 
2109     /// Put the open project's path on the clipboard, returning what was
2110     /// copied — the palette's path row, and the only thing a path on screen
2111     /// is ever wanted for. `None` when there is no project to name.
2112     pub fn copy_project_path(&mut self) -> Option<String> {
2113         let (path, _) = self.project_path_readout()?;
2114         // Not under test. `wl-copy` has to OUTLIVE its caller to serve the
2115         // selection, and it inherits the test binary's captured stdout — so a
2116         // test that really copied left cargo waiting on a pipe held open by a
2117         // clipboard daemon, which looks exactly like a hung test suite.
2118         #[cfg(not(test))]
2119         cce_ui::widget::clipboard::copy_to_clipboard(&path);
2120         self.update_status_text(&format!("Copied {path}"));
2121         Some(path)
2122     }
2123 
2124     /// The network zoom as the slider row reads it: the current x pitch as a
2125     /// percentage of the configured one, so 100 is Reset Zoom.
2126     pub fn zoom_percent(&self) -> f32 {
2127         let cfg = crate::app::configured_grid_geometry();
2128         if cfg.pitch_x > 0.0 {
2129             self.grid_pitch_x / cfg.pitch_x * 100.0
2130         } else {
2131             100.0
2132         }
2133     }
2134 
2135     /// The zoom row's control: the live percentage over the pitch limits,
2136     /// nudged ten points at a time.
2137     fn zoom_control(&self) -> Control {
2138         let cfg = crate::app::configured_grid_geometry();
2139         let pct = |pitch: f32| pitch / cfg.pitch_x.max(1e-3) * 100.0;
2140         Control::Slider {
2141             value: self.zoom_percent(),
2142             min: pct(crate::app::MIN_PITCH_X),
2143             max: pct(crate::app::MAX_PITCH_X),
2144             dec: 0,
2145             step: 10.0,
2146             suffix: "%",
2147         }
2148     }
2149 
2150     /// Zoom the network to a percentage of the configured grid, about the
2151     /// cursor cell — what the slider row's drag lands on. `zoom` clamps, so
2152     /// the row is re-read afterwards rather than trusted.
2153     pub fn set_zoom_percent(&mut self, pct: f32) {
2154         let cfg = crate::app::configured_grid_geometry();
2155         if self.grid_pitch_x > 0.0 {
2156             let factor = cfg.pitch_x * pct / 100.0 / self.grid_pitch_x;
2157             if (factor - 1.0).abs() > 1e-4 {
2158                 self.zoom(factor, None);
2159             }
2160         }
2161         self.refresh_dialog_zoom();
2162     }
2163 
2164     /// Re-read the zoom row from the live zoom, in place.
2165     fn refresh_dialog_zoom(&mut self) {
2166         let c = self.zoom_control();
2167         if self.ui_context[self.slots.dialog].rows.iter().any(|r| r.id == ZOOM_ROW_ID) {
2168             self.ui_context[self.slots.dialog].set_control(ZOOM_ROW_ID, Some(c));
2169         }
2170     }
2171 
2172     /// What a toggle command's switch currently shows, or `None` for a
2173     /// command that is not a toggle.
2174     ///
2175     /// Read off the very field each command flips in `execute_action` /
2176     /// `execute_menu_action` — the same read the View menu's checkmarks are
2177     /// set from — so the switch cannot disagree with the menu. Snapping is
2178     /// a toggle only INSIDE a viewer state; outside one the command does
2179     /// nothing but say so, and a switch on a row that cannot flip would be a
2180     /// lie, so the row is plain until a state is entered.
2181     /// `dialog_toggle_rows_cover_every_toggle_command` keeps this list and
2182     /// the registry's `toggle_*` / `show_*_pane` rows in step.
2183     pub fn command_toggle_state(&self, id: &str) -> Option<bool> {
2184         Some(match id {
2185             "toggle_grid" => self.viewport().show_grid,
2186             "toggle_origin" => self.viewport().show_origin,
2187             "toggle_camera_pivot" => self.viewport().show_camera_pivot,
2188             "toggle_wireframe" => self.wireframe,
2189             "toggle_smooth_shading" => self.smooth_shading,
2190             "toggle_show_occluded" => self.show_occluded,
2191             "toggle_point_markers" => self.show_point_markers,
2192             "toggle_point_numbers" => self.show_point_numbers,
2193             "toggle_point_normals" => self.show_point_normals,
2194             "toggle_prim_numbers" => self.show_prim_numbers,
2195             "toggle_prim_normals" => self.show_prim_normals,
2196             "toggle_vertex_numbers" => self.show_vertex_numbers,
2197             "toggle_vertex_markers" => self.show_vertex_markers,
2198             "toggle_vertex_normals" => self.show_vertex_normals,
2199             "toggle_wire_single_color" => self.wire_single_color,
2200             "toggle_ray_traced_preview" => self.viewport().rt_mode,
2201             "toggle_square_viewport" => self.square_viewport,
2202             "toggle_params_plate" => self.params_plate,
2203             "toggle_circular_pane" => self.circular_network_pane,
2204             "detach_circular_window" => self.detached_circular_network,
2205             "toggle_spreadsheet" => self.show_spreadsheet,
2206             "show_network_pane" => self.show_network,
2207             "show_viewport_pane" => self.show_viewport,
2208             "show_parameters_pane" => self.show_parameters,
2209             "show_playbar_pane" => self.show_playbar,
2210             "toggle_playbar_repeat" => self.ui_context[self.slots.playbar].inner().repeat,
2211             "toggle_playbar_step_buttons" => self.ui_context[self.slots.playbar].inner().step_buttons,
2212             "toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
2213             _ => return None,
2214         })
2215     }
2216 
2217     /// Re-read every row's control from the live state, touching nothing
2218     /// else — not the ranking, not the selection, not the scroll. This is
2219     /// what a toggle pick or a setting edit runs instead of
2220     /// `refresh_dialog_rows`: the rows are the same rows, only a control has
2221     /// moved, and re-ranking would throw the selection back to the top of a
2222     /// list the user is still working down.
2223     pub(crate) fn refresh_dialog_controls(&mut self) {
2224         if self.ui_context[self.slots.dialog].mode == Mode::Groups {
2225             let ids: Vec<String> = self.ui_context[self.slots.dialog].rows.iter().map(|r| r.id.clone()).collect();
2226             for id in ids {
2227                 if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
2228                     let on = self.group_marked(name);
2229                     self.ui_context[self.slots.dialog].set_control(&id, Some(Control::Toggle(on)));
2230                 }
2231             }
2232             return;
2233         }
2234         if self.ui_context[self.slots.dialog].mode != Mode::Commands {
2235             return;
2236         }
2237         let ids: Vec<String> = self.ui_context[self.slots.dialog].rows.iter().map(|r| r.id.clone()).collect();
2238         for id in ids {
2239             let control = if let Some(s) = setting_of_row(&id) {
2240                 Some(self.setting_control(s))
2241             } else if id == ZOOM_ROW_ID {
2242                 Some(self.zoom_control())
2243             } else if let Some(on) = self.command_toggle_state(&id) {
2244                 Some(Control::Toggle(on))
2245             } else {
2246                 continue;
2247             };
2248             self.ui_context[self.slots.dialog].set_control(&id, control);
2249         }
2250     }
2251 
2252     /// A setting's row: its label, no chord, and the control its `Ctl`
2253     /// names over the live value.
2254     fn setting_row(&self, s: &Setting) -> Row {
2255         Row {
2256             id: setting_row_id(s.label),
2257             label: s.label.to_string(),
2258             chord: String::new(),
2259             control: Some(self.setting_control(s)),
2260             truncate_head: false,
2261         }
2262     }
2263 
2264     /// The control a setting draws, built from its `Ctl` and the value as
2265     /// [`State::setting_value`] reads it — so the control and the string the
2266     /// writer takes cannot disagree about what the value is.
2267     fn setting_control(&self, s: &Setting) -> Control {
2268         let value = self.setting_value(s);
2269         match s.ctl {
2270             Ctl::Toggle => Control::Toggle(value == "true"),
2271             Ctl::Color => Control::Color { hex: value },
2272             Ctl::Spin { min, max, .. } => Control::Slider {
2273                 value: value.parse().unwrap_or(min),
2274                 min,
2275                 max,
2276                 dec: 0,
2277                 step: 1.0,
2278                 suffix: "",
2279             },
2280             Ctl::Slider { min, max, dec } => Control::Slider {
2281                 value: value.parse().unwrap_or(min),
2282                 min,
2283                 max,
2284                 dec,
2285                 // Twenty nudges across the range: fine enough to land on a
2286                 // value, coarse enough that holding the arrow gets somewhere.
2287                 step: (max - min) / 20.0,
2288                 suffix: "",
2289             },
2290             Ctl::Choice(options) => Control::Choice {
2291                 options: options.iter().map(|o| o.to_string()).collect(),
2292                 index: options.iter().position(|o| o.eq_ignore_ascii_case(&value)).unwrap_or(0),
2293             },
2294         }
2295     }
2296 
2297     /// One setting's value as its row shows it — the params pane's
2298     /// encodings: a hex colour, a whole number in the spin's unit, a float
2299     /// to the slider's decimals, an option's text.
2300     pub(crate) fn setting_value(&self, s: &Setting) -> String {
2301         match s.owner {
2302             Owner::Field(key) => match s.ctl {
2303                 Ctl::Toggle => if self.settings_field_bool(key) { "true" } else { "false" }.to_string(),
2304                 Ctl::Color => crate::project::color_to_hex(self.settings_field_color(key)),
2305                 Ctl::Spin { unit, .. } => ((self.settings_field_f32(key) * unit).round() as i32).to_string(),
2306                 Ctl::Slider { dec, .. } => format!("{:.*}", dec, self.settings_field_f32(key)),
2307                 Ctl::Choice(_) => self.settings_field_text(key),
2308             },
2309         }
2310     }
2311 
2312     /// The value one setting row reads, by label. Test-facing:
2313     /// `dialog_settings_rows_name_owners_that_exist` round-trips every
2314     /// `Owner::Field` row through this and [`State::settings_write_row`],
2315     /// which is the only way to catch a key that no dispatch arm names.
2316     #[cfg(test)]
2317     pub(crate) fn settings_row_value(&self, label: &str) -> String {
2318         let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
2319         self.setting_value(s)
2320     }
2321 
2322     /// Write one setting row by label, as the writeback does — the value
2323     /// only, none of the apply pass.
2324     #[cfg(test)]
2325     pub(crate) fn settings_write_row(&mut self, label: &str, value: &str) {
2326         let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
2327         self.setting_write(s, value);
2328     }
2329 
2330     fn settings_field_bool(&self, key: &str) -> bool {
2331         match key {
2332             "wire_single_color" => self.wire_single_color,
2333             _ => false,
2334         }
2335     }
2336 
2337     fn settings_field_color(&self, key: &str) -> [f32; 3] {
2338         match key {
2339             "bg_color" => self.viewport().bg_color,
2340             "grid_color" => self.viewport().grid_color,
2341             "point_marker_color" => self.point_marker_color,
2342             "wire_color" => self.wire_color,
2343             _ => [0.0; 3],
2344         }
2345     }
2346 
2347     fn settings_field_f32(&self, key: &str) -> f32 {
2348         match key {
2349             "grid_thickness" => self.grid_thickness,
2350             "origin_size" => self.origin_size,
2351             "point_marker_size" => self.point_marker_size,
2352             "wire_width" => self.wire_width,
2353             "geo_opacity" => self.geo_opacity,
2354             "wire_opacity" => self.wire_opacity,
2355             "group_marker_size" => self.group_marker_size,
2356             "pull_arrow_scale" => self.pull_arrow_scale,
2357             _ => 0.0,
2358         }
2359     }
2360 
2361     fn settings_field_text(&self, key: &str) -> String {
2362         match key {
2363             "world_unit" => self.world_unit.suffix().to_string(),
2364             "gpu" => self.gpu_preference.clone(),
2365             "node_wire_style" => self.ui_context[self.slots.content].inner().wire_style().label().to_string(),
2366             _ => String::new(),
2367         }
2368     }
2369 
2370     /// Write one `Owner::Field` row's new value onto the live state.
2371     ///
2372     /// `dialog_settings_rows_name_owners_that_exist` walks the table against
2373     /// the four readers and this writer, because a key that no arm names
2374     /// reads as a default and writes nowhere — a row that looks live and is
2375     /// inert.
2376     fn settings_field_write(&mut self, key: &str, ctl: Ctl, value: &str) {
2377         match ctl {
2378             Ctl::Toggle => {
2379                 let on = value == "true";
2380                 match key {
2381                     "wire_single_color" => self.wire_single_color = on,
2382                     _ => {}
2383                 }
2384             }
2385             Ctl::Color => {
2386                 let Some(c) = crate::project::hex_to_color(value) else { return };
2387                 match key {
2388                     "bg_color" => self.viewport_mut().bg_color = c,
2389                     "grid_color" => self.viewport_mut().grid_color = c,
2390                     "point_marker_color" => self.point_marker_color = c,
2391                     "wire_color" => {
2392                         // Setting a wire colour means wanting to see it: the
2393                         // colour applies in single-colour mode only, so a
2394                         // colour edit turns that mode on if it was off. Twice
2395                         // read as "the colour did not take" (2026-09-21).
2396                         let changed = c != self.wire_color;
2397                         self.wire_color = c;
2398                         if changed && !self.wire_single_color {
2399                             self.wire_single_color = true;
2400                         }
2401                     }
2402                     _ => {}
2403                 }
2404             }
2405             Ctl::Spin { unit, .. } => {
2406                 let Ok(v) = value.parse::<f32>() else { return };
2407                 let v = v / unit;
2408                 match key {
2409                     "grid_thickness" => self.grid_thickness = v,
2410                     "origin_size" => self.origin_size = v,
2411                     _ => {}
2412                 }
2413             }
2414             Ctl::Slider { min, max, .. } => {
2415                 let Ok(v) = value.parse::<f32>() else { return };
2416                 let v = v.clamp(min, max);
2417                 match key {
2418                     "wire_width" => self.wire_width = v,
2419                     "geo_opacity" => self.geo_opacity = v,
2420                     "wire_opacity" => self.wire_opacity = v,
2421                     "group_marker_size" => self.group_marker_size = v,
2422                     "point_marker_size" => self.point_marker_size = v,
2423                     "pull_arrow_scale" => {
2424                         self.pull_arrow_scale = v;
2425                         self.rebuild_pull_arrow_verts();
2426                     }
2427                     _ => {}
2428                 }
2429             }
2430             Ctl::Choice(options) => match key {
2431                 "world_unit" => {
2432                     if let Some(u) = cce_ui::units::Unit::parse(value) {
2433                         self.world_unit = u;
2434                         self.viewport_dirty = true;
2435                     }
2436                 }
2437                 "gpu" => {
2438                     let Some(v) = options.iter().find(|o| o.eq_ignore_ascii_case(value)) else { return };
2439                     self.gpu_preference = v.to_string();
2440                 }
2441                 "node_wire_style" => {
2442                     if let Some(w) = cce_ui::widget::display::WireStyle::parse(value) {
2443                         self.set_node_wire_style(Some(w));
2444                     }
2445                 }
2446                 _ => {}
2447             },
2448         }
2449     }
2450 
2451     /// The node wires' style on the network editor — `None` follows the
2452     /// config's `wire_style`. The widget holds it; the save reads it back
2453     /// off it.
2454     pub(crate) fn set_node_wire_style(&mut self, style: Option<cce_ui::widget::display::WireStyle>) {
2455         self.ui_context[self.slots.content].inner_mut().set_wire_style(style);
2456     }
2457 
2458     /// Write one setting's value to whatever owns it.
2459     fn setting_write(&mut self, s: &Setting, value: &str) {
2460         match s.owner {
2461             Owner::Field(key) => self.settings_field_write(key, s.ctl, value),
2462         }
2463     }
2464 
2465     /// Apply a setting row's new value: write it to its owner, then run the
2466     /// one apply-and-persist pass and re-read the row.
2467     ///
2468     /// The viewport meshes bake their sizes and colours in, so a changed
2469     /// thickness/size/tint is a re-generate, not a re-draw. This is the same
2470     /// set the settings-file reload in `tick_frame` regenerates. Re-read
2471     /// rather than trusting the control: an apply can normalize a value (a
2472     /// clamp), and the row has to show what the state now holds.
2473     pub(crate) fn apply_setting(&mut self, label: &str, value: &str) {
2474         let Some(s) = SETTINGS.iter().find(|s| s.label == label) else { return };
2475         self.setting_write(s, value);
2476         self.update_grid_geometry();
2477         self.update_origin_geometry();
2478         self.update_pivot_geometry();
2479         self.update_viewport_bg_geometry();
2480         self.sync_grid_settings();
2481         self.rebuild_scene_geometry();
2482         self.sync_nodes();
2483         // A slider drag saves once, when it ends (`tick_frame`).
2484         if self.ui_context[self.slots.dialog].slider_dragging() {
2485             self.settings_save_pending = true;
2486         } else {
2487             self.save_settings();
2488         }
2489         // The params pane may be showing one of these very nodes.
2490         self.sync_parameters_pane();
2491         self.refresh_dialog_controls();
2492         // After the pass above, whose own status line would bury it.
2493         if s.owner == Owner::Field("gpu") {
2494             self.announce_gpu_setting();
2495         }
2496     }
2497 
2498     /// The renderer's device is fixed for the life of the process, so the
2499     /// GPU row says plainly whether this one is on it — a row that changed
2500     /// and a picture that did not would read as a setting that does nothing.
2501     fn announce_gpu_setting(&mut self) {
2502         let (want, have) = (&self.gpu_preference, &self.gpu_at_launch);
2503         let note = if want == have {
2504             format!("GPU: {want} — in use now.")
2505         } else {
2506             format!("GPU: {want} — takes effect when cce-designer restarts (running on {have}).")
2507         };
2508         self.update_status_text(&note);
2509     }
2510 
2511     /// Lay the dialog out over the window.
2512     ///
2513     /// Called at the end of `rebuild_positions`, after every layout branch has
2514     /// run — like the 2D page pane, the rect it wants never depends on which
2515     /// branch produced the panes underneath it.
2516     pub(crate) fn layout_dialog(&mut self) {
2517         if !self.dialog_visible() {
2518             self.positions[DIALOG_IDX] = (0.0, 0.0, 0.0, 0.0);
2519             return;
2520         }
2521         let (x, y, w, h) = match self.ui_context[self.slots.dialog].anchor {
2522             Some((ax, ay)) => layout_at(self.width, self.height, ax, ay),
2523             None => layout_in(self.width, self.height),
2524         };
2525         self.positions[DIALOG_IDX] = (x, y, w, h);
2526         self.ui_context[self.slots.dialog].set_page(visible_rows(x, y, w, h));
2527     }
2528 
2529     /// Every key, while the dialog is open.
2530     ///
2531     /// Total, not layered: the branch that calls this returns whatever it
2532     /// returns, so nothing below reaches the panes. A modal that leaks its
2533     /// typing is worse than no modal — typing "frame" into the filter would
2534     /// otherwise step the grid cursor and flip a node's geometry flag on the
2535     /// way past, since the network pane's bare-letter family is ungated.
2536     pub(crate) fn dialog_key_input(&mut self, event: &KeyEvent) -> bool {
2537         if event.state != ElementState::Pressed {
2538             return true;
2539         }
2540         // A colour row's hex well, while it is being typed into, has the
2541         // keyboard ahead of everything — Escape and Enter included, which
2542         // end the edit rather than the dialog.
2543         if let Some(sel) = self.ui_context[self.slots.dialog].editing_color() {
2544             let ptr = sel as *mut cce_ui::widget::Adapted<ColorSelector>;
2545             unsafe {
2546                 (*ptr).keyboard_input(event, &mut self.ui_context);
2547             }
2548             self.drain_dialog_clicks();
2549             return true;
2550         }
2551         // An open dropdown has the keys next: it is in front of the rows.
2552         if self.dialog_dropdown_open() {
2553             return self.dialog_dropdown_key(event);
2554         }
2555         // The dialog's own chord closes it, wherever the user has bound it —
2556         // asked for by id rather than hardcoded to Alt+D, so a rebind in
2557         // `input.kdl` keeps working both ways.
2558         if self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
2559             == Some("toggle_dialog")
2560         {
2561             self.close_dialog();
2562             return true;
2563         }
2564         match &event.logical_key {
2565             Key::Named(NamedKey::Escape) => {
2566                 self.close_dialog();
2567                 return true;
2568             }
2569             Key::Named(NamedKey::Tab) => {
2570                 // Tab is what opened the add-node palette, so the same key
2571                 // closes it again. In the commands list it means nothing.
2572                 if self.ui_context[self.slots.dialog].mode == Mode::AddNode {
2573                     self.close_dialog();
2574                 }
2575                 return true;
2576             }
2577             _ => {}
2578         }
2579 
2580         match &event.logical_key {
2581             Key::Named(NamedKey::ArrowDown) => self.ui_context[self.slots.dialog].move_selection(1),
2582             Key::Named(NamedKey::ArrowUp) => self.ui_context[self.slots.dialog].move_selection(-1),
2583             Key::Named(NamedKey::PageDown) => {
2584                 let page = self.ui_context[self.slots.dialog].page_len() as i32;
2585                 self.ui_context[self.slots.dialog].move_selection(page);
2586             }
2587             Key::Named(NamedKey::PageUp) => {
2588                 let page = self.ui_context[self.slots.dialog].page_len() as i32;
2589                 self.ui_context[self.slots.dialog].move_selection(-page);
2590             }
2591             Key::Named(NamedKey::Home) => {
2592                 self.ui_context[self.slots.dialog].selected = 0;
2593                 self.ui_context[self.slots.dialog].scroll_to_selected();
2594             }
2595             Key::Named(NamedKey::End) => {
2596                 let last = self.ui_context[self.slots.dialog].rows.len().saturating_sub(1);
2597                 self.ui_context[self.slots.dialog].selected = last;
2598                 self.ui_context[self.slots.dialog].scroll_to_selected();
2599             }
2600             Key::Named(NamedKey::Enter) => {
2601                 if let Some(id) = self.ui_context[self.slots.dialog].selected_id().map(str::to_string) {
2602                     self.take_dialog_pick(id);
2603                 }
2604             }
2605             // The arrows work the selected row's control in place: a
2606             // slider by its step, a choice to the next or previous option,
2607             // a float2's first end — its second with shift. On any other
2608             // row they mean nothing here.
2609             Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowRight) => {
2610                 let dir: i32 = if matches!(event.logical_key, Key::Named(NamedKey::ArrowRight)) { 1 } else { -1 };
2611                 self.nudge_dialog_selection(dir);
2612             }
2613             Key::Named(NamedKey::Backspace) => {
2614                 if self.ui_context[self.slots.dialog].query.pop().is_some() {
2615                     self.refresh_dialog_rows();
2616                 }
2617             }
2618             Key::Named(NamedKey::Space) => {
2619                 self.ui_context[self.slots.dialog].query.push(' ');
2620                 self.refresh_dialog_rows();
2621             }
2622             Key::Character(c) => {
2623                 // Bare typing only: a modified key is a chord, and the ones
2624                 // this dialog answers to are handled above.
2625                 if !self.modifiers.control_key()
2626                     && !self.modifiers.alt_key()
2627                     && !self.modifiers.super_key()
2628                 {
2629                     self.ui_context[self.slots.dialog].query.push_str(c);
2630                     self.refresh_dialog_rows();
2631                 }
2632             }
2633             _ => {}
2634         }
2635         true
2636     }
2637 
2638     /// Step the selected row's control by `dir` (-1 or 1) and land the
2639     /// value: a slider by its step, a choice to its neighbouring option.
2640     fn nudge_dialog_selection(&mut self, dir: i32) {
2641         let Some(id) = self.ui_context[self.slots.dialog].selected_id().map(str::to_string) else { return };
2642         let Some(control) = self.ui_context[self.slots.dialog].selected_control().cloned() else { return };
2643         match control {
2644             Control::Slider { value, step, .. } => {
2645                 let i = self.ui_context[self.slots.dialog].selected;
2646                 self.ui_context[self.slots.dialog].set_slider_value(i, value + dir as f32 * step);
2647                 let Some(v) = self.ui_context[self.slots.dialog].rows[i].slider_value() else { return };
2648                 self.land_dialog_slider(&id, v);
2649             }
2650             Control::Choice { options, index } => {
2651                 if options.is_empty() {
2652                     return;
2653                 }
2654                 let n = options.len() as i32;
2655                 let next = (index as i32 + dir).rem_euclid(n) as usize;
2656                 self.land_dialog_choice(&id, &options[next]);
2657             }
2658             _ => {}
2659         }
2660     }
2661 
2662     /// A choice row's new option, from its dropdown or an arrow key: a
2663     /// setting row writes its setting.
2664     pub(crate) fn land_dialog_choice(&mut self, id: &str, value: &str) {
2665         if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
2666             let label = label.to_string();
2667             self.apply_setting(&label, value);
2668         }
2669     }
2670 
2671     /// Open row `id`'s dropdown: the params pane's own control. The live
2672     /// `Dialog::dropdown` takes the row's options and selection, is laid out
2673     /// on the row's band, and opens — its plate growing out of the trigger
2674     /// into the list, the current option highlighted. A pick lands through
2675     /// [`Self::land_dialog_choice`]; the dialog stays up.
2676     pub(crate) fn open_dialog_dropdown(&mut self, id: &str) {
2677         let Some(i) = self.ui_context[self.slots.dialog].rows.iter().position(|r| r.id == id) else { return };
2678         let Some(Control::Choice { options, index }) = self.ui_context[self.slots.dialog].rows[i].control.clone() else { return };
2679         if options.is_empty() {
2680             return;
2681         }
2682         self.ui_context[self.slots.dialog].selected = i;
2683         self.ui_context[self.slots.dialog].scroll_to_selected();
2684         {
2685             let dd = self.dialog_dd();
2686             let dd = self.ui_context[dd].inner_mut();
2687             dd.options = options;
2688             dd.selected = index;
2689         }
2690         self.ui_context[self.slots.dialog].dropdown_row = Some(id.to_string());
2691         if !self.sync_dialog_dropdown() {
2692             self.ui_context[self.slots.dialog].dropdown_row = None;
2693             return;
2694         }
2695         // Opened as the toolkit opens one from the keyboard: focused, then
2696         // Enter, which unfolds it with the current option highlighted.
2697         // In sight while it is open (`Dialog::tick_ctx` hides it again once it has closed).
2698         let dd = self.dialog_dd();
2699         self.ui_context[dd].set_visible(true);
2700         // The keys are the dropdown's (the focus record names it, which a closed dropdown
2701         // checks before it takes Enter), but it is not told it is focused, so its trigger
2702         // wears no focus ring: the row's band already says which control is open, as the
2703         // params pane's dropdowns do.
2704         self.ui_context.claim_focus(dd.id());
2705         let enter = Event::KeyInput(KeyEvent {
2706             state: ElementState::Pressed,
2707             logical_key: Key::Named(NamedKey::Enter),
2708             text: None,
2709             repeat: false,
2710             ctrl: false,
2711             shift: false,
2712             alt: false,
2713         });
2714         self.ui_context.lend_h(dd, |w, ctx| w.handle_event(&enter, ctx));
2715         self.ui_context[self.slots.dialog].dropdown_armed = true;
2716     }
2717 
2718     /// The dialog's live dropdown: the context's since the dialog was inserted (an
2719     /// `Embedded` child of the dialog).
2720     pub(crate) fn dialog_dd(&self) -> Handle<Adapted<Dropdown>> {
2721         self.ui_context[self.slots.dialog].dropdown.handle().expect("the dialog's dropdown is the context's")
2722     }
2723 
2724     /// Lay the live dropdown out on its row's band, as the dialog now
2725     /// stands. False when there is no such row in view.
2726     pub(crate) fn sync_dialog_dropdown(&mut self) -> bool {
2727         let (x, y, w, h) = self.positions[DIALOG_IDX];
2728         let Some(band) = self.ui_context[self.slots.dialog].dropdown_trigger(Rect { x, y, width: w, height: h }) else { return false };
2729         let dd = self.dialog_dd();
2730         WidgetHost::set_rect(&mut self.ui_context[dd], band.x, band.y, band.width, band.height);
2731         true
2732     }
2733 
2734     /// Whether the dropdown is open and taking input — not while it
2735     /// shrinks closed, when the dialog under it has the pointer again.
2736     pub(crate) fn dialog_dropdown_open(&self) -> bool {
2737         self.dialog_visible() && self.ui_context[self.slots.dialog].dropdown_row.is_some() && self.ui_context[self.dialog_dd()].is_expanded()
2738     }
2739 
2740     /// Close the dropdown — animated, as an outside press closes it.
2741     pub(crate) fn close_dialog_dropdown(&mut self) {
2742         self.ui_context[self.slots.dialog].dropdown_armed = false;
2743         let dd = self.dialog_dd();
2744         if self.ui_context[dd].open {
2745             self.ui_context.lend_h(dd, |w, ctx| w.handle_event(&Event::FocusOut, ctx));
2746         }
2747     }
2748 
2749     /// Hand `ev` to the open dropdown, and land what it picked. True when
2750     /// it took the event; a press it does not take (one made while it
2751     /// shrinks closed) goes on to the dialog.
2752     pub(crate) fn dialog_dropdown_event(&mut self, ev: &Event) -> bool {
2753         if !self.dialog_dropdown_open() {
2754             return false;
2755         }
2756         self.sync_dialog_dropdown();
2757         let dd = self.dialog_dd();
2758         let taken = self.ui_context.lend_h(dd, |w, ctx| w.handle_event(ev, ctx)).unwrap_or(false);
2759         self.land_dialog_dropdown_pick();
2760         taken
2761     }
2762 
2763     /// Land what the dropdown picked, however the event that picked it
2764     /// reached it, and note whether it is still expanded.
2765     fn land_dialog_dropdown_pick(&mut self) {
2766         let picked = {
2767             let dd = self.dialog_dd();
2768             let dd = self.ui_context[dd].inner_mut();
2769             dd.take_change().then(|| dd.options.get(dd.selected).cloned()).flatten()
2770         };
2771         self.ui_context[self.slots.dialog].dropdown_armed = self.ui_context[self.dialog_dd()].is_expanded();
2772         if let (Some(value), Some(id)) = (picked, self.ui_context[self.slots.dialog].dropdown_row.clone()) {
2773             self.land_dialog_choice(&id, &value);
2774         }
2775     }
2776 
2777     /// Whether a press has to be the dropdown's: it is expanded, or it was
2778     /// until this very press — which the runner then handed it first (see
2779     /// `Dialog::dropdown_armed`).
2780     pub(crate) fn dialog_dropdown_takes_press(&self) -> bool {
2781         self.dialog_dropdown_open() || (self.dialog_visible() && self.ui_context[self.slots.dialog].dropdown_armed)
2782     }
2783 
2784     /// A press while the dropdown is open: on a row it picks it, on the
2785     /// trigger it closes it, anywhere else it closes it and is swallowed —
2786     /// the dialog stays up, as under any menu's dismissing press.
2787     pub(crate) fn dialog_dropdown_press(&mut self, button: MouseButton) -> bool {
2788         let (x, y) = (self.cursor_x, self.cursor_y);
2789         if !self.dialog_dropdown_open() {
2790             // The runner handed it the press already: a pick, or the close
2791             // a press anywhere else makes. Land it, and the press is spent.
2792             self.land_dialog_dropdown_pick();
2793             self.ui_context[self.slots.dialog].dropdown_armed = false;
2794             return true;
2795         }
2796         if button != MouseButton::Left {
2797             self.close_dialog_dropdown();
2798             return true;
2799         }
2800         let ev = Event::MouseButton { button, state: ElementState::Pressed, x, y, local_x: x, local_y: y };
2801         self.dialog_dropdown_event(&ev);
2802         true
2803     }
2804 
2805     /// A key while the dropdown is open: Up and Down walk it, Enter picks
2806     /// the highlighted option, Escape closes it, Tab closes it too; nothing
2807     /// reaches the dialog behind it.
2808     fn dialog_dropdown_key(&mut self, event: &KeyEvent) -> bool {
2809         if event.state == ElementState::Pressed && matches!(event.logical_key, Key::Named(NamedKey::Tab)) {
2810             self.close_dialog_dropdown();
2811             return true;
2812         }
2813         self.dialog_dropdown_event(&Event::KeyInput(event.clone()));
2814         true
2815     }
2816 
2817     /// A slider row's value arriving from a drag, a wheel or an arrow: the
2818     /// zoom row zooms, a setting row writes its setting.
2819     ///
2820     /// A slider or spin row is a draw-time value, so it lands through the
2821     /// viewport menu's own `land_draw_time_setting` — the field, the one
2822     /// mesh it feeds, a redraw — and the row re-reads in place. During a
2823     /// drag this runs on every motion, so state.kdl is written on the
2824     /// RELEASE (`dialog_mouse_input`) rather than here; a wheel notch or an
2825     /// arrow key is a single landing and saves at once, as the menu's wheel
2826     /// does. A spin row lands its whole number over the row's unit. A row the landing
2827     /// does not know falls through to `apply_setting`.
2828     pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
2829         if id == ZOOM_ROW_ID {
2830             self.set_zoom_percent(v);
2831         } else if let Some(s) = setting_of_row(id) {
2832             let landed = match (s.owner, s.ctl) {
2833                 (Owner::Field(key), Ctl::Slider { dec, .. }) => {
2834                     // Rounded as the row shows it, so the field holds the
2835                     // value the readout names rather than the pointer's raw
2836                     // fraction.
2837                     let shown: f32 = format!("{:.*}", dec, v).parse().unwrap_or(v);
2838                     self.land_draw_time_setting(key, shown)
2839                 }
2840                 (Owner::Field(key), Ctl::Spin { unit, .. }) => self.land_draw_time_setting(key, v.round() / unit),
2841                 _ => false,
2842             };
2843             if landed {
2844                 self.refresh_dialog_controls();
2845                 if !self.ui_context[self.slots.dialog].slider_dragging() {
2846                     self.save_settings();
2847                 }
2848                 return;
2849             }
2850             let value = match s.ctl {
2851                 Ctl::Slider { dec, .. } => format!("{:.*}", dec, v),
2852                 _ => (v.round() as i64).to_string(),
2853             };
2854             self.apply_setting(s.label, &value);
2855         }
2856     }
2857 
2858     /// Run a command the dialog chose, and close.
2859     ///
2860     /// Closing FIRST, so a command that opens something of its own (a file
2861     /// chooser) does not come up behind the dialog. The dialog's own row is
2862     /// the exception: toggling it here would reopen what was just closed.
2863     ///
2864     /// A CONTROL row does not close at all. A switch you can only flip once
2865     /// before the panel it is on vanishes is a button with extra steps: Show
2866     /// Grid, Show Origin and Square Aspect are the kind of thing you set
2867     /// together, looking at the viewport, and the dialog staying up is what
2868     /// lets you. A toggle flips, a choice steps to its next option, a slider
2869     /// or a colour is worked by the pointer or the arrows and Enter on it
2870     /// does nothing; the controls re-read, and the selection stays where it
2871     /// was — by Enter or by a click, since both arrive here.
2872     pub(crate) fn take_dialog_pick(&mut self, id: String) {
2873         let mode = self.ui_context[self.slots.dialog].mode;
2874         if mode == Mode::Commands && id == ZOOM_ROW_ID {
2875             return;
2876         }
2877         // A group's row is its switch: flipped in place, the list stays up.
2878         if mode == Mode::Groups {
2879             if let Some(name) = id.strip_prefix(GROUP_ROW_PREFIX) {
2880                 let name = name.to_string();
2881                 let on = !self.group_marked(&name);
2882                 self.set_group_marked(&name, on);
2883                 self.refresh_dialog_controls();
2884             }
2885             return;
2886         }
2887         if mode == Mode::Commands {
2888             if let Some(s) = setting_of_row(&id) {
2889                 let control = self.ui_context[self.slots.dialog].rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone());
2890                 match control {
2891                     Some(Control::Toggle(on)) => {
2892                         let v = if on { "false" } else { "true" };
2893                         self.apply_setting(s.label, v);
2894                     }
2895                     Some(Control::Choice { .. }) => self.open_dialog_dropdown(&id),
2896                     _ => {}
2897                 }
2898                 return;
2899             }
2900         }
2901         // The path row: copy, say so, and close. A copy is done the moment it
2902         // happens — unlike a toggle, there is nothing to sit and adjust — so
2903         // it leaves the way a command does.
2904         if mode == Mode::Commands && id == PATH_ROW_ID {
2905             self.close_dialog();
2906             self.copy_project_path();
2907             return;
2908         }
2909         // A recent project: open it, and say so if it will not open — a
2910         // path in this list can have been moved or deleted since.
2911         if mode == Mode::Commands {
2912             if let Some(path) = id.strip_prefix(RECENT_ROW_PREFIX) {
2913                 let path = std::path::PathBuf::from(path);
2914                 self.close_dialog();
2915                 if let Err(e) = self.load_from_file(&path) {
2916                     self.update_status_text(&format!("Could not open {}: {e}", path.display()));
2917                 }
2918                 return;
2919             }
2920         }
2921         if mode == Mode::Commands {
2922             if let Some(name) = id.strip_prefix(CAMERA_ROW_PREFIX) {
2923                 let name = name.to_string();
2924                 self.close_dialog();
2925                 self.choose_camera(&name);
2926                 return;
2927             }
2928         }
2929         // A row that turns the palette into another list does so in place,
2930         // the palette left on the trail for a swipe back.
2931         if mode == Mode::Commands && dialog_row_leads(mode, &id) {
2932             self.run_command(&id);
2933             return;
2934         }
2935         if mode == Mode::Commands && self.command_toggle_state(&id).is_some() {
2936             self.run_command(&id);
2937             self.refresh_dialog_controls();
2938             return;
2939         }
2940         let (gx, gy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
2941         self.close_dialog();
2942         match mode {
2943             // Not `toggle_dialog`: toggling here would reopen what was just
2944             // closed. Picking the dialog's own row is a no-op, which is the
2945             // least surprising thing it could be.
2946             Mode::Commands => {
2947                 if id != "toggle_dialog" {
2948                     self.run_command(&id);
2949                 }
2950             }
2951             // Fire-and-forget at the grid cursor, exactly as the popup's
2952             // answer used to arrive — read BEFORE the close, since closing
2953             // relays the panes.
2954             Mode::Rename => {
2955                 let Some(target) = self.rename_target.take() else { return };
2956                 let typed = self.ui_context[self.slots.dialog].query.clone();
2957                 match self.rename_node(&target, &typed) {
2958                     Ok(said) | Err(said) => self.update_status_text(&said),
2959                 }
2960             }
2961             Mode::Groups => {}
2962             Mode::AddNode => {
2963                 // A free cursor cell a wire runs through is a place in that
2964                 // chain: the new node is spliced into the wire, as a node
2965                 // dropped there would be. Asked before the add, which puts
2966                 // a node on the cell and its own wires through it.
2967                 let free = !self.current_dir().children.iter().any(|c| c.position == (gx, gy));
2968                 let wire = if free { self.graph().input_wire_through_cell(gx, gy) } else { None };
2969                 let mut redraw = false;
2970                 let action = crate::app::McpAction::AddNode {
2971                     template_name: id,
2972                     name: None,
2973                     x: gx,
2974                     y: gy,
2975                 };
2976                 match self.apply_action(action, &mut redraw) {
2977                     Ok(_) => {
2978                         if let Some((src_id, dest_id)) = wire {
2979                             self.splice_new_node(&src_id, &dest_id);
2980                         }
2981                     }
2982                     // The one refusal this can hit is a template that does
2983                     // not belong at this level, which `refresh_dialog_rows`
2984                     // already filters out — but the rule lives in
2985                     // `apply_action`, so say what it said rather than assume
2986                     // it cannot fire.
2987                     Err(e) => self.update_status_text(&e),
2988                 }
2989             }
2990         }
2991     }
2992 
2993     /// Drain what the pointer (or a picker, or a hex edit) did inside the
2994     /// dialog. Returns whether anything changed.
2995     pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
2996         let mut changed = false;
2997         if let Some(id) = self.ui_context[self.slots.dialog].take_activated() {
2998             self.take_dialog_pick(id);
2999             changed = true;
3000         }
3001         if let Some((id, v)) = self.ui_context[self.slots.dialog].take_slider_change() {
3002             self.land_dialog_slider(&id, v);
3003             changed = true;
3004         }
3005         for (id, hex) in self.ui_context[self.slots.dialog].take_color_changes() {
3006             if let Some(s) = setting_of_row(&id) {
3007                 self.apply_setting(s.label, &hex);
3008                 changed = true;
3009             }
3010         }
3011         changed
3012     }
3013 
3014     /// A mouse button, while the dialog is open.
3015     ///
3016     /// `None` hands the press back to the ordinary cascade — only the middle
3017     /// button, which the dialog has no use for. `Some(handled)` means the
3018     /// dialog dealt with it and nothing else should. A RIGHT press is the
3019     /// dialog's too: inside the plate it is swallowed (nothing in the dialog
3020     /// has a context menu, and until 2026-09-22 it fell through to the pane
3021     /// beneath, whose menu then opened over a modal with its labels clipped
3022     /// by the dialog's occluder — a menu with no legible entries), outside
3023     /// it dismisses, exactly as a left press does.
3024     pub(crate) fn dialog_mouse_input(
3025         &mut self,
3026         button: MouseButton,
3027         state: ElementState,
3028     ) -> Option<bool> {
3029         let (x, y) = (self.cursor_x, self.cursor_y);
3030         if button == MouseButton::Right {
3031             if !self.in_dialog_slot(DIALOG_IDX, x, y) && state == ElementState::Pressed {
3032                 self.close_dialog();
3033             }
3034             return Some(true);
3035         }
3036         if button != MouseButton::Left {
3037             return None;
3038         }
3039 
3040         // A slider drag ends wherever the pointer happens to be — including
3041         // outside the plate. Ending it has to come before the dismiss test
3042         // below, or dragging a value past the dialog's edge and letting go
3043         // would close the dialog instead of committing.
3044         if state == ElementState::Released && self.drag_widget == Some(DIALOG_IDX) {
3045             self.ui_context.lend_h(self.slots.dialog, |d, ui| {
3046                 d.handle_event(&cce_ui::widget::Event::DragEnd, ui);
3047                 d.handle_event(&cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y }, ui);
3048             });
3049             self.drag_widget = None;
3050             self.drag_press_cursor = None;
3051             self.drain_dialog_clicks();
3052             // The drag's motions landed without saving; the release does.
3053             self.save_settings();
3054             return Some(true);
3055         }
3056 
3057         if !self.in_dialog_slot(DIALOG_IDX, x, y) {
3058             // Outside: a press dismisses, a release is the tail of that press
3059             // and is simply eaten.
3060             if state == ElementState::Pressed {
3061                 self.close_dialog();
3062             }
3063             return Some(true);
3064         }
3065 
3066         let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
3067         self.dispatch_uncovered(DIALOG_IDX, &ev);
3068         // A press that took a slider's band arms the same widget drag the
3069         // params pane's sliders arm, so the value follows the pointer
3070         // wherever it goes until the release.
3071         if state == ElementState::Pressed && self.ui_context[self.slots.dialog].slider_dragging() {
3072             let ev = cce_ui::widget::Event::DragStart { start_x: x, start_y: y };
3073             self.ui_context.lend_h(self.slots.dialog, |d, ui| d.handle_event(&ev, ui));
3074             self.drag_widget = Some(DIALOG_IDX);
3075             self.drag_press_cursor = Some((x, y));
3076         }
3077         self.drain_dialog_clicks();
3078         Some(true)
3079     }
3080 
3081     /// The wheel, while the dialog is open: the dialog's, and no further —
3082     /// it scrolls its own list, so there is nothing to fall through to.
3083     pub(crate) fn dialog_mouse_wheel(&mut self, delta: MouseScrollDelta) -> bool {
3084         let (x, y) = (self.cursor_x, self.cursor_y);
3085         let ev = cce_ui::widget::Event::MouseWheel { delta, x, y, local_x: x, local_y: y };
3086         if self.in_dialog_slot(DIALOG_IDX, x, y) {
3087             // A side swipe turns the dialog: into what the row under the
3088             // pointer leads to, or back to where it was turned from.
3089             match cce_ui::widget::side_swipe::feed(&delta) {
3090                 Some(cce_ui::widget::SwipeDir::Forward) => {
3091                     let (rx, ry, rw, rh) = self.positions[DIALOG_IDX];
3092                     let rect = Rect { x: rx, y: ry, width: rw, height: rh };
3093                     let mode = self.ui_context[self.slots.dialog].mode;
3094                     let row = self.ui_context[self.slots.dialog].row_index_at(rect, x, y).and_then(|i| self.ui_context[self.slots.dialog].rows.get(i)).map(|r| r.id.clone());
3095                     if let Some(id) = row.filter(|id| dialog_row_leads(mode, id)) {
3096                         self.take_dialog_pick(id);
3097                         return true;
3098                     }
3099                 }
3100                 Some(cce_ui::widget::SwipeDir::Back) => {
3101                     if self.dialog_back() {
3102                         return true;
3103                     }
3104                 }
3105                 None => {}
3106             }
3107             let taken = self.dispatch_uncovered(DIALOG_IDX, &ev);
3108             // A wheel over a slider row moved it: land the value.
3109             self.drain_dialog_clicks();
3110             return taken;
3111         }
3112         false
3113     }
3114 
3115     /// Is `(x, y)` inside a dialog slot's laid-out rect?
3116     ///
3117     /// The rect, not `hit_test`: the dialog registers itself as a text
3118     /// occluder (see `Dialog::popover`) and `Adapted::hit_test` reads that
3119     /// same list as COVERAGE, so every point inside the dialog reports as
3120     /// covered — by the dialog's own plate. Inside a modal the geometry IS
3121     /// the answer.
3122     pub(crate) fn in_dialog_slot(&self, idx: usize, x: f32, y: f32) -> bool {
3123         if !self.slots.get_dyn(&self.ui_context, idx).visible() {
3124             return false;
3125         }
3126         let (rx, ry, rw, rh) = self.positions[idx];
3127         rw > 0.0 && rh > 0.0 && x >= rx && x < rx + rw && y >= ry && y < ry + rh
3128     }
3129 
3130     /// Deliver `ev` to a dialog slot with the dialog's occluder claim lowered.
3131     ///
3132     /// `Adapted::handle_event` hit-gates presses and wheels through
3133     /// `is_coordinate_covered`, which asks every REGISTERED widget for its
3134     /// `popover_rect` — including the dialog's, which covers the whole plate
3135     /// (see [`Dialog::popover`]). So while the claim stands, every press
3136     /// aimed at a control inside the plate is rejected as covered by the
3137     /// surface the control is drawn on. The dialog's own routing has already
3138     /// decided who gets this event, so the claim comes down for the dispatch
3139     /// and goes straight back up.
3140     pub(crate) fn dispatch_uncovered(&mut self, idx: usize, ev: &cce_ui::widget::Event) -> bool {
3141         self.ui_context[self.slots.dialog].set_occluding(false);
3142         // The coverage answer is memoized per point, so lowering the claim is
3143         // not enough — a query from earlier this frame is served from the
3144         // cache, and the engine makes one on every left press
3145         // (`close_popovers_missed_by_press`).
3146         self.ui_context.invalidate_coverage_cache();
3147         // Straight to handle_event, not propagate_event: so the gesture
3148         // bookkeeping the router would have done is done here, or the
3149         // dialog's panes inherit the main pane's gesture state.
3150         if matches!(ev, cce_ui::widget::Event::MouseWheel { .. }) {
3151             self.ui_context.note_scroll_event();
3152         }
3153         let ptr = self.slots.get_dyn_mut(&mut self.ui_context, idx) as *mut (dyn cce_ui::widget::WidgetHost + 'static);
3154         let taken = unsafe { (*ptr).handle_event(ev, &mut self.ui_context) };
3155         self.ui_context[self.slots.dialog].set_occluding(true);
3156         self.ui_context.invalidate_coverage_cache();
3157         taken
3158     }
3159 }