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

commit7818e654053596e8a0df2870ab56cf6b519f96ee
parent55dc54159e
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 10:37
feat: the row menu reads out a control's range and a choice's options

Two more header rows where they apply: `Range: lo..hi`, with `, step s`
when a step is set, for a slider, float3 or spinbox; `Options: a, b, c`
for a choice. The range comes from ParamDef::range — an inline
`slider:-2:2` first, then the template's min/max, then the pane's own
defaults (0..2, -10..10, 1..10000) — and param_display now builds the
pane's slider, float3 and spinbox rows from the same call, so the menu
cannot report a range the pane does not clamp to.

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

 CLAUDE.md    |  5 ++++-
 src/app.rs   | 35 +++++++++++++++++++++++------------
 src/main.rs  | 22 ++++++++++++++++------
 src/param.rs | 29 +++++++++++++++++++++++++++++
 4 files changed, 72 insertions(+), 19 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5d97702..cff34d9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -721,7 +721,10 @@ 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). Two lines because
+request), 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
 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 601459a..ee7f246 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -581,18 +581,14 @@ pub fn param_display(params: &[ParamDef]) -> Vec<(String, String, String)> {
             // to offer.
             "text".to_string()
         } else if p.ty() == "slider" {
-            let min = p.min.unwrap_or(0.0);
-            let max = p.max.unwrap_or(2.0);
+            let (min, max, _) = p.range().expect("a slider has a range");
             format!("slider:{}:{}", min, max)
         } else if p.ty() == "float3" {
-            let min = p.min.unwrap_or(-10.0);
-            let max = p.max.unwrap_or(10.0);
+            let (min, max, _) = p.range().expect("a float3 has a range");
             format!("float3:{}:{}", min, max)
         } else if p.ty() == "spinbox" {
-            let min = p.min.unwrap_or(1.0) as i32;
-            let max = p.max.unwrap_or(10000.0) as i32;
-            let step = p.step.unwrap_or(1.0) as i32;
-            format!("spinbox:{}:{}:{}", min, max, step)
+            let (min, max, step) = p.range().expect("a spinbox has a range");
+            format!("spinbox:{}:{}:{}", min as i32, max as i32, step.unwrap_or(1.0) as i32)
         } else if p.ty() == "choice" {
             format!("choice:{}", p.options().join(","))
         } else {
@@ -4443,7 +4439,9 @@ 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.
+    /// 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`.
     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);
@@ -4451,8 +4449,21 @@ 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}"), "-".to_string(), "Copy Parameter".to_string()];
-        let mut actions = vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter];
+        let mut options = vec![format!("Control: {control}"), format!("Value: {value}")];
+        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();
+            options.push(format!("Range: {}..{}{step}", fmt(lo), fmt(hi)));
+        }
+        if let Some(p) = param.filter(|p| p.kind() == ParamKind::Choice) {
+            options.push(format!("Options: {}", p.choice_options().join(", ")));
+        }
+        let headers = options.len();
+        let mut actions = vec![ParamMenuAction::Info; headers];
+        options.push("-".to_string());
+        options.push("Copy Parameter".to_string());
+        actions.push(ParamMenuAction::Separator);
+        actions.push(ParamMenuAction::CopyParameter);
         if self.copied_param.is_some() {
             options.push("Paste Relative Reference".to_string());
             actions.push(ParamMenuAction::PasteRelative);
@@ -4468,7 +4479,7 @@ impl State {
             options.push("Edit Expression".to_string());
             actions.push(ParamMenuAction::EditExpression);
         }
-        (options, actions, 2)
+        (options, actions, headers)
     }
 
     /// Open a parameter row's right-click menu.
diff --git a/src/main.rs b/src/main.rs
index 65a15b1..917f140 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7756,16 +7756,26 @@ mod tests {
         assert!(!state.viewport_menu_open());
         assert_eq!(
             state.param_menu_actions,
-            vec![ParamMenuAction::Info, ParamMenuAction::Info, ParamMenuAction::Separator, ParamMenuAction::CopyParameter, ParamMenuAction::Separator, ParamMenuAction::EditExpression],
+            vec![ParamMenuAction::Info, 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, in a programmer's terms. Radius is a slider
-        // holding a float.
+        // 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.
         let shown = cce_ui::widget::context_menu::options();
-        assert_eq!(&shown[..3], &["Control: slider".to_string(), "Value: float".to_string(), "-".to_string()]);
+        assert_eq!(&shown[..4], &["Control: slider".to_string(), "Value: float".to_string(), "Range: 0..2".to_string(), "-".to_string()]);
         let (_, _, headers) = state.param_menu_rows(sphere, "Radius");
-        assert_eq!(headers, 2);
+        assert_eq!(headers, 3);
+        // 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));
+        let (rows, _, _) = state.param_menu_rows(sphere, "Rows");
+        assert_eq!(rows[2], "Range: 2..128, step 1");
+        let (rows, _, h) = state.param_menu_rows(sphere, "Method");
+        assert_eq!((&rows[2], h), (&"Options: UV, Icosphere, Cube".to_string(), 3));
+        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");
         // 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 });
diff --git a/src/param.rs b/src/param.rs
index 94a45ba..10deb1f 100644
--- a/src/param.rs
+++ b/src/param.rs
@@ -445,6 +445,35 @@ impl ParamDef {
         }
     }
 
+    /// The range the pane holds this parameter to, as `(min, max, step)`,
+    /// for the kinds that have one — Slider, Float3 and Spin. An inline
+    /// detail (`slider:-2:2`) wins, then the template's `min` / `max`, then
+    /// the pane's own defaults (0..2, -10..10, 1..10000), which are what a
+    /// row with none declared clamps to. `param_display` builds the pane's
+    /// row from this and the row menu's `Range:` reads it, so the two
+    /// cannot disagree. `None` for a kind with no range.
+    pub fn range(&self) -> Option<(f32, f32, Option<f32>)> {
+        let inline = || {
+            let parts: Vec<&str> = self.param_type.split(':').collect();
+            match parts[..] {
+                [_, lo, hi] => Some((lo.trim().parse::<f32>().ok()?, hi.trim().parse::<f32>().ok()?)),
+                _ => None,
+            }
+        };
+        let (lo, hi) = match self.kind() {
+            ParamKind::Slider => (0.0, 2.0),
+            ParamKind::Float3 => (-10.0, 10.0),
+            ParamKind::Spin => (1.0, 10000.0),
+            _ => return None,
+        };
+        let (lo, hi) = inline().unwrap_or((self.min.unwrap_or(lo), self.max.unwrap_or(hi)));
+        let step = match self.kind() {
+            ParamKind::Spin => Some(self.step.unwrap_or(1.0)),
+            _ => self.step,
+        };
+        Some((lo, hi, step))
+    }
+
     /// Whether a value that READS as a reference should become an
     /// expression here. Not for a code parameter: a kernel or a wrangle
     /// script is a program, and one whose whole text happens to be