graphic design tool
git clone https://git.lucas.co/cce-designer.git
refactor: retire the cce-cloud popups; every picker is the plate dialog
The add-node palette (Tab) and the command palette (Ctrl+P) each spawned
`cce-cloud --dmenu`: a second process with its own window, handed one
line of text per row on stdin and answering with one line on stdout.
Both are now openings of the in-app dialog.
Dialog gains a Mode. Tabbed is Alt+D — the tab strip over Commands and
Settings, and where Ctrl+P lands (on Commands specifically, which is the
one difference between the two registry rows). AddNode is Tab in the
network pane: one list of templates, a title where the strip goes, and a
pick that instantiates at the grid cursor. One widget rather than two
because the app used to put two filterable lists in front of the user
that looked and behaved nothing alike.
Everything awkward about the old palettes followed from the pipe. A
chord had to be padded into the label to fake a column, since a tab
rendered as one literal stop; the answer had to be matched back by the
LONGEST label a row starts with, since "Save" prefixes "Save As"'s row.
palette_row/from_palette_row go with them. fuzzy_rank and
palette_entries stay — the ranking was never the problem, and node
templates now rank through the same fuzzy_rank the commands do.
Retiring them took more scaffolding than expected: CloudPopupTracker,
the CloudSpawned/CloudClosed pid-adoption events, the
RunCommand(&'static str) event that existed only because the popup ran
on its own thread and could not touch State, and the libc dependency,
whose one use was kill()ing a stray popup.
active_menu_cloud_pid/_idx and the menu_closed MCP tool went with them.
They were already dead — left from a retired attempt at menubar
dropdowns over cce-cloud, with nothing anywhere setting them to Some.
cce_ui::process::CloudPopup itself is untouched: the designer was its
only consumer in the workspace, so it is now unused public API in a
shared crate, which is a coordination job of its own.
Verified in a headless shadow at output scale 2: Tab opens Add Node,
"box" filters to Box, Enter lands Box 1 at the grid cursor, Ctrl+P
opens the same plate on Commands, and no cce-cloud process is spawned
for any of it.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 77 ++++++++++++-----
Cargo.lock | 1 -
Cargo.toml | 1 -
src/api.rs | 14 +---
src/app.rs | 138 ++-----------------------------
src/dialog.rs | 259 +++++++++++++++++++++++++++++++++++++++++++++-------------
src/main.rs | 130 +++++++++++++++++++++++++++++
src/window.rs | 18 ----
8 files changed, 398 insertions(+), 240 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 6b82934..e14a073 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -726,19 +726,28 @@ keypress at the keyboard and two distinct values in memory — and the collision
detector quietly failed to report exactly the collision it exists to catch. Both
now go through one `same_key`.
-**The palette** is the node palette's mechanism, not a new widget: the same
-`cce-cloud --dmenu` popup at the cursor, with the same keys. Two pickers in one
-app that look and behave differently is worse than either. Ranking is
-`fuzzy_rank`, which reproduces the plugin's fuzzyfinder exactly — shortest
-contiguous span, then earliest start, then alphabetical — so muscle memory
-survives the move; the focused pane's commands are then partitioned to the
-front, stably, without dropping anything (a palette that hides what you are
-looking for is worse than one that lists it second). Rows are the label padded
-to a column and then its chord, so the palette teaches the keyboard rather than
-replacing it; padded rather than tab-separated because the popup renders a tab
-as one literal stop and the chords came out ragged. A row is matched back to its
-command by the LONGEST label it starts with, since "Save" starts "Save As"'s
-row.
+**The palette** is the dialog's Commands half (`src/dialog.rs`, below), not a
+widget of its own. Ranking is `fuzzy_rank`, which reproduces the plugin's
+fuzzyfinder exactly — shortest contiguous span, then earliest start, then
+alphabetical — so muscle memory survives; the focused pane's commands are then
+partitioned to the front, stably, without dropping anything (a palette that
+hides what you are looking for is worse than one that lists it second). Each row
+carries its chord in a column of its own, so the palette teaches the keyboard
+rather than replacing it.
+
+It was a `cce-cloud --dmenu` popup until 2026-09-19: a second PROCESS with its
+own window, handed one line of text per row on stdin and answering with one line
+on stdout. Everything awkward about it followed from that pipe — the chord had
+to be padded into the label to fake a column (a tab rendered as one literal
+stop, so they came out ragged), and the answer had to be matched back to a
+command by the LONGEST label the row starts with, since "Save" is a prefix of
+"Save As"'s row. `palette_row` / `from_palette_row` were that encode/decode pair
+and are gone with it; `fuzzy_rank` and `palette_entries` survive, because the
+ranking was never the problem.
+
+`command_palette` (Ctrl+P) and `toggle_dialog` (Alt+D) both reach the same
+dialog and differ in exactly one way, which is the reason both rows exist:
+Ctrl+P LANDS on Commands, Alt+D toggles the dialog as a whole.
`test_every_menu_command_names_a_label_that_is_dispatched` scans `app.rs` for
`execute_menu_action`'s arms. Scanning source is an odd way to assert it, but
@@ -747,17 +756,31 @@ 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)
+### The dialog (Alt+D, Ctrl+P, Tab)
-`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,
+`src/dialog.rs` is the app's one modal overlay, and **every filterable list in
+the designer is now an opening of it**. It is two roster slots — `DIALOG_IDX`,
+an app-owned `Dialog` that paints the plate, the header, the query line and the
+row 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.
+`Mode` says what an opening is for, and it is the reason there is one widget
+rather than two:
+
+- `Mode::Tabbed` (**Alt+D**, and **Ctrl+P** onto Commands) — a tab strip over
+ two halves. **Commands** is the registry fuzzy-filtered in place;
+ **Settings** is the viewport/graph display state `DesignSettings` persists.
+- `Mode::AddNode` (**Tab**, in the network pane) — one list of node templates,
+ a title where the strip goes, and a pick that instantiates at the grid
+ cursor. No tabs: adding a node is a contextual act, not a peer of the app's
+ settings. Tab is what opened it, so Tab closes it again.
+
+Both modes share the plate, the keys and `fuzzy_rank`, which is the whole
+point — the app used to put two filterable lists in front of the user that
+looked and behaved nothing alike.
+
**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
@@ -814,6 +837,20 @@ 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.
+**Nothing in this crate shells out to `cce-cloud` any more**, and
+`nothing_shells_out_to_cce_cloud_any_more` scans the source to keep it that
+way. Retiring the two popups took a surprising amount of scaffolding with
+them: `CloudPopupTracker` (the single-active-popup toggle bookkeeping), the
+`CloudSpawned` / `CloudClosed` events that adopted a popup's pid, the
+`RunCommand(&'static str)` event that existed because the popup ran on its own
+thread and could not touch `State`, and the `libc` dependency, whose only use
+was `kill`ing a stray popup. `active_menu_cloud_pid` / `_idx` and the
+`menu_closed` MCP tool went too — they were already dead, left from a retired
+attempt at menubar dropdowns over `cce-cloud`, and nothing had set them to
+`Some` in a long time. `cce_ui::process::CloudPopup` itself still exists; the
+designer was its only consumer, so it is now unused public API in a shared
+crate, which is a coordination job of its own.
+
### Runtime paths point into the source tree
Node templates (`nodes/*.json`) and `default_project.json` are located via
diff --git a/Cargo.lock b/Cargo.lock
index dd81938..56b1cc2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -408,7 +408,6 @@ dependencies = [
"cce-ui",
"env_logger",
"glam",
- "libc",
"log",
"opencl3",
"png",
diff --git a/Cargo.toml b/Cargo.toml
index 7c7c0cf..a7e06e7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -19,7 +19,6 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
opencl3 = "0.9"
-libc = "0.2"
log = "0.4"
env_logger = "0.11"
png = "0.17"
diff --git a/src/api.rs b/src/api.rs
index 10fe5ed..67b2a77 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -238,21 +238,9 @@ pub(crate) fn mcp_tools() -> Vec<McpTool> {
"required": ["widget_idx", "menu_idx", "item_idx"],
}),
),
- tool(
- "menu_closed",
- "Notify that a menu cloud was closed (clears the active menu-cloud state).",
- json!({
- "type": "object",
- "properties": {
- "widget_idx": { "type": "integer" },
- "menu_idx": { "type": "integer" },
- },
- "required": ["widget_idx", "menu_idx"],
- }),
- ),
tool(
"run_command",
- "Run a command by its registry id (e.g. \"command_palette\", \"toggle_grid\", \"save_document\") — every command the palette lists, including the ones no menu label reaches.",
+ "Run a command by its registry id (e.g. \"toggle_dialog\", \"toggle_grid\", \"save_document\") — every command the dialog lists, including the ones no menu label reaches.",
json!({
"type": "object",
"properties": { "id": { "type": "string", "description": "Command id, snake_case, as input.kdl binds it" } },
diff --git a/src/app.rs b/src/app.rs
index 8564a9e..cc1656f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -364,7 +364,6 @@ pub enum McpAction {
DeleteParam { slot: usize, name: String },
ToggleCircularPane,
MenuClick { widget_idx: usize, menu_idx: usize, item_idx: usize },
- MenuClosed { widget_idx: usize, menu_idx: usize },
/// Execute a label-matched menu-pane action ("Show Spreadsheet Pane", "Save", ...)
/// — the items `menu_click`'s index-matched menubar dispatch cannot reach.
MenuAction { label: String },
@@ -393,17 +392,9 @@ pub enum CustomEvent {
/// An MCP `tools/call` from the embedded MCP server (carries its own
/// reply channel) — the tool name is an `McpAction` tag, or `get_state`.
McpCall(cce_ui::mcp::McpToolCall),
- /// A command chosen in the palette, which runs on its own thread and so
- /// cannot touch `State` — it sends the id back to the event loop instead.
- RunCommand(&'static str),
/// A fire-and-forget action from an app-internal thread (the cce-files
/// choosers deliver their picked path this way).
RunAction(McpAction),
- /// A cce-cloud popup thread announced its process (CloudPopupTracker
- /// adoption — the add-node palette).
- CloudSpawned { pid: u32, source: String },
- /// A cce-cloud popup thread reported its popup closed.
- CloudClosed { pid: u32, source: String },
/// App-requested exit (menu File > Exit, MCP menu_action): the engine's
/// update hook is the only place with exit access, so input handlers that
/// see `exit_requested` route it here.
@@ -1102,10 +1093,6 @@ pub struct State {
/// 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).
- pub cloud_popups: cce_ui::process::CloudPopupTracker,
-
/// The node right-click context menu: the targeted node slot and the
/// actions parallel to the visible items pushed into `context_menu::show`.
/// `None` when no menu is open. The menu's geometry/paint lives in the
@@ -1261,8 +1248,6 @@ pub struct State {
pub last_project_check: std::time::Instant,
pub needs_autosave: bool,
pub last_autosave_time: std::time::Instant,
- pub active_menu_cloud_pid: Option<u32>,
- pub active_menu_cloud_idx: Option<(usize, usize)>,
/// The drop-target glow's animation state: position glides toward the
/// cell an in-flight node drag will land on, alpha fades in on drag
/// start and out after release (the glow lingers at its last cell while
@@ -3090,49 +3075,6 @@ impl State {
});
}
- /// The add-node palette, as a `cce-cloud --dmenu` popup: toggle-tracked like the
- /// status bar's popups, positioned at the pointer and parented to the
- /// designer surface. The picked template comes back through the event
- /// loop as a fire-and-forget `AddNode` at the grid cursor.
- pub fn open_node_palette(&mut self) {
- const SOURCE: &str = "node-palette";
- if self.cloud_popups.click(SOURCE) == cce_ui::process::CloudPopupClick::ToggledOff {
- return;
- }
- let Some(sender) = self.event_sender.clone() else { return };
- // In a utility dir geometry templates are rejected at placement —
- // don't offer them.
- let in_utility = self.in_settings_dir();
- let items: String = self
- .node_templates
- .iter()
- .filter(|t| !in_utility || !crate::geometry::is_geometry_node_type(&t.node.node_type))
- .map(|t| t.label.as_str())
- .collect::<Vec<_>>()
- .join("\n");
- let (px, py) = (self.cursor_x as i32, self.cursor_y as i32);
- let (gx, gy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
- std::thread::spawn(move || {
- let popup = cce_ui::process::CloudPopup::at(px, py).parent_app_id("cce-designer");
- let mut spawned_pid = 0;
- let result = popup.run_dmenu("Add Node:", &items, |pid| {
- spawned_pid = pid;
- let _ = sender.send(CustomEvent::CloudSpawned { pid, source: SOURCE.to_string() });
- });
- if let Ok(Some(selected)) = &result {
- if !selected.is_empty() {
- let _ = sender.send(CustomEvent::RunAction(McpAction::AddNode {
- template_name: selected.clone(),
- name: None,
- x: gx,
- y: gy,
- }));
- }
- }
- let _ = sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: SOURCE.to_string() });
- });
- }
-
/// Open the node right-click context menu at the cursor for `slot`. The
/// items are contextual: Enter (dive into the subnet) for enterable nodes,
/// Show/Hide Geometry for non-utility nodes, and Delete always.
@@ -4188,7 +4130,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
positions,
dialog_settings_shown: Vec::new(),
splitter_layout,
- cloud_popups: cce_ui::process::CloudPopupTracker::new(),
node_menu_slot: None,
node_menu_actions: Vec::new(),
viewport_menu_active: false,
@@ -4277,8 +4218,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
last_project_check: std::time::Instant::now(),
needs_autosave: false,
last_autosave_time: std::time::Instant::now(),
- active_menu_cloud_pid: None,
- active_menu_cloud_idx: None,
// Cells and gaps render as ONE surface (the graph's bg_color is
// the cell tint): the checkerboard grout is off by design; the
// drop-target glow (render.rs) carries the only cell highlight.
@@ -5239,56 +5178,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
- /// The command palette: every command, fuzzy-searched, focused pane first.
- ///
- /// It is the node palette's mechanism rather than a new widget — the same
- /// `cce-cloud --dmenu` popup, at the cursor, with the same keys — because
- /// two pickers in one app that look and behave differently is worse than
- /// either. The proposal's point about Houdini was that a palette should not
- /// exist to work around a missing API; it does not, here.
- pub fn open_command_palette(&mut self) {
- const SOURCE: &str = "command-palette";
- if self.cloud_popups.click(SOURCE) == cce_ui::process::CloudPopupClick::ToggledOff {
- return;
- }
- let Some(sender) = self.event_sender.clone() else { return };
- // Ranked here, not in the popup: the popup filters as you type, but the
- // ORDER it starts from is ours, and that is where the focused pane's
- // commands come first.
- let entries = crate::command::palette_entries("", self.focused_context());
- // The chord goes on the row so the palette teaches the keyboard rather
- // than replacing it. Padded to a column rather than separated by a tab:
- // the popup renders a tab as one literal tab stop, so the chords came
- // out ragged and stopped reading as a column at all.
- let width = entries.iter().map(|c| c.label.len()).max().unwrap_or(0) + 2;
- let items: String = entries
- .iter()
- .map(|c| {
- let chord = self.shortcut_manager.chord_for(c.id).map(|s| s.describe());
- crate::command::palette_row(c.label, chord, width)
- })
- .collect::<Vec<_>>()
- .join("\n");
- let (px, py) = (self.cursor_x as i32, self.cursor_y as i32);
- std::thread::spawn(move || {
- let popup = cce_ui::process::CloudPopup::at(px, py).parent_app_id("cce-designer");
- let mut spawned_pid = 0;
- let result = popup.run_dmenu("Command:", &items, |pid| {
- spawned_pid = pid;
- let _ = sender.send(CustomEvent::CloudSpawned { pid, source: SOURCE.to_string() });
- });
- if let Ok(Some(selected)) = &result {
- // The row is the label padded out, then its chord. Match the
- // LONGEST label the row starts with, so "Save" cannot claim a
- // row that belongs to "Save As".
- if let Some(cmd) = crate::command::from_palette_row(selected) {
- let _ = sender.send(CustomEvent::RunCommand(cmd.id));
- }
- }
- let _ = sender.send(CustomEvent::CloudClosed { pid: spawned_pid, source: SOURCE.to_string() });
- });
- }
-
/// The command context of the focused pane, for palette ranking.
pub fn focused_context(&self) -> crate::command::Context {
use crate::command::Context;
@@ -5576,7 +5465,12 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
pub fn execute_action(&mut self, action: Action) {
let mut settings_changed = false;
match action {
- Action::CommandPalette => self.open_command_palette(),
+ // Ctrl+P lands on Commands specifically, where Alt+D toggles the
+ // dialog as a whole — the one difference between the two rows.
+ Action::CommandPalette => {
+ self.open_dialog();
+ self.set_dialog_tab(crate::dialog::Tab::Commands);
+ }
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
@@ -6453,26 +6347,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
}
- let hits_any_menu = (0..WIDGET_COUNT).any(|i| {
- hits_widget(self, i, self.cursor_x, self.cursor_y)
- && self.menubar_at(i).and_then(|m| m.get_menu_items_at(self.cursor_x, self.cursor_y)).is_some()
- });
-
- if !hits_any_menu {
- if let Some(pid) = self.active_menu_cloud_pid {
- let is_running = unsafe {
- libc::kill(pid as libc::pid_t, 0) == 0
- };
- if is_running {
- unsafe {
- libc::kill(pid as libc::pid_t, libc::SIGTERM);
- }
- }
- self.active_menu_cloud_pid = None;
- self.active_menu_cloud_idx = None;
- }
- }
-
if *button == MouseButton::Left && self.circular_network_pane {
// A border press focuses the pane and consumes. It used to also arm
// a NETWORK_PANEL_IDX widget drag, but PassivePlate has no drag
diff --git a/src/dialog.rs b/src/dialog.rs
index 472f1ae..7613dee 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -45,15 +45,35 @@ impl Tab {
}
}
-/// One command row, resolved: the registry id to run, plus what to draw.
+/// What the dialog was opened to do.
+///
+/// One widget, two entry points, because a picker and a settings page are the
+/// same plate with the same keys — what differs is the strip at the top and
+/// what a row MEANS. Splitting them into two widgets is how an app ends up
+/// with two filterable lists that behave differently, which is the thing this
+/// dialog replaced.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+ /// `Alt+D`: the tab strip, Commands and Settings.
+ Tabbed,
+ /// `Tab` in the network pane: one list, a title where the strip goes, and
+ /// a pick that instantiates a node template at the grid cursor. No tabs —
+ /// adding a node is a contextual act, not a peer of the app's settings.
+ AddNode,
+}
+
+/// One row: what picking it means, plus what to draw.
#[derive(Debug, Clone)]
pub struct Row {
- pub id: &'static str,
+ /// What the app does with this row: a command id in [`Mode::Tabbed`], a
+ /// node template's name in [`Mode::AddNode`]. Owned rather than
+ /// `&'static str` because a template name is read off disk.
+ pub id: String,
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.
+ /// The chord as a human reads it, empty when there is none. Drawn in its
+ /// own right-hand column so the dialog teaches the keyboard rather than
+ /// replacing it — which the `cce-cloud` palette could only approximate by
+ /// padding the label out, since all it could send was one line of text.
pub chord: String,
}
@@ -125,6 +145,7 @@ pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
}
pub struct Dialog {
+ pub mode: Mode,
pub tab: Tab,
/// What has been typed into the Commands half's filter.
pub query: String,
@@ -142,7 +163,7 @@ pub struct Dialog {
hover_row: Option<usize>,
hover_tab: Option<Tab>,
/// A row the pointer activated, drained by the app.
- activated: Option<&'static str>,
+ activated: Option<String>,
/// 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
@@ -155,6 +176,7 @@ pub struct Dialog {
impl Dialog {
pub fn new() -> Adapted<Dialog> {
let mut d = Adapted::new(Dialog {
+ mode: Mode::Tabbed,
tab: Tab::Commands,
query: String::new(),
rows: Vec::new(),
@@ -187,6 +209,12 @@ impl Dialog {
self.occluding = on;
}
+ /// Whether the body is the filterable row list — everything but the
+ /// Settings half, which hands its body to `DIALOG_PARAMS_IDX`.
+ pub fn shows_list(&self) -> bool {
+ self.mode == Mode::AddNode || self.tab == Tab::Commands
+ }
+
fn row_rect(&self, rect: Rect, i: usize) -> Option<Rect> {
let list = list_rect(rect);
if i < self.scroll {
@@ -238,12 +266,12 @@ impl Dialog {
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)
+ /// What Enter would pick.
+ pub fn selected_id(&self) -> Option<&str> {
+ self.rows.get(self.selected).map(|r| r.id.as_str())
}
- pub fn take_activated(&mut self) -> Option<&'static str> {
+ pub fn take_activated(&mut self) -> Option<String> {
self.activated.take()
}
@@ -348,9 +376,23 @@ impl Paint for Dialog {
display::truncate_tail(text, cols)
};
+ // --- The header. In AddNode there are no halves to move between, so
+ // the strip's band carries a title instead: the same plate, saying
+ // what this opening of it is for.
+ if self.mode == Mode::AddNode {
+ let strip = tab_strip(rect);
+ let title = "Add Node";
+ let tw = display::measure_text_width(title, &family, font_size);
+ let tx = strip.x + (strip.width - tw) * 0.5;
+ let ty = cce_ui::layout::align_text_y(strip.y, strip.height, font_size, 0.0);
+ ctx.text_with(title, tx, ty, font_size, [0xf0, 0xf0, 0xf6], Some(family.clone()), own);
+ }
// --- 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 {
+ if self.mode != Mode::Tabbed {
+ break;
+ }
let r = tab_rect(rect, tab);
let active = tab == self.tab;
// Active wears the focus language the rest of the app uses for
@@ -379,7 +421,7 @@ impl Paint for Dialog {
// 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 {
+ if self.mode == Mode::Tabbed && self.tab == Tab::Settings {
return;
}
@@ -392,7 +434,13 @@ impl Paint for Dialog {
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);
+ let hint = fit(
+ match self.mode {
+ Mode::Tabbed => "Type to filter commands",
+ Mode::AddNode => "Type to filter nodes",
+ },
+ 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
@@ -414,7 +462,11 @@ impl Paint for Dialog {
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);
+ let empty = match self.mode {
+ Mode::Tabbed => "No matching command",
+ Mode::AddNode => "No matching node",
+ };
+ ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
return;
}
for i in self.scroll..self.rows.len() {
@@ -472,14 +524,16 @@ impl Input for Dialog {
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.mode == Mode::Tabbed {
+ if let Some(tab) = self.tab_at(rect, *x, *y) {
+ self.tab_click = Some(tab);
+ return true;
+ }
}
- if self.tab == Tab::Commands {
+ if self.shows_list() {
if let Some(i) = self.row_at(rect, *x, *y) {
self.selected = i;
- self.activated = self.rows.get(i).map(|r| r.id);
+ self.activated = self.rows.get(i).map(|r| r.id.clone());
return true;
}
}
@@ -488,15 +542,15 @@ impl Input for Dialog {
true
}
Event::PointerMove { x, y, .. } => {
- let row = self.row_at(rect, *x, *y);
- let tab = self.tab_at(rect, *x, *y);
+ let row = self.shows_list().then(|| self.row_at(rect, *x, *y)).flatten();
+ let tab = (self.mode == Mode::Tabbed).then(|| self.tab_at(rect, *x, *y)).flatten();
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() {
+ if !self.shows_list() || self.rows.is_empty() {
return false;
}
let lines = match delta {
@@ -633,25 +687,54 @@ impl State {
}
pub fn toggle_dialog(&mut self) {
- if self.dialog_visible() {
+ if self.dialog_visible() && self.slots.dialog.mode == Mode::Tabbed {
self.close_dialog();
} else {
self.open_dialog();
}
}
+ /// Open the tabbed dialog on Commands — `Alt+D`, and what `Ctrl+P` now
+ /// reaches instead of spawning a popup process.
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.open_dialog_in(Mode::Tabbed);
+ }
+
+ /// The add-node palette: the same plate, one list, and a pick that
+ /// instantiates a template at the grid cursor.
+ ///
+ /// Was a `cce-cloud --dmenu` popup — a second process with its own
+ /// window, fed one line of text per row and answering with one line back.
+ /// It could not show a chord in a column of its own, could not be styled
+ /// with the app, and put a second filterable list in front of the user
+ /// that looked nothing like the first.
+ pub fn open_node_palette(&mut self) {
+ if self.dialog_visible() && self.slots.dialog.mode == Mode::AddNode {
+ self.close_dialog();
+ return;
+ }
+ self.open_dialog_in(Mode::AddNode);
+ }
+
+ fn open_dialog_in(&mut self, mode: Mode) {
+ // Always with an empty query, and the tabbed mode always on Commands:
+ // 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.mode = mode;
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();
+ if mode == Mode::Tabbed {
+ self.refresh_dialog_settings();
+ }
self.rebuild_positions();
self.apply_layout();
- self.update_status_text("Dialog: type to filter, Tab switches halves, Escape closes.");
+ self.update_status_text(match mode {
+ Mode::Tabbed => "Dialog: type to filter, Tab switches halves, Escape closes.",
+ Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
+ });
}
pub fn close_dialog(&mut self) {
@@ -671,7 +754,7 @@ impl State {
}
pub fn set_dialog_tab(&mut self, tab: Tab) {
- if self.slots.dialog.tab == tab {
+ if self.slots.dialog.mode != Mode::Tabbed || self.slots.dialog.tab == tab {
return;
}
self.slots.dialog.tab = tab;
@@ -686,24 +769,52 @@ impl State {
self.apply_layout();
}
- /// Re-rank the Commands half against the current query.
+ /// Re-rank the row list against the current query, for whichever mode is
+ /// up.
///
- /// 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.
+ /// Commands rank through [`crate::command::palette_entries`] — the fuzzy
+ /// rank plus the focused-pane-first partition. Node templates rank
+ /// through the same [`crate::command::fuzzy_rank`], so typing means the
+ /// same thing in both lists; they carry no chord, so the column is simply
+ /// empty for them.
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 query = self.slots.dialog.query.clone();
+ let rows: Vec<Row> = match self.slots.dialog.mode {
+ Mode::Tabbed => crate::command::palette_entries(&query, self.focused_context())
+ .iter()
+ .map(|c| Row {
+ id: c.id.to_string(),
+ label: c.label.to_string(),
+ chord: self
+ .shortcut_manager
+ .chord_for(c.id)
+ .map(|s| s.describe())
+ .unwrap_or_default(),
+ })
+ .collect(),
+ Mode::AddNode => {
+ // In a utility dir geometry templates are rejected at
+ // placement — don't offer them.
+ let in_utility = self.in_settings_dir();
+ let offered: Vec<&str> = self
+ .node_templates
+ .iter()
+ .filter(|t| {
+ !in_utility
+ || !crate::geometry::is_geometry_node_type(&t.node.node_type)
+ })
+ .map(|t| t.label.as_str())
+ .collect();
+ crate::command::fuzzy_rank(&query, &offered)
+ .into_iter()
+ .map(|i| Row {
+ id: offered[i].to_string(),
+ label: offered[i].to_string(),
+ chord: String::new(),
+ })
+ .collect()
+ }
};
- 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);
}
@@ -814,7 +925,7 @@ impl State {
/// 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 {
+ if !self.dialog_visible() || self.slots.dialog.shows_list() {
return;
}
let updated = self.slots.dialog_params().node_params();
@@ -946,15 +1057,25 @@ impl State {
}
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);
+ // two, so Tab and Shift+Tab are the same move. In AddNode
+ // there are no halves — and Tab is what OPENED it, so the
+ // same key closes it again.
+ if self.slots.dialog.mode == Mode::AddNode {
+ self.close_dialog();
+ } else {
+ 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 {
+ if !self.slots.dialog.shows_list() {
// 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.
@@ -991,8 +1112,8 @@ impl State {
self.slots.dialog.scroll_to_selected();
}
Key::Named(NamedKey::Enter) => {
- if let Some(id) = self.slots.dialog.selected_id() {
- self.run_dialog_command(id);
+ if let Some(id) = self.slots.dialog.selected_id().map(str::to_string) {
+ self.take_dialog_pick(id);
}
}
Key::Named(NamedKey::Backspace) => {
@@ -1026,10 +1147,38 @@ impl State {
/// `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) {
+ pub(crate) fn take_dialog_pick(&mut self, id: String) {
+ let mode = self.slots.dialog.mode;
+ let (gx, gy) = (self.grid_cursor_col as f32, self.grid_cursor_row as f32);
self.close_dialog();
- if id != "toggle_dialog" {
- self.run_command(id);
+ match mode {
+ // Not `toggle_dialog`: toggling here would reopen what was just
+ // closed. Picking the dialog's own row is a no-op, which is the
+ // least surprising thing it could be.
+ Mode::Tabbed => {
+ if id != "toggle_dialog" {
+ self.run_command(&id);
+ }
+ }
+ // Fire-and-forget at the grid cursor, exactly as the popup's
+ // answer used to arrive — read BEFORE the close, since closing
+ // relays the panes.
+ Mode::AddNode => {
+ let mut redraw = false;
+ let action = crate::app::McpAction::AddNode {
+ template_name: id,
+ name: None,
+ x: gx,
+ y: gy,
+ };
+ if let Err(e) = self.apply_action(action, &mut redraw) {
+ // The one refusal this can hit is a geometry template in
+ // a utility dir, which `refresh_dialog_rows` already
+ // filters out — but the rule lives in `apply_action`, so
+ // say what it said rather than assume it cannot fire.
+ self.update_status_text(&e);
+ }
+ }
}
}
@@ -1042,7 +1191,7 @@ impl State {
changed = true;
}
if let Some(id) = self.slots.dialog.take_activated() {
- self.run_dialog_command(id);
+ self.take_dialog_pick(id);
changed = true;
}
changed
diff --git a/src/main.rs b/src/main.rs
index e34e258..7a6da9a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7352,4 +7352,134 @@ mod tests {
assert_eq!(state.slots.dialog.query, "");
assert_eq!(state.dialog_tab(), Tab::Commands);
}
+
+ /// Tab opens the same plate in its AddNode mode: one list of node
+ /// templates, no tab strip, no chord column.
+ #[test]
+ fn dialog_add_node_mode_lists_the_templates() {
+ use cce_ui::widget::WidgetHost as _;
+ use crate::dialog::Mode;
+ let mut state = State::new(false);
+ state.open_node_palette();
+
+ assert!(state.dialog_visible());
+ assert_eq!(state.slots.dialog.mode, Mode::AddNode);
+ assert!(!state.slots.dialog_params.visible(), "no settings body in this mode");
+ assert_eq!(state.slots.dialog.rows.len(), state.node_templates.len());
+ assert!(
+ state.slots.dialog.rows.iter().all(|r| r.chord.is_empty()),
+ "a template has no chord to teach"
+ );
+
+ // Tab is what opened it, so Tab closes it again rather than looking
+ // for a second half that is not there.
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Tab)));
+ assert!(!state.dialog_visible());
+ }
+
+ /// Typing filters the templates through the SAME `fuzzy_rank` the command
+ /// half uses, and Enter instantiates at the grid cursor.
+ #[test]
+ fn dialog_add_node_filters_and_adds_at_the_cursor() {
+ let mut state = State::new(false);
+ state.focused_pane = crate::slots::LEFT_MENUBAR_IDX;
+ state.grid_cursor_col = 3;
+ state.grid_cursor_row = 2;
+ let before = state.current_dir().children.len();
+
+ state.open_node_palette();
+ for c in ["b", "o", "x"] {
+ state.dialog_key_input(&typed(c));
+ }
+ assert_eq!(
+ state.slots.dialog.selected_id(),
+ Some("Box"),
+ "rows: {:?}",
+ state.slots.dialog.rows.iter().map(|r| r.label.as_str()).collect::<Vec<_>>()
+ );
+
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(!state.dialog_visible(), "the pick closes the dialog");
+ assert_eq!(state.current_dir().children.len(), before + 1);
+ let added = state.current_dir().children.last().expect("the new node");
+ assert!(added.name.starts_with("Box"), "added {}", added.name);
+ assert_eq!(added.position, (3.0, 2.0), "placed at the grid cursor");
+ }
+
+ /// Geometry templates are refused inside a utility dir, so the list does
+ /// not offer them there — the same filter the popup was fed.
+ #[test]
+ fn dialog_add_node_hides_geometry_templates_in_a_utility_dir() {
+ let mut state = State::new(false);
+ let offered_at_root = {
+ state.open_node_palette();
+ let n = state.slots.dialog.rows.len();
+ state.close_dialog();
+ n
+ };
+
+ // Into the root meta node, which `in_settings_dir` reports as utility.
+ let meta = state
+ .fs_root
+ .children
+ .iter()
+ .position(|c| c.node_type == "meta")
+ .expect("the root meta node");
+ state.current_path.push(meta);
+ assert!(state.in_settings_dir());
+
+ state.open_node_palette();
+ let offered_in_utility = state.slots.dialog.rows.len();
+ assert!(
+ offered_in_utility < offered_at_root,
+ "{offered_in_utility} offered in a utility dir vs {offered_at_root} at the root"
+ );
+ assert!(
+ !state.slots.dialog.rows.iter().any(|r| r.label == "Grid"),
+ "a geometry template would be refused at placement"
+ );
+ // Box/Sphere/Plane/Extrude are `"type": "node"` SUBNET templates, not
+ // native geometry types, so `is_geometry_node_type` does not claim
+ // them and the filter leaves them offered. Pre-existing, and exactly
+ // what the popup was fed — asserted so the next reader does not take
+ // it for a hole in this filter.
+ assert!(state.slots.dialog.rows.iter().any(|r| r.label == "Box"));
+ }
+
+ /// Ctrl+P lands on Commands rather than toggling, which is the one thing
+ /// that distinguishes it from Alt+D now that both open the same dialog.
+ #[test]
+ fn command_palette_opens_the_dialog_on_commands() {
+ use crate::dialog::{Mode, Tab};
+ let mut state = State::new(false);
+ state.run_command("toggle_dialog");
+ state.set_dialog_tab(Tab::Settings);
+ assert_eq!(state.dialog_tab(), Tab::Settings);
+
+ state.run_command("command_palette");
+ assert!(state.dialog_visible(), "it lands, it does not toggle");
+ assert_eq!(state.dialog_tab(), Tab::Commands);
+ assert_eq!(state.slots.dialog.mode, Mode::Tabbed);
+ }
+
+ /// No designer code path spawns a cce-cloud popup any more.
+ ///
+ /// A source scan, like `test_every_menu_command_names_a_label_that_is_
+ /// dispatched`: the alternative is asserting on a process that does not
+ /// start, which is indistinguishable from one that failed to.
+ #[test]
+ fn nothing_shells_out_to_cce_cloud_any_more() {
+ for name in ["app.rs", "window.rs", "dialog.rs", "render.rs", "api.rs", "slots.rs"] {
+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join(name);
+ let src = std::fs::read_to_string(&path).expect("read source");
+ for needle in ["CloudPopup", "run_dmenu", "CloudPopupTracker"] {
+ // The doc comments say what the dialog REPLACED, so only code
+ // counts: skip comment lines.
+ let hit = src
+ .lines()
+ .find(|l| l.contains(needle) && !l.trim_start().starts_with("//"));
+ assert!(hit.is_none(), "{name} still uses {needle}: {}", hit.unwrap().trim());
+ }
+ }
+ }
}
diff --git a/src/window.rs b/src/window.rs
index 8abf9a0..7abef4a 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -593,22 +593,12 @@ impl State {
let res = state.apply_mcp_call(&call, &mut needs_redraw);
let _ = call.reply.send(res);
}
- CustomEvent::RunCommand(id) => {
- state.run_command(id);
- needs_redraw = true;
- }
CustomEvent::RunAction(action) => {
if let Err(e) = state.apply_action(action, &mut needs_redraw) {
state.update_status_text(&e);
needs_redraw = true;
}
}
- CustomEvent::CloudSpawned { pid, source } => {
- state.cloud_popups.on_spawned(pid, &source);
- }
- CustomEvent::CloudClosed { pid, source } => {
- let _ = state.cloud_popups.on_closed(pid, &source);
- }
// Exit is handled by the Application::update wrapper
// (autosave + engine exit) before this is reached.
CustomEvent::Exit => {}
@@ -1049,14 +1039,6 @@ impl State {
Err(format!("unknown command: {}", id.replace(['"', '\\'], "'")))
}
}
- McpAction::MenuClosed { widget_idx, menu_idx } => {
- if state.active_menu_cloud_idx == Some((widget_idx, menu_idx)) {
- state.active_menu_cloud_pid = None;
- state.active_menu_cloud_idx = None;
- }
- Ok("Menu closed".to_string())
- }
-
};
if needs_redraw {
*redraw = true;