graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: Rename is a row of the node's menu, and a command
The dialog opens on the node's name and its one row says what Enter
will do: the name as it will be written, or why it will not be. The
menu, the rename_node command and MCP's rename_node share one entry,
which refuses a sibling's name.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 10 ++++++++
src/app.rs | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
src/command.rs | 1 +
src/dialog.rs | 46 +++++++++++++++++++++++++++++++++
src/main.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++
src/window.rs | 29 +++++----------------
6 files changed, 209 insertions(+), 24 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index cf9cf85..b224e4e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2737,6 +2737,16 @@ rather than two:
section rows and the second slot are gone, and with them the double
paint, the `dispatch_uncovered` routing into a second slot and the
`dialog_settings_shown` baseline the writeback diffed against.
+- `Mode::Rename` (the node menu's **Rename**, the `rename_node` command)
+ — the query line is the NAME, opened holding the one the node has, and
+ the one row (`RENAME_ROW_ID`) says what Enter will do: `Rename camera1
+ to lens`, the name as it will be written (`sanitize_node_name`, so
+ `My Ball` reads `my_ball` before it is committed), or why it will not
+ be — its name already, another node's, none. `State::rename_check` is
+ that rule and `State::rename_node` the one entry the dialog and MCP's
+ `rename_node` share; a sibling's name is refused in both, since wires
+ are by name. `State::rename_target` holds the node by id.
+ `a_node_is_renamed_from_its_menu` is the test.
- `Mode::AddNode` (**Tab**, in the network pane) — one list of node
templates, and a pick that instantiates at the grid cursor. Tab is what
opened it, so Tab closes it again. The query hint names the mode; there
diff --git a/src/app.rs b/src/app.rs
index 2578330..8b21991 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -381,6 +381,8 @@ pub enum NodeMenuAction {
ToggleBypass,
/// Enter/exit the curve viewer state (curve nodes only).
EditCurve,
+ /// Open the dialog on the node's name.
+ Rename,
/// Remove the node.
Delete,
}
@@ -2059,6 +2061,8 @@ pub struct State {
/// Copy Parameter's clipboard: (node id, parameter name). An id, so a
/// rename between the copy and the paste still pastes the right path.
pub copied_param: Option<(String, String)>,
+ /// The node the rename dialog is open on, by id.
+ pub rename_target: Option<String>,
/// Undo for edits to the node tree, parameters and structure — see
/// `src/edit_history.rs`. Consulted after a code row and a viewer
/// state have had their turn.
@@ -3937,6 +3941,10 @@ impl State {
"Reset Parameters" => {
self.reset_parameters();
}
+ "Rename Node" => match self.selected_slots().first().copied() {
+ Some(slot) => self.open_rename_dialog(slot),
+ None => self.update_status_text("Select a node to rename."),
+ },
"New Project" | "New" => {
self.new_project();
self.update_status_text("New project");
@@ -4597,11 +4605,13 @@ impl State {
/// 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, and Delete.
- fn open_node_context_menu(&mut self, slot: usize) {
+ /// The rows of a node's right-click menu and the action each runs.
+ /// Split from the open so a test reads them.
+ pub(crate) fn node_menu_rows(&self, slot: usize) -> (Vec<String>, Vec<NodeMenuAction>) {
let bypassed = self.current_dir().children.get(slot).is_some_and(|n| n.bypassed);
let (is_utility, geom_visible, enterable, curve_editing) = {
let dir = self.current_dir();
- let Some(node) = dir.children.get(slot) else { return };
+ let Some(node) = dir.children.get(slot) else { return (Vec::new(), Vec::new()) };
let enterable = node.is_enterable();
// None: no viewer state for this node type; Some(bool): editable,
// and whether it is being edited right now. The types that have a
@@ -4643,10 +4653,20 @@ impl State {
actions.push(NodeMenuAction::EditCurve);
}
if deletable {
+ options.push("Rename".to_string());
+ actions.push(NodeMenuAction::Rename);
options.push("Delete".to_string());
actions.push(NodeMenuAction::Delete);
}
+ (options, actions)
+ }
+
+ fn open_node_context_menu(&mut self, slot: usize) {
+ let (options, actions) = self.node_menu_rows(slot);
+ if options.is_empty() {
+ return;
+ }
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.node_menu_slot = Some(slot);
@@ -5753,6 +5773,11 @@ impl State {
false
}
+ /// Run a row of a node's menu: what a click on it runs.
+ pub(crate) fn run_node_menu_action(&mut self, slot: usize, action: NodeMenuAction) {
+ self.dispatch_node_menu(slot, action);
+ }
+
fn dispatch_node_menu(&mut self, slot: usize, action: NodeMenuAction) {
match action {
NodeMenuAction::Enter => {
@@ -5771,12 +5796,63 @@ impl State {
NodeMenuAction::EditCurve => {
self.toggle_viewer_state(slot);
}
+ NodeMenuAction::Rename => {
+ self.open_rename_dialog(slot);
+ }
NodeMenuAction::Delete => {
self.delete_node(slot);
}
}
}
+ /// The name a rename of `id` to `typed` would write, or why it would
+ /// write none. A name is a segment of a path: it is sanitized as every
+ /// name is, and a sibling's is refused, since wires are by name and
+ /// two nodes of one name leave every wire to either naming both.
+ pub fn rename_check(&self, id: &str, typed: &str) -> Result<String, String> {
+ let node = crate::viewer_state::find_node_by_id(&self.fs_root, id).ok_or("The node is gone")?;
+ if typed.trim().is_empty() {
+ return Err("A node needs a name".to_string());
+ }
+ let new = sanitize_node_name(typed);
+ if new == node.name {
+ return Err(format!("{new} is its name already"));
+ }
+ let taken = crate::geometry::find_parent_node(&self.fs_root, id)
+ .is_some_and(|p| p.children.iter().any(|c| c.id != id && c.name == new));
+ if taken {
+ return Err(format!("{new} is another node's name"));
+ }
+ Ok(new)
+ }
+
+ /// Rename the node `id`, and everything that names it with it: the
+ /// wires, the expression paths anywhere in the tree, the active
+ /// camera. The one entry the node menu, the command and MCP share.
+ pub fn rename_node(&mut self, id: &str, typed: &str) -> Result<String, String> {
+ let new = self.rename_check(id, typed)?;
+ let old = crate::viewer_state::find_node_by_id(&self.fs_root, id).map(|n| n.name.clone()).unwrap_or_default();
+ // The active camera is looked up where it applies, the current
+ // level, and is this node only if this node is there.
+ let is_camera = self
+ .current_dir()
+ .children
+ .iter()
+ .any(|c| c.id == id && c.node_type == "camera" && c.name == self.active_camera);
+ crate::geometry::rename_node_in_tree(&mut self.fs_root, id, &new);
+ if is_camera {
+ // Both copies of the name: the viewport routes the wheel by
+ // its own.
+ self.set_active_camera(new.clone());
+ }
+ self.sync_nodes();
+ // Connections reference nodes by name (Input params), so a rename
+ // changes downstream evaluation.
+ self.rebuild_scene_geometry();
+ self.sync_parameters_pane();
+ Ok(format!("Renamed {old} to {new}"))
+ }
+
pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<Vec<String>>) {
// One row per POINT, not per triangle corner. The soup listed the same
@@ -6696,6 +6772,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
param_menu_actions: Vec::new(),
param_menu_target: None,
copied_param: None,
+ rename_target: None,
edit_history: Default::default(),
structure_base: None,
network_menu_active: false,
diff --git a/src/command.rs b/src/command.rs
index 2e10006..ccca4e4 100644
--- a/src/command.rs
+++ b/src/command.rs
@@ -184,6 +184,7 @@ pub const COMMANDS: &[Command] = &[
// graph and do nothing. Not `toggle_`: that family is the settings'
// switches, and this acts on what is selected.
Command { id: "bypass_node", label: "Bypass Node", context: Context::Network, run: Run::Key(Action::ToggleBypass), default_chord: Some("b") },
+ Command { id: "rename_node", label: "Rename Node", context: Context::Network, run: Run::Menu("Rename Node"), default_chord: None },
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 },
diff --git a/src/dialog.rs b/src/dialog.rs
index d00dbaf..481ec65 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -45,6 +45,10 @@ pub enum Mode {
/// that instantiates one at the grid cursor. Tab is what opened it, so
/// Tab closes it again.
AddNode,
+ /// The node menu's Rename and the `rename_node` command: the query
+ /// line is the NAME, opened holding the one the node has, and the one
+ /// row says what Enter will do with it.
+ Rename,
}
/// The control a row carries, drawn over its right end and worked in place —
@@ -785,6 +789,7 @@ impl Paint for Dialog {
match self.mode {
Mode::Commands => "Type to filter commands and settings",
Mode::AddNode => "Add Node: type to filter nodes",
+ Mode::Rename => "Rename: type the node's name",
},
q_w,
);
@@ -819,6 +824,7 @@ impl Paint for Dialog {
let empty = match self.mode {
Mode::Commands => "No matching command or setting",
Mode::AddNode => "No matching node",
+ Mode::Rename => "No node to rename",
};
ctx.text_with(empty, list.x + 8.0, ty, font_size, [0x70, 0x70, 0x7c], Some(family.clone()), own);
return;
@@ -1177,6 +1183,8 @@ pub const PATH_ROW_ID: &str = "project_path";
/// document's own path already is: rows under the path row, each opening its
/// project. Ranked against the path text like everything else.
pub const RECENT_ROW_PREFIX: &str = "recent:";
+/// The one row of [`Mode::Rename`].
+pub const RENAME_ROW_ID: &str = "rename:";
/// A camera NODE of the current level, as a palette row: picking it looks
/// through that camera. The Default Camera is a registry command instead
/// (`default_camera`), there being always exactly one.
@@ -1349,6 +1357,29 @@ impl State {
self.open_dialog_in(Mode::AddNode);
}
+ /// Open the dialog to rename the node in `slot` of the current level.
+ /// The query line is the name: it opens holding the one the node has,
+ /// so a rename that changes a letter is a letter typed.
+ pub fn open_rename_dialog(&mut self, slot: usize) {
+ let Some((id, name)) = self.current_dir().children.get(slot).map(|n| (n.id.clone(), n.name.clone())) else {
+ self.update_status_text("Select a node to rename.");
+ return;
+ };
+ self.rename_target = Some(id);
+ self.open_dialog_in(Mode::Rename);
+ self.slots.dialog.query = name;
+ self.refresh_dialog_rows();
+ }
+
+ /// What renaming the dialog's node to `typed` would do: the name it
+ /// has and the one it would get, or why not. None when the node is
+ /// gone.
+ pub fn rename_outcome(&self, typed: &str) -> Option<Result<(String, String), String>> {
+ let id = self.rename_target.as_ref()?;
+ let node = crate::viewer_state::find_node_by_id(&self.fs_root, id)?;
+ Some(self.rename_check(id, typed).map(|new| (node.name.clone(), new)))
+ }
+
fn open_dialog_in(&mut self, mode: Mode) {
// 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
@@ -1364,6 +1395,7 @@ impl State {
self.update_status_text(match mode {
Mode::Commands => "Dialog: type to filter commands and settings, Escape closes.",
Mode::AddNode => "Add Node: type to filter, Enter adds at the cursor, Escape closes.",
+ Mode::Rename => "Rename: type the name, Enter renames, Escape closes.",
});
}
@@ -1394,6 +1426,13 @@ impl State {
pub fn refresh_dialog_rows(&mut self) {
let query = self.slots.dialog.query.clone();
let rows: Vec<Row> = match self.slots.dialog.mode {
+ // One row, which is what Enter will do: the name as it will be
+ // written, or why it will not be.
+ Mode::Rename => match self.rename_outcome(&query) {
+ Some(Ok((old, new))) => vec![Row::plain(RENAME_ROW_ID, format!("Rename {old} to {new}"), "")],
+ Some(Err(why)) => vec![Row::plain(RENAME_ROW_ID, why, "")],
+ None => vec![],
+ },
Mode::Commands => {
let cmds = crate::command::COMMANDS;
// The level's camera nodes rank among the commands, as
@@ -2227,6 +2266,13 @@ impl State {
// 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::Rename => {
+ let Some(target) = self.rename_target.take() else { return };
+ let typed = self.slots.dialog.query.clone();
+ match self.rename_node(&target, &typed) {
+ Ok(said) | Err(said) => self.update_status_text(&said),
+ }
+ }
Mode::AddNode => {
let mut redraw = false;
let action = crate::app::McpAction::AddNode {
diff --git a/src/main.rs b/src/main.rs
index 77b8c49..ec21db0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7103,6 +7103,72 @@ mod tests {
assert_eq!(dir.children[normal].name, old);
}
+ /// Rename is a row of the node's menu and a command: the dialog opens
+ /// holding the node's name, the row says what Enter will do, and a name
+ /// that cannot be written is refused there and not on the way in.
+ #[test]
+ fn a_node_is_renamed_from_its_menu() {
+ use crate::dialog::{Mode, RENAME_ROW_ID};
+ let mut state = State::new(false);
+ state.record_structure_changes();
+ let sphere = state.current_dir().children.iter().position(|c| c.node_type == "sphere").unwrap();
+ let camera = state.current_dir().children.iter().position(|c| c.node_type == "camera").unwrap();
+ let old = state.current_dir().children[sphere].name.clone();
+ let other = state.current_dir().children[camera].name.clone();
+ let row = |state: &State| state.slots.dialog.rows.iter().map(|r| r.label.clone()).collect::<Vec<_>>();
+ let retype = |state: &mut State, name: &str| {
+ while !state.slots.dialog.query.is_empty() {
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Backspace)));
+ }
+ for c in name.chars() {
+ if c == ' ' {
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Space)));
+ } else {
+ state.dialog_key_input(&typed(&c.to_string()));
+ }
+ }
+ };
+
+ state.run_node_menu_action(sphere, crate::app::NodeMenuAction::Rename);
+ assert!(state.dialog_visible());
+ assert_eq!(state.slots.dialog.mode, Mode::Rename);
+ assert_eq!(state.slots.dialog.query, old, "the dialog opens holding the name");
+ assert_eq!(state.slots.dialog.rows[0].id, RENAME_ROW_ID);
+
+ // Its own name, a sibling's, and none: each is said, and Enter on
+ // it writes nothing.
+ assert!(row(&state)[0].contains("already"), "{:?}", row(&state));
+ retype(&mut state, &other);
+ assert!(row(&state)[0].contains("another node"), "{:?}", row(&state));
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert_eq!(state.current_dir().children[sphere].name, old);
+
+ // A name as typed is written as a name is: lowercase, no spaces.
+ state.run_node_menu_action(sphere, crate::app::NodeMenuAction::Rename);
+ retype(&mut state, "My Ball");
+ assert_eq!(row(&state), vec![format!("Rename {old} to my_ball")]);
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Enter)));
+ assert!(!state.dialog_visible());
+ assert_eq!(state.current_dir().children[sphere].name, "my_ball");
+ assert!(state.last_status_text.contains("my_ball"), "{}", state.last_status_text);
+
+ // And it is a step.
+ assert!(state.run_command("undo"));
+ assert_eq!(state.current_dir().children[sphere].name, old);
+
+ // The command renames the selection, and says so when there is none.
+ state.graph_mut().set_selected_node(Some(sphere));
+ assert!(state.run_command("rename_node"));
+ assert_eq!(state.slots.dialog.mode, Mode::Rename);
+ assert_eq!(state.slots.dialog.query, old);
+ state.dialog_key_input(&key_press(Key::Named(NamedKey::Escape)));
+ assert_eq!(state.current_dir().children[sphere].name, old, "Escape renames nothing");
+
+ // The menu has the row.
+ let labels = state.node_menu_rows(sphere).0;
+ assert!(labels.iter().any(|l| l == "Rename"), "{labels:?}");
+ }
+
/// New Project from the palette starts a project. The command named a
/// label no arm dispatched, so the row ran and nothing happened.
#[test]
diff --git a/src/window.rs b/src/window.rs
index db2673b..a4d4dcb 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -491,29 +491,14 @@ impl State {
}
}
McpAction::RenameNode { slot, new_name } => {
- let len = state.current_dir().children.len();
- if slot < len {
- let new_name = crate::app::sanitize_node_name(&new_name);
- let (id, old_name) = {
- let n = &state.current_dir().children[slot];
- (n.id.clone(), n.name.clone())
- };
- // Everything that names the node follows it: the wires,
- // the expressions anywhere in the tree, the active camera.
- crate::geometry::rename_node_in_tree(&mut state.fs_root, &id, &new_name);
- if state.active_camera == old_name {
- // Both copies of the name: the viewport's routes
- // the wheel by its own.
- state.set_active_camera(new_name.clone());
+ match state.current_dir().children.get(slot).map(|n| n.id.clone()) {
+ Some(id) => {
+ let res = state.rename_node(&id, &new_name);
+ needs_redraw |= res.is_ok();
+ // The reply body is interpolated into JSON unescaped.
+ res.map(|_| "Node renamed".to_string())
}
- state.sync_nodes();
- // Connections reference nodes by name (Input params), so a
- // rename changes downstream evaluation.
- state.rebuild_scene_geometry();
- needs_redraw = true;
- Ok("Node renamed".to_string())
- } else {
- Err("Slot out of bounds".to_string())
+ None => Err("Slot out of bounds".to_string()),
}
}
McpAction::MoveNode { slot, x, y } => {