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

commit843cc30785c92c3e2841d1df3bdae7127a9d09b5
parent96c91032ac
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 23:24
fix: the row menu's Control is the control drawn, its Type the type that control sets

The header rows read Control as the parameter's kind name, Type as the
raw type string and a third Value row as the type of the text held, so
the pull node's Value — a text parameter the pane presents as sliders
over a float3 — read `text` / `text` / `string` over what was plainly a
slider setting a vector. Control is now the control the pane draws
(slider, spinbox, dropdown, toggle, text box…) and Type the type of value
it sets (float, float3, integer, boolean, enum, string…), both read off
the row as the pane shows it. The Value row is gone: the Type row is the
value's type. An `Invalid:` row carries the reason a kept text does not
fit its kind, only when there is one, and the Range row shows the
presented row's range for a parameter that declares none.
ParamDef::value_type had no other reader and goes with the row.

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

 CLAUDE.md    |  35 +++++++++-------
 src/app.rs   | 135 +++++++++++++++++++++++++++++++++++++++++++++--------------
 src/main.rs  |  64 +++++++++++++++++++---------
 src/param.rs |  22 +---------
 4 files changed, 168 insertions(+), 88 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 303c641..a711454 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -742,15 +742,21 @@ 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…), `Type:` the raw type string that kind parsed from
-(`slider:-2:2`, `choice:UV,Icosphere,Cube`, `string` for an absent one)
-exactly as the template or the file wrote it, 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), `Expression:` the row's expression FLAG as `true` / `false`
-(the bit Edit Expression sets and Delete Expression clears, stated on
-its own even though Value reads `expression` when it is set), `Default:`
+control the pane DRAWS for the row (slider, spinbox, dropdown, toggle,
+text box, text box with picker, code editor, button) and `Type:` the type
+of value that control SETS, in a programmer's terms (float, float3,
+integer, boolean, enum, string, and node / attribute / group for a text
+that names one) — `control_and_type`, read off the row as the pane shows
+it, since the Attribute node's Value is a text parameter presented as
+sliders over a float3 and an expression is a text box whatever its kind.
+Until later the same day Control was the kind's name, Type the raw type
+string and a third `Value:` row the type of the text held, which read
+`text` / `text` / `string` over what was plainly a slider setting a
+vector; the Value row is gone, the Type row being the value's type, and
+the raw string's content is the range and options rows. `Expression:` is
+the row's expression FLAG as `true` / `false` (the bit Edit Expression
+sets and Delete Expression clears), `Invalid:` the reason a kept text does
+not fit its kind, shown only then, `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
@@ -760,13 +766,10 @@ template DECLARES them (`ParamDef::declared_range`, an inline
 `slider:-2:2` included, `none` where it says nothing) followed by
 `Range: lo..hi` with its step as the pane APPLIES it (`ParamDef::range`,
 the numbers `param_display` builds the pane's row from — the declared
-ends, else 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
-kept a text the kind refuses. They are the context menu's header rows —
+ends, else the pane's defaults — or the presented row's own range for a
+parameter that has none, the Value row's `VALUE_ROW_RANGE`),
+`Options: a, b, c` for a choice, and `Shown when:`
+with the row's `show_when` condition when it has one. 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
diff --git a/src/app.rs b/src/app.rs
index a25cf5e..a993d60 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -555,6 +555,59 @@ pub struct NodeTemplate {
     pub node: FsNode,
 }
 
+/// The row menu's `Control:` and `Type:` readouts for a row the pane shows
+/// as `shown` (a display type string) over a parameter of `kind`: the
+/// control drawn, and the type of value it sets. The control follows what
+/// is DRAWN — a float3 row is sliders, an expression a text box — and the
+/// type what is SET: a presented float3 sets a float3 whatever the
+/// parameter's kind, and otherwise the kind says (a `float` parameter in a
+/// text box still sets a float).
+pub fn control_and_type(shown: &str, kind: ParamKind) -> (&'static str, &'static str) {
+    let head = shown.split(':').next().unwrap_or("");
+    let control = match head {
+        "slider" | "float3" => "slider",
+        "spinbox" => "spinbox",
+        "choice" => "dropdown",
+        "toggle" | "checkbox" => "toggle",
+        "button" => "button",
+        "code" => "code editor",
+        "textpick" => "text box with picker",
+        "ramp" => "ramp",
+        "color" | "rgb" | "rgba" => "color picker",
+        _ => "text box",
+    };
+    let ty = if head == "float3" {
+        "float3"
+    } else {
+        match kind {
+            ParamKind::Slider | ParamKind::Float => "float",
+            ParamKind::Spin => "integer",
+            ParamKind::Float3 => "float3",
+            ParamKind::Toggle => "boolean",
+            ParamKind::Choice => "enum",
+            ParamKind::Text | ParamKind::Code => "string",
+            ParamKind::Node => "node",
+            ParamKind::Attribute => "attribute",
+            ParamKind::Group => "group",
+            ParamKind::Button => "none",
+        }
+    };
+    (control, ty)
+}
+
+/// The range a presented row carries in its display type (`slider:lo:hi`,
+/// `float3:lo:hi`), for a row whose parameter declares none of its own.
+fn shown_row_range(shown: &str) -> Option<(f32, f32, Option<f32>)> {
+    let mut parts = shown.split(':');
+    let head = parts.next()?;
+    if head != "slider" && head != "float3" {
+        return None;
+    }
+    let lo = parts.next()?.parse::<f32>().ok()?;
+    let hi = parts.next()?.parse::<f32>().ok()?;
+    Some((lo, hi, None))
+}
+
 /// The Attribute node's Value row's range when it is presented as a float3
 /// (`add_pick_lists`): wide, because a float3 row clamps to its range and
 /// Pos is set to whatever the scene needs. A drag is coarse at this width;
@@ -4631,31 +4684,31 @@ impl State {
     /// 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` — float, integer,
