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

commite6025cd471395098d8fcf129d27f0c9a483c35b9
parentd015ea1f27
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 10:02
feat(dialog): a visualizer's Manual Range is one float2 row

The dialog gains a Float2 control — the params pane's float2, as two
sliders side by side in the row, each with its readout, each end pressed,
dragged and wheeled on its own; the arrows nudge the first end and shift
the second. A visualizer holds manual_range as the Visualize node does,
where it held From and To apart, and a settings file that kept the two
ends reads as the range.

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

 CLAUDE.md         |  28 +++++--
 src/dialog.rs     | 233 +++++++++++++++++++++++++++++++++++++++++++-----------
 src/main.rs       | 103 ++++++++++++++++++++++++
 src/visualizer.rs |  48 +++++------
 4 files changed, 336 insertions(+), 76 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bd82f81..071bc98 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3330,11 +3330,9 @@ was checked in a shadow session before and after.
 a point attribute of whatever the viewport displays, coloured through a
 ramp or drawn as a line from each point, with NO node in the graph. A
 `Visualizer` is the Visualize node's settings under the node's own names
-and options (Attribute, Mode, Ramp, Range, From/To, Blend, Opacity, Scale,
-Group) plus a switch, and it runs through `geometry::apply_visualize` over
-a node built from them (`Visualizer::as_node`; From/To are the node's
-Manual Range, a float2, kept as two ends because the dialog edits each on
-a slider of its own and state.kdl names them so), so a visualizer and a
+and options (Attribute, Mode, Ramp, Range, Manual Range, Blend, Opacity,
+Scale, Group) plus a switch, and it runs through `geometry::apply_visualize`
+over a node built from them (`Visualizer::as_node`), so a visualizer and a
 Visualize node cannot disagree about what they draw
 (`a_visualizer_reads_as_the_visualize_node_does`). Several apply in order,
 the later over the earlier, as a chain of Visualize nodes composites.
@@ -3370,9 +3368,10 @@ the later over the earlier, as a chain of Visualize nodes composites.
   `take_dialog_pick_at`). `Mode::VisualizerEdit` (`State::vis_editing`) is
   that visualizer's settings as rows — the Visualize node's `show_when`
   applied, so Ramp's rows or Vector's; Attribute and Group are choices
-  over the scene's attributes and groups; a Manual range's From and To are
-  sliders over the attribute's range in the scene with a quarter to spare
-  — then Delete Visualizer and Back. Escape goes back to the list. Rows
+  over the scene's attributes and groups; a Manual range is one
+  `Control::Float2` row (since 2026-10-06; two slider rows, From and To,
+  until then) over the attribute's range in the scene with a quarter to
+  spare — then Delete Visualizer and Back. Escape goes back to the list. Rows
   re-read IN PLACE (`refresh_dialog_rows_in_place`, selection and scroll
   kept), since switching the mode swaps rows under the one selected.
   A new visualizer starts on the scene's first attribute that is not
@@ -3757,6 +3756,19 @@ selection stays where it was:
   pane's) is one of these over `State::zoom_percent` (100 = Reset Zoom,
   range the pitch limits), landing through `set_zoom_percent`, which zooms
   about the cursor cell and re-reads the row, since `zoom` clamps.
+- `Float2` — two numbers over one range, `lo:hi`: the params pane's
+  float2, as two sliders SIDE BY SIDE in the row (the rows are one
+  height, so not stacked as the pane stacks them), each with its readout
+  ahead of it. The control begins `FLOAT2_W` (two `SLIDER_W`) in from the
+  row's right end and the label is clipped short of it. Each end is worked
+  as a slider is — a press on its band jumps and drags it, the wheel over
+  it turns it — and the arrows nudge the FIRST end, shift and the arrows
+  the second. One `Slider` stamp paints both. A change comes out of
+  `take_float2_change` and lands through `land_dialog_float2`. Only the
+  visualizer's Manual Range is one; state.kdl keeps it as `manual_range=lo:hi`,
+  and a file from before, with `from=` and `to=`, reads as the range.
+  `a_visualizers_manual_range_is_one_float2_row` drives it by pointer,
+  wheel and keys.
 - `Choice` — a fixed set (World Unit, GPU, Node Wire Style, the
   visualizer settings): the PARAMS PANE'S DROPDOWN (since 2026-10-01,
   the toolkit `Dropdown`), in the control band the sliders and colour
