graphic design tool
git clone https://git.lucas.co/cce-designer.git
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 {