graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the network editor right-clicks to a context menu
A right press on empty graph space opened the add-node palette outright,
which left the network the one pane whose right-click was not a context
menu — and left every other graph-wide command reachable only by chord or
through the palette.
It opens the network's own menu now, headed by Add Node, which opens that
same palette. Rows are command IDS (`NETWORK_MENU_COMMANDS`) resolved
through `command::by_id` and dispatched through `run_command`, so a label
is the registry's label and a row is exactly as scriptable as the command
behind it; a toggle row carries the viewport menu's ●/○ mark, read through
the one `command_toggle_state` table the dialog's switches read.
`add_node` becomes a registry row of its own — the palette was reachable
only from Tab's inline handler before — shipping unbound, like `deselect`,
since Tab already claims that key in the event loop.
A press on a node still opens that node's menu, and the press moves the
grid cursor to the clicked cell first, because that cell is where Add Node
will place what it adds.
Co-Authored-By: Claude Opus 5 <[email protected]>
CLAUDE.md | 39 +++++++++++++--
src/app.rs | 146 ++++++++++++++++++++++++++++++++++++++++++++++++++++++---
src/command.rs | 5 ++
src/main.rs | 81 ++++++++++++++++++++++++++++++++
4 files changed, 262 insertions(+), 9 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 43d417f..f7c4631 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -124,9 +124,9 @@ gone from cce-ui with the wgpu path).
spreadsheet, playbar; NOT the viewport, whose plate is the window-spanning lip).
Geometry is derived from the slot's live rect, so it holds across all three
`rebuild_positions` branches; the circular network pane is special-cased onto its
- arc. The menu is a third `cce_ui::widget::context_menu` consumer alongside the node
- and viewport right-click menus, with the same `*_menu_actions` + `handle_*_menu_click`
- contract. Collapse shrinks a plate to its title stub via `apply_collapsed_panes`, a
+ arc. The menu is a fourth `cce_ui::widget::context_menu` consumer alongside the node,
+ viewport and network right-click menus, with the same `*_menu_actions` +
+ `handle_*_menu_click` contract. Collapse shrinks a plate to its title stub via `apply_collapsed_panes`, a
post-pass over `positions[..]` (one place, all three branches); the stub is exempt
from the minimum-span guard or it would lose the control that expands it again.
- `src/application.rs` — the `Application` impl: translates engine hooks into
@@ -676,6 +676,39 @@ the `toggle_network_plate` command. The action marks `settings_changed` and
lets `execute_action` save once at its end, like every other viewport toggle,
rather than writing the file itself.
+### The network editor's right-click menu
+
+A right press on EMPTY graph space opens the network's own context menu; a press
+ON a node still opens that node's menu, which is the more specific thing under
+the pointer. Until 2026-09-22 the empty-space press opened the **add-node
+palette** outright, which left the network the one pane whose right-click was
+not a context menu, and left every other graph-wide command reachable only by
+chord or through the palette. **Add Node is the first row** instead, and picking
+it opens the same palette.
+
+Rows are `NETWORK_MENU_COMMANDS` — a list of COMMAND IDS, `None` for a
+separator — resolved through `command::by_id`, so a label is the registry's
+label and `NetworkMenuAction::Command(id)` dispatches through `run_command`.
+The menu therefore cannot name work the palette spells differently, and a row is
+exactly as scriptable as the command behind it.
+`network_menu_rows_name_commands_that_exist` is the backstop, since a row whose
+id no longer resolves is simply skipped. A toggle command carries the viewport
+menu's `●`/`○` mark, read through `command_toggle_state` — the one table the
+dialog's switches read too.
+
+`add_node` is a registry row of its own now (`Run::Menu("Add Node")`), where the
+palette used to be reachable only from Tab's inline handler. It ships UNBOUND,
+like `deselect`: Tab already opens it from the event loop, and a default chord
+here would duplicate a key the loop claims.
+
+With the plate OFF the press never gets here — `in_network_pane` narrows to the
+nodes in overlay mode, so empty space is the scene's and opens the VIEWPORT
+menu. That is the overlay's whole rule, and it predates this menu.
+
+The press moves the grid cursor to the clicked cell BEFORE the menu goes up,
+because that cell is where Add Node will place what it adds — the cursor is the
+only thing carrying the pointed-at cell across to the palette.
+
### Keyboard graph navigation
The network pane's keyboard scheme is the plugin's, ported: **hjkl rather than
diff --git a/src/app.rs b/src/app.rs
index 1d8fb57..da1c62d 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -364,6 +364,34 @@ pub enum ViewportMenuAction {
Separator,
}
+/// The network editor's right-click context menu (on empty space — a press on
+/// a node still opens that node's menu). Every row but the separator names a
+/// COMMAND ID rather than a piece of work, so the labels cannot drift from the
+/// palette's and a row is exactly as scriptable as the command behind it.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NetworkMenuAction {
+ /// Run `command::by_id(id)` — the row's label came from the same row.
+ Command(&'static str),
+ /// A "-" row: engraved, inert.
+ Separator,
+}
+
+/// The command ids the network context menu offers, in order; `None` is a
+/// separator. Add Node leads because right-clicking empty space USED to open
+/// the add-node palette outright, and that is still the common reason to come
+/// here.
+pub const NETWORK_MENU_COMMANDS: &[Option<&'static str>] = &[
+ Some("add_node"),
+ None,
+ Some("layout_nodes"),
+ Some("frame_all"),
+ Some("frame_cursor"),
+ None,
+ Some("reset_zoom"),
+ Some("toggle_network_plate"),
+ Some("toggle_circular_pane"),
+];
+
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum McpAction {
@@ -1311,6 +1339,10 @@ pub struct State {
/// machinery as the node menu; this flag says the open menu is OURS).
pub viewport_menu_active: bool,
pub viewport_menu_actions: Vec<ViewportMenuAction>,
+ /// The network editor's right-click menu — the same thread-local again,
+ /// with the flag saying the open menu is this one.
+ pub network_menu_active: bool,
+ pub network_menu_actions: Vec<NetworkMenuAction>,
/// The plate corner menu — same `context_menu` thread-local again; the slot
/// says which plate's control opened it (and doubles as the pressed state
/// the corner control paints with).
@@ -2758,6 +2790,9 @@ impl State {
"Exit" => {
self.exit_requested = true;
}
+ "Add Node" => {
+ self.open_node_palette();
+ }
"Zoom In" => {
self.zoom(1.15, None);
}
@@ -3689,6 +3724,84 @@ impl State {
false
}
+ /// Open the network editor's right-click context menu at the cursor. It is
+ /// what a press on EMPTY graph space opens; a press on a node still opens
+ /// that node's menu, which is the more specific thing under the pointer.
+ ///
+ /// Until 2026-09-22 this press opened the add-node palette outright, which
+ /// left the network the one pane whose right-click was not a context menu
+ /// — and left every other graph-wide command reachable only by chord or
+ /// through the palette. Add Node is the first row instead.
+ ///
+ /// Rows are built from `NETWORK_MENU_COMMANDS` through `command::by_id`,
+ /// so a label is the registry's label and a row cannot name work the
+ /// palette spells differently. A toggle command carries the same ●/○ mark
+ /// the viewport menu's radio rows use, read through
+ /// `command_toggle_state` — the one table the dialog's switches read too.
+ fn open_network_context_menu(&mut self) {
+ let mut options: Vec<String> = Vec::new();
+ let mut actions: Vec<NetworkMenuAction> = Vec::new();
+ for entry in NETWORK_MENU_COMMANDS {
+ match entry {
+ None => {
+ if options.is_empty() || options.last().map(String::as_str) == Some("-") {
+ continue;
+ }
+ options.push("-".to_string());
+ actions.push(NetworkMenuAction::Separator);
+ }
+ Some(id) => {
+ let Some(cmd) = crate::command::by_id(id) else { continue };
+ options.push(match self.command_toggle_state(id) {
+ Some(on) => format!("{} {}", if on { "●" } else { "○" }, cmd.label),
+ None => cmd.label.to_string(),
+ });
+ actions.push(NetworkMenuAction::Command(cmd.id));
+ }
+ }
+ }
+ if options.last().map(String::as_str) == Some("-") {
+ options.pop();
+ actions.pop();
+ }
+
+ let target = self.slots.get_dyn(CONTENT_IDX).base().id();
+ cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+ self.network_menu_active = true;
+ self.network_menu_actions = actions;
+ }
+
+ fn network_menu_open(&self) -> bool {
+ cce_ui::widget::context_menu::is_visible() && self.network_menu_active
+ }
+
+ fn close_network_menu(&mut self) {
+ cce_ui::widget::context_menu::hide();
+ self.network_menu_active = false;
+ self.network_menu_actions.clear();
+ }
+
+ /// Route a left press while the network menu is open — same contract as
+ /// `handle_node_menu_click`. The menu is closed BEFORE the command runs,
+ /// since Add Node opens the dialog and a menu still standing over it would
+ /// be painted on top of the thing it asked for.
+ fn handle_network_menu_click(&mut self) -> bool {
+ if !self.network_menu_open() {
+ return false;
+ }
+ if cce_ui::widget::context_menu::hit_test(self.cursor_x, self.cursor_y) {
+ let idx = cce_ui::widget::context_menu::row_at(self.cursor_x, self.cursor_y);
+ let picked = idx.and_then(|i| self.network_menu_actions.get(i).copied());
+ self.close_network_menu();
+ if let Some(NetworkMenuAction::Command(id)) = picked {
+ self.run_command(id);
+ }
+ return true;
+ }
+ self.close_network_menu();
+ false
+ }
+
fn dispatch_node_menu(&mut self, slot: usize, action: NodeMenuAction) {
match action {
NodeMenuAction::Enter => {
@@ -4423,6 +4536,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
node_menu_actions: Vec::new(),
viewport_menu_active: false,
viewport_menu_actions: Vec::new(),
+ network_menu_active: false,
+ network_menu_actions: Vec::new(),
sim_cache: crate::geometry::SimCache::default(),
page_image: None,
seen_renderer: false,
@@ -6352,9 +6467,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.cursor_y = position.y as f32;
let mut changed = false;
- // Track hover on the node/viewport context menus so the
- // highlight follows.
- if (self.node_menu_open() || self.viewport_menu_open())
+ // Track hover on the node/viewport/network context menus so
+ // the highlight follows.
+ if (self.node_menu_open() || self.viewport_menu_open() || self.network_menu_open())
&& cce_ui::widget::context_menu::cursor_moved(self.cursor_x, self.cursor_y)
{
changed = true;
@@ -6687,6 +6802,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
return true;
}
}
+ // The network editor's menu, same contract again.
+ if self.network_menu_open() {
+ if *button == MouseButton::Left && self.handle_network_menu_click() {
+ return true;
+ }
+ self.close_network_menu();
+ if *button == MouseButton::Left {
+ return true;
+ }
+ }
if self.viewport_menu_open() {
if *button == MouseButton::Left && self.handle_viewport_menu_click() {
return true;
@@ -6851,13 +6976,19 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
if *button == MouseButton::Right {
self.close_node_menu();
self.close_viewport_menu();
+ self.close_network_menu();
if self.cursor_in_viewport() && !in_circle_network_pane {
self.open_viewport_context_menu();
return true;
}
if in_circle_network_pane {
// On a node → its context menu; empty space →
- // the add-node palette.
+ // the network's own, whose first row is Add
+ // Node. The grid cursor moves to the clicked
+ // cell FIRST, because that is where Add Node
+ // will place what it adds — the menu is opened
+ // over the cell the user pointed at, and the
+ // cursor is the only thing carrying it there.
if let Some(slot) = self.graph().node_at(self.cursor_x, self.cursor_y) {
self.graph_mut().set_selected_node(Some(slot));
self.sync_parameters_pane();
@@ -6867,7 +6998,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
let (col, row) = self.cell_at(self.cursor_x, self.cursor_y);
self.grid_cursor_col = col;
self.grid_cursor_row = row;
- self.open_node_palette();
+ self.open_network_context_menu();
return true;
}
return false;
@@ -7402,7 +7533,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
}
if event.state == ElementState::Pressed && event.logical_key == Key::Named(NamedKey::Escape) {
- // An open context menu — node, viewport, or plate-corner,
+ // An open context menu — node, viewport, network or
+ // plate-corner,
// all riding the shared context_menu thread-local — takes
// Escape ahead of connection-cancel. They were
// mouse-dismiss only, which left Escape wired to a
@@ -7412,6 +7544,8 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
self.close_node_menu();
} else if self.viewport_menu_open() {
self.close_viewport_menu();
+ } else if self.network_menu_open() {
+ self.close_network_menu();
} else {
self.close_plate_menu();
}
diff --git a/src/command.rs b/src/command.rs
index 3a9783a..62711ea 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -156,6 +156,11 @@ pub const COMMANDS: &[Command] = &[
Command { id: "frame_all", label: "Frame All", context: Context::Network, run: Run::Key(Action::FrameAll), default_chord: Some("Shift+f") },
// --- Network ---
+ // The add-node palette. Tab opens it inline (like Escape's cascade, and
+ // like `deselect` above, the row ships unbound rather than duplicating a
+ // key the event loop already claims), and it is the first row of the
+ // network's right-click menu, which dispatches through this id.
+ Command { id: "add_node", label: "Add Node", context: Context::Network, run: Run::Menu("Add Node"), default_chord: None },
Command { id: "zoom_in", label: "Zoom In", context: Context::Network, run: Run::Menu("Zoom In"), default_chord: None },
Command { id: "zoom_out", label: "Zoom Out", context: Context::Network, run: Run::Menu("Zoom Out"), default_chord: None },
Command { id: "reset_zoom", label: "Reset Zoom", context: Context::Network, run: Run::Menu("Reset Zoom"), default_chord: None },
diff --git a/src/main.rs b/src/main.rs
index e394540..4162563 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8679,6 +8679,87 @@ mod tests {
assert_eq!(state.dialog_tab(), Tab::Commands);
}
+ /// A right press on EMPTY network space opens the network's own context
+ /// menu — until 2026-09-22 it opened the add-node palette outright, which
+ /// left the network the one pane whose right-click was not a context menu,
+ /// and left every other graph-wide command reachable only by chord or
+ /// through the palette. Add Node is the first row, and picking it is what
+ /// opens the palette — at the cell the press landed on, since the press
+ /// moves the grid cursor there before the menu goes up.
+ #[test]
+ fn network_right_click_opens_a_menu_whose_first_row_is_add_node() {
+ use crate::dialog::Mode;
+ use crate::slots::CONTENT_IDX;
+ use crate::window::{LocalPosition, WindowEvent};
+ use cce_ui::widget::{ElementState, MouseButton};
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+
+ // A point in the network pane with no node under it: the plate is on,
+ // so the pane's rect is its own and empty space is still the graph's.
+ assert!(state.network_plate, "the plate is on by default");
+ let (cx, cy, cw, ch) = state.positions[CONTENT_IDX];
+ let (px, py) = (cx + cw * 0.85, cy + ch * 0.85);
+ state.handle_event(&WindowEvent::CursorMoved {
+ position: LocalPosition { x: px as f64, y: py as f64 },
+ });
+ assert!(state.in_network_pane(), "the press must land in the network pane");
+ assert!(
+ state.graph().node_at(px, py).is_none(),
+ "pick a cell with no node on it"
+ );
+ let cell = state.cell_at(px, py);
+
+ state.handle_event(&WindowEvent::MouseInput {
+ state: ElementState::Pressed,
+ button: MouseButton::Right,
+ });
+ assert!(!state.dialog_visible(), "no palette straight off the press");
+ assert!(cce_ui::widget::context_menu::is_visible());
+ let options = cce_ui::widget::context_menu::options();
+ assert_eq!(options.first().map(String::as_str), Some("Add Node"));
+ assert!(
+ options.iter().any(|o| o == "Layout Nodes"),
+ "the graph-wide commands come with it: {options:?}"
+ );
+ assert!(
+ options.iter().any(|o| o.ends_with("Network Plate")),
+ "a toggle row carries its mark: {options:?}"
+ );
+ assert_eq!((state.grid_cursor_col, state.grid_cursor_row), cell);
+
+ // A left click on the first row runs Add Node: the menu goes, the
+ // palette comes up, and it will add at the cell that was clicked.
+ let rx = cce_ui::widget::context_menu::x() + 8.0;
+ let ry = cce_ui::widget::context_menu::row_y(0) + 4.0;
+ state.handle_event(&WindowEvent::CursorMoved {
+ position: LocalPosition { x: rx as f64, y: ry as f64 },
+ });
+ state.handle_event(&WindowEvent::MouseInput {
+ state: ElementState::Pressed,
+ button: MouseButton::Left,
+ });
+ assert!(!cce_ui::widget::context_menu::is_visible(), "the pick closes the menu");
+ assert!(state.dialog_visible());
+ assert_eq!(state.slots.dialog.mode, Mode::AddNode);
+ assert_eq!((state.grid_cursor_col, state.grid_cursor_row), cell);
+ }
+
+ /// Every row of the network context menu names a command that exists —
+ /// the labels are the registry's, so a renamed or deleted command would
+ /// otherwise drop a row from the menu in silence.
+ #[test]
+ fn network_menu_rows_name_commands_that_exist() {
+ for id in crate::app::NETWORK_MENU_COMMANDS.iter().flatten() {
+ assert!(
+ crate::command::by_id(id).is_some(),
+ "the network menu offers `{id}`, which is not a command"
+ );
+ }
+ }
+
/// Tab opens the same plate in its AddNode mode: one list of node
/// templates, no tab strip, no chord column.
#[test]