diff --git a/src/dialog.rs b/src/dialog.rs
index 99b7908..aa2bcfe 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -77,6 +77,11 @@ pub enum Control {
     /// over the band turns it, a press on the band jumps to the pointer.
     /// A `dec` of zero snaps to whole numbers — the spinbox shape.
     Slider { value: f32, min: f32, max: f32, dec: usize, step: f32, suffix: &'static str },
+    /// Two numbers over one range, `lo:hi` — the params pane's `float2`:
+    /// two sliders side by side, each with its readout ahead of it, worked
+    /// as a [`Control::Slider`] is, an end at a time. The arrows nudge the
+    /// first end, shift and the arrows the second.
+    Float2 { values: [f32; 2], min: f32, max: f32, dec: usize, step: f32 },
     /// One of a fixed set: picking the row opens a dropdown of the options
     /// under its value ([`State::open_dialog_dropdown`]); the arrows step
     /// either way without it.
@@ -93,6 +98,7 @@ impl Control {
         match self {
             Control::Toggle(on) => if *on { "true" } else { "false" }.to_string(),
             Control::Slider { value, dec, .. } => format!("{:.*}", dec, value),
+            Control::Float2 { values, dec, .. } => format!("{:.*}:{:.*}", dec, values[0], dec, values[1]),
             Control::Choice { options, index } => options.get(*index).cloned().unwrap_or_default(),
             Control::Color { hex, .. } => hex.clone(),
         }
@@ -102,13 +108,33 @@ impl Control {
     /// readout shows none.
     fn quantize(&self, v: f32) -> f32 {
         match self {
-            Control::Slider { min, max, dec, .. } => {
+            Control::Slider { min, max, dec, .. } | Control::Float2 { min, max, dec, .. } => {
                 let v = v.clamp(min.min(*max), max.max(*min));
                 if *dec == 0 { v.round() } else { v }
             }
             _ => v,
         }
     }
+
+    /// Slider `k` of the control, as (value, min, max): a slider's one, a
+    /// float2's two.
+    fn slider_part(&self, k: usize) -> Option<(f32, f32, f32)> {
+        match self {
+            Control::Slider { value, min, max, .. } if k == 0 => Some((*value, *min, *max)),
+            Control::Float2 { values, min, max, .. } if k < 2 => Some((values[k], *min, *max)),
+            _ => None,
+        }
+    }
+
+    /// Move slider `k` to `v`, clamped and snapped as the control says.
+    fn set_slider_part(&mut self, k: usize, v: f32) {
+        let q = self.quantize(v);
+        match self {
+            Control::Slider { value, .. } if k == 0 => *value = q,
+            Control::Float2 { values, .. } if k < 2 => values[k] = q,
+            _ => {}
+        }
+    }
 }
 
 /// One row: what picking it means, plus what to draw.
@@ -157,8 +183,10 @@ impl Row {
         }
     }
 
+    /// Whether the row's control is made of sliders: a slider, or a
+    /// float2's two.
     fn is_slider(&self) -> bool {
-        matches!(self.control, Some(Control::Slider { .. }))
+        matches!(self.control, Some(Control::Slider { .. } | Control::Float2 { .. }))
     }
 
     fn is_color(&self) -> bool {
@@ -202,6 +230,11 @@ pub const SLIDER_W: f32 = 180.0;
 /// would paint and never show.
 const READOUT_W: f32 = 60.0;
 const READOUT_GAP: f32 = 8.0;
+/// How far in from the row's right end a float2 row's control begins —
+/// twice a slider's, since it is two sliders, each with its readout.
+const FLOAT2_W: f32 = 2.0 * SLIDER_W;
+/// The gap between a float2's two halves.
+const FLOAT2_GAP: f32 = 12.0;
 /// Gap between the query line and the list.
 const GAP: f32 = 8.0;
 
@@ -363,11 +396,19 @@ pub struct Dialog {
     /// (`draggable` and the `drag_*` hooks), so the drag survives the
     /// pointer leaving the plate.
     slider_drag: Option<usize>,
+    /// Which of the dragged row's sliders the press took: 0 for a slider,
+    /// either end of a float2.
+    slider_part: usize,
+    /// The float2 end under the pointer, while its row's control is
+    /// hovered — the one that lifts.
+    hover_part: usize,
     /// The band's (x, width) captured at the press, so a drag keeps
     /// mapping the pointer while the row scrolls under it.
     slider_track: (f32, f32),
     /// The row and value the pointer moved a slider to, drained by the app.
     slider_change: Option<(String, f32)>,
+    /// The row and values the pointer moved a float2 to, drained by the app.
+    float2_change: Option<(String, [f32; 2])>,
     /// One toolkit colour selector per colour row, by row id — real widgets,
     /// not stamps, because each carries state of its own: a hex edit in
     /// progress, a picker process streaming values. Kept across
@@ -410,8 +451,11 @@ impl Dialog {
             toggle_stamps: RefCell::new([off, on]),
             slider_stamp: RefCell::new(slider_stamp),
             slider_drag: None,
+            slider_part: 0,
+            hover_part: 0,
             slider_track: (0.0, 1.0),
             slider_change: None,
+            float2_change: None,
             colors: Vec::new(),
             color_changes: Vec::new(),
         });
@@ -640,11 +684,13 @@ impl Dialog {
 
     /// Move a slider row to a value, clamped and snapped as the row says.
     pub fn set_slider_value(&mut self, i: usize, v: f32) {
+        self.set_slider_part(i, 0, v);
+    }
+
+    /// Move slider `k` of row `i` — a float2's either end — to a value.
+    pub fn set_slider_part(&mut self, i: usize, k: usize, v: f32) {
         if let Some(c) = self.rows.get_mut(i).and_then(|r| r.control.as_mut()) {
-            let q = c.quantize(v);
-            if let Control::Slider { value, .. } = c {
-                *value = q;
-            }
+            c.set_slider_part(k, v);
         }
     }
 
@@ -652,6 +698,20 @@ impl Dialog {
         self.slider_change.take()
     }
 
+    pub fn take_float2_change(&mut self) -> Option<(String, [f32; 2])> {
+        self.float2_change.take()
+    }
+
+    /// Say what row `i`'s sliders now hold, for the app to land.
+    fn note_slider_change(&mut self, i: usize) {
+        let Some(row) = self.rows.get(i) else { return };
+        match row.control {
+            Some(Control::Slider { value, .. }) => self.slider_change = Some((row.id.clone(), value)),
+            Some(Control::Float2 { values, .. }) => self.float2_change = Some((row.id.clone(), values)),
+            _ => {}
+        }
+    }
+
     /// Whether a press has taken hold of a slider — the app arms its
     /// widget drag on this.
     pub fn slider_dragging(&self) -> bool {
@@ -753,6 +813,11 @@ impl Dialog {
                 height: TOGGLE_H,
             }),
             Some(Control::Slider { .. }) => Some(self.slider_rect(r)),
+            Some(Control::Float2 { .. }) => {
+                let parts = self.float2_parts(r);
+                let (a, b) = (parts[0].1, parts[1].1);
+                Some(Rect { width: b.x + b.width - a.x, ..a })
+            }
             Some(Control::Color { .. }) => Some(self.slider_band_rect(r)),
             Some(Control::Choice { .. }) => Some(self.slider_band_rect(r)),
             None => None,
@@ -772,27 +837,66 @@ impl Dialog {
         Rect { x: b.x - READOUT_W - READOUT_GAP, width: b.width + READOUT_W + READOUT_GAP, ..b }
     }
 
-    /// The band's (x, width), captured at a press for the drag.
-    fn slider_track_of(&self, r: Rect) -> (f32, f32) {
-        let b = self.slider_band_rect(r);
-        (b.x, b.width)
+    /// A float2 row's two sliders, each as (band, the band with its
+    /// readout lane ahead of it): the slider control's whole span — from
+    /// [`FLOAT2_W`] in from the row's right end out to the band's right
+    /// edge — halved, with [`FLOAT2_GAP`] between the halves.
+    fn float2_parts(&self, r: Rect) -> [(Rect, Rect); 2] {
+        let band = self.slider_band_rect(r);
+        let x0 = r.x + r.width - 8.0 - FLOAT2_W;
+        let half = ((band.x + band.width - x0 - FLOAT2_GAP) * 0.5).max(READOUT_W + READOUT_GAP + 10.0);
+        let part = |k: usize| {
+            let whole = Rect { x: x0 + k as f32 * (half + FLOAT2_GAP), width: half, ..band };
+            let lane = READOUT_W + READOUT_GAP;
+            (Rect { x: whole.x + lane, width: half - lane, ..whole }, whole)
+        };
+        [part(0), part(1)]
+    }
+
+    /// A slider row's sliders, each as (band, the whole control): one for a
+    /// slider, two for a float2.
+    fn slider_parts(&self, r: Rect, row: &Row) -> Vec<(Rect, Rect)> {
+        match row.control {
+            Some(Control::Slider { .. }) => vec![(self.slider_band_rect(r), self.slider_rect(r))],
+            Some(Control::Float2 { .. }) => self.float2_parts(r).to_vec(),
+            _ => Vec::new(),
+        }
     }
 
-    /// Step a slider row by wheel notches: 2% of the range each, the toolkit
-    /// slider's own rate, up meaning more — the sign the viewport's zoom
-    /// wheel has.
-    fn scroll_slider(&mut self, i: usize, notches: f32) -> bool {
-        let Some(Control::Slider { value, min, max, .. }) = self.rows.get(i).and_then(|r| r.control.as_ref()) else {
+    /// Which of row `i`'s sliders is under `x`, testing the band alone when
+    /// `band_only` (a press) and band and readout together otherwise (the
+    /// wheel, the hover).
+    fn slider_part_at(&self, r: Rect, i: usize, x: f32, band_only: bool) -> Option<(usize, Rect)> {
+        let row = self.rows.get(i)?;
+        self.slider_parts(r, row).into_iter().enumerate().find_map(|(k, (band, whole))| {
+            let s = if band_only { band } else { whole };
+            (x >= s.x && x < s.x + s.width).then_some((k, band))
+        })
+    }
+
+    /// Row `i`'s slider bands in window coordinates, while the row is in
+    /// view inside the dialog at `(x, y, w, h)` — for the suite to press
+    /// on where the pointer would.
+    #[cfg(test)]
+    pub fn slider_bands(&self, (x, y, w, h): (f32, f32, f32, f32), i: usize) -> Vec<Rect> {
+        let rect = Rect { x, y, width: w, height: h };
+        let Some(r) = self.row_rect(rect, i) else { return Vec::new() };
+        self.slider_parts(r, &self.rows[i]).into_iter().map(|(band, _)| band).collect()
+    }
+
+    /// Step slider `k` of row `i` by wheel notches: 2% of the range each,
+    /// the toolkit slider's own rate, up meaning more — the sign the
+    /// viewport's zoom wheel has.
+    fn scroll_slider(&mut self, i: usize, k: usize, notches: f32) -> bool {
+        let Some((cur, min, max)) = self.rows.get(i).and_then(|r| r.control.as_ref()).and_then(|c| c.slider_part(k)) else {
             return false;
         };
-        let (cur, min, max) = (*value, *min, *max);
-        let v = cur + notches * 0.02 * (max - min);
-        self.set_slider_value(i, v);
-        let Some(now) = self.rows[i].slider_value() else { return false };
+        self.set_slider_part(i, k, cur + notches * 0.02 * (max - min));
+        let Some((now, ..)) = self.rows[i].control.as_ref().and_then(|c| c.slider_part(k)) else { return false };
         if (now - cur).abs() < 1e-6 {
             return false;
         }
-        self.slider_change = Some((self.rows[i].id.clone(), now));
+        self.note_slider_change(i);
         true
     }
 
@@ -801,15 +905,15 @@ impl Dialog {
     /// to grab, and a click on a scale should mean "this much".
     fn slide_to(&mut self, px: f32) -> bool {
         let Some(i) = self.slider_drag else { return false };
-        let Some(Control::Slider { value, min, max, .. }) = self.rows.get(i).and_then(|r| r.control.as_ref()) else {
+        let k = self.slider_part;
+        let Some((old, min, max)) = self.rows.get(i).and_then(|r| r.control.as_ref()).and_then(|c| c.slider_part(k)) else {
             return false;
         };
-        let (old, min, max) = (*value, *min, *max);
         let (tx, tw) = self.slider_track;
         let t = ((px - tx) / tw).clamp(0.0, 1.0);
-        self.set_slider_value(i, min + t * (max - min));
-        let Some(now) = self.rows[i].slider_value() else { return false };
-        self.slider_change = Some((self.rows[i].id.clone(), now));
+        self.set_slider_part(i, k, min + t * (max - min));
+        let Some((now, ..)) = self.rows[i].control.as_ref().and_then(|c| c.slider_part(k)) else { return false };
+        self.note_slider_change(i);
         now != old
     }
 
@@ -1054,8 +1158,8 @@ impl Paint for Dialog {
             // state could not be read. On the plate it reads like the rest.
             // A slider or colour row's control is wider than the toggle
             // column and takes the chord column's place on that one row.
-            let ctl_col = if row.is_slider() {
-                (r.x + r.width) - self.slider_rect(r).x + 4.0
+            let ctl_col = if let Some(c) = row.is_slider().then(|| self.control_rect(r, row)).flatten() {
+                (r.x + r.width) - c.x + 4.0
             } else if row.is_color() {
                 (r.x + r.width) - self.slider_band_rect(r).x + 4.0
             } else {
@@ -1099,6 +1203,7 @@ impl Paint for Dialog {
             let chord_right = r.x + r.width - 8.0 - ctl_col;
             let label_right = match &row.control {
                 Some(Control::Choice { .. }) => self.slider_band_rect(r).x - 12.0,
+                Some(Control::Float2 { .. }) => self.float2_parts(r)[0].1.x - 12.0,
                 _ => chord_right - if right_w > 0.0 { right_w + 12.0 } else { 0.0 },
             };
             let glyph_y = r.y + (r.height - chev) * 0.5;
@@ -1164,6 +1269,21 @@ impl Paint for Dialog {
                         own,
                     );
                 }
+                Some(Control::Float2 { values, min, max, dec, .. }) => {
+                    // The Slider arm twice, an end each.
+                    for (k, (band, _)) in self.float2_parts(r).into_iter().enumerate() {
+                        {
+                            let mut stamp = self.slider_stamp.borrow_mut();
+                            stamp.set_range(*min, *max);
+                            stamp.set_scaled_value(values[k]);
+                            stamp.set_hovered(self.hover_ctl == Some(i) && self.hover_part == k);
+                            Paint::paint(&**stamp, band, ctx);
+                        }
+                        let readout = format!("{:.*}", *dec, values[k]);
+                        let rw = display::measure_text_width(&readout, &family, font_size);
+                        ctx.text_with(readout, band.x - READOUT_GAP - rw, ty, font_size, label_color, Some(family.clone()), own);
+                    }
+                }
                 Some(Control::Color { .. }) => {
                     if let Some(sel) = self.color_selector(&row.id) {
                         let band = self.slider_band_rect(r);
@@ -1301,13 +1421,11 @@ impl Input for Dialog {
                         // BAND rather than the whole control, or a click
                         // on the readout would jump the value to the end
                         // of the range nearest it.
-                        if let Some(r) = r {
-                            let s = self.slider_band_rect(r);
-                            if *x >= s.x && *x < s.x + s.width {
-                                self.slider_track = self.slider_track_of(r);
-                                self.slider_drag = Some(i);
-                                self.slide_to(*x);
-                            }
+                        if let Some((k, band)) = r.and_then(|r| self.slider_part_at(r, i, *x, true)) {
+                            self.slider_track = (band.x, band.width);
+                            self.slider_drag = Some(i);
+                            self.slider_part = k;
+                            self.slide_to(*x);
                         }
                         return true;
                     }
@@ -1358,8 +1476,12 @@ impl Input for Dialog {
                         .and_then(|r| self.control_rect(r, &self.rows[i]))
                         .is_some_and(|c| *x >= c.x && *x < c.x + c.width && *y >= c.y && *y < c.y + c.height)
                 });
-                let changed = row != self.hover_row || ctl != self.hover_ctl;
+                let part = ctl
+                    .and_then(|i| self.row_rect(rect, i).and_then(|r| self.slider_part_at(r, i, *x, false)))
+                    .map_or(0, |(k, _)| k);
+                let changed = row != self.hover_row || ctl != self.hover_ctl || part != self.hover_part;
                 self.hover_row = row;
+                self.hover_part = part;
                 if ctl != self.hover_ctl {
                     // A colour well is a widget of its own: told the way
                     // the runner tells any widget.
@@ -1383,15 +1505,10 @@ impl Input for Dialog {
                 // Over a slider row's control the wheel turns the slider,
                 // not the list — the rest of the row still scrolls.
                 if let Some(i) = self.row_at(rect, *x, *y) {
-                    if self.rows[i].is_slider() {
-                        if let Some(r) = self.row_rect(rect, i) {
-                            let s = self.slider_rect(r);
-                            if *x >= s.x && *x < s.x + s.width {
-                                // Up is more, for a wheel and for a
-                                // finger alike (`value_notches_y`).
-                                return self.scroll_slider(i, delta.value_notches_y());
-                            }
-                        }
+                    if let Some((k, _)) = self.row_rect(rect, i).and_then(|r| self.slider_part_at(r, i, *x, false)) {
+                        // Up is more, for a wheel and for a finger alike
+                        // (`value_notches_y`).
+                        return self.scroll_slider(i, k, delta.value_notches_y());
                     }
                 }
                 // The DE scroll model: a notch is one row and glides there, a
@@ -2580,8 +2697,9 @@ impl State {
                 }
             }
             // The arrows work the selected row's control in place: a
-            // slider by its step, a choice to the next or previous option.
-            // On any other row they mean nothing here.
+            // slider by its step, a choice to the next or previous option,
+            // a float2's first end — its second with shift. On any other
+            // row they mean nothing here.
             Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowRight) => {
                 let dir: i32 = if matches!(event.logical_key, Key::Named(NamedKey::ArrowRight)) { 1 } else { -1 };
                 self.nudge_dialog_selection(dir);
@@ -2623,6 +2741,13 @@ impl State {
                 let Some(v) = self.slots.dialog.rows[i].slider_value() else { return };
                 self.land_dialog_slider(&id, v);
             }
+            Control::Float2 { values, step, .. } => {
+                let i = self.slots.dialog.selected;
+                let k = self.modifiers.shift_key() as usize;
+                self.slots.dialog.set_slider_part(i, k, values[k] + dir as f32 * step);
+                let Some(Control::Float2 { values, .. }) = self.slots.dialog.selected_control().cloned() else { return };
+                self.land_dialog_float2(&id, values);
+            }
             Control::Choice { options, index } => {
                 if options.is_empty() {
                     return;
@@ -2795,6 +2920,18 @@ impl State {
     /// arrow key is a single landing and saves at once, as the menu's wheel
     /// does. A spin row lands its whole number over the row's unit. A row the landing
     /// does not know falls through to `apply_setting`.
+    /// A float2 row's new values, from the pointer or an arrow key. Only a
+    /// visualizer's Manual Range is one; it lands as its sliders do.
+    pub(crate) fn land_dialog_float2(&mut self, id: &str, v: [f32; 2]) {
+        let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) else { return };
+        let field = field.to_string();
+        let save = !self.slots.dialog.slider_dragging();
+        self.set_visualizer_field(i, &field, &format!("{}:{}", v[0], v[1]), save);
+        if let Some(row) = self.visualizer_edit_rows(i).into_iter().find(|r| r.id == id) {
+            self.slots.dialog.set_control(id, row.control);
+        }
+    }
+
     pub(crate) fn land_dialog_slider(&mut self, id: &str, v: f32) {
         if let (Some(field), Some(i)) = (id.strip_prefix(VIS_FIELD_PREFIX), self.vis_editing) {
             // In place: the rows do not change under a drag, and a rebuild
@@ -3028,6 +3165,10 @@ impl State {
             self.land_dialog_slider(&id, v);
             changed = true;
         }
+        if let Some((id, v)) = self.slots.dialog.take_float2_change() {
+            self.land_dialog_float2(&id, v);
+            changed = true;
+        }
         for (id, hex) in self.slots.dialog.take_color_changes() {
             if let Some(s) = setting_of_row(&id) {
                 self.apply_setting(s.label, &hex);
diff --git a/src/main.rs b/src/main.rs
index fc189a6..0c533c1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8074,6 +8074,109 @@ mod tests {
         state.close_dialog();
     }
 
+    /// A visualizer's Manual Range is the node's float2, and the dialog
+    /// edits it as one: a row of two sliders side by side, each end worked
+    /// on its own — pressed and dragged, turned by the wheel, nudged by the
+    /// arrows (shift for the second). A drag lands live and saves on the
+    /// release. A settings file from before, which kept From and To, reads
+    /// as the range.
+    #[test]
+    fn a_visualizers_manual_range_is_one_float2_row() {
+        use crate::dialog::Control;
+        use crate::slots::DIALOG_IDX;
+        use crate::visualizer::{decode, VIS_ADD_ROW_ID, VIS_FIELD_PREFIX};
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::{ElementState, MouseButton, MouseScrollDelta};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        state.rebuild_scene_geometry();
+        assert!(state.run_command("attribute_visualizers"));
+        state.take_dialog_pick(VIS_ADD_ROW_ID.to_string());
+        state.set_visualizer_field(0, "attribute", "N", true);
+        state.set_visualizer_field(0, "range", "Manual", true);
+        state.refresh_dialog_controls();
+        let (lo, hi) = {
+            let a = state.scene_attributes.iter().find(|a| a.name == "N").unwrap();
+            (a.min, a.max)
+        };
+        assert_eq!(state.visualizers[0].manual_range, [lo, hi], "Manual starts at what Auto showed");
+
+        let id = format!("{VIS_FIELD_PREFIX}manual_range");
+        let i = state.slots.dialog.rows.iter().position(|r| r.id == id).expect("a Manual Range row");
+        let ids: Vec<&str> = state.slots.dialog.rows.iter().map(|r| r.id.as_str()).collect();
+        assert!(!ids.iter().any(|r| r.ends_with(":from") || r.ends_with(":to")), "no From and To rows: {ids:?}");
+        let control = |state: &State| match state.slots.dialog.rows[i].control.clone() {
+            Some(Control::Float2 { values, min, max, .. }) => (values, min, max),
+            other => panic!("not a float2: {other:?}"),
+        };
+        let (values, min, max) = control(&state);
+        assert_eq!(values, [lo, hi]);
+        let at = |t: f32| min + t * (max - min);
+
+        let bands = state.slots.dialog.slider_bands(state.positions[DIALOG_IDX], i);
+        assert_eq!(bands.len(), 2, "two sliders");
+        assert!(bands[0].x + bands[0].width < bands[1].x, "side by side");
+        let point = |state: &mut State, k: usize, t: f32| {
+            let b = bands[k];
+            let (px, py) = (b.x + b.width * t, b.y + b.height * 0.5);
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: px as f64, y: py as f64 } });
+        };
+        let saved = || {
+            let kdl = fs::read_to_string(crate::app::DesignSettings::file_path()).expect("saved");
+            decode(&crate::app::DesignSettings::from_kdl_str(&kdl).viewport.visualizers)[0].manual_range
+        };
+        let close = |a: f32, b: f32| (a - b).abs() < (max - min) * 0.02;
+
+        // The first end, pressed and dragged: live, saved on the release.
+        point(&mut state, 0, 0.25);
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        assert!(state.slots.dialog.slider_dragging());
+        point(&mut state, 0, 0.1);
+        let [a, b] = state.visualizers[0].manual_range;
+        assert!(close(a, at(0.1)) && b == hi, "{a} {b}");
+        assert_eq!(control(&state).0, state.visualizers[0].manual_range, "the row shows what the visualizer holds");
+        assert_eq!(saved(), [lo, hi], "not written mid-drag");
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        assert_eq!(saved(), state.visualizers[0].manual_range, "written on the release");
+
+        // The second end, on its own band; the first stays.
+        point(&mut state, 1, 0.9);
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left });
+        state.handle_event(&WindowEvent::MouseInput { state: ElementState::Released, button: MouseButton::Left });
+        let [a2, b2] = state.visualizers[0].manual_range;
+        assert!(a2 == a && close(b2, at(0.9)), "{a2} {b2}");
+
+        // The wheel over an end turns that end, up being more.
+        point(&mut state, 0, 0.5);
+        state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::LineDelta(0.0, 1.0) });
+        let [a3, b3] = state.visualizers[0].manual_range;
+        assert!(a3 > a2 && b3 == b2, "{a3} {b3}");
+
+        // The arrows nudge the first end; with shift, the second.
+        state.slots.dialog.selected = i;
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowRight)));
+        let [a4, b4] = state.visualizers[0].manual_range;
+        assert!(a4 > a3 && b4 == b3, "{a4} {b4}");
+        state.modifiers = ModifiersState { shift: true, ..Default::default() };
+        state.dialog_key_input(&key_press(Key::Named(NamedKey::ArrowLeft)));
+        state.modifiers = ModifiersState::default();
+        let [a5, b5] = state.visualizers[0].manual_range;
+        assert!(a5 == a4 && b5 < b4, "{a5} {b5}");
+        assert_eq!(saved(), [a5, b5], "a key's landing saves at once");
+
+        // What it draws is the node's Manual Range.
+        let node = state.visualizers[0].as_node();
+        assert_eq!(crate::geometry::node_param_vec2(&node, "manual_range", [0.0, 0.0]), [a5, b5]);
+
+        // A settings file from before kept the two ends apart.
+        let old = decode("attribute=N|mode=Ramp|range=Manual|from=2|to=5");
+        assert_eq!(old[0].manual_range, [2.0, 5.0]);
+        assert_eq!(decode(&crate::visualizer::encode(&old)), old);
+        state.close_dialog();
+    }
+
     /// A visualizer is the Visualize node's reading: the same settings give
     /// the same colours as the node does, and the settings round-trip
     /// through their one string.
diff --git a/src/visualizer.rs b/src/visualizer.rs
index a80afb8..495d047 100644
--- a/src/visualizer.rs
+++ b/src/visualizer.rs
@@ -55,12 +55,11 @@ pub struct Visualizer {
     pub mode: String,
     pub ramp: String,
     /// `Auto` spreads the ramp over the attribute's range in the scene;
-    /// `Manual` over From..To — the node's Manual Range, a float2, kept as
-    /// its two ends because the dialog edits each on a slider of its own
-    /// (it has no float2 control) and the stored form names them so.
+    /// `Manual` over Manual Range.
     pub range: String,
-    pub from: f32,
-    pub to: f32,
+    /// The ramp's two ends under a Manual range: the node's float2, edited
+    /// in the dialog as one (`Control::Float2`).
+    pub manual_range: [f32; 2],
     pub blend: String,
     pub opacity: f32,
     /// A Vector line's length per unit of the attribute.
@@ -78,8 +77,7 @@ impl Visualizer {
             mode: ramp_mode(),
             ramp: viridis(),
             range: auto(),
-            from: 0.0,
-            to: 1.0,
+            manual_range: [0.0, 1.0],
             blend: set(),
             opacity: 1.0,
             scale: fifth(),
@@ -116,7 +114,7 @@ impl Visualizer {
                 p("mode", "choice", self.mode.clone()),
                 p("ramp", "choice", self.ramp.clone()),
                 p("range", "choice", self.range.clone()),
-                p("manual_range", "float2", format!("{}:{}", self.from, self.to)),
+                p("manual_range", "float2", format!("{}:{}", self.manual_range[0], self.manual_range[1])),
                 p("blend", "choice", self.blend.clone()),
                 p("opacity", "float", self.opacity.to_string()),
                 p("scale", "float", self.scale.to_string()),
@@ -161,8 +159,7 @@ pub fn encode(visualizers: &[Visualizer]) -> String {
                 ("mode", v.mode.clone()),
                 ("ramp", v.ramp.clone()),
                 ("range", v.range.clone()),
-                ("from", v.from.to_string()),
-                ("to", v.to.to_string()),
+                ("manual_range", format!("{}:{}", v.manual_range[0], v.manual_range[1])),
                 ("blend", v.blend.clone()),
                 ("opacity", v.opacity.to_string()),
                 ("scale", v.scale.to_string()),
@@ -177,6 +174,12 @@ pub fn encode(visualizers: &[Visualizer]) -> String {
         .join(";")
 }
 
+/// `lo:hi` as two numbers.
+fn two(text: &str) -> Option<[f32; 2]> {
+    let (a, b) = text.split_once(':')?;
+    Some([a.trim().parse().ok()?, b.trim().parse().ok()?])
+}
+
 /// The visualizers out of the settings' one string; a record that names no
 /// field it knows is dropped.
 pub fn decode(text: &str) -> Vec<Visualizer> {
@@ -196,8 +199,10 @@ pub fn decode(text: &str) -> Vec<Visualizer> {
                     "mode" => v.mode = val,
                     "ramp" => v.ramp = val,
                     "range" => v.range = val,
-                    "from" => v.from = num(v.from),
-                    "to" => v.to = num(v.to),
+                    "manual_range" => v.manual_range = two(&val).unwrap_or(v.manual_range),
+                    // Its two ends, as a settings file from before kept them.
+                    "from" => v.manual_range[0] = num(v.manual_range[0]),
+                    "to" => v.manual_range[1] = num(v.manual_range[1]),
                     "blend" => v.blend = val,
                     "opacity" => v.opacity = num(v.opacity),
                     "scale" => v.scale = num(v.scale),
@@ -343,8 +348,7 @@ impl State {
             if v.is_manual() {
                 let (lo, hi) = self.visualizer_value_range(v);
                 let step = ((hi - lo) / 100.0).max(1e-4);
-                rows.push(row("from", "From", Some(Control::Slider { value: v.from, min: lo, max: hi, dec: 3, step, suffix: "" })));
-                rows.push(row("to", "To", Some(Control::Slider { value: v.to, min: lo, max: hi, dec: 3, step, suffix: "" })));
+                rows.push(row("manual_range", "Manual Range", Some(Control::Float2 { values: v.manual_range, min: lo, max: hi, dec: 3, step })));
             }
             rows.push(row("blend", "Blend", Some(choice(&BLENDS, &v.blend))));
             rows.push(row("opacity", "Opacity", Some(Control::Slider { value: v.opacity, min: 0.0, max: 1.0, dec: 2, step: 0.05, suffix: "" })));
@@ -373,8 +377,9 @@ impl State {
             }
             None => (0.0, 1.0),
         };
-        lo = lo.min(v.from).min(v.to);
-        hi = hi.max(v.from).max(v.to);
+        let [a, b] = v.manual_range;
+        lo = lo.min(a).min(b);
+        hi = hi.max(a).max(b);
         (lo, hi)
     }
 
@@ -424,17 +429,16 @@ impl State {
             "range" => v.range = value.to_string(),
             "blend" => v.blend = value.to_string(),
             "group" => v.group = if value == ALL_POINTS { String::new() } else { value.to_string() },
-            "from" => v.from = number().unwrap_or(v.from),
-            "to" => v.to = number().unwrap_or(v.to),
+            "manual_range" => v.manual_range = two(value).unwrap_or(v.manual_range),
             "opacity" => v.opacity = number().unwrap_or(v.opacity).clamp(0.0, 1.0),
             "scale" => v.scale = number().unwrap_or(v.scale).max(0.0),
             _ => return,
         }
-        // Switching to Manual starts From and To at the attribute's own
-        // range, which is what Auto was showing — not at 0..1.
-        if field == "range" && v.is_manual() && v.from == 0.0 && v.to == 1.0 {
+        // Switching to Manual starts the range at the attribute's own,
+        // which is what Auto was showing — not at 0..1.
+        if field == "range" && v.is_manual() && v.manual_range == [0.0, 1.0] {
             if let Some(a) = self.scene_attributes.iter().find(|a| a.name == v.attribute) {
-                (v.from, v.to) = (a.min, a.max);
+                v.manual_range = [a.min, a.max];
             }
         }
         self.visualizers_changed(save);