git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commite1276057e7131988430973382997716e9196d7eb
parent7818e65405
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 10:42
feat: the row menu reads out the parameter's template default

A `Default:` header row after `Value:`, holding the template's text for
the row as written there — an expression shows as the expression.
State::template_default resolves it: a child of a subnet instance takes
the SUBNET template's override (the Embryo's sphere1 defaults its Radius
to chf("../Radius"), which is what the merge refreshes it from), any
other node its own template's parameter, and a parameter no template
names (one added over MCP) has no row. template_for is hoisted out of
merge_template_defs to module level so the lookup and the merge share
the one rule for which template an instance came from.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md   |  6 ++++-
 src/app.rs  | 77 ++++++++++++++++++++++++++++++++++++++++++-------------------
 src/main.rs | 36 +++++++++++++++++++++--------
 3 files changed, 84 insertions(+), 35 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index cff34d9..2e5af34 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -721,7 +721,11 @@ kind the template gave it (`ParamKind::name` — slider, float3,
 attribute…) and `Value:` what its text holds right now
 (`ParamDef::value_type` — float, integer, vec3, boolean, enum, string,
 expression, or invalid with the reason; a programmer's terms, by
-request), then `Range: lo..hi` with its step for a slider, float3 or
+request), `Default:` the template's value as written there
+(`State::template_default`, which takes a subnet template's override for
+a child inside an instance — the Embryo's `sphere1` defaults its Radius to
+`chf("../Radius")` — and is absent for a parameter no template names),
+then `Range: lo..hi` with its step for a slider, float3 or
 spinbox (`ParamDef::range`, the numbers `param_display` builds the pane's
 row from — an inline `slider:-2:2`, else the template's min/max, else
 the pane's defaults) and `Options: a, b, c` for a choice. Two lines because
diff --git a/src/app.rs b/src/app.rs
index ee7f246..0c661bf 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -606,6 +606,32 @@ pub fn flatten_node_templates(root: &FsNode) -> Vec<NodeTemplate> {
     out
 }
 
+/// The template an instance came from: a native node by TYPE, a subnet
+/// instance by NAME with its index stripped ("sphere3" → "Sphere",
+/// case-insensitively). The one rule the template merge, and everything
+/// that asks what a node's template says (the row menu's `Default:`),
+/// resolve by.
+pub fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
+    if node.node_type.eq_ignore_ascii_case("node") {
+        let base = node
+            .name
+            .trim_end_matches(|c: char| c.is_ascii_digit())
+            .trim_end_matches(|c: char| c == '_' || c.is_whitespace());
+        // Case-insensitively: the template is "Sphere", its instances
+        // are "sphere1".
+        templates.iter().map(|t| &t.node).find(|t| {
+            t.node_type.eq_ignore_ascii_case("node")
+                && (t.name.eq_ignore_ascii_case(&node.name)
+                    || (!base.is_empty() && t.name.eq_ignore_ascii_case(base)))
+        })
+    } else {
+        templates.iter().map(|t| &t.node).find(|t| {
+            !t.node_type.eq_ignore_ascii_case("node")
+                && t.node_type.eq_ignore_ascii_case(&node.node_type)
+        })
+    }
+}
+
 /// Strip the per-node `meta` (preferences) children from a loaded tree.
 ///
 /// Every geometry node used to carry one, holding four display switches —
@@ -900,26 +926,6 @@ pub fn merge_template_defs(root: &mut FsNode, templates: &[NodeTemplate]) {
     }
     nativize_kernel_subnets(root, templates);
 
