graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: an Alt+D dialog that runs commands and edits the display settings
Two halves behind one chord: the command registry, fuzzy-filtered in
place, and the viewport/graph display state DesignSettings persists.
The settings controls are a SECOND ParametersBg laid out inside the
dialog's plate, not new widgets — a slider in the dialog should be the
same slider as one in the params pane. What the dialog adds is the row
table (SETTINGS), which says who OWNS each value: the meta node's
utility subnets own most of them, because apply_settings_from_menubar_
subnets copies those over the live state on every param change, so a
write to State::grid_thickness survives exactly until the next one.
Alt+D rather than Super+D: the compositor claims every Super chord
before a client sees one (input.kdl binds super+d to the app launcher),
and Super held is the DE's window-adjust modifier.
Three of the four hard parts are paint order and occlusion:
- The dialog paints AFTER the overlay passes, not in the widget walk. A
high z_order is not enough — append_frame_text and the viewport
readouts run after the whole walk and drew the graph's node labels
over a dialog that had already covered them.
- Text is not painted in display-list order at all: the engine lays
every Text prim out at the end, so a plate over a label hides nothing
at any depth. Dialog::popover claims the rect for the engine's
popover-occlusion clamp, and every label inside carries that same
rect (the clamp's exemption) and truncates itself.
- That one claim then made the Settings half inert, because
is_coordinate_covered reads popover_rect off every registered widget
to reject a covered press — so each control was covered by the plate
it is drawn on. Dialog::occluding lowers the claim for the length of
a dispatch into the dialog.
Input is intercepted whole at the top of handle_event's keyboard and
mouse branches; dialog_key_input is total rather than layered, since
the network pane's bare-letter family is ungated and typing "frame"
into the filter would otherwise step the grid cursor and flip a node's
geometry toggle on the way past.
Verified in a headless shadow at output scale 2: opens on Alt+D, Tab
switches halves, a Settings toggle reaches the Guides subnet, the live
viewport and state.kdl, and a press outside dismisses.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 72 ++++
src/app.rs | 50 ++-
src/command.rs | 6 +
src/dialog.rs | 1180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/main.rs | 229 +++++++++++
src/render.rs | 130 +++++-
src/shortcut.rs | 3 +
src/slots.rs | 24 ++
8 files changed, 1692 insertions(+), 2 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 32bcea2..6b82934 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -114,6 +114,11 @@ gone from cce-ui with the wgpu path).
slot's concrete type (`viewport()`, `graph_mut()`, `menu(idx)`, …) live here too, and
`State` keeps one-line forwarders. `PassivePlate` and `Canvas`, the two app-owned
slot-only widgets, are also here.
+- `src/dialog.rs` — the Alt+D dialog: the `Dialog` widget (a third app-owned
+ slot-only widget) plus the `State` half that fills it, routes its input and
+ writes its settings back. See "The dialog (Alt+D)" below — three of its four
+ hard parts are about paint order and occlusion, none of which is guessable
+ from the widget.
- `src/plate_corner.rs` — the plate corner control: a circular menu trigger on the
top-right of each pane that draws its own plate (`PLATE_SLOTS` — network, params,
spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
@@ -742,6 +747,73 @@ the alternative is calling every command to see whether it is handled, and
argues for: a label kept in two places drifts, and a renamed one fails silently
— the dispatch falls through its match and the command does nothing.
+### The dialog (Alt+D)
+
+`src/dialog.rs` is a modal overlay with two halves: **Commands**, the registry
+fuzzy-filtered in place, and **Settings**, the viewport/graph display state
+`DesignSettings` persists. It is two roster slots — `DIALOG_IDX`, an app-owned
+`Dialog` that paints the plate, the tab strip, the query line and the command
+list, and `DIALOG_PARAMS_IDX`, a **second `ParametersBg`** laid out inside it.
+The settings controls are that second params pane rather than new widgets,
+because a slider in the dialog should be the same slider as a slider in the
+params pane; what the dialog adds is the row TABLE, not the rows.
+
+**Alt+D, not Super+D.** Every Super chord is the compositor's before any client
+sees one (`input.kdl`'s `cce-window-manager` domain has `super+d` on the app
+launcher), and Super held is the DE's window-adjust modifier besides. Alt is the
+app's own — the `move_*` family already lives there.
+
+**A Settings row edits the meta node, never the live field.** The values behind
+those rows have exactly one owner, and it is not `State` and not
+`DesignSettings`: `apply_settings_from_menubar_subnets` copies the utility
+subnets (`Main`, `View`, `Guides`) onto the live state on EVERY param change, so
+a write straight to `State::grid_thickness` survives until the next one and no
+longer. `SETTINGS` is the table of which row belongs to which owner, and `Owner`
+has three arms for the three kinds there turn out to be: a subnet param, a
+registry command (Square Aspect and Show Camera Pivot are per-CAMERA, with no
+node at all behind the Default Camera — their commands are the only code that
+gets both cases right), and an active-camera param with the live field as its
+fallback. Writeback is `sync_parameters_to_project`'s shape, polled rather than
+pushed for the same reason: a `ParametersBg` reports its values, it does not
+emit events. `dialog_settings_rows_name_owners_that_exist` is the backstop,
+because the failure is silent — `dialog_settings_params` SKIPS a row whose param
+it cannot find, so a rename quietly shortens the Settings half.
+
+**The dialog is painted after the overlay passes, not in the widget walk.** A
+high `z_order` is not enough: `append_frame_text`, `append_scale_readout` and the
+meta-point overlays all run AFTER the whole walk, so the graph's node labels drew
+straight over a dialog that had already covered them. `append_dialog` runs
+between the plate corners and the context menu instead.
+
+**And even that is not enough, because text is not painted in display-list
+order.** The engine collects every `Prim::Text` and lays them all out at the end,
+so a plate over a label does not hide it at any depth. What hides it is the
+engine's popover-occlusion clamp, which reads `UiContext::active_popovers` — so
+`Dialog::popover` claims the dialog's whole rect, and the designer's
+registration loop picks it up. The clamp exempts text whose own bounds COINCIDE
+with the occluder, so every label inside the dialog carries the dialog's rect and
+truncates itself; the settings body is painted twice for this (once for its
+controls, once to re-emit its text retagged), since a `PaintCtx` can be handed
+text back but not geometry.
+
+**`Dialog::occluding` exists because that one claim serves two mechanisms that
+want opposite answers.** `UiContext::is_coordinate_covered` reads the same
+`popover_rect` — off every REGISTERED widget, not just the ones in
+`active_popovers` — to decide a press landed under something else. With the claim
+standing, every control in the Settings half is covered by the plate it is drawn
+on and nothing can be clicked; the toggles looked laid out, painted, and
+completely inert. `State::dispatch_uncovered` lowers the flag for the length of a
+dispatch into the dialog and puts it back, invalidating the coverage memo on both
+edges (the engine queries it on every left press, so lowering the flag alone
+leaves a stale cached answer).
+
+Input is intercepted whole, at the top of `handle_event`'s keyboard and mouse
+branches: `dialog_key_input` is TOTAL rather than a layer, because the network
+pane's bare-letter family is ungated and typing "frame" into the filter would
+otherwise step the grid cursor four times and flip a node's geometry toggle on
+the way past. A press outside the plate dismisses and is swallowed, the way the
+node and plate-corner menus behave.
+
### Runtime paths point into the source tree
Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/src/app.rs b/src/app.rs
index dff68d3..8564a9e 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1095,6 +1095,12 @@ pub struct State {
pub slots: Box<WidgetSlots>,
pub positions: Vec<(f32, f32, f32, f32)>,
+ /// The Alt+D dialog's Settings rows as last handed to its `ParametersBg` —
+ /// the baseline `sync_dialog_settings_to_project` diffs the controls
+ /// against. The params pane gets away without one because it can compare a
+ /// reported value to the `ParamDef` it came from; the dialog's rows are
+ /// assembled from several owners, so what was shown is its own fact.
+ pub dialog_settings_shown: Vec<(String, String, String)>,
pub splitter_layout: cce_ui::layout::SplitterLayout,
/// The add-node palette's cce-cloud popup (single active popup, toggle
/// semantics — the status bar's tracker pattern).
@@ -1171,7 +1177,7 @@ pub struct State {
/// slide the graph under the armed node drag, or the commit re-derives the
/// node's cell against the panned origin and it teleports. Cleared (latch
/// open) once the pointer strays a real-drag distance from the press.
- drag_press_cursor: Option<(f32, f32)>,
+ pub(crate) drag_press_cursor: Option<(f32, f32)>,
pub focused_widget: Option<usize>,
pub cursor_x: f32,
@@ -4082,6 +4088,14 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
v.set_visible(false);
v
},
+ dialog: crate::dialog::Dialog::new(),
+ dialog_params: {
+ // Shown only while the dialog's Settings tab is up; laid out
+ // inside the dialog's plate, so it draws no plate of its own.
+ let mut p = ParametersBg::new();
+ p.set_visible(false);
+ p
+ },
});
if let Some(viewport) = slots.viewport.as_any_mut().downcast_mut::<Viewport3D>() {
@@ -4172,6 +4186,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
event_sender: None,
slots,
positions,
+ dialog_settings_shown: Vec::new(),
splitter_layout,
cloud_popups: cce_ui::process::CloudPopupTracker::new(),
node_menu_slot: None,
@@ -5134,6 +5149,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
self.apply_collapsed_panes();
+ // Last of all: the dialog floats over whatever the branches above
+ // produced, so its rect depends on the window and nothing else.
+ self.layout_dialog();
}
@@ -5559,6 +5577,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
let mut settings_changed = false;
match action {
Action::CommandPalette => self.open_command_palette(),
+ Action::ToggleDialog => self.toggle_dialog(),
// The network navigation families. Each returns false when the
// network pane does not have focus, which is how one gate covers
// all fourteen of them.
@@ -5911,6 +5930,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pub fn handle_event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::MouseWheel { delta } => {
+ // The dialog is modal: a wheel over it scrolls it, and a wheel
+ // anywhere else does nothing rather than scrolling — and
+ // focusing — the pane it is covering.
+ if self.dialog_visible() {
+ return self.dialog_mouse_wheel(*delta);
+ }
let in_network_pane = self.in_network_pane();
// eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
let node_area_y = self.positions[CONTENT_IDX].1;
@@ -6235,6 +6260,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
changed = true;
if idx == PARAM_IDX {
self.sync_parameters_to_project();
+ } else if idx == crate::slots::DIALOG_PARAMS_IDX {
+ self.sync_dialog_settings_to_project();
}
}
}
@@ -6286,6 +6313,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.viewport_mut().reset_velocity();
}
+ // The dialog is modal over what it covers, so it takes the
+ // press before the pan trigger and before the whole
+ // hit-target cascade. A press OUTSIDE it dismisses — the
+ // convention every other floating surface in this app follows
+ // (the node menu, the plate corner menu) — and is swallowed
+ // rather than also acting on the pane it landed on.
+ if self.dialog_visible() {
+ if let Some(handled) = self.dialog_mouse_input(*button, *btn_state) {
+ return handled;
+ }
+ }
let in_network_pane = self.in_network_pane();
let node_area_x = self.positions[CONTENT_IDX].0;
let node_area_y = self.positions[CONTENT_IDX].1;
@@ -7092,6 +7130,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.space_pressed = event.state == ElementState::Pressed;
}
+ // The dialog owns the keyboard outright while it is open —
+ // ahead of the context chords, ahead of the params pane, ahead
+ // of everything. It has a text field in it, and a modal whose
+ // typing leaks into the pane behind it is worse than no modal:
+ // typing "frame" into the filter would step the grid cursor
+ // four times and toggle a node's geometry on the way past.
+ if self.dialog_visible() {
+ return self.dialog_key_input(event);
+ }
+
// Context switching dispatches ahead of the widget key paths
// (param pane, node palette) so the chord works from any pane.
if event.state == ElementState::Pressed {
diff --git a/src/command.rs b/src/command.rs
index d924c5d..bed43f4 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -113,6 +113,12 @@ pub const COMMANDS: &[Command] = &[
Command { id: "next_context", label: "Focus Next Pane", context: Context::Always, run: Run::Key(Action::NextContext), default_chord: Some("Ctrl+Tab") },
Command { id: "previous_context", label: "Focus Previous Pane", context: Context::Always, run: Run::Key(Action::PrevContext), default_chord: Some("Ctrl+Shift+Tab") },
Command { id: "command_palette", label: "Command Palette", context: Context::Always, run: Run::Key(Action::CommandPalette), default_chord: Some("Ctrl+p") },
+ // Alt+D, not Super+D: the compositor claims every Super chord before any
+ // client sees one (`input.kdl`'s `cce-window-manager` domain binds
+ // super+d to the app launcher), and Super held is also the DE's
+ // window-adjust modifier. Alt is the app's own — the network move family
+ // already lives there.
+ Command { id: "toggle_dialog", label: "Dialog", context: Context::Always, run: Run::Key(Action::ToggleDialog), default_chord: Some("Alt+d") },
Command { id: "toggle_configure", label: "Configure", context: Context::Always, run: Run::Key(Action::ToggleConfigure), default_chord: Some("Ctrl+,") },
// Escape already does this, handled inline with the rest of Escape's
diff --git a/src/dialog.rs b/src/dialog.rs
new file mode 100644
index 0000000..472f1ae
--- /dev/null
+++ b/src/dialog.rs
@@ -0,0 +1,1180 @@
+//! The dialog (`Alt+D`): run a command, or change a setting, without leaving
+//! the keyboard.
+//!
+//! Two halves behind one chord because they answer the same question — "make
+//! the app do the thing". The Commands half is the registry
+//! ([`crate::command`]) fuzzy-filtered in place; the Settings half is the
+//! viewport/graph display state that `DesignSettings` persists.
+//!
+//! This widget owns the FRAME — plate, tab strip, query line, command list —
+//! and not the settings controls. Those are a second roster slot
+//! (`DIALOG_PARAMS_IDX`, a `ParametersBg`) laid out inside this one's body, so
+//! a slider in the dialog is the same slider as a slider in the params pane
+//! rather than a second implementation that drifts from it. The values behind
+//! those rows stay owned by the meta node's utility subnets, which is where
+//! `apply_settings_from_menubar_subnets` reads them from — see
+//! `State::dialog_settings_rows`.
+//!
+//! App-owned on the narrow traits wrapped in `Adapted<Dialog>`, like
+//! [`crate::playbar::Playbar`], and a subtree painter for the same reason:
+//! `paint` authors geometry AND text, so `append_frame_text` skips the slot.
+//! Geometry helpers take the laid-out `rect` rather than caching one, again
+//! like the playbar — the designer computes the same rects from
+//! `positions[DIALOG_IDX]` when it needs them outside a paint.
+
+use cce_ui::colors;
+use cce_ui::scene::layout::Rect;
+use cce_ui::scene::paint::PaintCtx;
+use cce_ui::widget::*;
+
+/// Which half of the dialog is showing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Tab {
+ Commands,
+ Settings,
+}
+
+impl Tab {
+ pub const ALL: [Tab; 2] = [Tab::Commands, Tab::Settings];
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Tab::Commands => "Commands",
+ Tab::Settings => "Settings",
+ }
+ }
+}
+
+/// One command row, resolved: the registry id to run, plus what to draw.
+#[derive(Debug, Clone)]
+pub struct Row {
+ pub id: &'static str,
+ pub label: String,
+ /// The chord as a human reads it, or empty for an unbound command. Drawn
+ /// in its own right-hand column so the dialog teaches the keyboard rather
+ /// than replacing it — the same argument the `cce-cloud` palette's padded
+ /// rows make, except that here the column can be a real column.
+ pub chord: String,
+}
+
+/// The dialog's outer size. Fixed rather than proportional: it is a focused
+/// list, and a list that grows with the window turns into a wall of rows with
+/// the one you want somewhere in it.
+const DIALOG_W: f32 = 520.0;
+const DIALOG_H: f32 = 420.0;
+
+const PAD: f32 = 12.0;
+/// Tab strip height, and the query line's.
+const TAB_H: f32 = 30.0;
+const QUERY_H: f32 = 30.0;
+pub const ROW_H: f32 = 24.0;
+/// Gap between the tab strip, the query line and the list.
+const GAP: f32 = 8.0;
+
+/// The dialog's rect inside a `width` x `height` window: centered
+/// horizontally, and a little above centre vertically so the list grows into
+/// the window's roomier half rather than down over the status bar.
+pub fn layout_in(width: f32, height: f32) -> (f32, f32, f32, f32) {
+ let w = DIALOG_W.min((width - 2.0 * PAD).max(200.0));
+ let h = DIALOG_H.min((height - 2.0 * PAD).max(160.0));
+ let x = ((width - w) * 0.5).max(0.0).round();
+ let y = ((height - h) * 0.4).max(0.0).round();
+ (x, y, w, h)
+}
+
+fn tab_strip(rect: Rect) -> Rect {
+ Rect { x: rect.x + PAD, y: rect.y + PAD, width: (rect.width - 2.0 * PAD).max(0.0), height: TAB_H }
+}
+
+/// One tab's segment of the strip — the strip split evenly, which is what
+/// makes the pair read as one segmented control rather than two buttons.
+fn tab_rect(rect: Rect, tab: Tab) -> Rect {
+ let strip = tab_strip(rect);
+ let n = Tab::ALL.len() as f32;
+ let w = strip.width / n;
+ let i = Tab::ALL.iter().position(|t| *t == tab).unwrap_or(0) as f32;
+ Rect { x: strip.x + i * w, y: strip.y, width: w, height: strip.height }
+}
+
+/// The query line. Commands only — the Settings half has no filter, since its
+/// rows are a fixed handful and a filter over them would hide more than it
+/// found.
+fn query_rect(rect: Rect) -> Rect {
+ let strip = tab_strip(rect);
+ Rect { x: strip.x, y: strip.y + strip.height + GAP, width: strip.width, height: QUERY_H }
+}
+
+/// The command list's viewport.
+fn list_rect(rect: Rect) -> Rect {
+ let q = query_rect(rect);
+ let top = q.y + q.height + GAP;
+ Rect { x: q.x, y: top, width: q.width, height: (rect.y + rect.height - PAD - top).max(0.0) }
+}
+
+/// The Settings half's body — where the dialog's `ParametersBg` goes. It
+/// starts where the query line would, there being no query line.
+pub fn settings_rect(x: f32, y: f32, w: f32, h: f32) -> (f32, f32, f32, f32) {
+ let strip = tab_strip(Rect { x, y, width: w, height: h });
+ let top = strip.y + strip.height + GAP;
+ (strip.x, top, strip.width, (y + h - PAD - top).max(0.0))
+}
+
+/// How many rows the command list can show at once, for a dialog of this size.
+pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
+ (list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
+}
+
+pub struct Dialog {
+ pub tab: Tab,
+ /// What has been typed into the Commands half's filter.
+ pub query: String,
+ /// The filtered, ranked rows — rebuilt by the app whenever `query`
+ /// changes, never here: ranking needs the registry AND the focused pane,
+ /// and this widget knows neither.
+ pub rows: Vec<Row>,
+ /// Which row Enter would run. Kept in range by [`Dialog::set_rows`].
+ pub selected: usize,
+ /// First visible row, scrolled to keep `selected` in view.
+ pub scroll: usize,
+ /// How many rows fit — pushed in from the layout, since `on_event` and the
+ /// app's key handling both need it and neither has the rect to hand.
+ page: usize,
+ hover_row: Option<usize>,
+ hover_tab: Option<Tab>,
+ /// A row the pointer activated, drained by the app.
+ activated: Option<&'static str>,
+ /// A tab the pointer chose, drained by the app.
+ tab_click: Option<Tab>,
+ /// Whether the dialog is currently claiming its rect as an occluder — see
+ /// [`Paint::popover`]. Lowered for the length of an event dispatch into
+ /// the dialog, because the one claim serves two mechanisms that want
+ /// opposite answers.
+ occluding: bool,
+}
+
+impl Dialog {
+ pub fn new() -> Adapted<Dialog> {
+ let mut d = Adapted::new(Dialog {
+ tab: Tab::Commands,
+ query: String::new(),
+ rows: Vec::new(),
+ selected: 0,
+ scroll: 0,
+ page: 1,
+ hover_row: None,
+ hover_tab: None,
+ activated: None,
+ tab_click: None,
+ occluding: true,
+ });
+ d.set_visible(false);
+ d
+ }
+
+ /// Record how many rows fit, from the laid-out rect.
+ pub fn set_page(&mut self, page: usize) {
+ self.page = page.max(1);
+ self.scroll_to_selected();
+ }
+
+ /// How many rows fit — what PageUp/PageDown step by.
+ pub fn page_len(&self) -> usize {
+ self.page.max(1)
+ }
+
+ /// Claim, or stop claiming, the dialog's rect as an occluder.
+ pub fn set_occluding(&mut self, on: bool) {
+ self.occluding = on;
+ }
+
+ fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
+ let list = list_rect(rect);
+ if i < self.scroll {
+ return None;
+ }
+ let offset = (i - self.scroll) as f32 * ROW_H;
+ if offset + ROW_H > list.height {
+ return None;
+ }
+ Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
+ }
+
+ fn row_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
+ let list = list_rect(rect);
+ if x < list.x || x >= list.x + list.width || y < list.y || y >= list.y + list.height {
+ return None;
+ }
+ let i = self.scroll + ((y - list.y) / ROW_H).floor() as usize;
+ (i < self.rows.len()).then_some(i)
+ }
+
+ fn tab_at(&self, rect: Rect, x: f32, y: f32) -> Option<Tab> {
+ Tab::ALL.into_iter().find(|t| {
+ let r = tab_rect(rect, *t);
+ x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
+ })
+ }
+
+ /// Keep `selected` inside the scrolled window.
+ pub fn scroll_to_selected(&mut self) {
+ let per_page = self.page.max(1);
+ if self.selected < self.scroll {
+ self.scroll = self.selected;
+ } else if self.selected >= self.scroll + per_page {
+ self.scroll = self.selected + 1 - per_page;
+ }
+ }
+
+ pub fn move_selection(&mut self, delta: i32) {
+ if self.rows.is_empty() {
+ self.selected = 0;
+ self.scroll = 0;
+ return;
+ }
+ let n = self.rows.len() as i32;
+ // Wrapping, not clamping: a list you can fall off the bottom of makes
+ // the last row harder to reach than the first, and this one is short.
+ self.selected = (self.selected as i32 + delta).rem_euclid(n) as usize;
+ self.scroll_to_selected();
+ }
+
+ /// The command Enter would run.
+ pub fn selected_id(&self) -> Option<&'static str> {
+ self.rows.get(self.selected).map(|r| r.id)
+ }
+
+ pub fn take_activated(&mut self) -> Option<&'static str> {
+ self.activated.take()
+ }
+
+ pub fn take_tab_click(&mut self) -> Option<Tab> {
+ self.tab_click.take()
+ }
+
+ /// Swap in a freshly ranked row list, keeping the selection in range.
+ ///
+ /// The selection goes back to the top rather than trying to follow the
+ /// command it was on: the rows are re-ranked by the query, so "the same
+ /// row" after a keystroke is a different command, and the best match
+ /// being preselected is the whole point of ranking them.
+ pub fn set_rows(&mut self, rows: Vec<Row>) {
+ self.rows = rows;
+ self.selected = 0;
+ self.scroll = 0;
+ }
+}
+
+impl Layout for Dialog {
+ /// Above every pane, and above the second network editor's plates: the
+ /// dialog is modal in practice — a press inside it never reaches what it
+ /// covers — so it has to be drawn that way too. The dialog's params body
+ /// sits one above this (see `DIALOG_PARAMS_IDX`).
+ fn z_order(&self) -> i32 {
+ 900
+ }
+}
+
+impl Paint for Dialog {
+ /// `paint` authors geometry AND text, so the Text prims pass through
+ /// `paint_self` verbatim instead of the single-font own-labels bridge —
+ /// the chord column needs its own family and its own clip bounds.
+ fn paints_own_subtree(&self) -> bool {
+ true
+ }
+
+ /// The dialog IS its own plate, the contract every floating surface in
+ /// this app wears: the parameter plate's fill, so it tracks the configured
+ /// tint, opacity and blur-behind marker with the panes.
+ fn color(&self) -> [f32; 4] {
+ colors::param_plate_fill()
+ }
+
+ fn solid_border(&self) -> Option<([f32; 4], f32)> {
+ colors::plate_border_color().map(|bc| (bc, colors::plate_border_thickness()))
+ }
+
+ fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
+ let r = cce_ui::layout::plate_corner_radius();
+ (r > 0.0).then_some((r, (true, true, true, true)))
+ }
+
+ /// The dialog's whole rect, as an occluder.
+ ///
+ /// Text is not painted in display-list order — the engine collects every
+ /// Text prim and lays them all out at the end — so a plate drawn over a
+ /// label does not hide it, whatever the z. What hides it is the
+ /// popover-occlusion clamp, which reads `UiContext::active_popovers`; the
+ /// designer registers every visible widget whose `popover_rect` is `Some`,
+ /// so claiming one here is how the graph's node labels and the viewport's
+ /// readouts stop bleeding through the plate.
+ ///
+ /// The clamp exempts text whose OWN bounds coincide with the occluder, so
+ /// everything drawn inside the dialog — this widget's labels and, via
+ /// `append_dialog`, the settings body's — carries these exact bounds and
+ /// does its own truncating.
+ /// **`occluding` exists because one claim serves two mechanisms that want
+ /// opposite answers.** `UiContext::is_coordinate_covered` reads the same
+ /// `popover_rect` — off every REGISTERED widget, not only the ones in
+ /// `active_popovers` — to decide that a press has landed under something
+ /// else. With the claim standing, every control inside the dialog is
+ /// covered by the plate it is drawn on and nothing in the Settings half
+ /// can be clicked. `State::dispatch_uncovered` lowers the flag for the
+ /// length of a dispatch into the dialog and puts it straight back.
+ fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
+ self.occluding.then_some((rect.x, rect.y, rect.width, rect.height))
+ }
+
+ fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+ if rect.width <= 0.0 || rect.height <= 0.0 {
+ return;
+ }
+ let (family, font_size) = cce_ui::layout::control_label_font_parsed();
+ let accent = colors::highlight_primary_color();
+ let tint = [accent[0], accent[1], accent[2]];
+ let depth = colors::plate_bevel_width();
+ let ctrl_r = cce_ui::layout::control_corner_radius();
+ let radii = (ctrl_r, ctrl_r, ctrl_r, ctrl_r);
+ // The occlusion-clamp exemption (see `popover`): every label in here
+ // carries the dialog's own rect, so none of them is clipped away by
+ // the occluder the dialog itself registers. The cost is that bounds
+ // no longer trim an overlong label, so the rows truncate by hand.
+ let own = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
+ let fit = |text: &str, width: f32| -> String {
+ if width <= 0.0 {
+ return String::new();
+ }
+ let cols = (width / display::measure_text_width("M", &family, font_size).max(1.0))
+ .floor() as usize;
+ display::truncate_tail(text, cols)
+ };
+
+ // --- The tab strip: one segmented control, so the seam between the
+ // two halves reads as a seam and not as a gap.
+ for tab in Tab::ALL {
+ let r = tab_rect(rect, tab);
+ let active = tab == self.tab;
+ // Active wears the focus language the rest of the app uses for
+ // "this is the live one": the tinted bevel, a glint on the
+ // control's own silhouette.
+ if active {
+ ctx.bevel_tinted(r, radii, colors::param_plate_fill(), depth, tint);
+ } else if self.hover_tab == Some(tab) {
+ ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+ }
+ let label = tab.label();
+ let tw = display::measure_text_width(label, &family, font_size);
+ let tx = r.x + (r.width - tw) * 0.5;
+ let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
+ let color = if active { [0xf0, 0xf0, 0xf6] } else { [0x9a, 0x9a, 0xa6] };
+ ctx.text_with(
+ label,
+ tx,
+ ty,
+ font_size,
+ color,
+ Some(family.clone()),
+ own,
+ );
+ }
+
+ // The Settings half's body is a separate roster slot, painted by the
+ // designer's own walk — nothing more to draw here.
+ if self.tab == Tab::Settings {
+ return;
+ }
+
+ // --- The query line: a well, like the text rows in the params pane.
+ // The caret is a plain rule and does not blink: the dialog owns the
+ // keyboard outright while it is open, so there is no focus to signal.
+ let q = query_rect(rect);
+ ctx.rounded_rect(q, ctrl_r, (true, true, true, true), [0.0, 0.0, 0.0, 0.22]);
+ let qty = cce_ui::layout::align_text_y(q.y, q.height, font_size, 0.0);
+ let qtx = q.x + 8.0;
+ let q_w = q.width - 16.0;
+ if self.query.is_empty() {
+ let hint = fit("Type to filter commands", q_w);
+ ctx.text_with(hint, qtx, qty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
+ } else {
+ // Head-truncated: what matters while typing is the end of the
+ // query, which is where the caret is.
+ let shown = display::truncate_head(&self.query, (q_w / display::measure_text_width("M", &family, font_size).max(1.0)).floor() as usize);
+ ctx.text_with(shown, qtx, qty, font_size, [0xe6, 0xe6, 0xee], Some(family.clone()), own);
+ }
+ let caret_x = qtx + display::measure_text_width(&self.query, &family, font_size) + 1.0;
+ if caret_x < q.x + q.width - 4.0 {
+ ctx.quad(
+ Rect { x: caret_x, y: q.y + 6.0, width: 1.0, height: q.height - 12.0 },
+ [accent[0], accent[1], accent[2], 0.9],
+ );
+ }
+
+ // --- The rows. The chord column is right-aligned against the list's
+ // right edge rather than padded out to a fixed width: the label is
+ // what gets read, so it is the label that keeps the stable left edge.
+ let list = list_rect(rect);
+ if self.rows.is_empty() {
+ let ty = cce_ui::layout::align_text_y(list.y, ROW_H, font_size, 0.0);
+ ctx.text_with("No matching command", list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
+ return;
+ }
+ for i in self.scroll..self.rows.len() {
+ let Some(r) = self.row_rect(rect, i) else { break };
+ let row = &self.rows[i];
+ if i == self.selected {
+ ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [accent[0], accent[1], accent[2], 0.16]);
+ ctx.bevel_tinted(r, radii, [0.0; 4], depth, tint);
+ } else if self.hover_row == Some(i) {
+ ctx.rounded_rect(r, ctrl_r, (true, true, true, true), [1.0, 1.0, 1.0, 0.05]);
+ }
+ let ty = cce_ui::layout::align_text_y(r.y, r.height, font_size, 0.0);
+ let chord_w = if row.chord.is_empty() {
+ 0.0
+ } else {
+ display::measure_text_width(&row.chord, &family, font_size)
+ };
+ // The label's clip stops short of the chord column so a long
+ // label is cut by it rather than running under it.
+ let label_right = r.x + r.width - 8.0 - if chord_w > 0.0 { chord_w + 12.0 } else { 0.0 };
+ let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
+ ctx.text_with(
+ fit(&row.label, label_right - (r.x + 8.0)),
+ r.x + 8.0,
+ ty,
+ font_size,
+ label_color,
+ Some(family.clone()),
+ own,
+ );
+ if chord_w > 0.0 {
+ ctx.text_with(
+ row.chord.clone(),
+ r.x + r.width - 8.0 - chord_w,
+ ty,
+ font_size,
+ [0x85, 0x85, 0x92],
+ Some(family.clone()),
+ own,
+ );
+ }
+ }
+ }
+}
+
+impl Input for Dialog {
+ /// The whole rect, always — this is what makes the dialog modal over what
+ /// it covers: the designer's press cascade asks the dialog first and a hit
+ /// never falls through to the pane underneath.
+ fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
+ x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
+ }
+
+ fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+ let rect = ectx.rect;
+ match event {
+ Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, .. } => {
+ if let Some(tab) = self.tab_at(rect, *x, *y) {
+ self.tab_click = Some(tab);
+ return true;
+ }
+ if self.tab == Tab::Commands {
+ if let Some(i) = self.row_at(rect, *x, *y) {
+ self.selected = i;
+ self.activated = self.rows.get(i).map(|r| r.id);
+ return true;
+ }
+ }
+ // Inside the plate but on no control: consumed anyway, so the
+ // press cannot reach the pane the dialog is covering.
+ true
+ }
+ Event::PointerMove { x, y, .. } => {
+ let row = self.row_at(rect, *x, *y);
+ let tab = self.tab_at(rect, *x, *y);
+ let changed = row != self.hover_row || tab != self.hover_tab;
+ self.hover_row = row;
+ self.hover_tab = tab;
+ changed
+ }
+ Event::MouseWheel { delta, .. } => {
+ if self.tab != Tab::Commands || self.rows.is_empty() {
+ return false;
+ }
+ let lines = match delta {
+ MouseScrollDelta::LineDelta(_, y) => -*y,
+ MouseScrollDelta::PixelDelta(p) => -(p.y as f32) / ROW_H,
+ };
+ let max_scroll = self.rows.len().saturating_sub(self.page.max(1));
+ let next = (self.scroll as f32 + lines).round().clamp(0.0, max_scroll as f32) as usize;
+ let changed = next != self.scroll;
+ self.scroll = next;
+ changed
+ }
+ _ => false,
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// The designer's half: what the dialog shows, and what choosing a row does.
+// ---------------------------------------------------------------------------
+
+use crate::app::{param_display, ParamDef, State};
+use crate::slots::{DIALOG_IDX, DIALOG_PARAMS_IDX};
+
+/// Where a Settings row's value actually lives.
+///
+/// Not the live `State` fields, and not `DesignSettings`: both are DOWNSTREAM
+/// of the meta node. `apply_settings_from_menubar_subnets` copies the utility
+/// subnets onto the live state on every param change, so a write straight to
+/// `State::grid_thickness` would survive exactly until the next one. The
+/// subnet param is the value's owner; this enum says which owner each row has,
+/// so the dialog edits values in the one place that keeps them.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Owner {
+ /// A param on a utility subnet under the root meta node (`Main`, `View`,
+ /// `Guides`), named here by subnet and param name.
+ Subnet(&'static str, &'static str),
+ /// A toggle the command registry already owns end to end: the command does
+ /// the live flip, the menu checkmark AND the per-camera writeback in one
+ /// place. Square Aspect and Show Camera Pivot are per-CAMERA settings with
+ /// no node at all behind the Default Camera, and their commands are the
+ /// only code that gets both cases right — so the row dispatches instead of
+ /// writing, and reads its displayed value off the live state.
+ Command(&'static str),
+ /// A param on the ACTIVE camera node, with the live field as the fallback:
+ /// the Default Camera has no node, so there is nothing to write but the
+ /// field, and `apply_settings_from_menubar_subnets` leaves it alone.
+ ActiveCamera(&'static str),
+}
+
+/// One row of the Settings half.
+pub struct Setting {
+ /// What the dialog calls it — and, because `param_display` keys a row by
+ /// its label, the identity the writeback resolves back to this row. Unique
+ /// across the table, section titles included.
+ ///
+ /// Spelled out rather than borrowed from the owning param's own label: the
+ /// subnets' labels are tuned for the section they sit in ("Plate", under
+ /// the View node's Network section) and stop making sense anywhere else.
+ pub label: &'static str,
+ pub owner: Option<Owner>,
+}
+
+impl Setting {
+ const fn section(label: &'static str) -> Self {
+ Setting { label, owner: None }
+ }
+
+ const fn row(label: &'static str, owner: Owner) -> Self {
+ Setting { label, owner: Some(owner) }
+ }
+}
+
+/// The Settings half, in order.
+///
+/// Scope is exactly what `DesignSettings` persists — the viewport and graph
+/// DISPLAY state, which is the part of the app's configuration that is a
+/// preference rather than part of a project. What is deliberately NOT here:
+/// the render subnet (per-project look), the pane-visibility toggles (the
+/// View menu and the plate corners already own those, and a settings dialog
+/// is a strange place to hide a pane from), and keybindings, which this DE
+/// edits as `input.kdl` on purpose.
+pub const SETTINGS: &[Setting] = &[
+ Setting::section("Viewport"),
+ Setting::row("Background Color", Owner::Subnet("Main", "Background Color")),
+ Setting::row("Square Aspect", Owner::Command("toggle_square_viewport")),
+ Setting::section("Grid"),
+ Setting::row("Show Grid", Owner::Subnet("Guides", "Show Grid Guide")),
+ Setting::row("Grid Color", Owner::Subnet("Guides", "Grid Color")),
+ Setting::row("Grid Thickness", Owner::Subnet("Guides", "Grid Thickness")),
+ Setting::section("Guides"),
+ Setting::row("Show Origin Axes", Owner::Subnet("Guides", "Show Origin Axes")),
+ Setting::row("Origin Size", Owner::Subnet("Guides", "Origin Guide Size")),
+ Setting::row("Show Reference Cube", Owner::Subnet("Guides", "Show Reference Cube")),
+ Setting::section("Camera"),
+ Setting::row("Show Camera Pivot", Owner::Command("toggle_camera_pivot")),
+ Setting::row("Camera Pivot Size", Owner::ActiveCamera("Camera Pivot Size")),
+ Setting::section("Network"),
+ Setting::row("Show Network Plate", Owner::Subnet("View", "Show Network Plate")),
+];
+
+fn setting_by_label(label: &str) -> Option<&'static Setting> {
+ SETTINGS.iter().find(|s| s.label == label)
+}
+
+/// A synthetic `ParamDef` carrying `label` and the shape of `src`, so the row
+/// renders as whatever control its owner already uses.
+fn relabel(src: &ParamDef, label: &'static str) -> ParamDef {
+ ParamDef { label: label.to_string(), ..src.clone() }
+}
+
+fn bool_param(label: &'static str, on: bool) -> ParamDef {
+ ParamDef {
+ name: label.to_string(),
+ label: label.to_string(),
+ param_type: "toggle".to_string(),
+ default: if on { "true" } else { "false" }.to_string(),
+ options: Vec::new(),
+ min: None,
+ max: None,
+ step: None,
+ show_when: String::new(),
+ }
+}
+
+impl State {
+ pub fn dialog_visible(&self) -> bool {
+ self.slots.dialog.visible()
+ }
+
+ /// The dialog's tab, or `Commands` when it is closed.
+ pub fn dialog_tab(&self) -> Tab {
+ self.slots.dialog.tab
+ }
+
+ pub fn toggle_dialog(&mut self) {
+ if self.dialog_visible() {
+ self.close_dialog();
+ } else {
+ self.open_dialog();
+ }
+ }
+
+ pub fn open_dialog(&mut self) {
+ // Always on Commands, and always with an empty query: a dialog that
+ // reopens holding the last search has to be cleared before it can be
+ // used, which is a step every single time to save one occasionally.
+ self.slots.dialog.tab = Tab::Commands;
+ self.slots.dialog.query.clear();
+ self.slots.dialog.set_visible(true);
+ self.refresh_dialog_rows();
+ self.refresh_dialog_settings();
+ self.rebuild_positions();
+ self.apply_layout();
+ self.update_status_text("Dialog: type to filter, Tab switches halves, Escape closes.");
+ }
+
+ pub fn close_dialog(&mut self) {
+ if !self.dialog_visible() {
+ return;
+ }
+ // The settings body keeps whatever a half-finished text edit left in
+ // it; drop that focus so the next open starts clean.
+ self.slots.dialog_params.unfocus();
+ self.slots.dialog.set_visible(false);
+ self.slots.dialog_params.set_visible(false);
+ if self.focused_widget == Some(DIALOG_IDX) || self.focused_widget == Some(DIALOG_PARAMS_IDX) {
+ self.focused_widget = None;
+ }
+ self.rebuild_positions();
+ self.apply_layout();
+ }
+
+ pub fn set_dialog_tab(&mut self, tab: Tab) {
+ if self.slots.dialog.tab == tab {
+ return;
+ }
+ self.slots.dialog.tab = tab;
+ if tab == Tab::Settings {
+ // Re-read on every entry: a chord or a menu may have changed one
+ // of these while the Commands half was up.
+ self.refresh_dialog_settings();
+ } else {
+ self.refresh_dialog_rows();
+ }
+ self.rebuild_positions();
+ self.apply_layout();
+ }
+
+ /// Re-rank the Commands half against the current query.
+ ///
+ /// Ranking is [`crate::command::palette_entries`] — the same fuzzy rank
+ /// and the same focused-pane-first partition as the `cce-cloud` palette,
+ /// so the two agree on what typing "sg" means.
+ pub fn refresh_dialog_rows(&mut self) {
+ let entries = {
+ let query = self.slots.dialog.query.clone();
+ crate::command::palette_entries(&query, self.focused_context())
+ };
+ let rows: Vec<Row> = entries
+ .iter()
+ .map(|c| Row {
+ id: c.id,
+ label: c.label.to_string(),
+ chord: self.shortcut_manager.chord_for(c.id).map(|s| s.describe()).unwrap_or_default(),
+ })
+ .collect();
+ self.slots.dialog.set_rows(rows);
+ }
+
+ /// The Settings half's rows, each read from whatever owns its value.
+ ///
+ /// Returns `ParamDef`s rather than display triples so the encoding
+ /// (`spinbox:min:max:step`, `choice:a,b`) stays in `param_display` — one
+ /// place, shared with the params pane, instead of a second copy here that
+ /// could disagree about what a spinbox is.
+ fn dialog_settings_params(&self) -> Vec<ParamDef> {
+ let subnet_param = |subnet: &str, name: &str| -> Option<&ParamDef> {
+ self.session_node()?
+ .children
+ .iter()
+ .find(|c| c.name == subnet)?
+ .params
+ .iter()
+ .find(|p| p.name == name)
+ };
+ let camera_param = |name: &str| -> Option<&ParamDef> {
+ if self.active_camera == "Default Camera" {
+ return None;
+ }
+ self.current_dir()
+ .children
+ .iter()
+ .find(|c| c.node_type == "camera" && c.name == self.active_camera)?
+ .params
+ .iter()
+ .find(|p| p.name == name)
+ };
+
+ let mut out = Vec::with_capacity(SETTINGS.len());
+ for s in SETTINGS {
+ match s.owner {
+ None => out.push(ParamDef {
+ name: s.label.to_string(),
+ label: s.label.to_string(),
+ param_type: "section".to_string(),
+ default: String::new(),
+ options: Vec::new(),
+ min: None,
+ max: None,
+ step: None,
+ show_when: String::new(),
+ }),
+ Some(Owner::Subnet(subnet, name)) => {
+ // A subnet param that does not exist is a row that cannot
+ // work, so it is not offered — the subnets are recreated
+ // on every load, but a detached window has no meta node at
+ // all.
+ if let Some(p) = subnet_param(subnet, name) {
+ out.push(relabel(p, s.label));
+ }
+ }
+ Some(Owner::Command(id)) => {
+ let on = match id {
+ "toggle_square_viewport" => self.square_viewport,
+ "toggle_camera_pivot" => self.viewport().show_camera_pivot,
+ _ => false,
+ };
+ out.push(bool_param(s.label, on));
+ }
+ Some(Owner::ActiveCamera(name)) => match camera_param(name) {
+ Some(p) => out.push(relabel(p, s.label)),
+ // No camera node behind the Default Camera: the live field
+ // is the value, in the same tenths the camera param uses.
+ None => out.push(ParamDef {
+ name: s.label.to_string(),
+ label: s.label.to_string(),
+ param_type: "spinbox".to_string(),
+ default: ((self.camera_pivot_size * 10.0).round() as i32).to_string(),
+ options: Vec::new(),
+ min: Some(1.0),
+ max: Some(50.0),
+ step: Some(1.0),
+ show_when: String::new(),
+ }),
+ },
+ }
+ }
+ // A section with nothing under it is a header for an empty list.
+ let mut trimmed: Vec<ParamDef> = Vec::with_capacity(out.len());
+ for (i, p) in out.iter().enumerate() {
+ let empty_section = p.param_type == "section"
+ && out.get(i + 1).is_none_or(|n| n.param_type == "section");
+ if !empty_section {
+ trimmed.push(p.clone());
+ }
+ }
+ trimmed
+ }
+
+ /// Push the Settings rows into the dialog's params body, and remember them
+ /// as the baseline the writeback diffs against.
+ pub fn refresh_dialog_settings(&mut self) {
+ let rows = param_display(&self.dialog_settings_params());
+ self.slots.dialog_params_mut().set_display_params(&rows);
+ self.dialog_settings_shown = rows;
+ }
+
+ /// Apply whatever the Settings half's controls changed.
+ ///
+ /// The params pane's writeback (`sync_parameters_to_project`), pointed at
+ /// [`SETTINGS`] instead of the selected node: read the controls, diff
+ /// against what was put into them, write each changed row to its owner,
+ /// and then run the one apply-and-persist pass. Polled rather than pushed
+ /// for the same reason the params pane is — a `ParametersBg` reports its
+ /// values, it does not emit events.
+ pub fn sync_dialog_settings_to_project(&mut self) {
+ if !self.dialog_visible() || self.slots.dialog.tab != Tab::Settings {
+ return;
+ }
+ let updated = self.slots.dialog_params().node_params();
+ let mut commands: Vec<&'static str> = Vec::new();
+ let mut changed = false;
+ for (key, value, _) in &updated {
+ let was = self.dialog_settings_shown.iter().find(|(k, _, _)| k == key);
+ if was.is_none_or(|(_, v, _)| v == value) {
+ continue;
+ }
+ let Some(setting) = setting_by_label(key) else { continue };
+ let Some(owner) = setting.owner else { continue };
+ changed = true;
+ match owner {
+ Owner::Subnet(subnet, name) => {
+ if let Some(session) = self.session_node_mut() {
+ if let Some(node) = session.children.iter_mut().find(|c| c.name == subnet) {
+ if let Some(p) = node.params.iter_mut().find(|p| p.name == name) {
+ p.default = value.clone();
+ }
+ }
+ }
+ }
+ Owner::Command(id) => commands.push(id),
+ Owner::ActiveCamera(name) => {
+ let active = self.active_camera.clone();
+ let wrote = {
+ let dir = self.current_dir_mut();
+ match dir
+ .children
+ .iter_mut()
+ .find(|c| c.node_type == "camera" && c.name == active)
+ .and_then(|c| c.params.iter_mut().find(|p| p.name == name))
+ {
+ Some(p) => {
+ p.default = value.clone();
+ true
+ }
+ None => false,
+ }
+ };
+ if !wrote {
+ if let Ok(v) = value.parse::<f32>() {
+ self.camera_pivot_size = v / 10.0;
+ }
+ }
+ }
+ }
+ }
+ if !changed {
+ return;
+ }
+
+ // The commands run FIRST and on their own: each is a toggle whose
+ // whole job is to flip live state, mark the menus and persist, and
+ // running them after the apply below would have them flip against
+ // values the apply had just settled.
+ for id in commands {
+ self.run_command(id);
+ }
+
+ self.apply_settings_from_menubar_subnets();
+ // The viewport meshes bake their sizes and colors in, so a changed
+ // thickness/size/tint is a re-generate, not a re-draw. This is the
+ // same set the settings-file reload in `tick_frame` regenerates.
+ self.update_grid_geometry();
+ self.update_origin_geometry();
+ self.update_pivot_geometry();
+ self.update_viewport_bg_geometry();
+ self.sync_grid_settings();
+ self.rebuild_scene_geometry();
+ self.sync_nodes();
+ self.save_settings();
+ // The params pane may be showing one of these very nodes.
+ self.sync_parameters_pane();
+ // Re-read rather than patching the baseline: an apply can normalize a
+ // value (a spinbox clamp), and the baseline has to be what the
+ // controls now hold or the next poll reports a phantom change.
+ self.refresh_dialog_settings();
+ }
+
+ /// Lay the dialog and its settings body out over the window.
+ ///
+ /// Called at the end of `rebuild_positions`, after every layout branch has
+ /// run — like the 2D page pane, the rect it wants never depends on which
+ /// branch produced the panes underneath it.
+ pub(crate) fn layout_dialog(&mut self) {
+ let open = self.dialog_visible();
+ if !open {
+ self.positions[DIALOG_IDX] = (0.0, 0.0, 0.0, 0.0);
+ self.positions[DIALOG_PARAMS_IDX] = (0.0, 0.0, 0.0, 0.0);
+ self.slots.dialog_params.set_visible(false);
+ return;
+ }
+ let (x, y, w, h) = layout_in(self.width, self.height);
+ self.positions[DIALOG_IDX] = (x, y, w, h);
+ self.slots.dialog.set_page(visible_rows(x, y, w, h));
+
+ let settings = self.slots.dialog.tab == Tab::Settings;
+ self.positions[DIALOG_PARAMS_IDX] =
+ if settings { settings_rect(x, y, w, h) } else { (0.0, 0.0, 0.0, 0.0) };
+ self.slots.dialog_params.set_visible(settings);
+ }
+
+ /// Every key, while the dialog is open.
+ ///
+ /// Total, not layered: the branch that calls this returns whatever it
+ /// returns, so nothing below reaches the panes. A modal that leaks its
+ /// typing is worse than no modal — typing "frame" into the filter would
+ /// otherwise step the grid cursor and flip a node's geometry flag on the
+ /// way past, since the network pane's bare-letter family is ungated.
+ pub(crate) fn dialog_key_input(&mut self, event: &KeyEvent) -> bool {
+ if event.state != ElementState::Pressed {
+ return true;
+ }
+ // The dialog's own chord closes it, wherever the user has bound it —
+ // asked for by id rather than hardcoded to Alt+D, so a rebind in
+ // `input.kdl` keeps working both ways.
+ if self.shortcut_manager.match_command(&self.modifiers, &event.logical_key)
+ == Some("toggle_dialog")
+ {
+ self.close_dialog();
+ return true;
+ }
+ match &event.logical_key {
+ Key::Named(NamedKey::Escape) => {
+ self.close_dialog();
+ return true;
+ }
+ Key::Named(NamedKey::Tab) => {
+ // One key for two halves, in both directions: there are only
+ // two, so Tab and Shift+Tab are the same move.
+ let next = if self.slots.dialog.tab == Tab::Commands { Tab::Settings } else { Tab::Commands };
+ self.set_dialog_tab(next);
+ return true;
+ }
+ _ => {}
+ }
+
+ if self.slots.dialog.tab == Tab::Settings {
+ // The settings body is a real `ParametersBg` with real text
+ // fields; hand it the key and poll what it did, exactly as the
+ // params pane's own key path does.
+ let taken = {
+ let ptr = &mut self.slots.dialog_params as *mut cce_ui::widget::Adapted<ParametersBg>;
+ unsafe { (*ptr).keyboard_input(event, &mut self.ui_context) }
+ };
+ if taken {
+ self.sync_dialog_settings_to_project();
+ }
+ // Consumed either way: an unhandled key inside a modal does
+ // nothing, it does not fall through to the network pane.
+ return true;
+ }
+
+ match &event.logical_key {
+ Key::Named(NamedKey::ArrowDown) => self.slots.dialog.move_selection(1),
+ Key::Named(NamedKey::ArrowUp) => self.slots.dialog.move_selection(-1),
+ Key::Named(NamedKey::PageDown) => {
+ let page = self.slots.dialog.page_len() as i32;
+ self.slots.dialog.move_selection(page);
+ }
+ Key::Named(NamedKey::PageUp) => {
+ let page = self.slots.dialog.page_len() as i32;
+ self.slots.dialog.move_selection(-page);
+ }
+ Key::Named(NamedKey::Home) => {
+ self.slots.dialog.selected = 0;
+ self.slots.dialog.scroll_to_selected();
+ }
+ Key::Named(NamedKey::End) => {
+ let last = self.slots.dialog.rows.len().saturating_sub(1);
+ self.slots.dialog.selected = last;
+ self.slots.dialog.scroll_to_selected();
+ }
+ Key::Named(NamedKey::Enter) => {
+ if let Some(id) = self.slots.dialog.selected_id() {
+ self.run_dialog_command(id);
+ }
+ }
+ Key::Named(NamedKey::Backspace) => {
+ if self.slots.dialog.query.pop().is_some() {
+ self.refresh_dialog_rows();
+ }
+ }
+ Key::Named(NamedKey::Space) => {
+ self.slots.dialog.query.push(' ');
+ self.refresh_dialog_rows();
+ }
+ Key::Character(c) => {
+ // Bare typing only: a modified key is a chord, and the ones
+ // this dialog answers to are handled above.
+ if !self.modifiers.control_key()
+ && !self.modifiers.alt_key()
+ && !self.modifiers.super_key()
+ {
+ self.slots.dialog.query.push_str(c);
+ self.refresh_dialog_rows();
+ }
+ }
+ _ => {}
+ }
+ true
+ }
+
+ /// Run a command the dialog chose, and close.
+ ///
+ /// Closing FIRST, so a command that opens something of its own (the
+ /// `cce-cloud` palette, a file chooser) does not come up behind the
+ /// dialog. The dialog's own row is the exception: toggling it here would
+ /// reopen what was just closed.
+ pub(crate) fn run_dialog_command(&mut self, id: &'static str) {
+ self.close_dialog();
+ if id != "toggle_dialog" {
+ self.run_command(id);
+ }
+ }
+
+ /// Drain what the pointer did inside the dialog, after an event reached
+ /// one of its two slots. Returns whether anything changed.
+ pub(crate) fn drain_dialog_clicks(&mut self) -> bool {
+ let mut changed = false;
+ if let Some(tab) = self.slots.dialog.take_tab_click() {
+ self.set_dialog_tab(tab);
+ changed = true;
+ }
+ if let Some(id) = self.slots.dialog.take_activated() {
+ self.run_dialog_command(id);
+ changed = true;
+ }
+ changed
+ }
+
+ /// A mouse button, while the dialog is open.
+ ///
+ /// `None` hands the press back to the ordinary cascade — which happens
+ /// only for the buttons the dialog has no use for, so a right-click still
+ /// reaches whatever is under it outside the plate. `Some(handled)` means
+ /// the dialog dealt with it and nothing else should.
+ pub(crate) fn dialog_mouse_input(
+ &mut self,
+ button: MouseButton,
+ state: ElementState,
+ ) -> Option<bool> {
+ if button != MouseButton::Left {
+ return None;
+ }
+ let (x, y) = (self.cursor_x, self.cursor_y);
+
+ // A slider drag started in the settings body ends wherever the pointer
+ // happens to be — including outside the plate. Ending it has to come
+ // before the dismiss test below, or dragging a value past the dialog's
+ // edge and letting go would close the dialog instead of committing.
+ if state == ElementState::Released && self.drag_widget == Some(DIALOG_PARAMS_IDX) {
+ let ptr = &mut self.slots.dialog_params as *mut cce_ui::widget::Adapted<ParametersBg>;
+ unsafe {
+ (*ptr).handle_event(&cce_ui::widget::Event::DragEnd, &mut self.ui_context);
+ (*ptr).handle_event(
+ &cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y },
+ &mut self.ui_context,
+ );
+ }
+ self.drag_widget = None;
+ self.drag_press_cursor = None;
+ self.sync_dialog_settings_to_project();
+ return Some(true);
+ }
+
+ let hits_dialog = self.in_dialog_slot(DIALOG_IDX, x, y);
+ let hits_settings = self.in_dialog_slot(DIALOG_PARAMS_IDX, x, y);
+
+ if !hits_dialog && !hits_settings {
+ // Outside: a press dismisses, a release is the tail of that press
+ // and is simply eaten.
+ if state == ElementState::Pressed {
+ self.close_dialog();
+ }
+ return Some(true);
+ }
+
+ // The settings body first — it sits INSIDE the dialog's rect, so the
+ // dialog's own (deliberately total) hit test would otherwise claim
+ // every press meant for a control.
+ if hits_settings {
+ let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
+ let taken = self.dispatch_uncovered(DIALOG_PARAMS_IDX, &ev);
+ if taken {
+ self.sync_dialog_settings_to_project();
+ }
+ // Sliders and ramps drag; arm the same drag the params pane arms.
+ if state == ElementState::Pressed && self.slots.draggable(DIALOG_PARAMS_IDX) {
+ let ev = cce_ui::widget::Event::DragStart { start_x: x, start_y: y };
+ let ptr = &mut self.slots.dialog_params
+ as *mut cce_ui::widget::Adapted<ParametersBg>;
+ unsafe {
+ (*ptr).handle_event(&ev, &mut self.ui_context);
+ }
+ self.drag_widget = Some(DIALOG_PARAMS_IDX);
+ self.drag_press_cursor = Some((x, y));
+ }
+ return Some(true);
+ }
+
+ let ev = cce_ui::widget::Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
+ self.dispatch_uncovered(DIALOG_IDX, &ev);
+ self.drain_dialog_clicks();
+ Some(true)
+ }
+
+ /// The wheel, while the dialog is open: to whichever half is showing, and
+ /// no further. Both halves scroll their own list, so there is nothing to
+ /// fall through to.
+ pub(crate) fn dialog_mouse_wheel(&mut self, delta: MouseScrollDelta) -> bool {
+ let (x, y) = (self.cursor_x, self.cursor_y);
+ let ev = cce_ui::widget::Event::MouseWheel { delta, x, y, local_x: x, local_y: y };
+ if self.in_dialog_slot(DIALOG_PARAMS_IDX, x, y) {
+ return self.dispatch_uncovered(DIALOG_PARAMS_IDX, &ev);
+ }
+ if self.in_dialog_slot(DIALOG_IDX, x, y) {
+ return self.dispatch_uncovered(DIALOG_IDX, &ev);
+ }
+ false
+ }
+
+ /// Is `(x, y)` inside a dialog slot's laid-out rect?
+ ///
+ /// The rect, not `hit_test`: the dialog registers itself as a text
+ /// occluder (see `Dialog::popover`) and `Adapted::hit_test` reads that
+ /// same list as COVERAGE, so every point inside the dialog reports as
+ /// covered — by the dialog's own plate. Inside a modal the geometry IS
+ /// the answer.
+ pub(crate) fn in_dialog_slot(&self, idx: usize, x: f32, y: f32) -> bool {
+ if !self.slots.get_dyn(idx).visible() {
+ return false;
+ }
+ let (rx, ry, rw, rh) = self.positions[idx];
+ rw > 0.0 && rh > 0.0 && x >= rx && x < rx + rw && y >= ry && y < ry + rh
+ }
+
+ /// Deliver `ev` to a dialog slot with the dialog's occluder claim lowered.
+ ///
+ /// `Adapted::handle_event` hit-gates presses and wheels through
+ /// `is_coordinate_covered`, which asks every REGISTERED widget for its
+ /// `popover_rect` — including the dialog's, which covers the whole plate
+ /// (see [`Dialog::popover`]). So while the claim stands, every press
+ /// aimed at a settings control is rejected as covered by the surface the
+ /// control is drawn on, and the Settings half is inert. The dialog's own
+ /// routing has already decided who gets this event, so the claim comes
+ /// down for the dispatch and goes straight back up.
+ pub(crate) fn dispatch_uncovered(&mut self, idx: usize, ev: &cce_ui::widget::Event) -> bool {
+ self.slots.dialog.set_occluding(false);
+ // The coverage answer is memoized per point, so lowering the claim is
+ // not enough — a query from earlier this frame is served from the
+ // cache, and the engine makes one on every left press
+ // (`close_popovers_missed_by_press`).
+ self.ui_context.invalidate_coverage_cache();
+ let ptr = self.slots.get_dyn_mut(idx) as *mut (dyn cce_ui::widget::WidgetHost + 'static);
+ let taken = unsafe { (*ptr).handle_event(ev, &mut self.ui_context) };
+ self.slots.dialog.set_occluding(true);
+ self.ui_context.invalidate_coverage_cache();
+ taken
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 101cbfc..e34e258 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -26,6 +26,7 @@ pub mod render;
pub mod shortcut;
pub mod slots;
pub mod command;
+pub mod dialog;
pub mod layout;
pub mod mold;
pub mod page;
@@ -641,6 +642,7 @@ mod tests {
RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, STATUS_IDX, BREADCRUMB_IDX,
SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
NETWORK_PANEL2_IDX, CONTENT2_IDX, BREADCRUMB2_IDX, PAGE_IDX,
+ DIALOG_IDX, DIALOG_PARAMS_IDX,
];
assert_eq!(roster.len(), WIDGET_COUNT, "roster length vs WIDGET_COUNT");
for (i, idx) in roster.iter().enumerate() {
@@ -7123,4 +7125,231 @@ mod tests {
assert_eq!(copy.num_prims(), 4);
assert_eq!(d.num_prims(), 4, "the original is untouched");
}
+
+ // ----- The Alt+D dialog (src/dialog.rs) -----
+
+ /// A key, as the dialog's handler expects one.
+ fn key_press(key: Key) -> cce_ui::widget::KeyEvent {
+ cce_ui::widget::KeyEvent {
+ state: cce_ui::widget::ElementState::Pressed,
+ logical_key: key,
+ text: None,
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ }
+ }
+
+ fn typed(c: &str) -> cce_ui::widget::KeyEvent {
+ key_press(Key::Character(c.to_string()))
+ }
+
+ /// Every Settings row still names something that exists.
+ ///
+ /// The failure this catches is silent and the reason the table is a table:
+ /// `dialog_settings_params` SKIPS a row whose owning param it cannot find,
+ /// so renaming a subnet param quietly shortens the Settings half and
+ /// nothing says why. Same argument as
+ /// `test_every_menu_command_names_a_label_that_is_dispatched`.
+ #[test]
+ fn dialog_settings_rows_name_owners_that_exist() {
+ use crate::dialog::Owner;
+ let state = State::new(false);
+ let session = state.session_node().expect("the root meta node");
+ for s in crate::dialog::SETTINGS {
+ match s.owner {
+ None => {}
+ Some(Owner::Subnet(subnet, name)) => {
+ let node = session
+ .children
+ .iter()
+ .find(|c| c.name == subnet)
+ .unwrap_or_else(|| panic!("no '{subnet}' subnet for row '{}'", s.label));
+ assert!(
+ node.params.iter().any(|p| p.name == name),
+ "'{subnet}' has no param '{name}' — row '{}' would vanish",
+ s.label
+ );
+ }
+ Some(Owner::Command(id)) => assert!(
+ crate::command::by_id(id).is_some(),
+ "row '{}' names no command '{id}'",
+ s.label
+ ),
+ // The active camera's params exist only once a camera node
+ // does; the Default Camera branch is exercised below.
+ Some(Owner::ActiveCamera(_)) => {}
+ }
+ }
+ }
+
+ /// Row labels are the writeback's identity — `param_display` keys a row by
+ /// its label and `sync_dialog_settings_to_project` resolves it back the
+ /// same way — so two rows sharing one would write each other's values.
+ #[test]
+ fn dialog_settings_labels_are_unique() {
+ let mut seen: Vec<&str> = Vec::new();
+ for s in crate::dialog::SETTINGS {
+ assert!(!seen.contains(&s.label), "two Settings rows are called '{}'", s.label);
+ seen.push(s.label);
+ }
+ }
+
+ /// The dialog opens on its registry command, lists every command, and
+ /// closes on Escape.
+ #[test]
+ fn dialog_opens_on_its_command_and_escape_closes_it() {
+ let mut state = State::new(false);
+ assert!(!state.dialog_visible(), "closed until asked for");
+
+ assert!(state.run_command("toggle_dialog"));
+ assert!(state.dialog_visible());
+ assert_eq!(
+ state.slots.dialog.rows.len(),
+ crate::command::COMMANDS.len(),
+ "an empty query lists everything"
+ );
+
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+ assert!(!state.dialog_visible());
+ }
+
+ /// Typing filters, and Enter runs the row it landed on — then closes,
+ /// because a modal that stays up after acting hides what it just did.
+ #[test]
+ fn dialog_filters_as_you_type_and_enter_runs_the_selection() {
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+
+ for c in ["s", "q", "u", "a"] {
+ state.dialog_key_input(&typed(c));
+ }
+ assert_eq!(state.slots.dialog.query, "squa");
+ assert_eq!(
+ state.slots.dialog.selected_id(),
+ Some("toggle_square_viewport"),
+ "rows: {:?}",
+ state.slots.dialog.rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>()
+ );
+
+ let before = state.square_viewport;
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert_eq!(state.square_viewport, !before, "Enter ran the command");
+ assert!(!state.dialog_visible(), "and closed behind it");
+ }
+
+ /// Backspace walks the query back, and the ranking follows it.
+ #[test]
+ fn dialog_backspace_widens_the_filter() {
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ for c in ["z", "z", "z"] {
+ state.dialog_key_input(&typed(c));
+ }
+ assert!(state.slots.dialog.rows.is_empty(), "nothing matches 'zzz'");
+ for _ in 0..3 {
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Backspace)));
+ }
+ assert_eq!(state.slots.dialog.query, "");
+ assert_eq!(state.slots.dialog.rows.len(), crate::command::COMMANDS.len());
+ }
+
+ /// The dialog owns the keyboard outright while it is open.
+ ///
+ /// The network pane's bare-letter family is ungated by design, so typing
+ /// "e" into an unguarded filter would flip the selected node's geometry
+ /// toggle on the way past. The guard is the whole reason
+ /// `dialog_key_input` is total rather than a layer.
+ #[test]
+ fn dialog_keys_never_reach_the_pane_underneath() {
+ use crate::window::WindowEvent;
+ let mut state = State::new(false);
+ state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+ let col = state.grid_cursor_col;
+ state.run_command("toggle_dialog");
+
+ // "l" is Cursor Right in the network pane and a plain letter here.
+ state.handle_event(&WindowEvent::KeyboardInput { event: typed("l") });
+ assert_eq!(state.grid_cursor_col, col, "the grid cursor must not move");
+ assert_eq!(state.slots.dialog.query, "l");
+ }
+
+ /// Tab moves between the halves, and entering Settings lays its body out
+ /// and fills it.
+ #[test]
+ fn dialog_tab_switches_halves_and_settings_has_rows() {
+ use cce_ui::widget::WidgetHost as _;
+ use crate::dialog::Tab;
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ assert_eq!(state.dialog_tab(), Tab::Commands);
+
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Tab)));
+ assert_eq!(state.dialog_tab(), Tab::Settings);
+ assert!(state.slots.dialog_params.visible(), "the settings body shows");
+ assert!(
+ state.positions[crate::slots::DIALOG_PARAMS_IDX].2 > 0.0,
+ "and is laid out inside the dialog"
+ );
+ // Every non-section row of the table, minus the ones whose owner is
+ // missing in a fresh project — there are none, per the test above.
+ let rows = state.dialog_settings_shown.clone();
+ assert_eq!(rows.len(), crate::dialog::SETTINGS.len());
+ assert!(rows.iter().any(|(k, _, t)| k == "Show Grid" && t == "toggle"));
+ assert!(rows.iter().any(|(k, _, t)| k == "Grid Color" && t == "color"));
+ assert!(rows.iter().any(|(k, _, t)| k == "Grid Thickness" && t.starts_with("spinbox")));
+
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Tab)));
+ assert_eq!(state.dialog_tab(), Tab::Commands);
+ assert!(!state.slots.dialog_params.visible());
+ }
+
+ /// A Settings row writes to whatever OWNS its value, not to the live field
+ /// — which is the only write that survives, since
+ /// `apply_settings_from_menubar_subnets` copies the subnets over the live
+ /// state on every param change.
+ #[test]
+ fn dialog_settings_write_reaches_the_owning_subnet() {
+ use crate::dialog::Tab;
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ state.set_dialog_tab(Tab::Settings);
+
+ let was = state.viewport().show_grid;
+ // What a click on the toggle leaves behind: the control reports the
+ // flipped value, and the poll picks it up.
+ let mut rows = state.dialog_settings_shown.clone();
+ let row = rows.iter_mut().find(|(k, _, _)| k == "Show Grid").expect("the Show Grid row");
+ row.1 = if was { "false" } else { "true" }.to_string();
+ state.slots.dialog_params_mut().set_display_params(&rows);
+ state.sync_dialog_settings_to_project();
+
+ assert_eq!(state.viewport().show_grid, !was, "the live state followed");
+ let guides = state
+ .session_node()
+ .expect("meta")
+ .children
+ .iter()
+ .find(|c| c.name == "Guides")
+ .expect("Guides");
+ let p = guides.params.iter().find(|p| p.name == "Show Grid Guide").expect("the param");
+ assert_eq!(p.default == "true", !was, "and so did its owner");
+ }
+
+ /// Reopening starts clean: on Commands, with an empty query.
+ #[test]
+ fn dialog_reopens_without_the_last_search() {
+ use crate::dialog::Tab;
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ state.dialog_key_input(&typed("g"));
+ state.set_dialog_tab(Tab::Settings);
+ state.run_command("toggle_dialog");
+ assert!(!state.dialog_visible());
+
+ state.run_command("toggle_dialog");
+ assert_eq!(state.slots.dialog.query, "");
+ assert_eq!(state.dialog_tab(), Tab::Commands);
+ }
}
diff --git a/src/render.rs b/src/render.rs
index 5866dc8..7aa04e1 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -9,6 +9,7 @@ use crate::slots::{
BREADCRUMB_IDX, HEADER_IDX, RIGHT_MENUBAR_IDX,
SPREADSHEET_MENUBAR_IDX, SPREADSHEET_IDX,
LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX, PLAYBAR_IDX,
+ DIALOG_IDX, DIALOG_PARAMS_IDX,
};
use crate::geometry::network_sphere_vertices_with_errors;
use cce_ui::scene::layout::Rect;
@@ -169,6 +170,14 @@ impl State {
if !self.slots.get_dyn(i).visible() {
continue;
}
+ // The dialog is painted after the overlay passes below, not in the
+ // walk. A high z_index is not enough: `append_frame_text` and the
+ // viewport overlays run AFTER the whole walk, so the graph's node
+ // labels and the scale readout drew straight over a dialog that
+ // had already covered them.
+ if i == DIALOG_IDX || i == DIALOG_PARAMS_IDX {
+ continue;
+ }
unsafe {
self.paint_element(&*widget_ptrs[i], &mut pc, show_cursor, &mut visited, clip, clip_circle);
}
@@ -182,6 +191,9 @@ impl State {
self.append_popovers(&mut pc);
self.append_dock_drag_overlay(&mut pc);
self.append_plate_corners(&mut pc);
+ // Above every pane AND every overlay text pass, below only the context
+ // menu — which can be opened from inside it.
+ self.append_dialog(&mut pc, show_cursor, &mut visited, clip);
// The context menu (node/viewport right-click AND the plate corner
// menus — one shared state) floats above everything, drawn last as
@@ -264,6 +276,45 @@ impl State {
fill[3] = fill[3].abs();
pc.circle(cx, cy, r, fill);
pc.arc(cx, cy, r, 3.0, 0.0, TAU, [0.35, 0.65, 0.95, 0.80 * self.network_opacity]);
+ } else if idx == DIALOG_IDX {
+ // Modern-paint surface, the playbar's contract: the designer
+ // authors the plate (the dialog floats, so the pane radii and the
+ // focus tint do not apply — it is never a pane and never the
+ // focused one), then Dialog::paint emits the tab strip, the query
+ // line and the rows. A subtree painter, so append_frame_text skips
+ // the slot and the chord column keeps its own font and bounds.
+ append_widget_plate(w, pc);
+ w.paint_self(&self.ui_context, pc);
+ } else if idx == DIALOG_PARAMS_IDX {
+ // The dialog's settings body: PARAM_IDX's arm without the plate,
+ // because it is laid out INSIDE the dialog's plate and a second
+ // one would draw a panel on a panel. The scrollbar straddle goes
+ // with it — over a plate it is not straddling, it is just on top.
+ let (px, py, pw, ph) = self.positions[DIALOG_PARAMS_IDX];
+ let view = rect(px, py, pw, ph);
+ pc.clip(view, |pc| {
+ w.paint_self(&self.ui_context, pc);
+ });
+ let scrollbar = self
+ .slots
+ .dialog_params
+ .as_any()
+ .downcast_ref::<cce_ui::widget::ParametersBg>()
+ .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+ .scrollbar_visible()
+ .then(|| {
+ self.slots
+ .dialog_params
+ .as_any()
+ .downcast_ref::<cce_ui::widget::ParametersBg>()
+ .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+ .scrollbar_quads()
+ });
+ if let Some(quads) = scrollbar {
+ for &(qx, qy, qw, qh, qc) in &quads {
+ pc.rounded_rect(rect(qx, qy, qw, qh), qw.min(qh) * 0.5, (true, true, true, true), qc);
+ }
+ }
} else if idx == PLAYBAR_IDX {
// Modern-paint pane: the plate from the legacy views like the other
// panes, then paint_self emits the transport controls — geometry AND
@@ -720,7 +771,13 @@ impl State {
// pass already (see paint_widget: subtree text for the playbar
// and spreadsheet, the own-labels bridge for the params pane) —
// drawing them here again would double it.
- if is_menubar || i == PLAYBAR_IDX || i == PARAM_IDX || i == SPREADSHEET_IDX {
+ if is_menubar
+ || i == PLAYBAR_IDX
+ || i == PARAM_IDX
+ || i == SPREADSHEET_IDX
+ || i == DIALOG_IDX
+ || i == DIALOG_PARAMS_IDX
+ {
continue;
}
let is_node = i == CONTENT_IDX;
@@ -810,6 +867,72 @@ impl State {
}
}
+ /// The Alt+D dialog, last of the pane content: its plate and command list,
+ /// its settings body, and that body's popovers.
+ ///
+ /// Out of the widget walk entirely, because the walk is not the end of the
+ /// frame — `append_frame_text` and the viewport overlays follow it, and
+ /// they drew the graph's node labels and the scale readout straight over
+ /// a dialog whose z_index had already put it on top of the same panes.
+ /// Only the context menu goes above this, and it can be opened from inside
+ /// the dialog.
+ fn append_dialog(
+ &self,
+ pc: &mut PaintCtx,
+ show_cursor: bool,
+ visited: &mut [bool],
+ clip: Rect,
+ ) {
+ if !self.slots.dialog.visible() {
+ return;
+ }
+ self.paint_widget(DIALOG_IDX, pc, show_cursor, visited, clip, None);
+ if !self.slots.dialog_params.visible() {
+ return;
+ }
+ // The settings body is painted twice, on purpose.
+ //
+ // Its labels have to carry the DIALOG's bounds or the occluder the
+ // dialog registers (see `Dialog::popover`) clamps them away: the
+ // clamp's exemption is bounds that COINCIDE with the occluder, and a
+ // params row's bounds are its row's. The first pass lays down the
+ // controls — its labels land inside the occluder and are clamped to
+ // nothing, which is exactly what should happen to a row-bounded label
+ // under this plate. The second pass re-emits only the text, retagged
+ // with the dialog's bounds, which is what is actually read.
+ //
+ // Two passes rather than one because a `PaintCtx` cannot be handed a
+ // prim back: text can be re-emitted through `text_with`, geometry
+ // cannot, so the geometry has to come from a pass that writes
+ // straight into `pc`. It costs a dozen labels' shaping while the
+ // Settings half is open.
+ let (dx, dy, dw, dh) = self.positions[DIALOG_IDX];
+ let own = Some([dx, dy, dx + dw, dy + dh]);
+ self.paint_widget(DIALOG_PARAMS_IDX, pc, show_cursor, visited, clip, None);
+ let mut scratch = PaintCtx::new();
+ visited[DIALOG_PARAMS_IDX] = false;
+ self.paint_widget(DIALOG_PARAMS_IDX, &mut scratch, show_cursor, visited, clip, None);
+ for item in scratch.finish().items {
+ if let Prim::Text { text, x, y, font_size, color, font, .. } = item.prim {
+ pc.text_with(text, x, y, font_size, color, font, own);
+ }
+ }
+
+ let mut popover_pc = cce_ui::layout::PopoverCollector::new();
+ self.slots.dialog_params.render_popover(&mut popover_pc);
+ for (color, px, py, pw, ph) in popover_pc.rects {
+ pc.quad(rect(px, py, pw, ph), color);
+ }
+ for (t, size, x, y, tc, font_opt, _) in popover_pc.texts {
+ let color = [
+ (tc[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+ (tc[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+ (tc[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+ ];
+ pc.text_with(t, x, y, size, color, font_opt, own);
+ }
+ }
+
fn append_popovers(&self, pc: &mut PaintCtx) {
for i in 0..WIDGET_COUNT {
let w = self.slots.get_dyn(i);
@@ -820,6 +943,11 @@ impl State {
if is_menubar {
continue;
}
+ if i == DIALOG_PARAMS_IDX {
+ // Drawn by `append_dialog`, after this pass: a popover of the
+ // dialog's belongs above the dialog, not under it.
+ continue;
+ }
if self.focused_widget == Some(i) || i == PARAM_IDX {
let mut popover_pc = cce_ui::layout::PopoverCollector::new();
w.render_popover(&mut popover_pc);
diff --git a/src/shortcut.rs b/src/shortcut.rs
index 30e451f..0f31e1d 100644
--- a/src/shortcut.rs
+++ b/src/shortcut.rs
@@ -25,6 +25,9 @@ pub enum Action {
/// Open the command palette — a command like any other, so it is
/// rebindable and lists itself.
CommandPalette,
+ /// Open or close the in-app dialog (`src/dialog.rs`): the same commands,
+ /// plus the viewport/graph settings, without leaving the window.
+ ToggleDialog,
/// Snap dragged handles in the active viewer state to a world increment.
ToggleSnap,
/// Enter or leave the selected node's viewer state.
diff --git a/src/slots.rs b/src/slots.rs
index 94b46e2..eec8f86 100644
--- a/src/slots.rs
+++ b/src/slots.rs
@@ -117,6 +117,12 @@ widget_roster! {
// its place when the displayed level holds a page. Appended, like the
// second network editor, so established slot indexes stay stable.
PAGE_IDX: page_view: ImageView,
+ // The Alt+D dialog: the frame (plate, tabs, query line, command list) and
+ // its settings body, a second ParametersBg so a control in the dialog is
+ // the same control as one in the params pane. Appended, like every slot
+ // since the second network editor, so established indexes stay stable.
+ DIALOG_IDX: dialog: crate::dialog::Dialog,
+ DIALOG_PARAMS_IDX: dialog_params: ParametersBg,
}
impl WidgetSlots {
@@ -183,6 +189,24 @@ impl WidgetSlots {
self.spreadsheet.as_any_mut().downcast_mut::<Spreadsheet>().expect("SPREADSHEET_IDX must be a Spreadsheet")
}
+ /// The dialog's settings body. A `ParametersBg` like `PARAM_IDX`, reached
+ /// through the same controller trait — the writeback in
+ /// `sync_dialog_settings_to_project` is the params pane's, pointed at the
+ /// dialog's row table instead of the selected node's params.
+ pub fn dialog_params(&self) -> &dyn cce_ui::widget::ParamController {
+ self.dialog_params
+ .as_any()
+ .downcast_ref::<ParametersBg>()
+ .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+ }
+
+ pub fn dialog_params_mut(&mut self) -> &mut dyn cce_ui::widget::ParamController {
+ self.dialog_params
+ .as_any_mut()
+ .downcast_mut::<ParametersBg>()
+ .expect("DIALOG_PARAMS_IDX must be a ParametersBg")
+ }
+
pub fn path_mut(&mut self) -> &mut dyn cce_ui::widget::PathController {
self.breadcrumb.as_any_mut().downcast_mut::<Breadcrumb>().expect("BREADCRUMB_IDX must be a Breadcrumb")
}