GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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]