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

commita07114e516301614a6f8fb89eae5fabb91bea903
parente1276057e7
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 10:47
feat: the row menu reads out the parameter's name, label and condition

`Name:` heads the header rows — the parameter's name, what a ch() path
and a wire spell — with `Label:` under it only when the template gives
one, since the pane shows the name otherwise and a Label row repeating
it would claim there is one. `Shown when:` closes the headers with the
row's show_when condition when it has one. The row-menu test now finds
each readout by its text rather than its position, so the next row
added does not shift every assertion.

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

 CLAUDE.md   | 11 ++++++++---
 src/app.rs  | 17 ++++++++++++++--
 src/main.rs | 64 +++++++++++++++++++++++++++++++++++++------------------------
 3 files changed, 62 insertions(+), 30 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 2e5af34..4e44648 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -715,8 +715,11 @@ Relative Reference** (`relative_ref_path`: `../sphere1`), **Paste Absolute
 Reference** (`/sphere1`), and **Edit Expression** / **Delete Expression** —
 the latter bakes the CURRENT value back as a value, as Delete Channels
 does. `copied_param` holds a node ID, not a path, so a rename between copy
-and paste still pastes the right path. **Two header rows read the
-parameter out** (since 2026-09-28, `param_menu_rows`): `Control:` is the
+and paste still pastes the right path. **Header rows read the
+parameter out** (since 2026-09-28, `param_menu_rows`): `Name:` is the
+parameter's name, what a `ch()` path spells, with `Label:` under it only
+when the template gives one (the pane shows the name otherwise, and a
+Label row repeating it would claim there is one); `Control:` is the
 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,
@@ -728,7 +731,9 @@ a child inside an instance — the Embryo's `sphere1` defaults its Radius to
 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
+the pane's defaults), `Options: a, b, c` for a choice, and `Shown when:`
+with the row's `show_when` condition when it has one. Control and Value
+are two lines because
 they answer different questions, the template's and the instance's, and
 they differ exactly when something is off: a slider over an expression is
 a row whose slider cannot be drawn, a slider over `invalid (…)` a load that
