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

commite7f24ae5091ad43fc9385e80ea8c97225f67dac1
parent4ff0738149
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 10:30
feat: a parameter's row menu reads out its control kind and its value type

Two header rows at the top of the params pane's right-click menu:
`Control:` is the parameter's kind as the template gave it
(ParamKind::name — slider, float3, attribute…) and `Value:` what its text
holds right now (ParamDef::value_type — number, whole number, vector,
true/false, option, text, expression, or invalid with the reason). 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 kept a text the kind refuses.

They are the context menu's header rows — dimmed, never hovered — and
ParamMenuAction::Info runs nothing. param_menu_rows is split from the
open so a test reads the rows; the row-menu test checks the readout for
a slider holding a number, the same slider holding an expression, a
choice and a spinbox.

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

 CLAUDE.md    | 12 +++++++++++-
 src/app.rs   | 44 ++++++++++++++++++++++++++++++++++----------
 src/main.rs  | 23 ++++++++++++++++++++++-
 src/param.rs | 38 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 105 insertions(+), 12 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ce5649f..f563251 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -715,7 +715,17 @@ 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. The paste writes `chs()` when the
+and paste still pastes the right path. **Two header rows read the
+parameter out** (since 2026-09-28, `param_menu_rows`): `Control:` is the
+kind the template gave it (`ParamKind::name` — slider, float3,
+attribute…) and `Value:` what its text holds right now
+(`ParamDef::value_type` — number, whole number, vector, true/false,
+option, text, expression, or invalid with the reason). 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
+kept a text the kind refuses. They are the context menu's header rows —
+dimmed, never hovered — and `ParamMenuAction::Info` runs nothing. The paste writes `chs()` when the
 target row holds text or a choice and `ch()` otherwise, by the TARGET,
 because that is what the value has to fit. Expression rows draw with a
 green tint (`render.rs`, PARAM_IDX arm) and as text in the pane
diff --git a/src/app.rs b/src/app.rs
index 26e0616..1cb430c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -384,6 +384,9 @@ pub enum ParamMenuAction {
     /// Houdini's Delete Channels: the expression's CURRENT value, as a value.
     DeleteExpression,
     Separator,
+    /// A header row that reads something out — the control's kind, the
+    /// value's type — and runs nothing.
+    Info,
 }
 
 /// The viewport menubar's Guides menu and its items, by position — the one
@@ -4428,14 +4431,28 @@ impl State {
         self.slots.param.as_any().downcast_ref::<ParametersBg>().map(|pb| pb.get_param_rects()).unwrap_or_default()
     }
 
-    /// Open a parameter row's right-click menu.
-    fn open_param_context_menu(&mut self, slot: usize, pname: String) {
-        let (node_id, is_expr) = {
-            let child = &self.param_editor_dir().children[slot];
-            (child.id.clone(), child.params.iter().find(|p| p.name == pname).is_some_and(|p| p.is_expr()))
-        };
-        let mut options = vec!["Copy Parameter".to_string()];
-        let mut actions = vec![ParamMenuAction::CopyParameter];
+    /// 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.
+    ///
+    /// The two headers read the parameter out: `Control:` is its kind
+    /// (`ParamKind::name` — slider, float3, attribute…) and `Value:` what
+    /// its text holds right now (`ParamDef::value_type` — number,
+    /// expression, invalid…). Two lines rather than one because they
+    /// answer different questions: the first is the template's, the second
+    /// 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.
+    pub fn param_menu_rows(&self, slot: usize, pname: &str) -> (Vec<String>, Vec<ParamMenuAction>, usize) {
+        let child = &self.param_editor_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}"), "-".to_string(), "Copy Parameter".to_string()];
+        let mut actions = vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter];
         if self.copied_param.is_some() {
             options.push("Paste Relative Reference".to_string());
             actions.push(ParamMenuAction::PasteRelative);
@@ -4451,8 +4468,15 @@ impl State {
             options.push("Edit Expression".to_string());
             actions.push(ParamMenuAction::EditExpression);
         }
+        (options, actions, 2)
+    }
+
+    /// Open a parameter row's right-click menu.
+    fn open_param_context_menu(&mut self, slot: usize, pname: String) {
+        let node_id = self.param_editor_dir().children[slot].id.clone();
+        let (options, actions, headers) = self.param_menu_rows(slot, &pname);
         let target = self.slots.get_dyn(crate::slots::PARAM_IDX).base().id();
-        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, 0, target);
+        cce_ui::widget::context_menu::show(self.cursor_x, self.cursor_y, options, headers, target);
         self.param_menu_active = true;
         self.param_menu_actions = actions;
         self.param_menu_target = Some((node_id, pname));
