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

src/dialog.rs (104.1K)

   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 }
  49 
  50 /// The control a row carries, drawn over its right end and worked in place —
  51 /// the dialog stays up while any of these is used, since a setting you can
  52 /// only touch once before the panel vanishes is a button with extra steps.
  53 #[derive(Debug, Clone, PartialEq)]
  54 pub enum Control {
  55     /// A switch: a toggle command's state, or a boolean setting. Picking
  56     /// the row flips it.
  57     Toggle(bool),
  58     /// A slider over `min..=max`, read out to `dec` decimals with `suffix`;
  59     /// the arrows nudge it by `step` while the row is selected, the wheel
  60     /// over the band turns it, a press on the band jumps to the pointer.
  61     /// A `dec` of zero snaps to whole numbers — the spinbox shape.
  62     Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
  63     /// One of a fixed set: picking the row steps to the next option, the
  64     /// arrows step either way.
  65     Choice { options: Vec<String>, index: usize },
  66     /// A colour, `#rrggbb` (or `#rrggbbaa` with `alpha`), drawn as the
  67     /// toolkit's colour selector: a hex well and a swatch that opens the
  68     /// picker.
  69     Color { hex: String, alpha: bool },
  70 }
  71 
  72 impl Control {
  73     /// The value a setting row writes back, in the string the setting's
  74     /// writer takes — the same encodings the params pane uses.
  75     pub fn value_string(&self) -> String {
  76         match self {
  77             Control::Toggle(on) => if *on { "true" } else { "false" }.to_string(),
  78             Control::Slider { value, dec, .. } => format!("{:.*}", dec, value),
  79             Control::Choice { options, index } => options.get(*index).cloned().unwrap_or_default(),
  80             Control::Color { hex, .. } => hex.clone(),
  81         }
  82     }
  83 
  84     /// Clamp `v` into a slider's range, snapping to whole numbers when the
  85     /// readout shows none.
  86     fn quantize(&self, v: f32) -> f32 {
  87         match self {
  88             Control::Slider { min, max, dec, .. } => {
  89                 let v = v.clamp(min.min(*max), max.max(*min));
  90                 if *dec == 0 { v.round() } else { v }
  91             }
  92             _ => v,
  93         }
  94     }
  95 }
  96 
  97 /// One row: what picking it means, plus what to draw.
  98 #[derive(Debug, Clone)]
  99 pub struct Row {
 100     /// What the app does with this row: a command id or a setting row id
 101     /// in [`Mode::Commands`], a node template's name in [`Mode::AddNode`].
 102     /// Owned rather than `&'static str` because a template name is read
 103     /// off disk.
 104     pub id: String,
 105     pub label: String,
 106     /// The chord as a human reads it, empty when there is none. Drawn in its
 107     /// own right-hand column so the dialog teaches the keyboard rather than
 108     /// replacing it — which the `cce-cloud` palette could only approximate by
 109     /// padding the label out, since all it could send was one line of text.
 110     pub chord: String,
 111     /// The row's control, if it is one — see [`Control`]. `None` for a
 112     /// command that runs and is done.
 113     pub control: Option<Control>,
 114     /// Truncate the label on the LEFT when it does not fit, rather than on
 115     /// the right: the tail of a path is what identifies it, and a row that
 116     /// cut `/home/me/projects/thing` down to `/home/me/pro...` would name
 117     /// every project in the directory equally badly.
 118     pub truncate_head: bool,
 119 }
 120 
 121 impl Row {
 122     /// A row with no control: a command, a template, a path.
 123     pub fn plain(id: impl Into<String>, label: impl Into<String>, chord: impl Into<String>) -> Row {
 124         Row { id: id.into(), label: label.into(), chord: chord.into(), control: None, truncate_head: false }
 125     }
 126 
 127     /// The switch's state, for a toggle row.
 128     pub fn toggle(&self) -> Option<bool> {
 129         match self.control {
 130             Some(Control::Toggle(on)) => Some(on),
 131             _ => None,
 132         }
 133     }
 134 
 135     /// The slider's value, for a slider row.
 136     pub fn slider_value(&self) -> Option<f32> {
 137         match self.control {
 138             Some(Control::Slider { value, .. }) => Some(value),
 139             _ => None,
 140         }
 141     }
 142 
 143     fn is_slider(&self) -> bool {
 144         matches!(self.control, Some(Control::Slider { .. }))
 145     }
 146 
 147     fn is_color(&self) -> bool {
 148         matches!(self.control, Some(Control::Color { .. }))
 149     }
 150 }
 151 
 152 /// The dialog's outer size. Fixed rather than proportional: it is a focused
 153 /// list, and a list that grows with the window turns into a wall of rows with
 154 /// the one you want somewhere in it.
 155 const DIALOG_W: f32 = 520.0;
 156 /// The plate's height, clamped to the window by [`layout_in`].
 157 ///
 158 /// 420 until 2026-09-23, which was sized for a Settings half of thirteen
 159 /// rows. Retiring the root meta node moved everything its four utility
 160 /// subnets held into that table — it is nearer thirty now — and a list that
 161 /// shows eight of them is a list you scroll rather than read.
 162 const DIALOG_H: f32 = 640.0;
 163 
 164 const PAD: f32 = 12.0;
 165 const QUERY_H: f32 = 30.0;
 166 pub const ROW_H: f32 = 24.0;
 167 /// A toggle row's switch: the toolkit's `Toggle`, at the row's height less a
 168 /// hair of air, and about twice as wide as tall — the proportion the params
 169 /// pane's toggles have.
 170 pub const TOGGLE_W: f32 = 36.0;
 171 const TOGGLE_H: f32 = ROW_H - 4.0;
 172 /// How far in from the row's right end a slider or colour row's BAND begins.
 173 /// It runs from there out to the CHORD column's right edge, so it ends
 174 /// exactly where every other row's key binding ends and the toggle column
 175 /// stays clear — a band that stopped short of the chords read as a control
 176 /// someone had forgotten to finish. Not reserved on the other rows: a
 177 /// control row has no chord, so it borrows the chord column rather than
 178 /// pushing every chord in the list left by half the plate.
 179 pub const SLIDER_W: f32 = 180.0;
 180 /// The readout's width and its gap from the band. It sits to the LEFT of the
 181 /// band, because the band's right end is spoken for. The readout is drawn by
 182 /// the dialog, not by the toolkit slider's own: the dialog claims its rect as
 183 /// a text occluder, and the clamp lets through only text carrying the
 184 /// dialog's exact bounds (see `Dialog::popover`), so the stamp's readout
 185 /// would paint and never show.
 186 const READOUT_W: f32 = 60.0;
 187 const READOUT_GAP: f32 = 8.0;
 188 /// Gap between the query line and the list.
 189 const GAP: f32 = 8.0;
 190 
 191 /// The dialog plate's backdrop compression when `style.surface.dialog.
 192 /// compression` is unset — above a menu's 0.6, since a modal is read over
 193 /// the whole busy window rather than beside the one control that opened it.
 194 /// Compression pulls what shows through the frost toward the tint's key, so
 195 /// the rows read against an even ground whatever is behind them.
 196 pub const DIALOG_COMPRESSION: f32 = 0.8;
 197 
 198 /// The dialog plate's material: the param plate's fill, frosted as the
 199 /// plates are, with the compression raised to `compression`. An opaque
 200 /// plate (blur off in the config) has no backdrop to compress and is
 201 /// returned as it is.
 202 pub fn plate_material(compression: f32) -> cce_ui::scene::Material {
 203     let mut m = cce_ui::scene::Material::from_fill(colors::param_plate_fill());
 204     if let cce_ui::scene::Frost::Frosted { compression: c, .. } = &mut m.frost {
 205         *c = compression.clamp(0.0, 1.0);
 206     }
 207     m
 208 }
 209 
 210 /// The dialog's rect inside a `width` x `height` window: centered
 211 /// horizontally, and a little above centre vertically so the list grows into
 212 /// the window's roomier half rather than down over the status bar.
 213 pub fn layout_in(width: f32, height: f32) -> (f32, f32, f32, f32) {
 214     let w = DIALOG_W.min((width - 2.0 * PAD).max(200.0));
 215     let h = DIALOG_H.min((height - 2.0 * PAD).max(160.0));
 216     let x = ((width - w) * 0.5).max(0.0).round();
 217     let y = ((height - h) * 0.4).max(0.0).round();
 218     (x, y, w, h)
 219 }
 220 
 221 /// The query line, at the top of the plate.
 222 fn query_rect(rect: Rect) -> Rect {
 223     Rect { x: rect.x + PAD, y: rect.y + PAD, width: (rect.width - 2.0 * PAD).max(0.0), height: QUERY_H }
 224 }
 225 
 226 /// The list's viewport.
 227 fn list_rect(rect: Rect) -> Rect {
 228     let q = query_rect(rect);
 229     let top = q.y + q.height + GAP;
 230     Rect { x: q.x, y: top, width: q.width, height: (rect.y + rect.height - PAD - top).max(0.0) }
 231 }
 232 
 233 /// How many rows the list can show at once, for a dialog of this size.
 234 pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
 235     (list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
 236 }
 237 
 238 pub struct Dialog {
 239     pub mode: Mode,
 240     /// What has been typed into the filter.
 241     pub query: String,
 242     /// The filtered, ranked rows — rebuilt by the app whenever `query`
 243     /// changes, never here: ranking needs the registry AND the focused pane,
 244     /// and this widget knows neither.
 245     pub rows: Vec<Row>,
 246     /// Which row Enter would run. Kept in range by [`Dialog::set_rows`].
 247     pub selected: usize,
 248     /// The list's scroll offset in px (0 = row 0 flush with the list top),
 249     /// the drawn value: rows paint at `i * ROW_H - scroll_px`, clipped to the
 250     /// list. Driven by `scroll_motion` — the DE's one wheel→offset model, so
 251     /// a notch glides and a trackpad tracks and coasts — and jumped by the
 252     /// keyboard (`scroll_to_selected`). Until 2026-09-20 this was a ROW
 253     /// index: every wheel event rounded to whole rows, so a trackpad's small
 254     /// deltas did nothing until one crossed half a row and then jumped it —
 255     /// the choppy commands list.
 256     pub scroll_px: f32,
 257     scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion,
 258     /// The list's scrollbar, in the DE's sink-behind idiom (cce-mail's
 259     /// body bar, `ScrollRegion` with `sink_behind`): pills at the list's
 260     /// right edge that fade in on a scroll, stay while hovered or dragged,
 261     /// and fade out after the hold. Sunk, it is not drawn and takes no
 262     /// input — a press on its lane reaches the row beneath.
 263     sb_activity: cce_ui::widget::ScrollbarActivity,
 264     sb_dragging: bool,
 265     /// Where in the thumb the drag grabbed it, so the thumb does not jump
 266     /// to centre itself under the pointer.
 267     sb_drag_offset: f32,
 268     /// How many rows fit — pushed in from the layout, since `on_event` and the
 269     /// app's key handling both need it and neither has the rect to hand.
 270     page: usize,
 271     hover_row: Option<usize>,
 272     /// A row the pointer activated, drained by the app.
 273     activated: Option<String>,
 274     /// Whether the dialog is currently claiming its rect as an occluder — see
 275     /// [`Paint::popover`]. Lowered for the length of an event dispatch into
 276     /// the dialog, because the one claim serves two mechanisms that want
 277     /// opposite answers.
 278     occluding: bool,
 279     /// The switch a toggle row draws, off and on — the toolkit's own
 280     /// `Toggle`, painted by hand into the row, so a switch in the dialog IS
 281     /// the switch in the params pane. Two stamps rather than one set per row
 282     /// because `paint` takes `&self`, and building a widget per row per
 283     /// frame would be silly.
 284     toggle_stamps: [Adapted<Toggle>; 2],
 285     /// The slider every slider row draws — the toolkit's own `Slider`, so a
 286     /// slider in the dialog IS the slider in the params pane. One stamp for
 287     /// all of them, set to each row's range and value as it is painted;
 288     /// interior mutability because `paint` takes `&self`.
 289     slider_stamp: RefCell<Adapted<Slider>>,
 290     /// The row whose band a press took hold of, while the pointer is moving
 291     /// it — the app drives this through its widget-drag protocol
 292     /// (`draggable` and the `drag_*` hooks), so the drag survives the
 293     /// pointer leaving the plate.
 294     slider_drag: Option<usize>,
 295     /// The band's (x, width) captured at the press, so a drag keeps
 296     /// mapping the pointer while the row scrolls under it.
 297     slider_track: (f32, f32),
 298     /// The row and value the pointer moved a slider to, drained by the app.
 299     slider_change: Option<(String, f32)>,
 300     /// One toolkit colour selector per colour row, by row id — real widgets,
 301     /// not stamps, because each carries state of its own: a hex edit in
 302     /// progress, a picker process streaming values. Kept across
 303     /// re-rankings so a query that drops the row does not kill its picker.
 304     colors: Vec<(String, Adapted<ColorSelector>)>,
 305     /// Colour rows the selectors changed, `(row id, hex)`, drained by the app.
 306     color_changes: Vec<(String, String)>,
 307 }
 308 
 309 impl Dialog {
 310     pub fn new() -> Adapted<Dialog> {
 311         let mut off = Toggle::new();
 312         off.set_toggled(false);
 313         let mut on = Toggle::new();
 314         on.set_toggled(true);
 315         let mut slider_stamp = Slider::new().with_readout(false);
 316         slider_stamp.set_scroll(false);
 317         let mut d = Adapted::new(Dialog {
 318             mode: Mode::Commands,
 319             query: String::new(),
 320             rows: Vec::new(),
 321             selected: 0,
 322             scroll_px: 0.0,
 323             scroll_motion: cce_ui::widget::scroll_motion::ScrollMotion::new(),
 324             sb_activity: cce_ui::widget::ScrollbarActivity::new(),
 325             sb_dragging: false,
 326             sb_drag_offset: 0.0,
 327             page: 1,
 328             hover_row: None,
 329             activated: None,
 330             occluding: true,
 331             toggle_stamps: [off, on],
 332             slider_stamp: RefCell::new(slider_stamp),
 333             slider_drag: None,
 334             slider_track: (0.0, 1.0),
 335             slider_change: None,
 336             colors: Vec::new(),
 337             color_changes: Vec::new(),
 338         });
 339         d.set_visible(false);
 340         d
 341     }
 342 
 343     /// Record how many rows fit, from the laid-out rect. Re-clamps the
 344     /// offset to the new range and nothing more: this runs on EVERY
 345     /// relayout (`layout_dialog`, off `rebuild_positions`, which the frame
 346     /// tick reaches whenever anything animates), and snapping to the
 347     /// selection here undid every wheel and finger scroll within a frame
 348     /// (2026-09-20 — the list "would not scroll at all"). Keeping the
 349     /// selection in view is the keyboard's job: `move_selection` and
 350     /// `scroll_to_selected` at the call sites that change it.
 351     pub fn set_page(&mut self, page: usize) {
 352         self.page = page.max(1);
 353         self.set_scroll_px(self.scroll_px);
 354     }
 355 
 356     /// How many rows fit — what PageUp/PageDown step by.
 357     pub fn page_len(&self) -> usize {
 358         self.page.max(1)
 359     }
 360 
 361     /// Claim, or stop claiming, the dialog's rect as an occluder.
 362     pub fn set_occluding(&mut self, on: bool) {
 363         self.occluding = on;
 364     }
 365 
 366     /// The furthest the list scrolls: the last page flush with the bottom.
 367     fn max_scroll_px(&self) -> f32 {
 368         ((self.rows.len() as f32 - self.page.max(1) as f32) * ROW_H).max(0.0)
 369     }
 370 
 371     /// The first row with any part in view.
 372     fn first_row(&self) -> usize {
 373         (self.scroll_px / ROW_H).floor().max(0.0) as usize
 374     }
 375 
 376     /// Jump the list to `px` (clamped) — the keyboard's move, not a glide.
 377     fn set_scroll_px(&mut self, px: f32) {
 378         self.scroll_px = px.clamp(0.0, self.max_scroll_px());
 379         self.scroll_motion.y.jump_to(self.scroll_px);
 380     }
 381 
 382     /// The scrollbar's geometry — `(sb_x, track_y, sb_w, track_h, thumb_y,
 383     /// thumb_h)`, mirroring `ScrollRegion::scrollbar_geom` as cce-mail's
 384     /// body bar does — or `None` when the rows fit and there is no bar. The
 385     /// one source for paint, the press and the drag. The bar rides the
 386     /// plate's CENTRE line, as both of cce-mail's bars ride theirs — over
 387     /// the rows, reserving no lane, in front only while raised; the track
 388     /// stops 4px short at each end like every toolkit bar.
 389     pub fn scrollbar_geom(&self, rect: Rect) -> Option<(f32, f32, f32, f32, f32, f32)> {
 390         let max_scroll = self.max_scroll_px();
 391         if max_scroll <= 0.0 {
 392             return None;
 393         }
 394         let list = list_rect(rect);
 395         let sb_w = cce_ui::layout::scrollbar_width() * 1.6;
 396         let sb_x = rect.x + (rect.width - sb_w) * 0.5;
 397         let track_y = list.y + 4.0;
 398         let track_h = (list.height - 8.0).max(0.0);
 399         let content_h = self.rows.len() as f32 * ROW_H;
 400         let visible_ratio = list.height / content_h.max(1.0);
 401         let thumb_h = if track_h <= 20.0 { track_h } else { (track_h * visible_ratio).clamp(20.0, track_h) };
 402         let thumb_y = track_y + (self.scroll_px / max_scroll) * (track_h - thumb_h);
 403         Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h))
 404     }
 405 
 406     fn over_scrollbar(&self, rect: Rect, px: f32, py: f32) -> bool {
 407         self.scrollbar_geom(rect).is_some_and(|(sb_x, track_y, sb_w, track_h, _, _)| {
 408             px >= sb_x - 4.0 && px <= sb_x + sb_w + 4.0 && py >= track_y && py <= track_y + track_h
 409         })
 410     }
 411 
 412     /// A left press on the bar's strip (±4px slop, like `ScrollRegion`):
 413     /// grab the thumb where it was clicked, or jump the track there and
 414     /// drag from the thumb's centre. A sunk bar is not drawn and takes no
 415     /// input — the press falls through to the row beneath.
 416     fn sb_press(&mut self, rect: Rect, px: f32, py: f32) -> bool {
 417         if !self.sb_activity.raised() {
 418             return false;
 419         }
 420         let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) else {
 421             return false;
 422         };
 423         if px < sb_x - 4.0 || px > sb_x + sb_w + 4.0 || py < track_y || py > track_y + track_h {
 424             return false;
 425         }
 426         self.sb_dragging = true;
 427         let click_offset = py - thumb_y;
 428         if (0.0..=thumb_h).contains(&click_offset) {
 429             self.sb_drag_offset = click_offset;
 430         } else {
 431             self.sb_drag_offset = thumb_h / 2.0;
 432             self.sb_drag_to(rect, py);
 433         }
 434         true
 435     }
 436 
 437     fn sb_drag_to(&mut self, rect: Rect, py: f32) -> bool {
 438         let Some((_, track_y, _, track_h, _, thumb_h)) = self.scrollbar_geom(rect) else {
 439             return false;
 440         };
 441         let target = py - self.sb_drag_offset;
 442         let ratio = if track_h - thumb_h > 0.0 { ((target - track_y) / (track_h - thumb_h)).clamp(0.0, 1.0) } else { 0.0 };
 443         let old = self.scroll_px;
 444         self.set_scroll_px(ratio * self.max_scroll_px());
 445         (self.scroll_px - old).abs() > 0.01
 446     }
 447 
 448     /// Whether the bar is raised — for the app's press cascade and tests.
 449     pub fn scrollbar_raised(&self) -> bool {
 450         self.sb_activity.raised()
 451     }
 452 
 453     /// A row's rect in the list, `Some` while any part of it is in view
 454     /// (the paint clips to the list, so a partly scrolled row draws cut).
 455     fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
 456         let list = list_rect(rect);
 457         let offset = i as f32 * ROW_H - self.scroll_px;
 458         if offset + ROW_H <= 0.0 || offset >= list.height {
 459             return None;
 460         }
 461         Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
 462     }
 463 
 464     fn row_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
 465         let list = list_rect(rect);
 466         if x < list.x || x >= list.x + list.width || y < list.y || y >= list.y + list.height {
 467             return None;
 468         }
 469         let i = ((y - list.y + self.scroll_px) / ROW_H).floor();
 470         (i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
 471     }
 472 
 473     /// Keep `selected` inside the scrolled window.
 474     pub fn scroll_to_selected(&mut self) {
 475         let view = self.page.max(1) as f32 * ROW_H;
 476         let top = self.selected as f32 * ROW_H;
 477         let bottom = top + ROW_H;
 478         if top < self.scroll_px {
 479             self.set_scroll_px(top);
 480             self.sb_activity.bump();
 481         } else if bottom > self.scroll_px + view {
 482             self.set_scroll_px(bottom - view);
 483             self.sb_activity.bump();
 484         }
 485     }
 486 
 487     pub fn move_selection(&mut self, delta: i32) {
 488         if self.rows.is_empty() {
 489             self.selected = 0;
 490             self.set_scroll_px(0.0);
 491             return;
 492         }
 493         let n = self.rows.len() as i32;
 494         // Wrapping, not clamping: a list you can fall off the bottom of makes
 495         // the last row harder to reach than the first, and this one is short.
 496         self.selected = (self.selected as i32 + delta).rem_euclid(n) as usize;
 497         self.scroll_to_selected();
 498     }
 499 
 500     /// What Enter would pick.
 501     pub fn selected_id(&self) -> Option<&str> {
 502         self.rows.get(self.selected).map(|r| r.id.as_str())
 503     }
 504 
 505     /// The selected row's control, if it has one.
 506     pub fn selected_control(&self) -> Option<&Control> {
 507         self.rows.get(self.selected).and_then(|r| r.control.as_ref())
 508     }
 509 
 510     pub fn take_activated(&mut self) -> Option<String> {
 511         self.activated.take()
 512     }
 513 
 514     /// Replace one row's control in place — the app re-reads a control from
 515     /// the live value after applying it, without touching the ranking, the
 516     /// selection or the scroll.
 517     pub fn set_control(&mut self, id: &str, control: Option<Control>) {
 518         if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
 519             row.control = control;
 520         }
 521         self.sync_color_selectors();
 522     }
 523 
 524     /// Move a slider row to a value, clamped and snapped as the row says.
 525     pub fn set_slider_value(&mut self, i: usize, v: f32) {
 526         if let Some(c) = self.rows.get_mut(i).and_then(|r| r.control.as_mut()) {
 527             let q = c.quantize(v);
 528             if let Control::Slider { value, .. } = c {
 529                 *value = q;
 530             }
 531         }
 532     }
 533 
 534     pub fn take_slider_change(&mut self) -> Option<(String, f32)> {
 535         self.slider_change.take()
 536     }
 537 
 538     /// Whether a press has taken hold of a slider — the app arms its
 539     /// widget drag on this.
 540     pub fn slider_dragging(&self) -> bool {
 541         self.slider_drag.is_some()
 542     }
 543 
 544     /// The colour selector behind a colour row, if that row has one.
 545     pub fn color_selector(&self, id: &str) -> Option<&Adapted<ColorSelector>> {
 546         self.colors.iter().find(|(k, _)| k == id).map(|(_, s)| s)
 547     }
 548 
 549     /// The colour selector whose hex well is being typed into, if any — the
 550     /// app hands it the keyboard ahead of the filter.
 551     pub fn editing_color(&mut self) -> Option<&mut Adapted<ColorSelector>> {
 552         self.colors.iter_mut().find(|(_, s)| s.inner().editing).map(|(_, s)| s)
 553     }
 554 
 555     pub fn take_color_changes(&mut self) -> Vec<(String, String)> {
 556         std::mem::take(&mut self.color_changes)
 557     }
 558 
 559     /// Give every colour row a selector, and set each from its row — unless
 560     /// the selector is mid-edit, when the buffer is the user's and the row's
 561     /// value is what it was opened on. A value the APP put in is not a
 562     /// change to report back, so the flag it raises is dropped here.
 563     fn sync_color_selectors(&mut self) {
 564         for row in &self.rows {
 565             let Some(Control::Color { hex, alpha }) = &row.control else { continue };
 566             let k = match self.colors.iter().position(|(k, _)| *k == row.id) {
 567                 Some(k) => k,
 568                 None => {
 569                     let sel = if *alpha { ColorSelector::new_rgba([0, 0, 0, 255]) } else { ColorSelector::new([0; 3]) };
 570                     self.colors.push((row.id.clone(), sel));
 571                     self.colors.len() - 1
 572                 }
 573             };
 574             let sel = &mut self.colors[k].1;
 575             if !sel.inner().editing {
 576                 sel.set_value_string(hex);
 577                 let _ = sel.take_change();
 578             }
 579         }
 580     }
 581 
 582     /// Collect what the selectors changed — a picker line, a hex committed
 583     /// by Enter — as `(row id, hex)`, and move the rows to match.
 584     fn drain_color_selectors(&mut self) -> bool {
 585         let mut changed = false;
 586         for (id, sel) in &mut self.colors {
 587             if !sel.take_change() {
 588                 continue;
 589             }
 590             let Some(hex) = sel.get_value_string() else { continue };
 591             if let Some(Control::Color { hex: h, .. }) =
 592                 self.rows.iter_mut().find(|r| r.id == *id).and_then(|r| r.control.as_mut())
 593             {
 594                 *h = hex.clone();
 595             }
 596             self.color_changes.push((id.clone(), hex));
 597             changed = true;
 598         }
 599         changed
 600     }
 601 
 602     /// The switch column's width: reserved on EVERY row as soon as any row
 603     /// has a toggle, so the chord column keeps a straight edge.
 604     fn toggle_col(&self) -> f32 {
 605         if self.rows.iter().any(|r| r.toggle().is_some()) { TOGGLE_W + 12.0 } else { 0.0 }
 606     }
 607 
 608     /// The band a slider or colour row draws its control over — so the
 609     /// pointer maps to the value where the band is drawn. It ends at the
 610     /// chord column's right edge, not the row's, which is why it needs the
 611     /// roster rather than the rect alone.
 612     fn slider_band_rect(&self, r: Rect) -> Rect {
 613         let x = r.x + r.width - 8.0 - SLIDER_W;
 614         let right = r.x + r.width - 8.0 - self.toggle_col();
 615         Rect { x, y: r.y + 2.0, width: (right - x).max(10.0), height: ROW_H - 4.0 }
 616     }
 617 
 618     /// The whole slider control: the band plus the readout lane ahead of it.
 619     /// This is what the pointer tests against, so the wheel turns the slider
 620     /// over the readout too.
 621     fn slider_rect(&self, r: Rect) -> Rect {
 622         let b = self.slider_band_rect(r);
 623         Rect { x: b.x - READOUT_W - READOUT_GAP, width: b.width + READOUT_W + READOUT_GAP, ..b }
 624     }
 625 
 626     /// The band's (x, width), captured at a press for the drag.
 627     fn slider_track_of(&self, r: Rect) -> (f32, f32) {
 628         let b = self.slider_band_rect(r);
 629         (b.x, b.width)
 630     }
 631 
 632     /// Step a slider row by wheel notches: 2% of the range each, the toolkit
 633     /// slider's own rate, up meaning more — the sign the viewport's zoom
 634     /// wheel has.
 635     fn scroll_slider(&mut self, i: usize, notches: f32) -> bool {
 636         let Some(Control::Slider { value, min, max, .. }) = self.rows.get(i).and_then(|r| r.control.as_ref()) else {
 637             return false;
 638         };
 639         let (cur, min, max) = (*value, *min, *max);
 640         let v = cur + notches * 0.02 * (max - min);
 641         self.set_slider_value(i, v);
 642         let Some(now) = self.rows[i].slider_value() else { return false };
 643         if (now - cur).abs() < 1e-6 {
 644             return false;
 645         }
 646         self.slider_change = Some((self.rows[i].id.clone(), now));
 647         true
 648     }
 649 
 650     /// Put the dragged slider where the pointer is along the captured band.
 651     /// Jumps, rather than dragging relative to a grab: the band has no thumb
 652     /// to grab, and a click on a scale should mean "this much".
 653     fn slide_to(&mut self, px: f32) -> bool {
 654         let Some(i) = self.slider_drag else { return false };
 655         let Some(Control::Slider { value, min, max, .. }) = self.rows.get(i).and_then(|r| r.control.as_ref()) else {
 656             return false;
 657         };
 658         let (old, min, max) = (*value, *min, *max);
 659         let (tx, tw) = self.slider_track;
 660         let t = ((px - tx) / tw).clamp(0.0, 1.0);
 661         self.set_slider_value(i, min + t * (max - min));
 662         let Some(now) = self.rows[i].slider_value() else { return false };
 663         self.slider_change = Some((self.rows[i].id.clone(), now));
 664         now != old
 665     }
 666 
 667     /// Swap in a freshly ranked row list, keeping the selection in range.
 668     ///
 669     /// The selection goes back to the top rather than trying to follow the
 670     /// row it was on: the rows are re-ranked by the query, so "the same row"
 671     /// after a keystroke is a different one, and the best match being
 672     /// preselected is the whole point of ranking them.
 673     pub fn set_rows(&mut self, rows: Vec<Row>) {
 674         self.rows = rows;
 675         self.selected = 0;
 676         self.set_scroll_px(0.0);
 677         self.sync_color_selectors();
 678     }
 679 
 680     /// Hand a press or wheel on a colour row's band to its selector: the well
 681     /// begins a hex edit, the swatch opens the picker. The selector's rect is
 682     /// the band, set here because the band moves with the scroll.
 683     fn color_event(&mut self, i: usize, band: Rect, event: &Event, ectx: &mut EventCtx) -> bool {
 684         let id = self.rows[i].id.clone();
 685         let Some(k) = self.colors.iter().position(|(k, _)| *k == id) else { return false };
 686         let Some(ui) = ectx.ui.as_deref_mut() else { return false };
 687         let sel = &mut self.colors[k].1;
 688         WidgetHost::set_rect(sel, band.x, band.y, band.width, band.height);
 689         let taken = sel.handle_event(event, ui);
 690         self.drain_color_selectors();
 691         taken
 692     }
 693 }
 694 
 695 impl Layout for Dialog {
 696     /// Above every pane, and above the second network editor's plates: the
 697     /// dialog is modal in practice — a press inside it never reaches what it
 698     /// covers — so it has to be drawn that way too.
 699     fn z_order(&self) -> i32 {
 700         900
 701     }
 702 }
 703 
 704 impl Paint for Dialog {
 705     /// `paint` authors geometry AND text, so the Text prims pass through
 706     /// `paint_self` verbatim instead of the single-font own-labels bridge —
 707     /// the chord column needs its own family and its own clip bounds.
 708     fn paints_own_subtree(&self) -> bool {
 709         true
 710     }
 711 
 712     /// The dialog IS its own plate, the contract every floating surface in
 713     /// this app wears: the parameter plate's fill, so it tracks the configured
 714     /// tint, opacity and blur-behind marker with the panes.
 715     fn color(&self) -> [f32; 4] {
 716         colors::param_plate_fill()
 717     }
 718 
 719     fn solid_border(&self) -> Option<([f32; 4], f32)> {
 720         colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
 721     }
 722 
 723     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
 724         let r = cce_ui::layout::plate_corner_radius();
 725         (r > 0.0).then_some((r, (true, true, true, true)))
 726     }
 727 
 728     /// The dialog's whole rect, as an occluder.
 729     ///
 730     /// Text is not painted in display-list order — the engine collects every
 731     /// Text prim and lays them all out at the end — so a plate drawn over a
 732     /// label does not hide it, whatever the z. What hides it is the
 733     /// popover-occlusion clamp, which reads `UiContext::active_popovers`; the
 734     /// designer registers every visible widget whose `popover_rect` is `Some`,
 735     /// so claiming one here is how the graph's node labels and the viewport's
 736     /// readouts stop bleeding through the plate.
 737     ///
 738     /// The clamp exempts text whose OWN bounds coincide with the occluder, so
 739     /// everything drawn inside the dialog carries these exact bounds and does
 740     /// its own truncating — the hosted colour selectors' text included, which
 741     /// is re-emitted retagged (see `paint`).
 742     ///
 743     /// **`occluding` exists because one claim serves two mechanisms that want
 744     /// opposite answers.** `UiContext::is_coordinate_covered` reads the same
 745     /// `popover_rect` — off every REGISTERED widget, not only the ones in
 746     /// `active_popovers` — to decide that a press has landed under something
 747     /// else. With the claim standing, every control inside the dialog is
 748     /// covered by the plate it is drawn on and nothing can be clicked.
 749     /// `State::dispatch_uncovered` lowers the flag for the length of a
 750     /// dispatch into the dialog and puts it straight back.
 751     fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
 752         self.occluding.then_some((rect.x, rect.y, rect.width, rect.height))
 753     }
 754 
 755     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
 756         if rect.width <= 0.0 || rect.height <= 0.0 {
 757             return;
 758         }
 759         let (family, font_size) = cce_ui::layout::control_label_font_parsed();
 760         let accent = colors::highlight_primary_color();
 761         let tint = [accent[0], accent[1], accent[2]];
 762         let depth = colors::plate_bevel_width();
 763         let ctrl_r = cce_ui::layout::control_corner_radius();
 764         let radii = (ctrl_r, ctrl_r, ctrl_r, ctrl_r);
 765         // The occlusion-clamp exemption (see `popover`): every label in here
 766         // carries the dialog's own rect, so none of them is clipped away by
 767         // the occluder the dialog itself registers. The cost is that bounds
 768         // no longer trim an overlong label, so the rows truncate by hand.
 769         let own = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
 770         let cols_for = |width: f32| -> usize {
 771             (width / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize
 772         };
 773         let fit = |text: &str, width: f32| -> String {
 774             if width <= 0.0 {
 775                 return String::new();
 776             }
 777             display::truncate_tail(text, cols_for(width))
 778         };
 779         // The same budget, cut from the other end — a path's tail is what
 780         // identifies it.
 781         let fit_head = |text: &str, width: f32| -> String {
 782             if width <= 0.0 {
 783                 return String::new();
 784             }
 785             display::truncate_head(text, cols_for(width))
 786         };
 787 
 788         // --- The query line: a well, like the text rows in the params pane.
 789         // The caret is a plain rule and does not blink: the dialog owns the
 790         // keyboard outright while it is open, so there is no focus to signal.
 791         // In AddNode the hint says what this opening of the plate is for;
 792         // there is no title band, so the two openings are the same height.
 793         let q = query_rect(rect);
 794         ctx.rounded_rect(q, ctrl_r, (true, true, true, true), [0.0, 0.0, 0.0, 0.22]);
 795         let qty = cce_ui::layout::align_text_y(q.y, q.height, font_size, 0.0);
 796         let qtx = q.x + 8.0;
 797         let q_w = q.width - 16.0;
 798         if self.query.is_empty() {
 799             let hint = fit(
 800                 match self.mode {
 801                     Mode::Commands => "Type to filter commands and settings",
 802                     Mode::AddNode => "Add Node: type to filter nodes",
 803                 },
 804                 q_w,
 805             );
 806             ctx.text_with(hint, qtx, qty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
 807         } else {
 808             // Head-truncated: what matters while typing is the end of the
 809             // query, which is where the caret is.
 810             let shown = display::truncate_head(&self.query, (q_w / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize);
 811             ctx.text_with(shown, qtx, qty, font_size, [0xe6, 0xe6, 0xee], Some(family.clone()), own);
 812         }
 813         let caret_x = qtx + display::measure_text_width(&self.query, &family, font_size) + 1.0;
 814         if caret_x < q.x + q.width - 4.0 {
 815             ctx.quad(
 816                 Rect { x: caret_x, y: q.y + 6.0, width: 1.0, height: q.height - 12.0 },
 817                 [accent[0], accent[1], accent[2], 0.9],
 818             );
 819         }
 820 
 821         // --- The rows. The chord column is right-aligned against the list's
 822         // right edge rather than padded out to a fixed width: the label is
 823         // what gets read, so it is the label that keeps the stable left edge.
 824         //
 825         // The switches get a column of their own at the far right, reserved
 826         // for EVERY row as soon as any row has one, so the chord column keeps
 827         // a straight edge whether or not the row beside it toggles. Without
 828         // the reservation the chords step left on toggle rows and the column
 829         // reads as ragged, which is worse than the strip of air it costs.
 830         let list = list_rect(rect);
 831         let toggle_col = self.toggle_col();
 832         if self.rows.is_empty() {
 833             let ty = cce_ui::layout::align_text_y(list.y, ROW_H, font_size, 0.0);
 834             let empty = match self.mode {
 835                 Mode::Commands => "No matching command or setting",
 836                 Mode::AddNode => "No matching node",
 837             };
 838             ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
 839             return;
 840         }
 841         ctx.clip(list, |ctx| {
 842         for i in self.first_row()..self.rows.len() {
 843             let Some(r) = self.row_rect(rect, i) else { break };
 844             let row = &self.rows[i];
 845             // The highlight stops short of the switch column. A switch has
 846             // no face of its own — it is carved out of whatever it stands
 847             // on, the DE's convention — and carved out of the selection's
 848             // tinted bevel it vanished outright: the selected row, the one
 849             // row whose state Enter is about to flip, was the one row whose
 850             // state could not be read. On the plate it reads like the rest.
 851             // A slider or colour row's control is wider than the toggle
 852             // column and takes the chord column's place on that one row.
 853             let ctl_col = if row.is_slider() {
 854                 (r.x + r.width) - self.slider_rect(r).x + 4.0
 855             } else if row.is_color() {
 856                 (r.x + r.width) - self.slider_band_rect(r).x + 4.0
 857             } else {
 858                 toggle_col
 859             };
 860             let hl = Rect { width: (r.width - ctl_col).max(0.0), ..r };
 861             if i == self.selected {
 862                 ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
 863                 ctx.bevel_tinted(hl, radii, &cce_ui::scene::Material::from_fill([0.0; 4]), depth, tint);
 864             } else if self.hover_row == Some(i) {
 865                 ctx.rounded_rect(hl, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
 866             }
 867             let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
 868             let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
 869             // What the chord column shows: the chord, or a choice row's
 870             // current option between its two arrows — a value, so it wears
 871             // the label's colour rather than the chord's grey.
 872             let (right_text, right_color) = match &row.control {
 873                 Some(Control::Choice { options, index }) => {
 874                     (format!("\u{25c2} {} \u{25b8}", options.get(*index).map(String::as_str).unwrap_or("")), label_color)
 875                 }
 876                 _ => (row.chord.clone(), [0x85, 0x85, 0x92]),
 877             };
 878             let right_w = if right_text.is_empty() {
 879                 0.0
 880             } else {
 881                 display::measure_text_width(&right_text, &family, font_size)
 882             };
 883             // The label's clip stops short of the chord column so a long
 884             // label is cut by it rather than running under it.
 885             let chord_right = r.x + r.width - 8.0 - ctl_col;
 886             let label_right = chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 };
 887             let label_x = r.x + 8.0;
 888             ctx.text_with(
 889                 if row.truncate_head {
 890                     fit_head(&row.label, label_right - label_x)
 891                 } else {
 892                     fit(&row.label, label_right - label_x)
 893                 },
 894                 label_x,
 895                 ty,
 896                 font_size,
 897                 label_color,
 898                 Some(family.clone()),
 899                 own,
 900             );
 901             if right_w > 0.0 {
 902                 ctx.text_with(
 903                     right_text,
 904                     chord_right - right_w,
 905                     ty,
 906                     font_size,
 907                     right_color,
 908                     Some(family.clone()),
 909                     own,
 910                 );
 911             }
 912             match &row.control {
 913                 Some(Control::Toggle(on)) => {
 914                     let tr = Rect {
 915                         x: r.x + r.width - 8.0 - TOGGLE_W,
 916                         y: r.y + (r.height - TOGGLE_H) * 0.5,
 917                         width: TOGGLE_W,
 918                         height: TOGGLE_H,
 919                     };
 920                     Paint::paint(&*self.toggle_stamps[*on as usize], tr, ctx);
 921                 }
 922                 Some(Control::Slider { value, min, max, dec, suffix, .. }) => {
 923                     let band = self.slider_band_rect(r);
 924                     {
 925                         let mut stamp = self.slider_stamp.borrow_mut();
 926                         stamp.set_range(*min, *max);
 927                         stamp.set_scaled_value(*value);
 928                         Paint::paint(&**stamp, band, ctx);
 929                     }
 930                     // The readout, right-aligned in its lane ahead of the
 931                     // band — the dialog's own text, so it clears the
 932                     // occlusion clamp.
 933                     let readout = format!("{:.*}{}", *dec, value, suffix);
 934                     let rw = display::measure_text_width(&readout, &family, font_size);
 935                     ctx.text_with(
 936                         readout,
 937                         band.x - READOUT_GAP - rw,
 938                         ty,
 939                         font_size,
 940                         label_color,
 941                         Some(family.clone()),
 942                         own,
 943                     );
 944                 }
 945                 Some(Control::Color { .. }) => {
 946                     if let Some(sel) = self.color_selector(&row.id) {
 947                         let band = self.slider_band_rect(r);
 948                         // Painted twice, on purpose. The selector's hex text
 949                         // has to carry the DIALOG's bounds or the occluder
 950                         // the dialog registers clamps it away, and a
 951                         // `PaintCtx` cannot be handed a prim back: the first
 952                         // pass lays down the well and the swatch (its text
 953                         // lands inside the occluder and is clamped to
 954                         // nothing), the second is a scratch pass whose text
 955                         // alone is re-emitted retagged.
 956                         Paint::paint(&**sel, band, ctx);
 957                         let mut scratch = PaintCtx::new();
 958                         Paint::paint(&**sel, band, &mut scratch);
 959                         for item in scratch.finish().items {
 960                             if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
 961                                 ctx.text_with(text, x, y, font_size, color, font, own);
 962                             }
 963                         }
 964                     }
 965                 }
 966                 Some(Control::Choice { .. }) | None => {}
 967             }
 968         }
 969         });
 970 
 971         // The scrollbar's fore copy, over the rows, at the activity's fade:
 972         // pills, as `ScrollRegion::push_scrollbar_prims` and cce-mail's body
 973         // bar draw them. Driven by the fade rather than the latch so it
 974         // draws all the way out. The dialog's plate is the host's, so there
 975         // is no under-plate copy to show through while sunk — sunk is
 976         // simply not drawn, as cce-mail's body bar over its opaque window.
 977         let a = self.sb_activity.fade().clamp(0.0, 1.0);
 978         if a > 0.001 {
 979             if let Some((sb_x, track_y, sb_w, track_h, thumb_y, thumb_h)) = self.scrollbar_geom(rect) {
 980                 let dim = |mut c: [f32; 4]| {
 981                     c[3] *= a;
 982                     c
 983                 };
 984                 let all = (true, true, true, true);
 985                 ctx.rounded_rect(
 986                     Rect { x: sb_x, y: track_y, width: sb_w, height: track_h },
 987                     sb_w.min(track_h) * 0.5,
 988                     all,
 989                     dim(cce_ui::color::scrollbar_track_color()),
 990                 );
 991                 ctx.rounded_rect(
 992                     Rect { x: sb_x, y: thumb_y, width: sb_w, height: thumb_h },
 993                     sb_w.min(thumb_h) * 0.5,
 994                     all,
 995                     dim(cce_ui::color::scrollbar_thumb_color()),
 996                 );
 997             }
 998         }
 999     }
1000 }
1001 
1002 impl Input for Dialog {
1003     /// Advance the list's glide / coast (see `scroll_px`), and poll the
1004     /// colour selectors — a picker streams its values through a reader
1005     /// thread that only a tick can see.
1006     fn tick(&mut self, dt: f32, rect: Rect) -> bool {
1007         let max = self.max_scroll_px();
1008         self.scroll_motion.reconcile(0.0, self.scroll_px);
1009         let moved = self.scroll_motion.tick(dt, cce_ui::widget::Bounds::max(0.0), cce_ui::widget::Bounds::max(max));
1010         if moved {
1011             self.scroll_px = self.scroll_motion.y.pos();
1012         }
1013         // The bar's raise/sink latch and its fade: frames keep coming while
1014         // the hold runs and while the fore copy is still chasing the latch,
1015         // so the sink actually renders instead of freezing mid-fade.
1016         let visible = self.scrollbar_geom(rect).is_some();
1017         let flipped = self.sb_activity.tick(dt, visible, self.sb_dragging);
1018         let fade = self.sb_activity.fade();
1019         let fading = if self.sb_activity.raised() { fade < 1.0 } else { fade > 0.0 };
1020         let mut picking = false;
1021         for (_, sel) in &mut self.colors {
1022             picking |= Input::tick(sel.inner_mut(), dt, rect);
1023         }
1024         let colored = self.drain_color_selectors();
1025         moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored
1026     }
1027 
1028     /// The whole rect, always — this is what makes the dialog modal over what
1029     /// it covers: the designer's press cascade asks the dialog first and a hit
1030     /// never falls through to the pane underneath.
1031     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
1032         x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
1033     }
1034 
1035     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
1036         let rect = ectx.rect;
1037         match event {
1038             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
1039                 // The raised bar is in front of the rows; sunk, it is not
1040                 // there and the press reaches the row.
1041                 if self.sb_press(rect, *x, *y) {
1042                     return true;
1043                 }
1044                 if let Some(i) = self.row_at(rect, *x, *y) {
1045                     self.selected = i;
1046                     let r = self.row_rect(rect, i);
1047                     if self.rows[i].is_slider() {
1048                         // On the band: take hold and jump there. On the
1049                         // rest of the row — the readout lane included:
1050                         // selected, and nothing to run. A press tests the
1051                         // BAND rather than the whole control, or a click
1052                         // on the readout would jump the value to the end
1053                         // of the range nearest it.
1054                         if let Some(r) = r {
1055                             let s = self.slider_band_rect(r);
1056                             if *x >= s.x && *x < s.x + s.width {
1057                                 self.slider_track = self.slider_track_of(r);
1058                                 self.slider_drag = Some(i);
1059                                 self.slide_to(*x);
1060                             }
1061                         }
1062                         return true;
1063                     }
1064                     if self.rows[i].is_color() {
1065                         // On the band: the selector's — a hex edit or the
1066                         // picker. Elsewhere on the row: selected, nothing
1067                         // to run.
1068                         if let Some(r) = r {
1069                             let s = self.slider_band_rect(r);
1070                             if *x >= s.x && *x < s.x + s.width {
1071                                 self.color_event(i, s, event, ectx);
1072                             }
1073                         }
1074                         return true;
1075                     }
1076                     self.activated = self.rows.get(i).map(|r| r.id.clone());
1077                     return true;
1078                 }
1079                 // Inside the plate but on no control: consumed anyway, so the
1080                 // press cannot reach the pane the dialog is covering.
1081                 true
1082             }
1083             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, .. } => {
1084                 if self.slider_drag.take().is_some() {
1085                     return true;
1086                 }
1087                 if std::mem::take(&mut self.sb_dragging) {
1088                     // The release starts the hold before the bar sinks.
1089                     self.sb_activity.bump();
1090                     return true;
1091                 }
1092                 false
1093             }
1094             Event::PointerMove { x, y, .. } => {
1095                 if self.slider_drag.is_some() {
1096                     return self.slide_to(*x);
1097                 }
1098                 if self.sb_dragging {
1099                     return self.sb_drag_to(rect, *y);
1100                 }
1101                 self.sb_activity.set_hover(self.over_scrollbar(rect, *x, *y));
1102                 let row = self.row_at(rect, *x, *y);
1103                 let changed = row != self.hover_row;
1104                 self.hover_row = row;
1105                 changed
1106             }
1107             Event::MouseWheel { delta, x, y, .. } => {
1108                 if self.rows.is_empty() {
1109                     return false;
1110                 }
1111                 // Over a slider row's control the wheel turns the slider,
1112                 // not the list — the rest of the row still scrolls.
1113                 if let Some(i) = self.row_at(rect, *x, *y) {
1114                     if self.rows[i].is_slider() {
1115                         if let Some(r) = self.row_rect(rect, i) {
1116                             let s = self.slider_rect(r);
1117                             if *x >= s.x && *x < s.x + s.width {
1118                                 return self.scroll_slider(i, delta.notches_y());
1119                             }
1120                         }
1121                     }
1122                 }
1123                 // The DE scroll model: a notch is one row and glides there, a
1124                 // trackpad tracks 1:1 and coasts on the lift (`tick` advances).
1125                 let max = self.max_scroll_px();
1126                 self.scroll_motion.reconcile(0.0, self.scroll_px);
1127                 let moved = self.scroll_motion.apply(
1128                     delta,
1129                     (ROW_H, ROW_H),
1130                     cce_ui::widget::Bounds::max(0.0),
1131                     cce_ui::widget::Bounds::max(max),
1132                 );
1133                 self.scroll_px = self.scroll_motion.y.pos();
1134                 // A scroll raises the bar and starts its hold.
1135                 self.sb_activity.bump();
1136                 moved || self.scroll_motion.is_animating()
1137             }
1138             _ => false,
1139         }
1140     }
1141 
1142     // A slider drag rides the app's widget-drag protocol (armed by
1143     // `State::dialog_mouse_input` once a press has taken a band), so the
1144     // pointer keeps moving the value after it leaves the plate, as a params
1145     // pane slider's does.
1146     fn draggable(&self, _rect: Rect) -> bool {
1147         self.slider_drag.is_some()
1148     }
1149     fn is_dragging(&self) -> bool {
1150         self.slider_drag.is_some()
1151     }
1152     fn drag_begin(&mut self, px: f32, _py: f32, _rect: Rect) {
1153         self.slide_to(px);
1154     }
1155     fn drag_update(&mut self, px: f32, _py: f32, _rect: Rect) -> bool {
1156         self.slide_to(px)
1157     }
1158     fn drag_end(&mut self) {
1159         self.slider_drag = None;
1160     }
1161 }
1162 
1163 // ---------------------------------------------------------------------------
1164 // The designer's half: what the dialog shows, and what choosing a row does.
1165 // ---------------------------------------------------------------------------
1166 
1167 use crate::app::State;
1168 use crate::command::Context;
1169 use crate::slots::DIALOG_IDX;
1170 
1171 /// The list's zoom row: not a registry command but a control — a slider
1172 /// over the network zoom, present only while the network pane is focused,
1173 /// since zoom is that pane's and a slider for a pane you are not looking at
1174 /// would be a strange thing to offer. Picking it runs nothing; dragging it,
1175 /// or the arrow keys while it is selected, zoom in place with the dialog
1176 /// up, the way the toggle rows stay up.
1177 pub const ZOOM_ROW_ID: &str = "zoom_level";
1178 
1179 /// The list's other non-command row: the open project's PATH, with its file
1180 /// name in the chord column the way a command's chord sits there — the
1181 /// palette's readout of what is being edited. Picking it copies the path to
1182 /// the clipboard, which is the one thing anyone wants a path on screen for.
1183 /// It heads the list, where it reads as the document the rest of the
1184 /// commands act on.
1185 pub const PATH_ROW_ID: &str = "project_path";
1186 
1187 /// Prefix of a recent-project row's id; the rest is the path.
1188 ///
1189 /// The recent list was the Main utility node's "Open" dropdown, and it went
1190 /// with that node — leaving `State::recent_files` written on every save and
1191 /// read by nothing. It is a list of documents, so it belongs where the open
1192 /// document's own path already is: rows under the path row, each opening its
1193 /// project. Ranked against the path text like everything else.
1194 pub const RECENT_ROW_PREFIX: &str = "recent:";
1195 
1196 /// How many recent projects the list offers. `recent_files` keeps ten; five
1197 /// is what fits above the commands without the palette reading as a file
1198 /// manager, and a query narrows the rest.
1199 pub const RECENT_ROW_LIMIT: usize = 5;
1200 
1201 /// Prefix of a setting row's id; the rest is the [`Setting`]'s label, which
1202 /// is unique across the table (`dialog_settings_labels_are_unique`).
1203 pub const SETTING_ROW_PREFIX: &str = "setting:";
1204 
1205 pub fn setting_row_id(label: &str) -> String {
1206     format!("{SETTING_ROW_PREFIX}{label}")
1207 }
1208 
1209 /// The setting a row id names, if it is a setting row.
1210 pub fn setting_of_row(id: &str) -> Option<&'static Setting> {
1211     let label = id.strip_prefix(SETTING_ROW_PREFIX)?;
1212     SETTINGS.iter().find(|s| s.label == label)
1213 }
1214 
1215 /// Where a setting row's value lives.
1216 ///
1217 /// It used to be neither the live `State` fields nor `DesignSettings`: both
1218 /// were DOWNSTREAM of the root meta node, whose utility subnets were copied
1219 /// over live state on every param change, so a write straight to
1220 /// `State::grid_thickness` survived exactly until the next one. With that
1221 /// node retired the live field IS the value; this enum says which of the two
1222 /// remaining kinds of owner each row has. (A third kind — a toggle the
1223 /// command registry owns — went when the settings joined the commands list:
1224 /// those toggles ARE command rows there, and a second row for each would
1225 /// have listed every switch twice.)
1226 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1227 pub enum Owner {
1228     /// A display setting the app owns outright: a live field on `State`,
1229     /// persisted by `DesignSettings` into `state.kdl`. Named by the key
1230     /// `settings_field_*` dispatch on.
1231     Field(&'static str),
1232     /// A param on the ACTIVE camera node, with the live field as the
1233     /// fallback: the Default Camera has no node, so there is nothing to write
1234     /// but the field.
1235     ActiveCamera(&'static str),
1236 }
1237 
1238 /// The control a [`Setting`] row draws.
1239 ///
1240 /// A bare Rust field carries no type and no range the way a param did, so
1241 /// the table spells it; the row's [`Control`] is built from it and the live
1242 /// value. The value strings are the params pane's encodings, so a setting
1243 /// reads and writes the way a node parameter of the same shape does.
1244 #[derive(Debug, Clone, Copy, PartialEq)]
1245 pub enum Ctl {
1246     Toggle,
1247     /// `#rrggbb`.
1248     Color,
1249     /// `#rrggbbaa` — the wire colour, whose alpha is its own opacity.
1250     Rgba,
1251     /// A whole number over `min..=max`. The stored float is scaled by
1252     /// `unit` (thousandths for Grid Thickness, tenths for Origin Size),
1253     /// which is the convention those params already used.
1254     Spin { min: f32, max: f32, unit: f32 },
1255     /// A float slider, `min..=max`, shown to `dec` decimals.
1256     Slider { min: f32, max: f32, dec: usize },
1257     /// A fixed set of strings.
1258     Choice(&'static [&'static str]),
1259 }
1260 
1261 /// One setting row of the list.
1262 pub struct Setting {
1263     /// What the dialog calls it — and the row's identity: the row id is
1264     /// the label under [`SETTING_ROW_PREFIX`], and the writeback resolves
1265     /// it back to this row. Unique across the table.
1266     pub label: &'static str,
1267     pub owner: Owner,
1268     pub ctl: Ctl,
1269 }
1270 
1271 impl Setting {
1272     /// A row over a live field.
1273     const fn field(label: &'static str, key: &'static str, ctl: Ctl) -> Self {
1274         Setting { label, owner: Owner::Field(key), ctl }
1275     }
1276 
1277     /// A row over the active camera's param of that name, a whole number
1278     /// in tenths as the camera template's own spinbox is.
1279     const fn camera(label: &'static str, name: &'static str) -> Self {
1280         Setting { label, owner: Owner::ActiveCamera(name), ctl: Ctl::Spin { min: 1.0, max: 50.0, unit: 10.0 } }
1281     }
1282 }
1283 
1284 /// The setting rows, in the order an empty query lists them.
1285 ///
1286 /// Scope is exactly what `DesignSettings` persists: the DISPLAY state, which
1287 /// is the part of the app's configuration that is a preference rather than
1288 /// part of a project. The four utility subnets under the root meta node
1289 /// (`main`, `view`, `guides`, `render`) held these values until 2026-09-23,
1290 /// and every one of them that was reachable only by selecting one of those
1291 /// nodes is a row here or a command in the registry. Anything left out would
1292 /// not be "hidden in the node tree", it would be gone —
1293 /// `every_retired_subnet_setting_is_reachable` is the backstop.
1294 ///
1295 /// The TOGGLES those subnets held (Show Grid, Show Wireframe, Square Aspect
1296 /// …) are not here: each is a registry command with a switch on its own
1297 /// row, and this table lists what is not a command. Still deliberately NOT
1298 /// here either: the pane-visibility toggles (commands too), the active
1299 /// camera (the viewport menubar's own menu, whose entries are the camera
1300 /// NODES and so cannot be a fixed table), and keybindings, which this DE
1301 /// edits as `input.kdl` on purpose.
1302 pub const SETTINGS: &[Setting] = &[
1303     Setting::field("Background Color", "bg_color", Ctl::Color),
1304     Setting::field("World Unit", "world_unit", Ctl::Choice(&["mm", "cm", "m", "in"])),
1305     Setting::field("Geometry Opacity", "geo_opacity", Ctl::Slider { min: 0.0, max: 1.0, dec: 2 }),
1306     // The colour applies only in single-colour mode (off, the wires carry
1307     // the geometry's vertex colours and the colour row sets their alpha
1308     // alone) — so a colour edit turns that mode on, or a colour set here
1309     // looks ignored.
1310     Setting::field("Wireframe Color", "wire_color", Ctl::Rgba),
1311     Setting::field("Wire Thickness", "wire_width", Ctl::Slider { min: 1.0, max: 8.0, dec: 1 }),
1312     Setting::field("Point Size", "point_size", Ctl::Slider { min: 0.0, max: 0.1, dec: 3 }),
1313     Setting::field("Point Color", "point_color", Ctl::Color),
1314     // The Selected-Group markers, as a multiple of Point Size: they draw
1315     // beside the Render points on the same vertices, so what matters is
1316     // how much larger they are — 1.25 was the hard-coded ratio until
1317     // 2026-09-24.
1318     Setting::field("Group Marker Scale", "group_marker_scale", Ctl::Slider { min: 0.5, max: 4.0, dec: 2 }),
1319     Setting::field("Point Marker Size", "point_marker_size", Ctl::Spin { min: 5.0, max: 100.0, unit: 1000.0 }),
1320     Setting::field("Point Marker Color", "point_marker_color", Ctl::Color),
1321     Setting::field("Grid Color", "grid_color", Ctl::Color),
1322     Setting::field("Grid Thickness", "grid_thickness", Ctl::Spin { min: 2.0, max: 200.0, unit: 1000.0 }),
1323     Setting::field("Origin Size", "origin_size", Ctl::Spin { min: 1.0, max: 50.0, unit: 10.0 }),
1324     Setting::camera("Camera Pivot Size", "Camera Pivot Size"),
1325 ];
1326 
1327 impl State {
1328     pub fn dialog_visible(&self) -> bool {
1329         self.slots.dialog.visible()
1330     }
1331 
1332     pub fn toggle_dialog(&mut self) {
1333         if self.dialog_visible() && self.slots.dialog.mode == Mode::Commands {
1334             self.close_dialog();
1335         } else {
1336             self.open_dialog();
1337         }
1338     }
1339 
1340     /// Open the dialog on its commands-and-settings list — `Alt+D`, and what
1341     /// `Ctrl+P` reaches instead of spawning a popup process.
1342     pub fn open_dialog(&mut self) {
1343         self.open_dialog_in(Mode::Commands);
1344     }
1345 
1346     /// The add-node palette: the same plate, one list, and a pick that
1347     /// instantiates a template at the grid cursor.
1348     ///
1349     /// Was a `cce-cloud --dmenu` popup — a second process with its own
1350     /// window, fed one line of text per row and answering with one line back.
1351     /// It could not show a chord in a column of its own, could not be styled
1352     /// with the app, and put a second filterable list in front of the user
1353     /// that looked nothing like the first.
1354     pub fn open_node_palette(&mut self) {
1355         if self.dialog_visible() && self.slots.dialog.mode == Mode::AddNode {
1356             self.close_dialog();
1357             return;
1358         }
1359         self.open_dialog_in(Mode::AddNode);
1360     }
1361 
1362     fn open_dialog_in(&mut self, mode: Mode) {
1363         // Always with an empty query: a dialog that reopens holding the last
1364         // search has to be cleared before it can be used, which is a step
1365         // every single time to save one occasionally.
1366         self.slots.dialog.mode = mode;
1367         self.slots.dialog.query.clear();
1368         self.slots.dialog.set_visible(true);
1369         self.refresh_dialog_rows();
1370         self.rebuild_positions();
1371         self.apply_layout();
1372         self.update_status_text(match mode {
1373             Mode::Commands => "Dialog: type to filter commands and settings, Escape closes.",
1374             Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
1375         });
1376     }
1377 
1378     pub fn close_dialog(&mut self) {
1379         if !self.dialog_visible() {
1380             return;
1381         }
1382         self.slots.dialog.set_visible(false);
1383         if self.focused_widget == Some(DIALOG_IDX) {
1384             self.focused_widget = None;
1385         }
1386         self.rebuild_positions();
1387         self.apply_layout();
1388     }
1389 
1390     /// Re-rank the row list against the current query, for whichever mode is
1391     /// up.
1392     ///
1393     /// Commands and settings rank together through the one
1394     /// [`crate::command::fuzzy_rank`], with the focused pane's commands
1395     /// partitioned to the front as [`crate::command::palette_entries`] does
1396     /// (a setting belongs to no pane, so it ranks among the rest). Node
1397     /// templates rank through the same function, so typing means the same
1398     /// thing in every list; they carry no chord, so the column is simply
1399     /// empty for them.
1400     pub fn refresh_dialog_rows(&mut self) {
1401         let query = self.slots.dialog.query.clone();
1402         let rows: Vec<Row> = match self.slots.dialog.mode {
1403             Mode::Commands => {
1404                 let cmds = crate::command::COMMANDS;
1405                 let mut labels: Vec<&str> = cmds.iter().map(|c| c.label).collect();
1406                 labels.extend(SETTINGS.iter().map(|s| s.label));
1407                 let contexts: Vec<Context> = cmds
1408                     .iter()
1409                     .map(|c| c.context)
1410                     .chain(SETTINGS.iter().map(|_| Context::Always))
1411                     .collect();
1412                 let ranked = crate::command::rank_with_focus(&query, &labels, &contexts, self.focused_context());
1413                 let mut rows: Vec<Row> = ranked
1414                     .into_iter()
1415                     .map(|i| {
1416                         if i < cmds.len() {
1417                             let c = &cmds[i];
1418                             Row {
1419                                 id: c.id.to_string(),
1420                                 label: c.label.to_string(),
1421                                 chord: self
1422                                     .shortcut_manager
1423                                     .chord_for(c.id)
1424                                     .map(|s| s.describe())
1425                                     .unwrap_or_default(),
1426                                 control: self.command_toggle_state(c.id).map(Control::Toggle),
1427                                 truncate_head: false,
1428                             }
1429                         } else {
1430                             self.setting_row(&SETTINGS[i - cmds.len()])
1431                         }
1432                     })
1433                     .collect();
1434                 // The open project's path heads the list — ranked against
1435                 // the path text, so typing any part of it (the project's
1436                 // name included, that being the tail) finds or drops the row
1437                 // like any other. Absent when no project is loaded: the
1438                 // bundled `default_project.json` leaves `loaded_project_path`
1439                 // None on purpose, and a row offering to copy a path to a
1440                 // versioned file in the source tree would be a trap.
1441                 if let Some((path, name)) = self.project_path_readout() {
1442                     if !crate::command::fuzzy_rank(&query, &[path.as_str()]).is_empty() {
1443                         rows.insert(
1444                             0,
1445                             Row { id: PATH_ROW_ID.to_string(), label: path, chord: name, control: None, truncate_head: true },
1446                         );
1447                     }
1448                 }
1449                 // The recent projects, under the path row — the open
1450                 // document, then the ones before it. The project already
1451                 // open is not offered again.
1452                 let open_now = self.loaded_project_path.clone();
1453                 let recent: Vec<std::path::PathBuf> = self
1454                     .recent_files
1455                     .iter()
1456                     .filter(|p| Some(*p) != open_now.as_ref())
1457                     .take(RECENT_ROW_LIMIT)
1458                     .cloned()
1459                     .collect();
1460                 for path in recent.iter().rev() {
1461                     let text = path.to_string_lossy().to_string();
1462                     if crate::command::fuzzy_rank(&query, &[text.as_str()]).is_empty() {
1463                         continue;
1464                     }
1465                     let name = path
1466                         .file_name()
1467                         .map(|n| n.to_string_lossy().to_string())
1468                         .unwrap_or_default();
1469                     rows.insert(
1470                         0,
1471                         Row {
1472                             id: format!("{RECENT_ROW_PREFIX}{text}"),
1473                             label: text,
1474                             chord: name,
1475                             control: None,
1476                             // Same reason as the path row: the tail of a
1477                             // path is what identifies it.
1478                             truncate_head: true,
1479                         },
1480                     );
1481                 }
1482                 // The zoom slider heads the network pane's list, ranked like
1483                 // a row labelled "Zoom" so a query still finds (or drops) it.
1484                 if self.focused_context() == Context::Network
1485                     && !crate::command::fuzzy_rank(&query, &["Zoom"]).is_empty()
1486                 {
1487                     rows.insert(
1488                         0,
1489                         Row {
1490                             id: ZOOM_ROW_ID.to_string(),
1491                             label: "Zoom".to_string(),
1492                             chord: String::new(),
1493                             control: Some(self.zoom_control()),
1494                             truncate_head: false,
1495                         },
1496                     );
1497                 }
1498                 rows
1499             }
1500             Mode::AddNode => {
1501                 // Every template, everywhere. The settings directories that
1502                 // refused geometry were the root meta node's utility subnets,
1503                 // and they are gone.
1504                 let offered: Vec<&str> =
1505                     self.node_templates.iter().map(|t| t.label.as_str()).collect();
1506                 crate::command::fuzzy_rank(&query, &offered)
1507                     .into_iter()
1508                     .map(|i| Row::plain(offered[i], offered[i], ""))
1509                     .collect()
1510             }
1511         };
1512         self.slots.dialog.set_rows(rows);
1513     }
1514 
1515     /// The open project's path and its file name, for the palette's path row
1516     /// — `None` when no project is loaded.
1517     ///
1518     /// The name is `file_name()`, which is the same thing the WINDOW TITLE
1519     /// shows, so the palette and the title bar cannot disagree about what is
1520     /// open. A project is a DIRECTORY holding `state.json`, so that name is
1521     /// the directory's; the bundled `default_project.json` is the one single
1522     /// file, and it never gets here because loading it leaves
1523     /// `loaded_project_path` None — the app's own position is that nothing is
1524     /// loaded, and Set As Default says the same.
1525     pub fn project_path_readout(&self) -> Option<(String, String)> {
1526         let path = self.loaded_project_path.as_ref()?;
1527         let name = path.file_name()?.to_string_lossy().into_owned();
1528         Some((path.to_string_lossy().into_owned(), name))
1529     }
1530 
1531     /// Put the open project's path on the clipboard, returning what was
1532     /// copied — the palette's path row, and the only thing a path on screen
1533     /// is ever wanted for. `None` when there is no project to name.
1534     pub fn copy_project_path(&mut self) -> Option<String> {
1535         let (path, _) = self.project_path_readout()?;
1536         // Not under test. `wl-copy` has to OUTLIVE its caller to serve the
1537         // selection, and it inherits the test binary's captured stdout — so a
1538         // test that really copied left cargo waiting on a pipe held open by a
1539         // clipboard daemon, which looks exactly like a hung test suite.
1540         #[cfg(not(test))]
1541         cce_ui::widget::clipboard::copy_to_clipboard(&path);
1542         self.update_status_text(&format!("Copied {path}"));
1543         Some(path)
1544     }
1545 
1546     /// The network zoom as the slider row reads it: the current x pitch as a
1547     /// percentage of the configured one, so 100 is Reset Zoom.
1548     pub fn zoom_percent(&self) -> f32 {
1549         let cfg = crate::app::configured_grid_geometry();
1550         if cfg.pitch_x > 0.0 {
1551             self.grid_pitch_x / cfg.pitch_x * 100.0
1552         } else {
1553             100.0
1554         }
1555     }
1556 
1557     /// The zoom row's control: the live percentage over the pitch limits,
1558     /// nudged ten points at a time.
1559     fn zoom_control(&self) -> Control {
1560         let cfg = crate::app::configured_grid_geometry();
1561         let pct = |pitch: f32| pitch / cfg.pitch_x.max(1e-3) * 100.0;
1562         Control::Slider {
1563             value: self.zoom_percent(),
1564             min: pct(crate::app::MIN_PITCH_X),
1565             max: pct(crate::app::MAX_PITCH_X),
1566             dec: 0,
1567             step: 10.0,
1568             suffix: "%",
1569         }
1570     }
1571 
1572     /// Zoom the network to a percentage of the configured grid, about the
1573     /// cursor cell — what the slider row's drag lands on. `zoom` clamps, so
1574     /// the row is re-read afterwards rather than trusted.
1575     pub fn set_zoom_percent(&mut self, pct: f32) {
1576         let cfg = crate::app::configured_grid_geometry();
1577         if self.grid_pitch_x > 0.0 {
1578             let factor = cfg.pitch_x * pct / 100.0 / self.grid_pitch_x;
1579             if (factor - 1.0).abs() > 1e-4 {
1580                 self.zoom(factor, None);
1581             }
1582         }
1583         self.refresh_dialog_zoom();
1584     }
1585 
1586     /// Re-read the zoom row from the live zoom, in place.
1587     fn refresh_dialog_zoom(&mut self) {
1588         let c = self.zoom_control();
1589         if self.slots.dialog.rows.iter().any(|r| r.id == ZOOM_ROW_ID) {
1590             self.slots.dialog.set_control(ZOOM_ROW_ID, Some(c));
1591         }
1592     }
1593 
1594     /// What a toggle command's switch currently shows, or `None` for a
1595     /// command that is not a toggle.
1596     ///
1597     /// Read off the very field each command flips in `execute_action` /
1598     /// `execute_menu_action` — the same read the View menu's checkmarks are
1599     /// set from — so the switch cannot disagree with the menu. Snapping is
1600     /// a toggle only INSIDE a viewer state; outside one the command does
1601     /// nothing but say so, and a switch on a row that cannot flip would be a
1602     /// lie, so the row is plain until a state is entered.
1603     /// `dialog_toggle_rows_cover_every_toggle_command` keeps this list and
1604     /// the registry's `toggle_*` / `show_*_pane` rows in step.
1605     pub fn command_toggle_state(&self, id: &str) -> Option<bool> {
1606         Some(match id {
1607             "toggle_grid" => self.viewport().show_grid,
1608             "toggle_cube" => self.viewport().show_cube,
1609             "toggle_origin" => self.viewport().show_origin,
1610             "toggle_camera_pivot" => self.viewport().show_camera_pivot,
1611             "toggle_wireframe" => self.wireframe,
1612             "toggle_smooth_shading" => self.smooth_shading,
1613             "toggle_point_markers" => self.show_point_markers,
1614             "toggle_point_numbers" => self.show_point_numbers,
1615             "toggle_point_normals" => self.show_point_normals,
1616             "toggle_render_points" => self.render_points,
1617             "toggle_wire_single_color" => self.wire_single_color,
1618             "toggle_ray_traced_preview" => self.viewport().rt_mode,
1619             "toggle_square_viewport" => self.square_viewport,
1620             "toggle_network_plate" => self.network_plate,
1621             "toggle_circular_pane" => self.circular_network_pane,
1622             "detach_circular_window" => self.detached_circular_network,
1623             "toggle_spreadsheet" => self.show_spreadsheet,
1624             "show_network_pane" => self.show_network,
1625             "show_viewport_pane" => self.show_viewport,
1626             "show_parameters_pane" => self.show_parameters,
1627             "show_playbar_pane" => self.show_playbar,
1628             "toggle_snap" => self.viewer_tool.as_ref()?.snap.is_some(),
1629             _ => return None,
1630         })
1631     }
1632 
1633     /// Re-read every row's control from the live state, touching nothing
1634     /// else — not the ranking, not the selection, not the scroll. This is
1635     /// what a toggle pick or a setting edit runs instead of
1636     /// `refresh_dialog_rows`: the rows are the same rows, only a control has
1637     /// moved, and re-ranking would throw the selection back to the top of a
1638     /// list the user is still working down.
1639     pub(crate) fn refresh_dialog_controls(&mut self) {
1640         if self.slots.dialog.mode != Mode::Commands {
1641             return;
1642         }
1643         let ids: Vec<String> = self.slots.dialog.rows.iter().map(|r| r.id.clone()).collect();
1644         for id in ids {
1645             let control = if let Some(s) = setting_of_row(&id) {
1646                 Some(self.setting_control(s))
1647             } else if id == ZOOM_ROW_ID {
1648                 Some(self.zoom_control())
1649             } else if let Some(on) = self.command_toggle_state(&id) {
1650                 Some(Control::Toggle(on))
1651             } else {
1652                 continue;
1653             };
1654             self.slots.dialog.set_control(&id, control);
1655         }
1656     }
1657 
1658     /// A setting's row: its label, no chord, and the control its `Ctl`
1659     /// names over the live value.
1660     fn setting_row(&self, s: &Setting) -> Row {
1661         Row {
1662             id: setting_row_id(s.label),
1663             label: s.label.to_string(),
1664             chord: String::new(),
1665             control: Some(self.setting_control(s)),
1666             truncate_head: false,
1667         }
1668     }
1669 
1670     /// The control a setting draws, built from its `Ctl` and the value as
1671     /// [`State::setting_value`] reads it — so the control and the string the
1672     /// writer takes cannot disagree about what the value is.
1673     fn setting_control(&self, s: &Setting) -> Control {
1674         let value = self.setting_value(s);
1675         match s.ctl {
1676             Ctl::Toggle => Control::Toggle(value == "true"),
1677             Ctl::Color => Control::Color { hex: value, alpha: false },
1678             Ctl::Rgba => Control::Color { hex: value, alpha: true },
1679             Ctl::Spin { min, max, .. } => Control::Slider {
1680                 value: value.parse().unwrap_or(min),
1681                 min,
1682                 max,
1683                 dec: 0,
1684                 step: 1.0,
1685                 suffix: "",
1686             },
1687             Ctl::Slider { min, max, dec } => Control::Slider {
1688                 value: value.parse().unwrap_or(min),
1689                 min,
1690                 max,
1691                 dec,
1692                 // Twenty nudges across the range: fine enough to land on a
1693                 // value, coarse enough that holding the arrow gets somewhere.
1694                 step: (max - min) / 20.0,
1695                 suffix: "",
1696             },
1697             Ctl::Choice(options) => Control::Choice {
1698                 options: options.iter().map(|o| o.to_string()).collect(),
1699                 index: options.iter().position(|o| o.eq_ignore_ascii_case(&value)).unwrap_or(0),
1700             },
1701         }
1702     }
1703 
1704     /// One setting's value as its row shows it — the params pane's
1705     /// encodings: a hex colour, a whole number in the spin's unit, a float
1706     /// to the slider's decimals, an option's text.
1707     pub(crate) fn setting_value(&self, s: &Setting) -> String {
1708         match s.owner {
1709             Owner::Field(key) => match s.ctl {
1710                 Ctl::Toggle => if self.settings_field_bool(key) { "true" } else { "false" }.to_string(),
1711                 Ctl::Color => crate::project::color_to_hex(self.settings_field_color(key)),
1712                 Ctl::Rgba => {
1713                     let c = match key {
1714                         "wire_color" => self.wire_color,
1715                         _ => [0.0, 0.0, 0.0, 1.0],
1716                     };
1717                     crate::project::color_to_hex8(c)
1718                 }
1719                 Ctl::Spin { unit, .. } => ((self.settings_field_f32(key) * unit).round() as i32).to_string(),
1720                 Ctl::Slider { dec, .. } => format!("{:.*}", dec, self.settings_field_f32(key)),
1721                 Ctl::Choice(_) => self.settings_field_text(key),
1722             },
1723             Owner::ActiveCamera(name) => {
1724                 let node_value = (self.active_camera != "Default Camera")
1725                     .then(|| {
1726                         self.current_dir()
1727                             .children
1728                             .iter()
1729                             .find(|c| c.node_type == "camera" && c.name == self.active_camera)?
1730                             .params
1731                             .iter()
1732                             .find(|p| p.name == name)
1733                             .map(|p| p.default.clone())
1734                     })
1735                     .flatten();
1736                 // No camera node behind the Default Camera: the live field
1737                 // is the value, in the same tenths the camera param uses.
1738                 node_value.unwrap_or_else(|| ((self.camera_pivot_size * 10.0).round() as i32).to_string())
1739             }
1740         }
1741     }
1742 
1743     /// The value one setting row reads, by label. Test-facing:
1744     /// `dialog_settings_rows_name_owners_that_exist` round-trips every
1745     /// `Owner::Field` row through this and [`State::settings_write_row`],
1746     /// which is the only way to catch a key that no dispatch arm names.
1747     #[cfg(test)]
1748     pub(crate) fn settings_row_value(&self, label: &str) -> String {
1749         let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
1750         self.setting_value(s)
1751     }
1752 
1753     /// Write one setting row by label, as the writeback does — the value
1754     /// only, none of the apply pass.
1755     #[cfg(test)]
1756     pub(crate) fn settings_write_row(&mut self, label: &str, value: &str) {
1757         let s = SETTINGS.iter().find(|s| s.label == label).expect("no such Settings row");
1758         self.setting_write(s, value);
1759     }
1760 
1761     fn settings_field_bool(&self, key: &str) -> bool {
1762         match key {
1763             "wire_single_color" => self.wire_single_color,
1764             "render_points" => self.render_points,
1765             _ => false,
1766         }
1767     }
1768 
1769     fn settings_field_color(&self, key: &str) -> [f32; 3] {
1770         match key {
1771             "bg_color" => self.viewport().bg_color,
1772             "grid_color" => self.viewport().grid_color,
1773             "point_color" => self.point_color,
1774             "point_marker_color" => self.point_marker_color,
1775             _ => [0.0; 3],
1776         }
1777     }
1778 
1779     fn settings_field_f32(&self, key: &str) -> f32 {
1780         match key {
1781             "grid_thickness" => self.grid_thickness,
1782             "origin_size" => self.origin_size,
1783             "point_marker_size" => self.point_marker_size,
1784             "wire_width" => self.wire_width,
1785             "geo_opacity" => self.geo_opacity,
1786             "point_size" => self.point_size,
1787             "group_marker_scale" => self.group_marker_scale,
1788             _ => 0.0,
1789         }
1790     }
1791 
1792     fn settings_field_text(&self, key: &str) -> String {
1793         match key {
1794             "world_unit" => self.world_unit.suffix().to_string(),
1795             _ => String::new(),
1796         }
1797     }
1798 
1799     /// Write one `Owner::Field` row's new value onto the live state.
1800     ///
1801     /// `dialog_settings_rows_name_owners_that_exist` walks the table against
1802     /// the four readers and this writer, because a key that no arm names
1803     /// reads as a default and writes nowhere — a row that looks live and is
1804     /// inert.
1805     fn settings_field_write(&mut self, key: &str, ctl: Ctl, value: &str) {
1806         match ctl {
1807             Ctl::Toggle => {
1808                 let on = value == "true";
1809                 match key {
1810                     "wire_single_color" => self.wire_single_color = on,
1811                     "render_points" => self.render_points = on,
1812                     _ => {}
1813                 }
1814             }
1815             Ctl::Color => {
1816                 let Some(c) = crate::project::hex_to_color(value) else { return };
1817                 match key {
1818                     "bg_color" => self.viewport_mut().bg_color = c,
1819                     "grid_color" => self.viewport_mut().grid_color = c,
1820                     "point_color" => self.point_color = c,
1821                     "point_marker_color" => self.point_marker_color = c,
1822                     _ => {}
1823                 }
1824             }
1825             Ctl::Rgba => {
1826                 let Some(c) = crate::project::hex_to_rgba(value) else { return };
1827                 if key == "wire_color" {
1828                     // Setting a wire colour means wanting to see it: the
1829                     // colour applies in single-colour mode only, so a colour
1830                     // edit turns that mode on if it was off. Twice read as
1831                     // "the colour did not take" (2026-09-21).
1832                     let changed = c != self.wire_color;
1833                     self.wire_color = c;
1834                     if changed && !self.wire_single_color {
1835                         self.wire_single_color = true;
1836                     }
1837                 }
1838             }
1839             Ctl::Spin { unit, .. } => {
1840                 let Ok(v) = value.parse::<f32>() else { return };
1841                 let v = v / unit;
1842                 match key {
1843                     "grid_thickness" => self.grid_thickness = v,
1844                     "origin_size" => self.origin_size = v,
1845                     "point_marker_size" => self.point_marker_size = v,
1846                     _ => {}
1847                 }
1848             }
1849             Ctl::Slider { min, max, .. } => {
1850                 let Ok(v) = value.parse::<f32>() else { return };
1851                 let v = v.clamp(min, max);
1852                 match key {
1853                     "wire_width" => self.wire_width = v,
1854                     "geo_opacity" => self.geo_opacity = v,
1855                     "point_size" => self.point_size = v,
1856                     "group_marker_scale" => self.group_marker_scale = v,
1857                     _ => {}
1858                 }
1859             }
1860             Ctl::Choice(_) => {
1861                 if key == "world_unit" {
1862                     if let Some(u) = cce_ui::units::Unit::parse(value) {
1863                         self.world_unit = u;
1864                         self.viewport_dirty = true;
1865                     }
1866                 }
1867             }
1868         }
1869     }
1870 
1871     /// Write one setting's value to whatever owns it.
1872     fn setting_write(&mut self, s: &Setting, value: &str) {
1873         match s.owner {
1874             Owner::Field(key) => self.settings_field_write(key, s.ctl, value),
1875             Owner::ActiveCamera(name) => {
1876                 let active = self.active_camera.clone();
1877                 let wrote = {
1878                     let dir = self.current_dir_mut();
1879                     match dir
1880                         .children
1881                         .iter_mut()
1882                         .find(|c| c.node_type == "camera" && c.name == active)
1883                         .and_then(|c| c.params.iter_mut().find(|p| p.name == name))
1884                     {
1885                         Some(p) => {
1886                             p.default = value.to_string();
1887                             true
1888                         }
1889                         None => false,
1890                     }
1891                 };
1892                 if !wrote {
1893                     if let Ok(v) = value.parse::<f32>() {
1894                         self.camera_pivot_size = v / 10.0;
1895                     }
1896                 }
1897             }
1898         }
1899     }
1900 
1901     /// Apply a setting row's new value: write it to its owner, then run the
1902     /// one apply-and-persist pass and re-read the row.
1903     ///
1904     /// The viewport meshes bake their sizes and colours in, so a changed
1905     /// thickness/size/tint is a re-generate, not a re-draw. This is the same
1906     /// set the settings-file reload in `tick_frame` regenerates. Re-read
1907     /// rather than trusting the control: an apply can normalize a value (a
1908     /// clamp), and the row has to show what the state now holds.
1909     pub(crate) fn apply_setting(&mut self, label: &str, value: &str) {
1910         let Some(s) = SETTINGS.iter().find(|s| s.label == label) else { return };
1911         self.setting_write(s, value);
1912         self.update_grid_geometry();
1913         self.update_origin_geometry();
1914         self.update_pivot_geometry();
1915         self.update_viewport_bg_geometry();
1916         self.sync_grid_settings();
1917         self.rebuild_scene_geometry();
1918         self.sync_nodes();
1919         self.save_settings();
1920         // The params pane may be showing one of these very nodes.
1921         self.sync_parameters_pane();
1922         self.refresh_dialog_controls();
1923     }
1924 
1925     /// Lay the dialog out over the window.
1926     ///
1927     /// Called at the end of `rebuild_positions`, after every layout branch has
1928     /// run — like the 2D page pane, the rect it wants never depends on which
1929     /// branch produced the panes underneath it.
1930     pub(crate) fn layout_dialog(&mut self) {
1931         if !self.dialog_visible() {
1932             self.positions[DIALOG_IDX] = (0.0, 0.0, 0.0, 0.0);
1933             return;
1934         }
1935         let (x, y, w, h) = layout_in(self.width, self.height);
1936         self.positions[DIALOG_IDX] = (x, y, w, h);
1937         self.slots.dialog.set_page(visible_rows(x, y, w, h));
1938     }
1939 
1940     /// Every key, while the dialog is open.
1941     ///
1942     /// Total, not layered: the branch that calls this returns whatever it
1943     /// returns, so nothing below reaches the panes. A modal that leaks its
1944     /// typing is worse than no modal — typing "frame" into the filter would
1945     /// otherwise step the grid cursor and flip a node's geometry flag on the
1946     /// way past, since the network pane's bare-letter family is ungated.
1947     pub(crate) fn dialog_key_input(&mut self, event: &KeyEvent) -> bool {
1948         if event.state != ElementState::Pressed {
1949             return true;
1950         }
1951         // A colour row's hex well, while it is being typed into, has the
1952         // keyboard ahead of everything — Escape and Enter included, which
1953         // end the edit rather than the dialog.
1954         if let Some(sel) = self.slots.dialog.editing_color() {
1955             let ptr = sel as *mut cce_ui::widget::Adapted<ColorSelector>;
1956             unsafe {
1957                 (*ptr).keyboard_input(event, &mut self.ui_context);
1958             }
1959             self.drain_dialog_clicks();
1960             return true;
1961         }
1962         // The dialog's own chord closes it, wherever the user has bound it —
1963         // asked for by id rather than hardcoded to Alt+D, so a rebind in
1964         // `input.kdl` keeps working both ways.
1965         if self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
1966             == Some("toggle_dialog")
1967         {
1968             self.close_dialog();
1969             return true;
1970         }
1971         match &event.logical_key {
1972             Key::Named(NamedKey::Escape) => {
1973                 self.close_dialog();
1974                 return true;
1975             }
1976             Key::Named(NamedKey::Tab) => {
1977                 // Tab is what opened the add-node palette, so the same key
1978                 // closes it again. In the commands list it means nothing.
1979                 if self.slots.dialog.mode == Mode::AddNode {
1980                     self.close_dialog();
1981                 }
1982                 return true;
1983             }
1984             _ => {}
1985         }
1986 
1987         match &event.logical_key {
1988             Key::Named(NamedKey::ArrowDown) => self.slots.dialog.move_selection(1),
1989             Key::Named(NamedKey::ArrowUp) => self.slots.dialog.move_selection(-1),
1990             Key::Named(NamedKey::PageDown) => {
1991                 let page = self.slots.dialog.page_len() as i32;
1992                 self.slots.dialog.move_selection(page);
1993             }
1994             Key::Named(NamedKey::PageUp) => {
1995                 let page = self.slots.dialog.page_len() as i32;
1996                 self.slots.dialog.move_selection(-page);
1997             }
1998             Key::Named(NamedKey::Home) => {
1999                 self.slots.dialog.selected = 0;
2000                 self.slots.dialog.scroll_to_selected();
2001             }
2002             Key::Named(NamedKey::End) => {
2003                 let last = self.slots.dialog.rows.len().saturating_sub(1);
2004                 self.slots.dialog.selected = last;
2005                 self.slots.dialog.scroll_to_selected();
2006             }
2007             Key::Named(NamedKey::Enter) => {
2008                 if let Some(id) = self.slots.dialog.selected_id().map(str::to_string) {
2009                     self.take_dialog_pick(id);
2010                 }
2011             }
2012             // The arrows work the selected row's control in place: a
2013             // slider by its step, a choice to the next or previous option.
2014             // On any other row they mean nothing here.
2015             Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowRight) => {
2016                 let dir: i32 = if matches!(event.logical_key, Key::Named(NamedKey::ArrowRight)) { 1 } else { -1 };
2017                 self.nudge_dialog_selection(dir);
2018             }
2019             Key::Named(NamedKey::Backspace) => {
2020                 if self.slots.dialog.query.pop().is_some() {
2021                     self.refresh_dialog_rows();
2022                 }
2023             }
2024             Key::Named(NamedKey::Space) => {
2025                 self.slots.dialog.query.push(' ');
2026                 self.refresh_dialog_rows();
2027             }
2028             Key::Character(c) => {
2029                 // Bare typing only: a modified key is a chord, and the ones
2030                 // this dialog answers to are handled above.
2031                 if !self.modifiers.control_key()
2032                     && !self.modifiers.alt_key()
2033                     && !self.modifiers.super_key()
2034                 {
2035                     self.slots.dialog.query.push_str(c);
2036                     self.refresh_dialog_rows();
2037                 }
2038             }
2039             _ => {}
2040         }
2041         true
2042     }
2043 
2044     /// Step the selected row's control by `dir` (-1 or 1) and land the
2045     /// value: a slider by its step, a choice to its neighbouring option.
2046     fn nudge_dialog_selection(&mut self, dir: i32) {
2047         let Some(id) = self.slots.dialog.selected_id().map(str::to_string) else { return };
2048         let Some(control) = self.slots.dialog.selected_control().cloned() else { return };
2049         match control {
2050             Control::Slider { value, step, .. } => {
2051                 let i = self.slots.dialog.selected;
2052                 self.slots.dialog.set_slider_value(i, value + dir as f32 * step);
2053                 let Some(v) = self.slots.dialog.rows[i].slider_value() else { return };
2054                 self.land_dialog_slider(&id, v);
2055             }
2056             Control::Choice { options, index } => {
2057                 if options.is_empty() {
2058                     return;
2059                 }
2060                 let n = options.len() as i32;
2061                 let next = (index as i32 + dir).rem_euclid(n) as usize;
2062                 if let Some(label) = id.strip_prefix(SETTING_ROW_PREFIX) {
2063                     let label = label.to_string();
2064                     self.apply_setting(&label, &options[next]);
2065                 }
2066             }
2067             _ => {}
2068         }
2069     }
2070 
2071     /// A slider row's value arriving from a drag, a wheel or an arrow: the
2072     /// zoom row zooms, a setting row writes its setting.
2073     fn land_dialog_slider(&mut self, id: &str, v: f32) {
2074         if id == ZOOM_ROW_ID {
2075             self.set_zoom_percent(v);
2076         } else if let Some(s) = setting_of_row(id) {
2077             let value = match s.ctl {
2078                 Ctl::Slider { dec, .. } => format!("{:.*}", dec, v),
2079                 _ => (v.round() as i64).to_string(),
2080             };
2081             self.apply_setting(s.label, &value);
2082         }
2083     }
2084 
2085     /// Run a command the dialog chose, and close.
2086     ///
2087     /// Closing FIRST, so a command that opens something of its own (a file
2088     /// chooser) does not come up behind the dialog. The dialog's own row is
2089     /// the exception: toggling it here would reopen what was just closed.
2090     ///
2091     /// A CONTROL row does not close at all. A switch you can only flip once
2092     /// before the panel it is on vanishes is a button with extra steps: Show
2093     /// Grid, Show Cube and Square Aspect are the kind of thing you set
2094     /// together, looking at the viewport, and the dialog staying up is what
2095     /// lets you. A toggle flips, a choice steps to its next option, a slider
2096     /// or a colour is worked by the pointer or the arrows and Enter on it
2097     /// does nothing; the controls re-read, and the selection stays where it
2098     /// was — by Enter or by a click, since both arrive here.
2099     pub(crate) fn take_dialog_pick(&mut self, id: String) {
2100         let mode = self.slots.dialog.mode;
2101         if mode == Mode::Commands && id == ZOOM_ROW_ID {
2102             return;
2103         }
2104         if mode == Mode::Commands {
2105             if let Some(s) = setting_of_row(&id) {
2106                 let control = self.slots.dialog.rows.iter().find(|r| r.id == id).and_then(|r| r.control.clone());
2107                 match control {
2108                     Some(Control::Toggle(on)) => {
2109                         let v = if on { "false" } else { "true" };
2110                         self.apply_setting(s.label, v);
2111                     }
2112                     Some(Control::Choice { .. }) => self.nudge_dialog_selection(1),
2113                     _ => {}
2114                 }
2115                 return;
2116             }
2117         }
2118         // The path row: copy, say so, and close. A copy is done the moment it
2119         // happens — unlike a toggle, there is nothing to sit and adjust — so
2120         // it leaves the way a command does.
2121         if mode == Mode::Commands && id == PATH_ROW_ID {
2122             self.close_dialog();
2123             self.copy_project_path();
2124             return;
2125         }
2126         // A recent project: open it, and say so if it will not open — a
2127         // path in this list can have been moved or deleted since.
2128         if mode == Mode::Commands {
2129             if let Some(path) = id.strip_prefix(RECENT_ROW_PREFIX) {
2130                 let path = std::path::PathBuf::from(path);
2131                 self.close_dialog();
2132                 if let Err(e) = self.load_from_file(&path) {
2133                     self.update_status_text(&format!("Could not open {}: {e}", path.display()));
2134                 }
2135                 return;
2136             }
2137         }
2138         if mode == Mode::Commands && self.command_toggle_state(&id).is_some() {
2139             self.run_command(&id);
2140             self.refresh_dialog_controls();
2141             return;
2142         }
2143         let (gx, gy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
2144         self.close_dialog();
2145         match mode {
2146             // Not `toggle_dialog`: toggling here would reopen what was just
2147             // closed. Picking the dialog's own row is a no-op, which is the
2148             // least surprising thing it could be.
2149             Mode::Commands => {
2150                 if id != "toggle_dialog" {
2151                     self.run_command(&id);
2152                 }
2153             }
2154             // Fire-and-forget at the grid cursor, exactly as the popup's
2155             // answer used to arrive — read BEFORE the close, since closing
2156             // relays the panes.
2157             Mode::AddNode => {
2158                 let mut redraw = false;
2159                 let action = crate::app::McpAction::AddNode {
2160                     template_name: id,
2161                     name: None,
2162                     x: gx,
2163                     y: gy,
2164                 };
2165                 if let Err(e) = self.apply_action(action, &mut redraw) {
2166                     // The one refusal this can hit is a geometry template in
2167                     // a utility dir, which `refresh_dialog_rows` already
2168                     // filters out — but the rule lives in `apply_action`, so
2169                     // say what it said rather than assume it cannot fire.
2170                     self.update_status_text(&e);
2171                 }
2172             }
2173         }
2174     }
2175 
2176     /// Drain what the pointer (or a picker, or a hex edit) did inside the
2177     /// dialog. Returns whether anything changed.
2178     pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
2179         let mut changed = false;
2180         if let Some(id) = self.slots.dialog.take_activated() {
2181             self.take_dialog_pick(id);
2182             changed = true;
2183         }
2184         if let Some((id, v)) = self.slots.dialog.take_slider_change() {
2185             self.land_dialog_slider(&id, v);
2186             changed = true;
2187         }
2188         for (id, hex) in self.slots.dialog.take_color_changes() {
2189             if let Some(s) = setting_of_row(&id) {
2190                 self.apply_setting(s.label, &hex);
2191                 changed = true;
2192             }
2193         }
2194         changed
2195     }
2196 
2197     /// A mouse button, while the dialog is open.
2198     ///
2199     /// `None` hands the press back to the ordinary cascade — only the middle
2200     /// button, which the dialog has no use for. `Some(handled)` means the
2201     /// dialog dealt with it and nothing else should. A RIGHT press is the
2202     /// dialog's too: inside the plate it is swallowed (nothing in the dialog
2203     /// has a context menu, and until 2026-09-22 it fell through to the pane
2204     /// beneath, whose menu then opened over a modal with its labels clipped
2205     /// by the dialog's occluder — a menu with no legible entries), outside
2206     /// it dismisses, exactly as a left press does.
2207     pub(crate) fn dialog_mouse_input(
2208         &mut self,
2209         button: MouseButton,
2210         state: ElementState,
2211     ) -> Option<bool> {
2212         let (x, y) = (self.cursor_x, self.cursor_y);
2213         if button == MouseButton::Right {
2214             if !self.in_dialog_slot(DIALOG_IDX, x, y) && state == ElementState::Pressed {
2215                 self.close_dialog();
2216             }
2217             return Some(true);
2218         }
2219         if button != MouseButton::Left {
2220             return None;
2221         }
2222 
2223         // A slider drag ends wherever the pointer happens to be — including
2224         // outside the plate. Ending it has to come before the dismiss test
2225         // below, or dragging a value past the dialog's edge and letting go
2226         // would close the dialog instead of committing.
2227         if state == ElementState::Released && self.drag_widget == Some(DIALOG_IDX) {
2228             let ptr = &mut self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
2229             unsafe {
2230                 (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context);
2231                 (*ptr).handle_event(
2232                     &cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y },
2233                     &mut self.ui_context,
2234                 );
2235             }
2236             self.drag_widget = None;
2237             self.drag_press_cursor = None;
2238             self.drain_dialog_clicks();
2239             return Some(true);
2240         }
2241 
2242         if !self.in_dialog_slot(DIALOG_IDX, x, y) {
2243             // Outside: a press dismisses, a release is the tail of that press
2244             // and is simply eaten.
2245             if state == ElementState::Pressed {
2246                 self.close_dialog();
2247             }
2248             return Some(true);
2249         }
2250 
2251         let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
2252         self.dispatch_uncovered(DIALOG_IDX, &ev);
2253         // A press that took a slider's band arms the same widget drag the
2254         // params pane's sliders arm, so the value follows the pointer
2255         // wherever it goes until the release.
2256         if state == ElementState::Pressed && self.slots.dialog.slider_dragging() {
2257             let ev = cce_ui::widget::Event::DragStart { start_x: x, start_y: y };
2258             let ptr = &mut self.slots.dialog as *mut cce_ui::widget::Adapted<Dialog>;
2259             unsafe {
2260                 (*ptr).handle_event(&ev, &mut self.ui_context);
2261             }
2262             self.drag_widget = Some(DIALOG_IDX);
2263             self.drag_press_cursor = Some((x, y));
2264         }
2265         self.drain_dialog_clicks();
2266         Some(true)
2267     }
2268 
2269     /// The wheel, while the dialog is open: the dialog's, and no further —
2270     /// it scrolls its own list, so there is nothing to fall through to.
2271     pub(crate) fn dialog_mouse_wheel(&mut self, delta: MouseScrollDelta) -> bool {
2272         let (x, y) = (self.cursor_x, self.cursor_y);
2273         let ev = cce_ui::widget::Event::MouseWheel { delta, x, y, local_x: x, local_y: y };
2274         if self.in_dialog_slot(DIALOG_IDX, x, y) {
2275             let taken = self.dispatch_uncovered(DIALOG_IDX, &ev);
2276             // A wheel over a slider row moved it: land the value.
2277             self.drain_dialog_clicks();
2278             return taken;
2279         }
2280         false
2281     }
2282 
2283     /// Is `(x, y)` inside a dialog slot's laid-out rect?
2284     ///
2285     /// The rect, not `hit_test`: the dialog registers itself as a text
2286     /// occluder (see `Dialog::popover`) and `Adapted::hit_test` reads that
2287     /// same list as COVERAGE, so every point inside the dialog reports as
2288     /// covered — by the dialog's own plate. Inside a modal the geometry IS
2289     /// the answer.
2290     pub(crate) fn in_dialog_slot(&self, idx: usize, x: f32, y: f32) -> bool {
2291         if !self.slots.get_dyn(idx).visible() {
2292             return false;
2293         }
2294         let (rx, ry, rw, rh) = self.positions[idx];
2295         rw > 0.0 && rh > 0.0 && x >= rx && x < rx + rw && y >= ry && y < ry + rh
2296     }
2297 
2298     /// Deliver `ev` to a dialog slot with the dialog's occluder claim lowered.
2299     ///
2300     /// `Adapted::handle_event` hit-gates presses and wheels through
2301     /// `is_coordinate_covered`, which asks every REGISTERED widget for its
2302     /// `popover_rect` — including the dialog's, which covers the whole plate
2303     /// (see [`Dialog::popover`]). So while the claim stands, every press
2304     /// aimed at a control inside the plate is rejected as covered by the
2305     /// surface the control is drawn on. The dialog's own routing has already
2306     /// decided who gets this event, so the claim comes down for the dispatch
2307     /// and goes straight back up.
2308     pub(crate) fn dispatch_uncovered(&mut self, idx: usize, ev: &cce_ui::widget::Event) -> bool {
2309         self.slots.dialog.set_occluding(false);
2310         // The coverage answer is memoized per point, so lowering the claim is
2311         // not enough — a query from earlier this frame is served from the
2312         // cache, and the engine makes one on every left press
2313         // (`close_popovers_missed_by_press`).
2314         self.ui_context.invalidate_coverage_cache();
2315         // Straight to handle_event, not propagate_event: so the gesture
2316         // bookkeeping the router would have done is done here, or the
2317         // dialog's panes inherit the main pane's gesture state.
2318         if matches!(ev, cce_ui::widget::Event::MouseWheel { .. }) {
2319             self.ui_context.note_scroll_event();
2320         }
2321         let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn cce_ui::widget::WidgetHost + 'static);
2322         let taken = unsafe { (*ptr).handle_event(ev, &mut self.ui_context) };
2323         self.slots.dialog.set_occluding(true);
2324         self.ui_context.invalidate_coverage_cache();
2325         taken
2326     }
2327 }