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