graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Session node — one permanent root container for session settings
The Main/View/Guides/Render utility subnets used to sit flat in the root
network. They now live inside a single permanent "Session" node (node type
"session") at /, which is the home for anything scoped to the whole session.
ensure_menubar_subnets creates it and migrates older saves: root-level
settings nodes are MOVED in — same nodes, params intact — not recreated, so
an old project's values keep seeding exactly as before (pinned by a
foreign-param probe test, since the live-synced toggles are rewritten from
app state by design and cannot distinguish the two).
Permanence is enforced at the one gate every deletion route funnels through —
delete_node refuses the session type (context menu, Delete key, and MCP all
route there; MCP gets an honest error message instead of "out of bounds").
The context menu also stops offering Delete, and the graph widget draws
session nodes without a geometry toggle, like utility nodes (cce-ui side).
The nesting broke two first-segment assumptions: the settings-dir check that
gates geometry templates out of utility dirs read current_path[0], which is
now the Session node — in_settings_dir() walks the whole path instead — and
three tests plus update_recent_files_layout resolved Main at the root.
Live-verified: get_state shows Session containing the four; deleting it over
MCP is refused; entering it and selecting Main drives the params pane; the
sphere renders unaffected.
CLAUDE.md | 13 +++++++++
src/app.rs | 62 ++++++++++++++++++++++++++++++++++-----
src/main.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++----
src/project.rs | 71 ++++++++++++++++++++++++++++++++++++++-------
src/window.rs | 7 ++++-
5 files changed, 220 insertions(+), 24 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 0937fd2..4e1bb40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,19 @@ The `zcce_inspector_v1` integration (window-position tracking + widget-state
streaming to cce-test-interface) was dropped in the engine migration; the HTTP API
is the introspection surface.
+### The Session node
+
+Session-wide settings live under one permanent root node: `Session` (node type
+`session`) contains the Main/View/Guides/Render utility subnets that used to
+sit flat in `/`. `ensure_menubar_subnets` creates it and MIGRATES root-level
+settings nodes from older saves into it (moved, not recreated — params
+survive). It cannot be deleted: `delete_node` refuses the `session` type (the
+one gate every deletion route funnels through), the context menu omits Delete,
+and the graph draws it without a geometry toggle. `State::session_node()` /
+`in_settings_dir()` are the accessors — the latter walks the whole
+`current_path`, since a first-segment check stopped working the day the
+settings nodes gained a parent.
+
### App-written settings: `~/.config/cce/cce-designer/state.kdl`
`default_project` in state.kdl points at the project the main window opens on
diff --git a/src/app.rs b/src/app.rs
index ffaf3b5..5ffa1db 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -146,7 +146,7 @@ impl FsNode {
/// of them learned about new container types: subnet-like types by name,
/// otherwise anything that actually has children.
pub fn is_enterable(&self) -> bool {
- matches!(self.node_type.as_str(), "node" | "utility" | "simnet")
+ matches!(self.node_type.as_str(), "node" | "utility" | "simnet" | "session")
|| !self.children.is_empty()
}
}
@@ -964,7 +964,10 @@ impl State {
}
opts.push("Other".to_string());
- if let Some(main_node) = self.fs_root.children.iter_mut().find(|c| c.name == "Main") {
+ let main_node = self
+ .session_node_mut()
+ .and_then(|s| s.children.iter_mut().find(|c| c.name == "Main"));
+ if let Some(main_node) = main_node {
if let Some(p) = main_node.params.iter_mut().find(|p| p.name == "Open") {
p.options = opts;
if !p.options.contains(&p.default) {
@@ -1262,6 +1265,37 @@ impl State {
self.splitter_layout.clamp(self.width, self.detached_circular_network);
}
+ /// The Session node: the permanent root container for the session-wide
+ /// settings nodes (Main/View/Guides/Render). `ensure_menubar_subnets`
+ /// guarantees it exists, so `None` only before the first ensure.
+ pub fn session_node(&self) -> Option<&FsNode> {
+ self.fs_root.children.iter().find(|c| c.node_type == "session")
+ }
+
+ pub fn session_node_mut(&mut self) -> Option<&mut FsNode> {
+ self.fs_root.children.iter_mut().find(|c| c.node_type == "session")
+ }
+
+ /// Is the network currently inside a settings directory (the Session node
+ /// or any utility node)? Geometry templates are refused there. Checks the
+ /// whole path, not `current_path[0]` — the settings nodes live NESTED
+ /// under Session now, so the old first-segment check would miss them.
+ pub fn in_settings_dir(&self) -> bool {
+ let mut node = &self.fs_root;
+ for &idx in &self.current_path {
+ match node.children.get(idx) {
+ Some(child) => {
+ if matches!(child.node_type.as_str(), "utility" | "session") {
+ return true;
+ }
+ node = child;
+ }
+ None => return false,
+ }
+ }
+ false
+ }
+
pub fn current_dir(&self) -> &FsNode {
let mut node = &self.fs_root;
for &i in &self.current_path {
@@ -1907,8 +1941,7 @@ impl State {
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.current_path.is_empty()
- && self.fs_root.children[self.current_path[0]].node_type == "utility";
+ let in_utility = self.in_settings_dir();
let items: String = self
.node_templates
.iter()
@@ -1947,7 +1980,15 @@ impl State {
let dir = self.current_dir();
let Some(node) = dir.children.get(slot) else { return };
let enterable = node.is_enterable();
- (node.node_type == "utility", node.geometry_visible, enterable)
+ (
+ matches!(node.node_type.as_str(), "utility" | "session"),
+ node.geometry_visible,
+ enterable,
+ )
+ };
+ let deletable = {
+ let dir = self.current_dir();
+ dir.children.get(slot).map(|n| n.node_type != "session").unwrap_or(false)
};
let mut options: Vec<String> = Vec::new();
let mut actions: Vec<NodeMenuAction> = Vec::new();
@@ -1959,8 +2000,10 @@ impl State {
options.push(if geom_visible { "Hide Geometry" } else { "Show Geometry" }.to_string());
actions.push(NodeMenuAction::ToggleGeometry);
}
- options.push("Delete".to_string());
- actions.push(NodeMenuAction::Delete);
+ if deletable {
+ options.push("Delete".to_string());
+ actions.push(NodeMenuAction::Delete);
+ }
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);
@@ -2415,6 +2458,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
pub fn delete_node(&mut self, slot: usize) -> bool {
let len = self.current_dir().children.len();
+ // The Session node is permanent: every deletion route (context menu,
+ // Delete key, MCP) funnels through here, so this is the one gate.
+ if slot < len && self.current_dir().children[slot].node_type == "session" {
+ return false;
+ }
if slot < len {
self.current_dir_mut().children.remove(slot);
if let Some(sel_idx) = self.graph().selected_node() {
diff --git a/src/main.rs b/src/main.rs
index da30991..a74a666 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -107,7 +107,8 @@ mod tests {
fn test_main_node_offers_set_as_default() {
let mut state = State::new(false);
state.ensure_menubar_subnets();
- let main = state.fs_root.children.iter().find(|c| c.name == "Main").expect("Main node");
+ let (s_idx, m_idx) = session_and_main(&state);
+ let main = &state.fs_root.children[s_idx].children[m_idx];
let names: Vec<&str> = main.params.iter().map(|p| p.name.as_str()).collect();
let idx = names.iter().position(|n| *n == "Set As Default").expect("Set As Default param");
let save_as = names.iter().position(|n| *n == "Save As").unwrap();
@@ -345,7 +346,7 @@ mod tests {
fn test_legacy_style_params_are_dropped_from_main() {
let mut state = State::new(false);
state.ensure_menubar_subnets();
- let main_idx = state.fs_root.children.iter().position(|c| c.name == "Main").expect("Main node");
+ let (s_idx, main_idx) = session_and_main(&state);
// Re-seed the params exactly as a pre-removal save carries them.
for (name, ty, val) in [
@@ -354,7 +355,7 @@ mod tests {
("Edge Profile", "ramp", "smooth;0.000:0.000,1.000:1.000"),
("Plate Color", "rgba", "#11223344"),
] {
- state.fs_root.children[main_idx].params.push(crate::app::ParamDef {
+ state.fs_root.children[s_idx].children[main_idx].params.push(crate::app::ParamDef {
name: name.to_string(),
label: String::new(),
param_type: ty.to_string(),
@@ -367,7 +368,8 @@ mod tests {
}
state.ensure_menubar_subnets();
- let names: Vec<&str> = state.fs_root.children[main_idx]
+ let (s_idx, main_idx) = session_and_main(&state);
+ let names: Vec<&str> = state.fs_root.children[s_idx].children[main_idx]
.params.iter().map(|p| p.name.as_str()).collect();
for retired in ["Style", "Bevel Profile", "Edge Profile", "Plate Color"] {
assert!(!names.contains(&retired), "retired style param survived load: {retired} in {names:?}");
@@ -393,6 +395,81 @@ mod tests {
}
}
+ /// The settings nodes live inside the permanent Session node now; tests
+ /// that need Main resolve it through there.
+ fn session_and_main(state: &State) -> (usize, usize) {
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node");
+ let m_idx = state.fs_root.children[s_idx].children.iter().position(|c| c.name == "Main").expect("Main inside Session");
+ (s_idx, m_idx)
+ }
+
+ /// The Session node: exists at root, typed "session", holds exactly the
+ /// four settings nodes, and refuses deletion through the one gate every
+ /// deletion route funnels into.
+ #[test]
+ fn test_session_node_exists_and_cannot_be_deleted() {
+ let mut state = State::new(false);
+ state.ensure_menubar_subnets();
+
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session node at root");
+ let session = &state.fs_root.children[s_idx];
+ assert_eq!(session.name, "Session");
+ let names: Vec<&str> = session.children.iter().map(|c| c.name.as_str()).collect();
+ for expected in ["Main", "View", "Guides", "Render"] {
+ assert!(names.contains(&expected), "Session is missing {expected}: {names:?}");
+ }
+ // None of the four remain at root.
+ for c in &state.fs_root.children {
+ assert!(
+ !(c.node_type == "utility" && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")),
+ "settings node '{}' still at root", c.name
+ );
+ }
+
+ let before = state.fs_root.children.len();
+ assert!(!state.delete_node(s_idx), "delete_node deleted the Session node");
+ assert_eq!(state.fs_root.children.len(), before, "Session vanished anyway");
+ assert!(state.fs_root.children[s_idx].node_type == "session");
+ }
+
+ /// An old save carries Main/View/Guides/Render at the root with the user's
+ /// values in their params — migration must MOVE them (values intact), not
+ /// recreate them fresh.
+ #[test]
+ fn test_old_saves_migrate_settings_nodes_into_session() {
+ let mut state = State::new(false);
+ state.ensure_menubar_subnets();
+
+ // Simulate the old shape: pull the four back out to root, drop the
+ // Session node, and plant a probe param ensure doesn't own — the live-
+ // synced toggles are rewritten from app state by design, so only a
+ // foreign param can distinguish MOVED (probe survives) from RECREATED
+ // (probe gone).
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").unwrap();
+ let mut session = state.fs_root.children.remove(s_idx);
+ for mut child in session.children.drain(..) {
+ if child.name == "Guides" {
+ child.params.push(crate::app::ParamDef {
+ name: "migration probe".to_string(),
+ label: String::new(),
+ param_type: "text".to_string(),
+ default: "survived".to_string(),
+ options: vec![],
+ min: None,
+ max: None,
+ step: None,
+ });
+ }
+ state.fs_root.children.push(child);
+ }
+
+ state.ensure_menubar_subnets();
+ let s_idx = state.fs_root.children.iter().position(|c| c.node_type == "session").expect("Session recreated");
+ let guides = state.fs_root.children[s_idx].children.iter().find(|c| c.name == "Guides").expect("Guides migrated in");
+ let v = guides.params.iter().find(|p| p.name == "migration probe").map(|p| p.default.as_str());
+ assert_eq!(v, Some("survived"), "migration recreated Guides instead of moving it");
+ }
+
#[test]
fn test_load_default_project() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("default_project.json");
@@ -1310,8 +1387,10 @@ mod tests {
// beneath the params pane's "Open" dropdown bled through it.
let mut state = State::new(false);
state.ensure_menubar_subnets();
- let main_idx = state.fs_root.children.iter().position(|c| c.name == "Main").expect("Main node");
- state.graph_mut().set_selected_node(Some(main_idx));
+ let (s_idx, m_idx) = session_and_main(&state);
+ state.current_path.push(s_idx);
+ state.on_path_changed();
+ state.graph_mut().set_selected_node(Some(m_idx));
state.sync_parameters_pane();
{
diff --git a/src/project.rs b/src/project.rs
index 65e0f92..55c37c2 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -397,8 +397,55 @@ impl State {
// Retain only the Main utility subnet, removing the rest
self.fs_root.children.retain(|c| c.name != "Network" && c.name != "Viewport" && c.name != "Parameters" && c.name != "Spreadsheet");
+ // The Session node: the permanent root container for the session-wide
+ // settings nodes (Main/View/Guides/Render). Older saves carried the
+ // four at the root — they are MOVED in, params intact, so an old
+ // project's values survive as the seeds. The node itself is
+ // undeletable (delete_node refuses the "session" type).
+ let mut migrated: Vec<FsNode> = Vec::new();
+ {
+ let mut idx = 0;
+ while idx < self.fs_root.children.len() {
+ let c = &self.fs_root.children[idx];
+ if c.node_type == "utility"
+ && matches!(c.name.as_str(), "Main" | "View" | "Guides" | "Render")
+ {
+ migrated.push(self.fs_root.children.remove(idx));
+ } else {
+ idx += 1;
+ }
+ }
+ }
+ let session_idx = match self.fs_root.children.iter().position(|c| c.node_type == "session" || c.name == "Session") {
+ Some(i) => {
+ self.fs_root.children[i].node_type = "session".to_string();
+ i
+ }
+ None => {
+ self.fs_root.children.push(FsNode {
+ id: crate::app::generate_node_id(),
+ name: "Session".to_string(),
+ node_type: "session".to_string(),
+ children: vec![],
+ params: vec![],
+ geometry_visible: true,
+ position: (0.0, 0.0),
+ inputs: 0,
+ outputs: 0,
+ });
+ self.fs_root.children.len() - 1
+ }
+ };
+ for node in migrated {
+ let session = &mut self.fs_root.children[session_idx];
+ if !session.children.iter().any(|c| c.name == node.name) {
+ session.children.push(node);
+ }
+ }
+ let session = &mut self.fs_root.children[session_idx];
+
// 1. Main subnet
- let main_node = find_or_create_subnet(&mut self.fs_root, "Main", "utility", (0.0, 0.0));
+ let main_node = find_or_create_subnet(session, "Main", "utility", (0.0, 0.0));
main_node.children.clear();
ensure_param(main_node, "File", "section", "", &[], None, None, None);
@@ -580,7 +627,7 @@ impl State {
// node; the header menu items stay as command access). Pane state is
// session-owned and never applied from the project, so the toggles
// seed and refresh from live state.
- let view_node = find_or_create_subnet(&mut self.fs_root, "View", "utility", (0.0, 2.0));
+ let view_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "View", "utility", (0.0, 2.0));
view_node.children.clear();
ensure_param(view_node, "Panes", "section", "", &[], None, None, None);
ensure_param(view_node, "Show Network Pane", "toggle", bool_str(show_network), &[], None, None, None);
@@ -611,7 +658,7 @@ impl State {
// migrated off Main). The utility column keeps one empty cell between
// nodes: Main (0,0), View (0,2), Guides (0,4), Render (0,6); older
// saves parked at prior defaults slide to the spaced slots.
- let guides_node = find_or_create_subnet(&mut self.fs_root, "Guides", "utility", (0.0, 4.0));
+ let guides_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Guides", "utility", (0.0, 4.0));
if guides_node.position == (0.0, 1.0) || guides_node.position == (0.0, 2.0) {
guides_node.position = (0.0, 4.0);
}
@@ -646,7 +693,7 @@ impl State {
// Main. Toggles reflect live state so a reopened project shows real
// switches. Two rows below Guides (the spaced column); older saves
// parked at the prior defaults slide down.
- let render_node = find_or_create_subnet(&mut self.fs_root, "Render", "utility", (0.0, 6.0));
+ let render_node = find_or_create_subnet(&mut self.fs_root.children[session_idx], "Render", "utility", (0.0, 6.0));
if render_node.position == (0.0, 1.0) || render_node.position == (0.0, 2.0) || render_node.position == (0.0, 4.0) {
render_node.position = (0.0, 6.0);
}
@@ -754,8 +801,14 @@ impl State {
}
pub(crate) fn apply_settings_from_menubar_subnets(&mut self) {
- if let Some(guides_idx) = self.fs_root.children.iter().position(|c| c.name == "Guides") {
- let params = self.fs_root.children[guides_idx].params.clone();
+ let session_params = |root: &FsNode, name: &str| -> Option<Vec<ParamDef>> {
+ root.children
+ .iter()
+ .find(|c| c.node_type == "session")
+ .and_then(|s| s.children.iter().find(|c| c.name == name))
+ .map(|n| n.params.clone())
+ };
+ if let Some(params) = session_params(&self.fs_root, "Guides") {
for p in ¶ms {
match p.name.as_str() {
"Show Grid Guide" => if let Ok(val) = p.default.parse::<bool>() { self.viewport_mut().show_grid = val; }
@@ -768,8 +821,7 @@ impl State {
}
}
}
- if let Some(main_idx) = self.fs_root.children.iter().position(|c| c.name == "Main") {
- let params = self.fs_root.children[main_idx].params.clone();
+ if let Some(params) = session_params(&self.fs_root, "Main") {
for p in ¶ms {
match p.name.as_str() {
// Network Settings
@@ -818,8 +870,7 @@ impl State {
}
}
- if let Some(render_idx) = self.fs_root.children.iter().position(|c| c.name == "Render") {
- let params = self.fs_root.children[render_idx].params.clone();
+ if let Some(params) = session_params(&self.fs_root, "Render") {
for p in ¶ms {
match p.name.as_str() {
"Show Wireframe" => if let Ok(val) = p.default.parse::<bool>() { self.wireframe = val; }
diff --git a/src/window.rs b/src/window.rs
index f93b1bb..54ea2e9 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -770,7 +770,7 @@ impl State {
if let Some(idx) = template_idx {
let mut node = state.node_templates[idx].node.clone();
let mut allowed = true;
- let is_in_utility = !state.current_path.is_empty() && state.fs_root.children[state.current_path[0]].node_type == "utility";
+ let is_in_utility = state.in_settings_dir();
if is_in_utility {
if crate::geometry::is_geometry_node_type(&node.node_type) {
allowed = false;
@@ -800,6 +800,11 @@ impl State {
}
}
McpAction::DeleteNode { slot } => {
+ if slot < state.current_dir().children.len()
+ && state.current_dir().children[slot].node_type == "session"
+ {
+ return Err("The Session node is permanent and cannot be deleted".to_string());
+ }
if state.delete_node(slot) {
needs_redraw = true;
Ok("Node deleted".to_string())