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 }