-    /// boolean, 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. `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`. `Type:` is the raw type STRING under
-    /// `Control:` — `slider:-2:2`, `choice:UV,Icosphere,Cube`, or `string`
-    /// for an absent one — exactly as the template or the file wrote it,
-    /// where Control is the kind that string parses to. Ahead of `Range:`, `Min:` / `Max:` / `Step:`
-    /// are what the template DECLARES (`ParamDef::declared_range`, an
-    /// inline `slider:-2:2` included), `none` where it declares nothing —
-    /// so Range is the clamp the pane applies and the three above it say
-    /// how much of that the template chose. `Expression:` is the row's expression FLAG,
-    /// `true` or `false` — the thing `ParamDef::expr` stores, which is
-    /// what Edit Expression sets and Delete Expression clears; `Value:`
-    /// already reads `expression` when it is set, and the flag row states
-    /// the bit itself. Around those, `Name:` heads the list — the
+    /// The headers read the parameter out. `Control:` is the control the
+    /// pane DRAWS for the row and `Type:` the type of value that control
+    /// sets, in a programmer's terms — both read off the row as the pane
+    /// shows it (`display_row_type`), not off the parameter's kind alone,
+    /// because the two part ways: the Attribute node's Value is a text
+    /// parameter the pane presents as three sliders over a float3
+    /// (`add_pick_lists`), and an expression is a text box whatever its
+    /// kind. Until 2026-09-28 Control was the kind's name, Type the raw
+    /// type string and a third `Value:` row the type of the text held, so
+    /// that Value row read `Control: text`, `Type: text`, `Value: string`
+    /// over a control that was plainly a slider setting a vector. The raw
+    /// type string's content is the rows below it (the range, the options).
+    /// `Expression:` is the row's expression FLAG, `true` or `false` — the
+    /// thing `ParamDef::expr` stores, which is what Edit Expression sets
+    /// and Delete Expression clears. `Invalid:` appears only for a text the
+    /// kind refuses, with the reason. `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 shows `Min:` / `Max:` / `Step:` as the
+    /// template DECLARES them (`ParamDef::declared_range`, an inline
+    /// `slider:-2:2` included), `none` where it declares nothing, then
+    /// `Range: lo..hi` as the pane APPLIES it, with its step when one is
+    /// set — the parameter's own range, or the presented row's when the
+    /// parameter has none (the Value row's `VALUE_ROW_RANGE`). A choice
+    /// adds `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
@@ -4666,21 +4719,27 @@ impl State {
         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 shown = param.map(|p| self.display_row_type(slot, p)).unwrap_or_default();
+        let (control, ty) = param
+            .map(|p| control_and_type(&shown, p.kind()))
+            .unwrap_or(("?", "?"));
         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!("Type: {}", param.map(|p| p.ty()).unwrap_or("?")));
-        options.push(format!("Value: {value}"));
+        options.push(format!("Type: {ty}"));
         options.push(format!("Expression: {is_expr}"));