@@ -4496,7 +4520,7 @@ impl State {
             .map(|n| format!("/{}", n.join("/")))
             .unwrap_or_else(|| node_id.to_string());
         match action {
-            ParamMenuAction::Separator => return,
+            ParamMenuAction::Separator | ParamMenuAction::Info => return,
             ParamMenuAction::CopyParameter => {
                 self.copied_param = Some((node_id.to_string(), pname.to_string()));
                 self.update_status_text(&format!("Copied {node_label}/{pname} — paste it as a reference on another parameter."));
diff --git a/src/main.rs b/src/main.rs
index ceff1e8..0f65108 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7754,7 +7754,19 @@ mod tests {
         state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Right });
         assert!(state.param_menu_open(), "a right press on a row opens its menu");
         assert!(!state.viewport_menu_open());
-        assert_eq!(state.param_menu_actions, vec![ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression], "nothing copied yet, and the row holds a value");
+        assert_eq!(
+            state.param_menu_actions,
+            vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression],
+            "nothing copied yet, and the row holds a value"
+        );
+        // The two header rows read the parameter out: its control's kind
+        // and its value's type. Radius is a slider holding a number.
+        let shown = cce_ui::widget::context_menu::options();
+        assert_eq!(&shown[..3], &["Control: slider".to_string(), "Value: number".to_string(), "-".to_string()]);
+        let (_, _, headers) = state.param_menu_rows(sphere, "Radius");
+        assert_eq!(headers, 2);
+        // 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 });
 
         // Copy, then paste onto ball's Radius — a sibling, so `../sphere1`.
@@ -7792,6 +7804,15 @@ mod tests {
         state.run_param_action(&ball_id, "Radius", ParamMenuAction::EditExpression);
         assert_eq!(radius(&state, ball).text(), "0.9");
         assert!(radius(&state, ball).is_expr());
+        // …and the menu's readout says so: the control is still a slider,
+        // the value is now an expression. Method is a choice holding an
+        // option, Rows a spinbox holding a whole number.
+        let (rows, _, _) = state.param_menu_rows(ball, "Radius");
+        assert_eq!(&rows[..2], &["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: option".to_string()]);
+        let (rows, _, _) = state.param_menu_rows(ball, "Rows");
+        assert_eq!(&rows[..2], &["Control: spinbox".to_string(), "Value: whole number".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();
diff --git a/src/param.rs b/src/param.rs
index 89385dd..decb5a3 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -78,6 +78,25 @@ impl ParamKind {
     pub const NAMES: &'static [&'static str] =
         &["text", "float", "slider", "spinbox", "float3", "choice", "toggle", "button", "code", "node", "attribute", "group"];
 
+    /// The kind's name — the type-string head that names it, and what
+    /// the row menu's `Control:` readout shows.
+    pub fn name(self) -> &'static str {
+        match self {
+            Self::Text => "text",
+            Self::Float => "float",
+            Self::Slider => "slider",
+            Self::Spin => "spinbox",
+            Self::Float3 => "float3",
+            Self::Choice => "choice",
+            Self::Toggle => "toggle",
+            Self::Button => "button",
+            Self::Code => "code",
+            Self::Node => "node",
+            Self::Attribute => "attribute",
+            Self::Group => "group",
+        }
+    }
+
     /// The kind a `type` string names, or `None` when it names none.
     pub fn parse(ty: &str) -> Option<Self> {
         let head = ty.split(':').next().unwrap_or("").trim();
@@ -379,6 +398,25 @@ impl ParamDef {
         self.slot == ParamSlot::Expr
     }
 
+    /// What the parameter HOLDS right now, in words — the row menu's
+    /// `Value:` readout: the parsed value's type (`number`, `whole number`,
+    /// `vector`, `true/false`, `option`, `text`), `expression` for one
+    /// still to be evaluated, or `invalid` with the reason. The control's
+    /// kind is [`ParamKind::name`]; the two differ exactly when the text
+    /// is not a value of the kind, which is what the pair is for.
+    pub fn value_type(&self) -> String {
+        match &self.slot {
+            ParamSlot::Value(ParamValue::Number(_)) => "number".to_string(),
+            ParamSlot::Value(ParamValue::Int(_)) => "whole number".to_string(),
+            ParamSlot::Value(ParamValue::Vec3(_)) => "vector".to_string(),
+            ParamSlot::Value(ParamValue::Bool(_)) => "true/false".to_string(),
+            ParamSlot::Value(ParamValue::Choice(_)) => "option".to_string(),
+            ParamSlot::Value(ParamValue::Text(_)) => "text".to_string(),
+            ParamSlot::Expr => "expression".to_string(),
+            ParamSlot::Invalid(why) => format!("invalid ({why})"),
+        }
+    }
+
     /// Why the text does not fit the kind, when it does not.
     pub fn invalid(&self) -> Option<&str> {
         match &self.slot {