git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit25b3f72d3e75e22be322fd44c8913722323729c9
parentb6d4cb5c45
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 14:51
feat: a slider's range can be soft: a typed value past an end widens it

Slider::set_soft / with_soft, Float3::set_soft, and a soft segment on a
slider or floatN parameter row. A value typed into the readout past an end
widens the range instead of being clamped; drags and the wheel still stop
at the ends. The params pane writes a slider row back from the slider's
own value rather than its fraction over the row's declared range.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             | 14 ++++++++++
 src/widget/container/parameters_bg.rs | 49 +++++++++++++++++++++++++++--------
 src/widget/display/float3.rs          |  8 ++++++
 src/widget/input/slider.rs            | 46 ++++++++++++++++++++++++++++++++
 4 files changed, 106 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a879720..c7295ab 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -253,6 +253,20 @@ direction is three numbers — so `float4:…:trackball` has none. The designer'
 Attribute node presents its Value through these.
 `float2_and_float4_rows_are_the_group_with_two_or_four_sliders` is the test.
 
+### A slider's range can be soft (since 2026-10-01)
+
+`Slider::set_soft` / `with_soft`, `Float3::set_soft`, and in `ParametersBg` a `soft`
+segment after the range (`slider:lo:hi:dec:soft`, `float3:lo:hi:trackball:soft`,
+`is_soft_row`): a value TYPED into the readout past either end widens the range to hold
+it, where a hard range clamps it to the end. A drag and the wheel still stop at the
+ends. It is for a value with no natural bounds whose range is only a scale to drag
+over — the host is expected to choose the range around the value and re-choose it (the
+designer's Attribute Value row). The pane now writes a slider row back from the
+slider's OWN value (`get_scaled_value`) rather than its fraction over the row's
+declared range, which was the same thing until a range could widen.
+`a_soft_range_widens_to_a_typed_value` and `a_soft_row_writes_back_what_its_slider_holds`
+are the tests.
+
 ### The keyboard can walk a menu (since 2026-10-01)
 
 `context_menu::set_hovered_item(Some(idx))` highlights a row as the pointer would, and
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 95aeef2..74f9ae7 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1340,8 +1340,7 @@ impl ParametersBg {
                 } else if p.2.starts_with("slider") {
                     if let Some(s) = &mut self.sliders[idx] {
                         s.unfocus();
-                        let (min, max) = parse_slider_range(&p.2);
-                        let new_val = min + s.value * (max - min);
+                        let new_val = s.get_scaled_value();
                         p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
                     }
                 } else if is_vec_row(&p.2) {
@@ -2302,8 +2301,7 @@ impl Input for ParametersBg {
         if let Some(i) = self.dragging_param {
             if let Some(s) = &mut self.sliders[i] {
                 if s.drag_update(px, py) {
-                    let (min, max) = parse_slider_range(&self.display_params[i].2);
-                    let new_val = min + s.value * (max - min);
+                    let new_val = s.get_scaled_value();
                     let old_val = &self.display_params[i].1;
                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
                     if *old_val != new_val_str {
@@ -2375,8 +2373,7 @@ impl Input for ParametersBg {
         for i in 0..self.sliders.len() {
             if let Some(s) = &mut self.sliders[i] {
                 if s.tick(dt, &mut dummy) {
-                    let (min, max) = parse_slider_range(&self.display_params[i].2);
-                    let new_val = min + s.value * (max - min);
+                    let new_val = s.get_scaled_value();
                     let new_val_str = format!("{:.*}", slider_decimals(&self.display_params[i].2), new_val);
                     if self.display_params[i].1 != new_val_str {
                         self.display_params[i].1 = new_val_str;
@@ -3109,8 +3106,7 @@ impl Input for ParametersBg {
                         } else if p.2.starts_with("slider") {
                             if let Some(s) = &mut self.sliders[idx] {
                                 if s.keyboard_input(event, ui) {
-                                    let (min, max) = parse_slider_range(&p.2);
-                                    let new_val = min + s.value * (max - min);
+                                    let new_val = s.get_scaled_value();
                                     p.1 = format!("{:.*}", slider_decimals(&p.2), new_val);
                                     if !s.editing {
                                         self.focused_param = None;
@@ -3283,8 +3279,7 @@ impl Input for ParametersBg {
                                 // clip the halo's fringe outside the row rect.
                                 if s.mouse_wheel_ungated(delta, px, py, ui) {
                                     wheel_taken = true;
-                                    let (min, max) = parse_slider_range(&p.2);
-                                    let new_val = min + s.value * (max - min);
+                                    let new_val = s.get_scaled_value();
                                     let old_val = &p.1;
                                     let new_val_str = format!("{:.*}", slider_decimals(&p.2), new_val);
                                     if *old_val != new_val_str {
@@ -3398,6 +3393,15 @@ fn is_vec_row(t: &str) -> bool {
     t.starts_with("float2") || t.starts_with("float3") || t.starts_with("float4")
 }
 
+/// Whether a slider or vector row's range is SOFT: a `soft` segment
+/// anywhere after the range (`slider:lo:hi:dec:soft`,
+/// `float3:lo:hi:trackball:soft`). A value typed past an end widens the
+/// row's range rather than being clamped to it (`Slider::set_soft`); the
+/// host is expected to choose the range around the value.
+fn is_soft_row(t: &str) -> bool {
+    t.split(':').skip(3).any(|s| s == "soft")
+}
+
 /// How many rows a vector row has: the digit after `float`.
 fn vec_row_n(t: &str) -> usize {
     t.get(5..6).and_then(|d| d.parse().ok()).unwrap_or(3)
@@ -3498,7 +3502,7 @@ impl ParamController for ParametersBg {
                     } else {
                         0.0
                     };
-                    let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2));
+                    let s = Slider::new().with_value(t).with_range(min, max).with_readout(true).with_decimals(slider_decimals(&p.2)).with_soft(is_soft_row(&p.2));
                     Some(if inline { s } else { s.with_label(&p.0) })
                 } else {
                     None
@@ -3509,6 +3513,7 @@ impl ParamController for ParametersBg {
                     let (min, max) = parse_slider_range(&p.2);
                     let n = vec_row_n(&p.2);
                     let mut f = Float3::new().with_components(n).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
+                    f.set_soft(is_soft_row(&p.2));
                     f.set_values_n(&parse_vec_value(&p.1, min, max, n));
                     f.set_view(self.trackball_view);
                     Some(if inline { f } else { f.with_label(&p.0) })
@@ -3966,6 +3971,28 @@ mod tests {
         assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
     }
 
+    /// A `soft` slider or float row builds its sliders with a soft range,
+    /// and the pane writes back the value the slider holds — past the
+    /// row's declared range once a typed value has widened it — rather
+    /// than re-reading the slider's fraction over the declared range.
+    #[test]
+    fn a_soft_row_writes_back_what_its_slider_holds() {
+        let mut p = panel_with(&[("V", "1.00", "slider:-10:10:2:soft"), ("W", "1:2", "float2:-10:10:soft"), ("H", "1.00", "slider:-10:10")]);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
+        assert!(is_soft_row("slider:-10:10:2:soft") && is_soft_row("float3:-1:1:trackball:soft"));
+        assert!(!is_soft_row("slider:-10:10") && !is_soft_row("float3:-1:1:trackball"));
+        assert_eq!(slider_decimals("slider:-10:10:2:soft"), 2);
+        {
+            let s = p.sliders[0].as_mut().unwrap();
+            s.set_range(-10.0, 500.0);
+            s.set_scaled_value(500.0);
+        }
+        p.inner_mut().focused_param = Some(0);
+        p.inner_mut().commit_and_unfocus();
+        assert_eq!(p.display_params[0].1, "500.00");
+        assert_eq!(p.display_params[2].1, "1.00", "the hard row is untouched");
+    }
+
     /// `float2` and `float4` rows are the float3 group with two or four
     /// rows (X Y, X Y Z W): that many sliders over the row's range, laid out
     /// and sized for that many, read from and written back as that many
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 059a299..1ce130d 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -206,6 +206,14 @@ impl Float3 {
         self.n
     }
 
+    /// Give every row a soft range (`Slider::set_soft`): a value typed
+    /// past an end widens that row's range.
+    pub fn set_soft(&mut self, soft: bool) {
+        for s in self.sliders.iter_mut() {
+            s.set_soft(soft);
+        }
+    }
+
     fn decimals(&self) -> usize {
         if self.ball { BALL_DECIMALS } else { DECIMALS }
     }
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index b377960..4c7d38a 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -34,6 +34,12 @@ pub struct Slider {
     edit_buffer: String,
     min: f32,
     max: f32,
+    /// A SOFT range: a value typed into the readout past either end widens
+    /// the range to hold it, where a hard range clamps it to the end. For
+    /// a value with no natural bounds, whose range is only a scale to drag
+    /// over — the host re-chooses it around the value. Off by default; a
+    /// drag and the wheel stop at the ends either way.
+    soft: bool,
     pub editor_state: TextEditorState,
     pub just_changed: bool,
     label: Option<String>,
@@ -68,6 +74,7 @@ impl Slider {
             edit_buffer: String::new(),
             min: 0.0,
             max: 1.0,
+            soft: false,
             editor_state: TextEditorState::new(String::new()),
             just_changed: false,
             label: None,
@@ -84,6 +91,11 @@ impl Slider {
         self.max = max;
     }
 
+    /// See the `soft` field.
+    pub fn set_soft(&mut self, soft: bool) {
+        self.soft = soft;
+    }
+
     pub fn set_scroll(&mut self, enabled: bool) {
         self.scroll_enabled = enabled;
     }
@@ -257,6 +269,13 @@ impl Slider {
             self.editing = false;
             let old_val = self.value;
             if let Ok(new_val) = self.edit_buffer.parse::<f32>() {
+                // A soft range widens to hold what was typed; the value is
+                // the number, not the end it would clamp to.
+                if self.soft && new_val.is_finite() && (new_val < self.min || new_val > self.max) {
+                    self.min = self.min.min(new_val);
+                    self.max = self.max.max(new_val);
+                    self.just_changed = true;
+                }
                 let range = self.max - self.min;
                 if range != 0.0 {
                     self.value = ((new_val - self.min) / range).clamp(0.0, 1.0);
@@ -299,6 +318,12 @@ impl Adapted<Slider> {
         self
     }
 
+    /// See the `soft` field.
+    pub fn with_soft(mut self, soft: bool) -> Self {
+        self.set_soft(soft);
+        self
+    }
+
 }
 
 impl Layout for Slider {
@@ -1011,6 +1036,27 @@ mod tests {
     use super::*;
     use crate::widget::{MouseScrollDelta, WidgetHost, UiContext};
 
+    /// A soft range takes a typed value past its end and widens to hold
+    /// it; a hard one clamps the same number to the end.
+    #[test]
+    fn a_soft_range_widens_to_a_typed_value() {
+        let typed = |soft: bool, text: &str| {
+            let mut sl = Slider::new().with_range(-10.0, 10.0).with_readout(true).with_soft(soft);
+            sl.editing = true;
+            sl.edit_buffer = text.to_string();
+            sl.commit_edit();
+            (sl.get_scaled_value(), sl.range(), sl.just_changed)
+        };
+        let (v, range, changed) = typed(true, "500");
+        assert!((v - 500.0).abs() < 1e-3 && range == (-10.0, 500.0) && changed, "{v} {range:?}");
+        let (v, range, _) = typed(true, "-42");
+        assert!((v + 42.0).abs() < 1e-3 && range == (-42.0, 10.0));
+        let (v, range, _) = typed(true, "3");
+        assert!((v - 3.0).abs() < 1e-3 && range == (-10.0, 10.0), "inside, nothing widens");
+        let (v, range, _) = typed(false, "500");
+        assert!((v - 10.0).abs() < 1e-3 && range == (-10.0, 10.0), "a hard range clamps");
+    }
+
     /// The legacy rangeslider interaction test, driven through the WidgetHost drag forwards
     /// (hosts call these directly): thumb selection by proximity, constrained updates.
     #[test]