diff --git a/src/app.rs b/src/app.rs
index 0c661bf..53eb5ac 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -4466,7 +4466,12 @@ impl State {
     /// 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`.
+    /// `Options: a, b, c`. Around those, `Name:` heads the list — the
+    /// parameter's name, which is what a `ch()` path and a wire spell —
+    /// with `Label:` after it only when the template gives one (the pane
+    /// shows the name otherwise, and a Label row repeating it would say
+    /// there is one), and `Shown when:` closes it with the row's
+    /// `show_when` condition when it has one.
     pub fn param_menu_rows(&self, slot: usize, pname: &str) -> (Vec<String>, Vec<ParamMenuAction>, usize) {
         let dir = self.param_editor_dir();
         let child = &dir.children[slot];
@@ -4475,7 +4480,12 @@ impl State {
         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}")];
+        let mut options = vec![format!("Name: {pname}")];
+        if let Some(label) = param.map(|p| p.label.as_str()).filter(|l| !l.is_empty()) {
+            options.push(format!("Label: {label}"));
+        }
+        options.push(format!("Control: {control}"));
+        options.push(format!("Value: {value}"));
         if let Some(d) = self.template_default(dir, child, pname) {
             options.push(format!("Default: {}", d.text()));
         }
@@ -4487,6 +4497,9 @@ impl State {
         if let Some(p) = param.filter(|p| p.kind() == ParamKind::Choice) {
             options.push(format!("Options: {}", p.choice_options().join(", ")));
         }
+        if let Some(cond) = param.map(|p| p.show_when.as_str()).filter(|c| !c.is_empty()) {
+            options.push(format!("Shown when: {cond}"));
+        }
         let headers = options.len();
         let mut actions = vec![ParamMenuAction::Info; headers];
         options.push("-".to_string());
diff --git a/src/main.rs b/src/main.rs
index d2e5ffa..5d6aded 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7756,41 +7756,55 @@ mod tests {
         assert!(!state.viewport_menu_open());
         assert_eq!(
             state.param_menu_actions,
-            vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression],
+            vec![ParamMenuAction::Info, 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, 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.
+        // The header rows read the parameter out: its name, its control's
+        // kind, its 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; it has no label and no condition, so neither
+        // row appears.
         let shown = cce_ui::widget::context_menu::options();
         assert_eq!(
-            &shown[..5],
-            &["Control: slider".to_string(), "Value: float".to_string(), "Default: 0.5".to_string(), "Range: 0..2".to_string(), "-".to_string()]
+            &shown[..6],
+            &["Name: Radius".to_string(), "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, 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[3], h), (&"Range: -2..2".to_string(), 4));
-        let (rows, _, _) = state.param_menu_rows(sphere, "Rows");
-        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], &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(), 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.
+        assert_eq!(headers, 5);
+        // The headers are the rows before the separator; each one is
+        // looked up by its readout, not its position.
+        let headers_of = |state: &State, pname: &str| -> Vec<String> {
+            let (rows, _, h) = state.param_menu_rows(sphere, pname);
+            assert_eq!(rows[h], "-");
+            rows[..h].to_vec()
+        };
+        // An inline range, a spinbox's range and step, a choice's options,
+        // and a conditional row's condition.
+        assert!(headers_of(&state, "Center X").contains(&"Range: -2..2".to_string()));
+        let rows = headers_of(&state, "Rows");
+        for want in ["Default: 16", "Range: 2..128, step 1", "Shown when: Method == UV"] {
+            assert!(rows.contains(&want.to_string()), "{want} missing from {rows:?}");
+        }
+        let rows = headers_of(&state, "Method");
+        for want in ["Default: UV", "Options: UV, Icosphere, Cube"] {
+            assert!(rows.contains(&want.to_string()), "{want} missing from {rows:?}");
+        }
+        assert_eq!(headers_of(&state, "Color"), vec!["Name: Color", "Control: toggle", "Value: boolean", "Default: true"], "a toggle has neither a range nor options");
+        // A parameter no template names has no default row; one with a
+        // label shows it under the name.
         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));
+        assert_eq!(headers_of(&state, "Extra"), vec!["Name: Extra", "Control: float", "Value: float"]);
+        state.current_dir_mut().children[sphere].params.iter_mut().find(|p| p.name == "Extra").unwrap().label = "Extra Size".into();
+        assert_eq!(headers_of(&state, "Extra")[..2], ["Name: Extra".to_string(), "Label: Extra Size".to_string()]);
         // 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\")");
+        let (rows, _, h) = state.param_menu_rows(inner, "Radius");
+        assert!(rows[..h].contains(&"Default: chf(\"../Radius\")".to_string()), "{rows:?}");
         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);
@@ -7835,11 +7849,11 @@ mod tests {
         // the value is now an expression. Method is a choice holding an
         // enum, Rows a spinbox holding an integer.
         let (rows, _, _) = state.param_menu_rows(ball, "Radius");
-        assert_eq!(&rows[..2], &["Control: slider".to_string(), "Value: expression".to_string()]);
+        assert_eq!(&rows[1..3], &["Control: slider".to_string(), "Value: expression".to_string()]);
         let (rows, _, _) = state.param_menu_rows(ball, "Method");
-        assert_eq!(&rows[..2], &["Control: choice".to_string(), "Value: enum".to_string()]);
+        assert_eq!(&rows[1..3], &["Control: choice".to_string(), "Value: enum".to_string()]);
         let (rows, _, _) = state.param_menu_rows(ball, "Rows");
-        assert_eq!(&rows[..2], &["Control: spinbox".to_string(), "Value: integer".to_string()]);
+        assert_eq!(&rows[1..3], &["Control: spinbox".to_string(), "Value: integer".to_string()]);
 
         // And a reference typed straight into a row (or scripted) becomes one.
         state.apply_action(McpAction::SetParam { slot: ball, name: "Rows".into(), value: "chi(\"../sphere1/Rows\") * 2".into() }, &mut redraw).unwrap();