-    fn template_for<'a>(node: &FsNode, templates: &'a [NodeTemplate]) -> Option<&'a FsNode> {
-        if node.node_type.eq_ignore_ascii_case("node") {
-            let base = node
-                .name
-                .trim_end_matches(|c: char| c.is_ascii_digit())
-                .trim_end_matches(|c: char| c == '_' || c.is_whitespace());
-            // Case-insensitively: the template is "Sphere", its instances
-            // are "sphere1".
-            templates.iter().map(|t| &t.node).find(|t| {
-                t.node_type.eq_ignore_ascii_case("node")
-                    && (t.name.eq_ignore_ascii_case(&node.name)
-                        || (!base.is_empty() && t.name.eq_ignore_ascii_case(base)))
-            })
-        } else {
-            templates.iter().map(|t| &t.node).find(|t| {
-                !t.node_type.eq_ignore_ascii_case("node")
-                    && t.node_type.eq_ignore_ascii_case(&node.node_type)
-            })
-        }
-    }
     fn merge_params(node: &mut FsNode, template: &FsNode) {
         // A missing parameter goes where the TEMPLATE puts it — after the
         // last template parameter the instance already has — not at the
@@ -4427,6 +4433,22 @@ impl State {
         self.slots.param.as_any().downcast_ref::<ParametersBg>().map(|pb| pb.get_param_rects()).unwrap_or_default()
     }
 
+    /// What the template says `pname` on `node` defaults to, where `dir` is
+    /// the level `node` sits in. A child of a subnet instance (the Embryo's
+    /// `sphere1`) takes the SUBNET template's word for it — its override
+    /// (`chf("../Radius")`) is the default that instance was built with,
+    /// and the merge refreshes the child from there — and any other node
+    /// its own template's. `None` for a parameter no template names, such
+    /// as one added over MCP.
+    pub fn template_default(&self, dir: &FsNode, node: &FsNode, pname: &str) -> Option<&ParamDef> {
+        let from_subnet = template_for(dir, &self.node_templates)
+            .and_then(|t| t.children.iter().find(|c| c.name.eq_ignore_ascii_case(&node.name)))
+            .and_then(|c| c.params.iter().find(|p| p.name == pname));
+        from_subnet.or_else(|| {
+            template_for(node, &self.node_templates).and_then(|t| t.params.iter().find(|p| p.name == pname))
+        })
+    }
+
     /// The rows of a parameter's right-click menu: labels, the action each
     /// runs, and how many leading rows are HEADERS. Split from the open so
     /// a test reads them.
@@ -4439,17 +4461,24 @@ impl State {
     /// the instance's, and they differ exactly when something is off — a
     /// `Control: slider` over `Value: expression` is a row whose slider
     /// cannot be shown, over `Value: invalid (…)` a load that kept a text
-    /// the kind refuses. A control with a range adds `Range: lo..hi`, with
-    /// its step when one is set (`ParamDef::range`, the pane's own
-    /// numbers); a choice adds `Options: a, b, c`.
+    /// the kind refuses. `Default:` is the template's value for the row
+    /// (`template_default`, as written there — an expression shows as the
+    /// expression), left out for a parameter no template names. A control
+    /// with a range adds `Range: lo..hi`, with its step when one is set
+    /// (`ParamDef::range`, the pane's own numbers); a choice adds
+    /// `Options: a, b, c`.
     pub fn param_menu_rows(&self, slot: usize, pname: &str) -> (Vec<String>, Vec<ParamMenuAction>, usize) {
-        let child = &self.param_editor_dir().children[slot];
+        let dir = self.param_editor_dir();
+        let child = &dir.children[slot];
         let param = child.params.iter().find(|p| p.name == pname);
         let is_expr = param.is_some_and(|p| p.is_expr());
         let (control, value) = param
             .map(|p| (p.kind().name().to_string(), p.value_type()))
             .unwrap_or_else(|| ("?".to_string(), "?".to_string()));
         let mut options = vec![format!("Control: {control}"), format!("Value: {value}")];
+        if let Some(d) = self.template_default(dir, child, pname) {
+            options.push(format!("Default: {}", d.text()));
+        }
         if let Some((lo, hi, step)) = param.and_then(|p| p.range()) {
             let fmt = |v: f32| crate::expr::fmt_num(v as f64);
             let step = step.map(|s| format!(", step {}", fmt(s))).unwrap_or_default();
diff --git a/src/main.rs b/src/main.rs
index 917f140..d2e5ffa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7756,26 +7756,42 @@ mod tests {
         assert!(!state.viewport_menu_open());
         assert_eq!(
             state.param_menu_actions,
-            vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression],
+            vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression],
             "nothing copied yet, and the row holds a value"
         );
         // The header rows read the parameter out: its control's kind, its
-        // value's type in a programmer's terms, and the control's range.
-        // Radius is a slider holding a float, clamped to the pane's default
-        // 0..2 since the template declares no range.
+        // value's type in a programmer's terms, the template's default and
+        // the control's range. Radius is a slider holding a float, clamped
+        // to the pane's default 0..2 since the template declares no range.
         let shown = cce_ui::widget::context_menu::options();
-        assert_eq!(&shown[..4], &["Control: slider".to_string(), "Value: float".to_string(), "Range: 0..2".to_string(), "-".to_string()]);
+        assert_eq!(
+            &shown[..5],
+            &["Control: slider".to_string(), "Value: float".to_string(), "Default: 0.5".to_string(), "Range: 0..2".to_string(), "-".to_string()]
+        );
         let (_, _, headers) = state.param_menu_rows(sphere, "Radius");
-        assert_eq!(headers, 3);
+        assert_eq!(headers, 4);
         // An inline range, a spinbox's range and step, a choice's options.
         let (rows, _, h) = state.param_menu_rows(sphere, "Center X");
-        assert_eq!((&rows[2], h), (&"Range: -2..2".to_string(), 3));
+        assert_eq!((&rows[3], h), (&"Range: -2..2".to_string(), 4));
         let (rows, _, _) = state.param_menu_rows(sphere, "Rows");
-        assert_eq!(rows[2], "Range: 2..128, step 1");
+        assert_eq!((&rows[2], &rows[3]), (&"Default: 16".to_string(), &"Range: 2..128, step 1".to_string()));
         let (rows, _, h) = state.param_menu_rows(sphere, "Method");
-        assert_eq!((&rows[2], h), (&"Options: UV, Icosphere, Cube".to_string(), 3));
+        assert_eq!((&rows[2], &rows[3], h), (&"Default: UV".to_string(), &"Options: UV, Icosphere, Cube".to_string(), 4));
         let (rows, _, h) = state.param_menu_rows(sphere, "Color");
-        assert_eq!((rows[2].as_str(), h), ("-", 2), "a toggle has neither a range nor options");
+        assert_eq!((rows[2].as_str(), rows[3].as_str(), h), ("Default: true", "-", 3), "a toggle has neither a range nor options");
+        // A parameter no template names has no default row.
+        state.apply_action(McpAction::AddParam { slot: sphere, name: "Extra".into(), param_type: "float".into(), default: "3".into() }, &mut redraw).unwrap();
+        let (rows, _, h) = state.param_menu_rows(sphere, "Extra");
+        assert_eq!((rows[2].as_str(), h), ("-", 2));
+        // Inside a subnet instance the SUBNET template's override is the
+        // default: the Embryo's sphere1 was built with an expression.
+        state.apply_action(McpAction::AddNode { template_name: "Embryo".into(), name: Some("embryo1".into()), x: 3.0, y: 8.0 }, &mut redraw).unwrap();
+        let embryo = slot_of(&state, "embryo1");
+        state.apply_action(McpAction::Enter { slot: embryo }, &mut redraw).unwrap();
+        let inner = slot_of(&state, "sphere1");
+        let (rows, _, _) = state.param_menu_rows(inner, "Radius");
+        assert_eq!(rows[2], "Default: chf(\"../Radius\")");
+        state.apply_action(McpAction::Up, &mut redraw).unwrap();
         // A click on a header runs nothing.
         state.run_param_action(&state.current_dir().children[sphere].id.clone(), "Radius", ParamMenuAction::Info);
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Right });