+        if let Some(why) = param.and_then(|p| p.invalid()) {
+            options.push(format!("Invalid: {why}"));
+        }
         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()) {
+        // The range the pane applies: the parameter's own, or — for a text
+        // parameter presented as a ranged control — the presented row's.
+        let applied = param.and_then(|p| p.range()).or_else(|| shown_row_range(&shown));
+        if let Some((lo, hi, step)) = applied {
             let fmt = |v: f32| crate::expr::fmt_num(v as f64);
             let declared = |v: Option<f32>| v.map(fmt).unwrap_or_else(|| "none".to_string());
             let (min, max, dstep) = param.map(|p| p.declared_range()).unwrap_or_default();
@@ -4720,6 +4779,20 @@ impl State {
         (options, actions, headers)
     }
 
+    /// The row type the pane shows `param` of node `slot` under — the
+    /// pane's own row when it is showing that node (so the picker and
+    /// float3 presentations `add_pick_lists` makes are seen), what
+    /// `param_display` alone would give otherwise.
+    fn display_row_type(&self, slot: usize, param: &ParamDef) -> String {
+        let key = if param.label.is_empty() { &param.name } else { &param.label };
+        if self.param_editor_selected() == Some(slot) {
+            if let Some(row) = self.param().node_params().into_iter().find(|r| r.0 == *key) {
+                return row.2;
+            }
+        }
+        param_display(std::slice::from_ref(param)).into_iter().next().map(|r| r.2).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 = self.param_editor_dir().children[slot].id.clone();
diff --git a/src/main.rs b/src/main.rs
index 331ea18..e104a6b 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7761,26 +7761,28 @@ mod tests {
         assert!(!state.viewport_menu_open());
         assert_eq!(
             state.param_menu_actions,
-            vec![ParamMenuAction::Info; 10].into_iter().chain([ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression]).collect::<Vec<_>>(),
+            vec![ParamMenuAction::Info; 9].into_iter().chain([ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression]).collect::<Vec<_>>(),
             "nothing copied yet, and the row holds a value"
         );
-        // 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.
+        // The header rows read the parameter out: its name, the control
+        // the pane draws, the type of value that control sets in a
+        // programmer's terms, the template's default and the control's
+        // range. Radius is a slider setting 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. There is no
+        // Value row: the Type row is the value's type.
         let shown = cce_ui::widget::context_menu::options();
         assert_eq!(
-            &shown[..11],
+            &shown[..10],
             &[
-                "Name: Radius".to_string(), "Control: slider".to_string(), "Type: slider".to_string(), "Value: float".to_string(),
+                "Name: Radius".to_string(), "Control: slider".to_string(), "Type: float".to_string(),
                 "Expression: false".to_string(), "Default: 0.5".to_string(), "Min: none".to_string(), "Max: none".to_string(),
                 "Step: none".to_string(), "Range: 0..2".to_string(), "-".to_string(),
             ]
         );
+        assert!(shown.iter().all(|r| !r.starts_with("Value:")), "{shown:?}");
         let (_, _, headers) = state.param_menu_rows(sphere, "Radius");
-        assert_eq!(headers, 10);
+        assert_eq!(headers, 9);
         // 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> {
@@ -7791,7 +7793,7 @@ mod tests {
         // An inline range, a spinbox's range and step, a choice's options,
         // and a conditional row's condition.
         let rows = headers_of(&state, "Center X");
-        for want in ["Type: slider:-2:2", "Min: -2", "Max: 2", "Step: none", "Range: -2..2"] {
+        for want in ["Control: slider", "Type: float", "Min: -2", "Max: 2", "Step: none", "Range: -2..2"] {
             assert!(rows.contains(&want.to_string()), "{want} missing from {rows:?}");
         }
         let rows = headers_of(&state, "Rows");
@@ -7799,14 +7801,15 @@ mod tests {
             assert!(rows.contains(&want.to_string()), "{want} missing from {rows:?}");
         }
         let rows = headers_of(&state, "Method");
-        for want in ["Type: choice:UV,Icosphere,Cube", "Default: UV", "Options: UV, Icosphere, Cube"] {
+        for want in ["Control: dropdown", "Type: enum", "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", "Type: toggle", "Value: boolean", "Expression: false", "Default: true"], "a toggle has neither a range nor options");
+        assert_eq!(headers_of(&state, "Color"), vec!["Name: Color", "Control: toggle", "Type: boolean", "Expression: false", "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();
-        assert_eq!(headers_of(&state, "Extra"), vec!["Name: Extra", "Control: float", "Type: float", "Value: float", "Expression: false"]);
+        // A `float` parameter is drawn as a text box, and sets a float.
+        assert_eq!(headers_of(&state, "Extra"), vec!["Name: Extra", "Control: text box", "Type: float", "Expression: false"]);
         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
@@ -7857,15 +7860,36 @@ 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
-        // enum, Rows a spinbox holding an integer.
+        // …and the menu's readout says so: an expression is drawn as a
+        // text box, and still sets the float its slider would. Method is a
+        // dropdown setting an enum, Rows a spinbox setting an integer.
+        show(&mut state, ball);
         let (rows, _, _) = state.param_menu_rows(ball, "Radius");
-        assert_eq!(&rows[1..5], &["Control: slider".to_string(), "Type: slider".to_string(), "Value: expression".to_string(), "Expression: true".to_string()]);
+        assert_eq!(&rows[1..4], &["Control: text box".to_string(), "Type: float".to_string(), "Expression: true".to_string()]);
         let (rows, _, _) = state.param_menu_rows(ball, "Method");
-        assert_eq!((&rows[1], &rows[3]), (&"Control: choice".to_string(), &"Value: enum".to_string()));
+        assert_eq!((&rows[1], &rows[2]), (&"Control: dropdown".to_string(), &"Type: enum".to_string()));
         let (rows, _, _) = state.param_menu_rows(ball, "Rows");
-        assert_eq!((&rows[1], &rows[3]), (&"Control: spinbox".to_string(), &"Value: integer".to_string()));
+        assert_eq!((&rows[1], &rows[2]), (&"Control: spinbox".to_string(), &"Type: integer".to_string()));
+
+        // The pull node: a text parameter the pane presents as sliders
+        // over a float3. The menu reads the control drawn and the type it
+        // sets, with the presented row's range; a text that stays a text
+        // box is a string.
+        state.apply_action(McpAction::AddNode { template_name: "Attribute".into(), name: Some("pull1".into()), x: 5.0, y: 8.0 }, &mut redraw).unwrap();
+        let pull = slot_of(&state, "pull1");
+        for (name, value) in [("Input", "sphere1"), ("Operation", "Modify"), ("Attribute Name", "Pos"), ("Value", "0.00:0.06:0.00")] {
+            state.apply_action(McpAction::SetParam { slot: pull, name: name.into(), value: value.into() }, &mut redraw).unwrap();
+        }
+        show(&mut state, pull);
+        let (rows, _, h) = state.param_menu_rows(pull, "Value");
+        let want: Vec<String> = ["Name: Value", "Control: slider", "Type: float3", "Expression: false"].iter().map(|s| s.to_string()).collect();
+        assert_eq!(&rows[..4], &want[..], "{rows:?}");
+        assert!(rows[..h].contains(&"Range: -1000..1000".to_string()), "{rows:?}");
+        assert!(rows[..h].iter().all(|r| !r.starts_with("Value:")), "{rows:?}");
+        state.apply_action(McpAction::SetParam { slot: pull, name: "Value".into(), value: "0.06".into() }, &mut redraw).unwrap();
+        show(&mut state, pull);
+        let (rows, _, _) = state.param_menu_rows(pull, "Value");
+        assert_eq!((&rows[1], &rows[2]), (&"Control: text box".to_string(), &"Type: string".to_string()), "a broadcast number stays a text box");
 
         // 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 b1067b5..b6ec600 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -78,8 +78,7 @@ 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.
+    /// The kind's name — the type-string head that names it.
     pub fn name(self) -> &'static str {
         match self {
             Self::Text => "text",
@@ -398,25 +397,6 @@ impl ParamDef {
         self.slot == ParamSlot::Expr
     }
 
-    /// What the parameter HOLDS right now — the row menu's `Value:`
-    /// readout: the parsed value's type in a programmer's terms (`float`,
-    /// `integer`, `vec3`, `boolean`, `enum`, `string`), `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(_)) => "float".to_string(),
-            ParamSlot::Value(ParamValue::Int(_)) => "integer".to_string(),
-            ParamSlot::Value(ParamValue::Vec3(_)) => "vec3".to_string(),
-            ParamSlot::Value(ParamValue::Bool(_)) => "boolean".to_string(),
-            ParamSlot::Value(ParamValue::Choice(_)) => "enum".to_string(),
-            ParamSlot::Value(ParamValue::Text(_)) => "string".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 {