graphic design tool
git clone https://git.lucas.co/cce-designer.git
feat: the Attribute Value row's range adapts to the value
The row runs over ± the smallest power of ten (at least one) whose middle
half holds the value, kept while the value stays inside it and never
re-chosen mid-drag, so a drag to the end re-scales on release. The rows use
cce-ui's soft range, so a typed value past an end is kept and the next span
holds it. Replaces the fixed ±1000.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 18 +++++++++++---
src/app.rs | 82 +++++++++++++++++++++++++++++++++++++++++++------------------
src/main.rs | 66 +++++++++++++++++++++++++++++++++++++------------
3 files changed, 122 insertions(+), 44 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 45207f6..0f359c6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -718,10 +718,20 @@ old text Center a float3 from the load on. Tests build parameters with
typed into blind; a template declares it now and needs no entry anywhere.
What is still `text` is text for a reason: Attribute's `Value` is as wide
as its `Type` row says (one number or two, three or four) — though the
- pane PRESENTS it as a control as wide as its target, over a deliberately
- wide `VALUE_ROW_RANGE` (`add_pick_lists` / `value_row_control`): a
- slider for one, cce-ui's `float2` / `float3` / `float4` group for more
- (the float3 since 2026-09-28, every width since 2026-10-01). The target
+ pane PRESENTS it as a control as wide as its target (`add_pick_lists` /
+ `value_row_control`): a slider for one, cce-ui's `float2` / `float3` /
+ `float4` group for more (the float3 since 2026-09-28, every width since
+ 2026-10-01). **Its range adapts to the value** (`value_row_span`, the
+ same day; it was a fixed ±1000, which a drag crossed in hundreds): ± the
+ smallest power of ten, at least one, whose middle half holds every
+ component — 1.00 drags over ±10, 9.6 over ±100. The span in use
+ (`State::value_row_span`, by node) is KEPT while the value stays between
+ a twentieth and nineteen twentieths of it, and is never re-chosen during
+ a drag in the pane (`drag_widget == PARAM_IDX`): a new span is a new
+ row type, which rebuilds the pane and would drop the slider being held.
+ So a drag to the end re-scales on the release. The rows are cce-ui
+ SOFT ranges (`:soft`): a value typed past an end widens the range
+ rather than clamping, and the next span holds it. The target
is Create's Type, or Modify's Pos, Col or input attribute. The text must
hold one number or as many as the target: ONE is shown spread over every
component, as the node spreads it (`fit` in `apply_attribute`), and the
diff --git a/src/app.rs b/src/app.rs
index 31a98ef..1a63b20 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -705,7 +705,7 @@ pub fn wrap_words(text: &str, width: usize) -> Vec<String> {
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" {
+ if !matches!(head, "slider" | "float2" | "float3" | "float4") {
return None;
}
let lo = parts.next()?.parse::<f32>().ok()?;
@@ -713,11 +713,29 @@ fn shown_row_range(shown: &str) -> Option<(f32, f32, Option<f32>)> {
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;
-/// the row's readouts take typed values.
-pub const VALUE_ROW_RANGE: (f32, f32) = (-1000.0, 1000.0);
+/// The half-span of the Attribute node's Value row: the row runs from
+/// minus this to plus it. It ADAPTS to the value (since 2026-10-01; until
+/// then a fixed ±1000, which a drag crossed in hundreds): the smallest power
+/// of ten, and at least one, whose middle half holds every component — so
+/// 1.00 drags over ±10 and 9.6 over ±100. A span already in use (`kept`)
+/// stands while the value stays between a twentieth of it and nineteen
+/// twentieths, so a value moving inside the row does not re-scale it under
+/// the pointer; one that reaches an end, or falls far inside, is given a
+/// new one. The row's range is SOFT: a value typed past an end widens it
+/// rather than being clamped, and the next span holds it.
+pub fn value_row_span(values: &[f32], kept: Option<f32>) -> f32 {
+ let m = values.iter().filter(|v| v.is_finite()).fold(0.0f32, |a, v| a.max(v.abs()));
+ if let Some(r) = kept {
+ if m <= 0.95 * r && (r <= 1.0 || m >= 0.05 * r) {
+ return r;
+ }
+ }
+ let mut r = 1.0f32;
+ while m > 0.5 * r && r < 1e9 {
+ r *= 10.0;
+ }
+ r
+}
/// What an Attribute node's `Value` is aimed at, as `add_pick_lists` needs
/// it: a width the node itself decides, or the name of the input attribute
@@ -744,14 +762,15 @@ pub fn float3_row(min: f32, max: f32, trackball: bool) -> String {
}
/// The control the Attribute node's Value row is presented as, for a target
-/// `width` components wide, and the row's text as that control shows it: a
-/// slider for one, the float group with two, three or four rows for more
-/// (cce-ui's `float2` / `float3` / `float4`), over [`VALUE_ROW_RANGE`].
-/// The text must hold one number or `width` of them; one is SPREAD to every
-/// component, which is what a single number means to the node (`fit` in
-/// `apply_attribute`), so the controls show it as it acts. `None` — a text
-/// box, as before — for a width it does not fit.
-pub fn value_row_control(text: &str, width: usize, ball: bool) -> Option<(String, String)> {
+/// `width` components wide, the row's text as that control shows it, and
+/// the half-span it runs over: a slider for one, the float group with two,
+/// three or four rows for more (cce-ui's `float2` / `float3` / `float4`),
+/// over ± [`value_row_span`] — `kept` is the span the row has now, kept
+/// outright while `hold` (a drag in the pane) — with a soft range. The text must hold one number or `width` of them; one is
+/// SPREAD to every component, which is what a single number means to the
+/// node (`fit` in `apply_attribute`), so the controls show it as it acts.
+/// `None` — a text box, as before — for a width it does not fit.
+pub fn value_row_control(text: &str, width: usize, ball: bool, kept: Option<f32>, hold: bool) -> Option<(String, String, f32)> {
let parts: Vec<&str> = text.split(|c| c == ':' || c == ',' || c == ' ').filter(|s| !s.is_empty()).collect();
if !(1..=4).contains(&width) || parts.iter().any(|p| p.parse::<f32>().is_err()) {
return None;
@@ -761,13 +780,18 @@ pub fn value_row_control(text: &str, width: usize, ball: bool) -> Option<(String
1 => vec![parts[0]; width].join(":"),
_ => return None,
};
- let (lo, hi) = VALUE_ROW_RANGE;
+ let values: Vec<f32> = parts.iter().filter_map(|p| p.parse().ok()).collect();
+ // Held (a drag in the pane), the span in use stands whatever the value.
+ let r = match (hold, kept) {
+ (true, Some(r)) => r,
+ _ => value_row_span(&values, kept),
+ };
let ty = match width {
- 1 => format!("slider:{lo}:{hi}"),
- 3 => float3_row(lo, hi, ball),
- n => format!("float{n}:{lo}:{hi}"),
+ 1 => format!("slider:{}:{}:2:soft", -r, r),
+ 3 => format!("{}:soft", float3_row(-r, r, ball)),
+ n => format!("float{n}:{}:{}:soft", -r, r),
};
- Some((ty, shown))
+ Some((ty, shown, r))
}
/// Whether `shown` is `kept` as a value row presents it — the same numbers,
@@ -2788,6 +2812,10 @@ pub struct State {
pub visualizers: Vec<crate::visualizer::Visualizer>,
/// The visualizer the dialog's VisualizerEdit page is editing.
pub vis_editing: Option<usize>,
+ /// The half-span the Attribute node's Value row runs over, with the
+ /// node it is for (`value_row_span`): kept between pane syncs so the row
+ /// re-scales only when its value leaves it.
+ pub value_row_span: Option<(String, f32)>,
/// The displayed scene's point attributes as last built, with their
/// ranges: what the visualizer editor offers.
pub scene_attributes: Vec<crate::visualizer::SceneAttribute>,
@@ -4710,9 +4738,9 @@ impl State {
/// numbers: a single number BROADCASTS to every component, and a row
/// that showed it as `(n, 0, 0)` would write that triple back on the
/// first drag; an expression is shown as its text like any other. The
- /// row's range is [`VALUE_ROW_RANGE`], deliberately wide — a float3 row
- /// holds a fraction of its range and clamps to it, and Pos is set to
- /// whatever the scene needs; the readouts are typed into for precision.
+ /// row's range adapts to the value ([`value_row_span`]) and is soft.
+ /// Since 2026-10-01 every width is presented, a single number spread
+ /// over the components ([`value_row_control`]).
fn add_pick_lists(
&mut self,
mut params: Vec<(String, String, String)>,
@@ -4759,10 +4787,15 @@ impl State {
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
.map_or(0, |(_, w)| *w),
};
+ // The span the row already has stands through a drag: a new
+ // one rebuilds the pane, which would drop the slider being held.
+ let kept = self.value_row_span.as_ref().filter(|(id, _)| *id == node_id).map(|(_, r)| *r);
+ let held = self.drag_widget == Some(crate::slots::PARAM_IDX);
if let Some(row) = params.iter_mut().find(|r| r.0 == key && r.2 == "text") {
- if let Some((ty, text)) = value_row_control(&row.1, width, ball) {
+ if let Some((ty, text, r)) = value_row_control(&row.1, width, ball, kept, held) {
row.2 = ty;
row.1 = text;
+ self.value_row_span = Some((node_id.clone(), r));
}
}
}
@@ -5424,7 +5457,7 @@ impl State {
/// `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
+ /// parameter has none (the Value row's adaptive span). 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
@@ -7757,6 +7790,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
marked_groups: Self::marked_groups_of(&settings.viewport.marked_groups),
visualizers: crate::visualizer::decode(&settings.viewport.visualizers),
vis_editing: None,
+ value_row_span: None,
scene_attributes: Vec::new(),
scene_base: None,
scene_groups: Vec::new(),
diff --git a/src/main.rs b/src/main.rs
index 9d6f2c4..e81ee28 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9656,7 +9656,7 @@ mod tests {
let (rows, h) = fields(&state, pull, "Value");
let want: Vec<String> = ["Name: Value", "Control: trackball and sliders", "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].contains(&"Range: -1..1".to_string()), "the span around 0.06: {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);
@@ -16614,9 +16614,31 @@ mod tests {
assert!(DesignSettings::from_kdl_str(&fs::read_to_string(DesignSettings::file_path()).unwrap()).playbar_repeat);
}
+ /// The Value row's span: the smallest power of ten (at least one)
+ /// whose middle half holds the value; kept while the value stays
+ /// between a twentieth and nineteen twentieths of it.
+ #[test]
+ fn the_value_rows_span_adapts_to_the_value() {
+ use crate::app::value_row_span as span;
+ assert_eq!(span(&[0.0], None), 1.0);
+ assert_eq!(span(&[0.06, 0.0, 0.0], None), 1.0);
+ assert_eq!(span(&[0.5], None), 1.0);
+ assert_eq!(span(&[0.6], None), 10.0);
+ assert_eq!(span(&[1.0], None), 10.0);
+ assert_eq!(span(&[-7.0, 2.0], None), 100.0, "the largest magnitude, either sign");
+ assert_eq!(span(&[300.0], None), 1000.0);
+ assert_eq!(span(&[3.0], Some(10.0)), 10.0, "inside: kept");
+ assert_eq!(span(&[9.0], Some(10.0)), 10.0);
+ assert_eq!(span(&[9.6], Some(10.0)), 100.0, "at the end: grows");
+ assert_eq!(span(&[6.0], Some(100.0)), 100.0, "a twentieth or more: kept");
+ assert_eq!(span(&[0.6], Some(100.0)), 10.0, "far inside: shrinks");
+ assert_eq!(span(&[0.4], Some(100.0)), 1.0);
+ assert_eq!(span(&[0.0], Some(1.0)), 1.0, "one is the floor");
+ }
+
/// The Attribute node's Value stays a text parameter, but the pane
/// presents it as a control as wide as its target — over the wide
- /// `VALUE_ROW_RANGE` — a slider for one, the float group with two, three
+ /// span around its value (`value_row_span`) — a slider for one, the float group with two, three
/// or four rows for more: Modify on Pos (the pull node), on an input
/// Float3 (Norm) or Float2 (UV), Create by its Type. A single number is
/// spread over the components, as the node spreads it, and the pane
@@ -16654,9 +16676,9 @@ mod tests {
state.param_mut().node_params().iter().find(|r| r.0 == "Value").expect("a Value row").2.clone()
};
// A vector gets the trackball beside its sliders by default.
- let wide = crate::app::float3_row(crate::app::VALUE_ROW_RANGE.0, crate::app::VALUE_ROW_RANGE.1, true);
+ // 0.06 sits in the middle half of ±1.
+ let wide = "float3:-1:1:trackball:soft".to_string();
assert_eq!(value_row(&mut state), wide, "Modify on Pos");
- assert!(crate::app::VALUE_ROW_RANGE.0 <= -100.0 && crate::app::VALUE_ROW_RANGE.1 >= 100.0, "a wide range");
let set = |state: &mut State, name: &str, val: &str| {
state.fs_root.children[1].params.iter_mut().find(|p| p.name == name).unwrap().set_text(val.to_string());
@@ -16666,7 +16688,7 @@ mod tests {
set(&mut state, "Attribute Name", "UV");
assert_eq!(value_row(&mut state), "text", "three numbers do not fit an input Float2");
set(&mut state, "Value", "1:2");
- assert_eq!(value_row(&mut state), "float2:-1000:1000", "Modify on an input Float2");
+ assert_eq!(value_row(&mut state), "float2:-10:10:soft", "Modify on an input Float2: 2 needs ±10");
set(&mut state, "Value", "0.00:0.06:0.00");
set(&mut state, "Attribute Name", "nothing_here");
assert_eq!(value_row(&mut state), "text", "an attribute the input lacks has no width");
@@ -16682,11 +16704,11 @@ mod tests {
state.param_mut().node_params().iter().find(|r| r.0 == "Value").unwrap().1.clone()
};
set(&mut state, "Value", "1.00");
- assert_eq!(value_row(&mut state), "slider:-1000:1000", "Create of a Float");
+ assert_eq!(value_row(&mut state), "slider:-10:10:2:soft", "Create of a Float");
for (ty, row, shown) in [
- ("Float2", "float2:-1000:1000".to_string(), "1.00:1.00"),
- ("Float3", wide.clone(), "1.00:1.00:1.00"),
- ("Float4", "float4:-1000:1000".to_string(), "1.00:1.00:1.00:1.00"),
+ ("Float2", "float2:-10:10:soft".to_string(), "1.00:1.00"),
+ ("Float3", "float3:-10:10:trackball:soft".to_string(), "1.00:1.00:1.00"),
+ ("Float4", "float4:-10:10:soft".to_string(), "1.00:1.00:1.00:1.00"),
] {
set(&mut state, "Type", ty);
assert_eq!(value_row(&mut state), row, "Create of a {ty}");
@@ -16700,7 +16722,19 @@ mod tests {
set(&mut state, "Type", "Float3");
set(&mut state, "Value", "1.00:2.00:3.00");
- assert_eq!(value_row(&mut state), wide);
+ assert_eq!(value_row(&mut state), "float3:-10:10:trackball:soft", "3 stays inside the ±10 in use");
+ // Past nineteen twentieths of it, the row re-scales: 9.6 to ±100.
+ set(&mut state, "Value", "9.6:0:0");
+ assert_eq!(value_row(&mut state), "float3:-100:100:trackball:soft");
+ // Held by a drag in the pane, it does not, whatever the value.
+ state.drag_widget = Some(crate::slots::PARAM_IDX);
+ set(&mut state, "Value", "99:0:0");
+ assert_eq!(value_row(&mut state), "float3:-100:100:trackball:soft", "no re-scale under the pointer");
+ state.drag_widget = None;
+ assert_eq!(value_row(&mut state), "float3:-1000:1000:trackball:soft", "and on the release it does");
+ // Far inside, it comes back down.
+ set(&mut state, "Value", "1.00:2.00:3.00");
+ assert_eq!(value_row(&mut state), "float3:-10:10:trackball:soft");
state.fs_root.children[1].params.iter_mut().find(|p| p.name == "Value").unwrap().set_expr(true);
assert_eq!(value_row(&mut state), "text", "an expression is shown as its text");
state.fs_root.children[1].params.iter_mut().find(|p| p.name == "Value").unwrap().set_expr(false);
@@ -16802,26 +16836,26 @@ mod tests {
};
let row = |state: &mut State, name: &str| state.param_mut().node_params().iter().find(|r| r.0 == name).expect("the row").2.clone();
let entries = |state: &State, slot: usize, pname: &str| state.param_menu_rows(slot, pname).1;
- let (lo, hi) = crate::app::VALUE_ROW_RANGE;
+ let (lo, hi) = (-1.0, 1.0);
// The pull's Value: a vector, so the ball is there; the menu hides it.
show(&mut state, pull);
- assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, true));
+ assert_eq!(row(&mut state, "Value"), format!("{}:soft", crate::app::float3_row(lo, hi, true)));
assert!(entries(&state, pull, "Value").contains(&A::HideTrackball));
let pull_id = state.current_dir().children[pull].id.clone();
state.run_param_action(&pull_id, "Value", A::HideTrackball);
- assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, false));
+ assert_eq!(row(&mut state, "Value"), format!("{}:soft", crate::app::float3_row(lo, hi, false)));
assert!(entries(&state, pull, "Value").contains(&A::ShowTrackball));
let saved = serde_json::to_string(&state.current_dir().children[pull]).unwrap();
assert!(saved.contains("\"view\":\"sliders\""), "the choice rides the file: {saved}");
state.run_param_action(&pull_id, "Value", A::ShowTrackball);
- assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, true));
+ assert_eq!(row(&mut state, "Value"), format!("{}:soft", crate::app::float3_row(lo, hi, true)));
// Aimed at Col the three numbers are a colour: no ball by default.
state.apply_action(McpAction::SetParam { slot: pull, name: "Attribute Name".into(), value: "Col".into() }, &mut redraw).unwrap();
state.current_dir_mut().children[pull].params.iter_mut().find(|p| p.name == "Value").unwrap().view.clear();
show(&mut state, pull);
- assert_eq!(row(&mut state, "Value"), crate::app::float3_row(lo, hi, false));
+ assert_eq!(row(&mut state, "Value"), format!("{}:soft", crate::app::float3_row(lo, hi, false)));
state.apply_action(McpAction::SetParam { slot: pull, name: "Attribute Name".into(), value: "Pos".into() }, &mut redraw).unwrap();
// A position (the Group node's Center): no ball until asked, and a
@@ -17032,7 +17066,7 @@ mod tests {
assert_eq!(child.param_mut().node_params().len(), 0, "a new state has nothing selected");
child.seed_detached_window(&channel);
assert_eq!(child.param_editor_selected(), Some(pull), "it opens on the main window's selection");
- assert!(child.param_mut().node_params().iter().any(|r| r.0 == "Value" && r.2.ends_with(":trackball")), "with its rows");
+ assert!(child.param_mut().node_params().iter().any(|r| r.0 == "Value" && r.2.contains(":trackball")), "with its rows");
assert!(same(ball_view(&child), ball_view(&main)), "and sees the ball from the main window's camera");
assert!(!child.needs_autosave, "a detached window has no camera to tell of");