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

commit8e307daa9893783667f95c8265cd8f996d134017
parent0a02bd2770
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-29 00:00
feat: a float3 group can carry a trackball

Three sliders set a vector a component at a time, which is the wrong
handle for its direction. Float3::set_trackball (a ParametersBg row typed
float3:lo:hi:trackball) stands a ball left of the rows, as tall as they
are, with the vector drawn on it from the centre — X right, Y up, Z
toward the viewer; bright on the near side, dim pointing away. Dragging
the ball rolls it under the pointer, turning the vector and keeping its
length. The rows stay, for a component typed or a length changed.

The drag turns its own full-precision copy: the rows hold the vector
rounded and a host writes the rounded string back between moves, and
turning that loses every step smaller than a readout tick. With the ball
on the rows read to three decimals. The ball is painted through the
host's scene path, since a sphere is not a prim the flat views carry,
and counts as chrome when the pane decides its label layout. A vector of
no length is given a length of one by the first drag.

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

 CLAUDE.md                             |  20 +++
 src/widget/container/parameters_bg.rs |  56 ++++++-
 src/widget/display/float3.rs          | 285 +++++++++++++++++++++++++++++++++-
 src/widget/input/slider.rs            |   6 +
 4 files changed, 355 insertions(+), 12 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 6ee4789..56a7e2b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -975,3 +975,23 @@ the old 40 only from a hundred up. By magnitude rather than a finer flat
 step: one fine enough to set 0.06 takes thousands of notches to reach
 1000, and this takes under sixty. A drag still maps the pointer to the
 whole range; the readout is still where an exact value is typed.
+
+### A float3 group can carry a trackball
+
+`Float3::set_trackball` (a `ParametersBg` row typed
+`float3:lo:hi:trackball`) stands a ball left of the three rows, as tall as
+they are. The vector is drawn on it from the centre — X right, Y up, Z
+toward the viewer; bright on the near side, dim pointing away — and
+dragging the ball rolls it under the pointer, turning the vector and
+keeping its length. The rows stay: a direction is turned on the ball, a
+component typed or a length changed on a row. Three things to know. The
+drag turns its OWN full-precision copy (`BallDrag`), because the rows hold
+the vector rounded and a host writes the rounded string back between
+moves; turning that loses every step smaller than a readout tick. With
+the ball on the rows read to three decimals, since at two a vector of
+length 0.06 has seven directions. And the ball is painted by
+`paint_ball` through the host's scene path (`paint_scene_rows`), not
+`Paint::paint`: a sphere is not a prim the legacy flat views carry. A
+vector of no length is given a length of one by the first drag. The ball
+counts as chrome in `ParametersBg::control_chrome`, so the label layout
+is decided by the track it leaves.
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 571bdce..0be2dc8 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -304,6 +304,12 @@ impl ParametersBg {
     /// box, dropdown, colour) is measured whole.
     pub const MIN_INLINE_TRACK_W: f32 = 120.0;
 
+    /// Whether a float3 row's type asks for the trackball: a fourth segment,
+    /// `float3:lo:hi:trackball`.
+    fn has_trackball(t: &str) -> bool {
+        t.starts_with("float3") && t.split(':').nth(3) == Some("trackball")
+    }
+
     /// What a row of type `t` spends of its control rect on everything but
     /// its track: a slider's readout and gap, plus a float3's axis column.
     /// Zero for the controls that have no track.
@@ -311,7 +317,8 @@ impl ParametersBg {
         if t.starts_with("slider") {
             crate::widget::input::slider::Slider::readout_chrome()
         } else if t.starts_with("float3") {
-            crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
+            let ball = if Self::has_trackball(t) { Float3::trackball_chrome() } else { 0.0 };
+            ball + crate::widget::display::float3::AXIS_W + crate::widget::input::slider::Slider::readout_chrome()
         } else {
             0.0
         }
@@ -1915,11 +1922,17 @@ impl ParametersBg {
         let hidden = self.hidden_rows();
         let dummy = UiContext::new();
         for (i, p) in self.display_params.iter().enumerate() {
-            if hidden[i] || p.2 != "ramp" {
+            if hidden[i] {
                 continue;
             }
-            if let Some(rp) = &self.ramps[i] {
-                rp.paint_self(&dummy, pc);
+            if p.2 == "ramp" {
+                if let Some(rp) = &self.ramps[i] {
+                    rp.paint_self(&dummy, pc);
+                }
+            } else if let Some(f) = self.float3s[i].as_ref().filter(|f| f.has_trackball()) {
+                // A float3's trackball: a sphere is not a prim the flat
+                // views carry, so it rides the scene path like the ramp.
+                f.paint_ball(pc);
             }
         }
     }
@@ -3457,7 +3470,7 @@ impl ParamController for ParametersBg {
                 if p.2.starts_with("float3") {
                     let (min, max) = parse_slider_range(&p.2);
                     let vals = parse_float3_value(&p.1, min, max);
-                    let f = Float3::new().with_values(vals).with_range(min, max);
+                    let f = Float3::new().with_values(vals).with_range(min, max).with_trackball(Self::has_trackball(&p.2));
                     Some(if inline { f } else { f.with_label(&p.0) })
                 } else {
                     None
@@ -3910,6 +3923,39 @@ mod tests {
         assert!(p.inner().inline_labels, "with the slider hidden the spinbox decides");
     }
 
+    /// A `float3:lo:hi:trackball` row builds its group with the ball, counts
+    /// the ball as chrome when the label layout is decided, paints it
+    /// through the scene path, and a press-and-drag on it through the
+    /// pane's own drag protocol turns the row's value.
+    #[test]
+    fn a_trackball_row_turns_its_value_through_the_pane() {
+        use crate::scene::paint::Prim;
+        let mut p = panel_with(&[("Name", "x", "text"), ("Pull", "0.000:0.000:2.000", "float3:-10:10:trackball"), ("Plain", "0:0:0", "float3:-10:10")]);
+        WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 400.0);
+        assert!(p.float3s[1].as_ref().unwrap().has_trackball());
+        assert!(!p.float3s[2].as_ref().unwrap().has_trackball());
+        assert_eq!(
+            ParametersBg::control_chrome("float3:-10:10:trackball") - ParametersBg::control_chrome("float3:-10:10"),
+            Float3::trackball_chrome(),
+            "the ball is chrome: the tracks are what is left of it"
+        );
+
+        let mut pc = crate::scene::paint::PaintCtx::new();
+        p.inner().paint_scene_rows(&mut pc);
+        let spheres = pc.finish().items.into_iter().filter(|i| matches!(i.prim, Prim::Sphere { .. })).count();
+        assert_eq!(spheres, 1, "one ball, for the one row that asks");
+
+        let (cx, cy, r) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
+        let rect = Rect { x: 0.0, y: 0.0, width: 500.0, height: 400.0 };
+        Input::drag_begin(p.inner_mut(), cx, cy, rect);
+        assert!(Input::is_dragging(p.inner()), "the pane is dragging the row");
+        assert!(Input::drag_update(p.inner_mut(), cx + r * std::f32::consts::FRAC_PI_2, cy, rect));
+        Input::drag_end(p.inner_mut());
+        let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
+        assert!((v[0] - 2.0).abs() < 2e-3 && v[1].abs() < 2e-3 && v[2].abs() < 2e-3, "a quarter turn right: {v:?}");
+        assert_eq!(p.display_params[2].1, "0:0:0", "the plain row is untouched");
+    }
+
     #[test]
     fn ramp_row_builds_from_spec_and_edits_serialize_back() {
         let mut ctx = UiContext::new();
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 249608a..c29c4c1 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -29,6 +29,23 @@ const ROW_GAP: f32 = 4.0;
 pub(crate) const AXIS_W: f32 = 16.0;
 /// Readout / edit-buffer precision of the rows, and of [`Float3::value_string`].
 const DECIMALS: usize = 2;
+/// The same with the trackball on. Turning a vector is a small change to
+/// each of its components, and at two decimals a short one — a pull of 0.06
+/// — has seven directions it can point in.
+const BALL_DECIMALS: usize = 3;
+/// Gap between the trackball and the axis-letter column.
+const BALL_GAP: f32 = 10.0;
+
+/// A trackball drag in progress: where the pointer last was, and the vector
+/// being turned at FULL precision. The rows hold it rounded to their
+/// readouts, and a host may write the rounded string back between moves;
+/// turning that instead would lose every step smaller than a readout tick.
+#[derive(Debug, Clone, Copy)]
+struct BallDrag {
+    last: (f32, f32),
+    dir: [f32; 3],
+    len: f32,
+}
 
 pub struct Float3 {
     /// The assigned (label-inclusive) rect.
@@ -37,6 +54,10 @@ pub struct Float3 {
     axes: [&'static str; 3],
     label: Option<String>,
     dragging_idx: Option<usize>,
+    /// Whether the group carries a TRACKBALL left of its rows: a ball the
+    /// vector is drawn on and turned by. See [`Float3::set_trackball`].
+    ball: bool,
+    ball_drag: Option<BallDrag>,
 }
 
 impl Float3 {
@@ -48,9 +69,154 @@ impl Float3 {
             axes: ["X", "Y", "Z"],
             label: None,
             dragging_idx: None,
+            ball: false,
+            ball_drag: None,
         })
     }
 
+    /// Give the group a trackball, or take it away.
+    ///
+    /// The three sliders set a vector a component at a time, which is the
+    /// wrong handle for its DIRECTION: pointing a pull somewhere else means
+    /// moving all three, by amounts no one can do in their head. The ball
+    /// is that handle. It sits left of the rows, as tall as they are, with
+    /// the vector drawn on it from the centre — bright on the near side,
+    /// dim when it points away — and dragging the ball rolls it under the
+    /// pointer, turning the vector with it and keeping its length. The rows
+    /// stay: they are still how a component is typed or a length changed.
+    ///
+    /// The view is fixed: X to the right, Y up, Z toward the viewer, the
+    /// axes the rows are lettered by. A vector of no length has no
+    /// direction to turn, so the first drag gives it a length of one.
+    pub fn set_trackball(&mut self, on: bool) {
+        self.ball = on;
+        self.ball_drag = None;
+        let decimals = self.decimals();
+        for s in self.sliders.iter_mut() {
+            s.set_decimals(decimals);
+        }
+        self.layout_rows();
+    }
+
+    pub fn has_trackball(&self) -> bool {
+        self.ball
+    }
+
+    fn decimals(&self) -> usize {
+        if self.ball { BALL_DECIMALS } else { DECIMALS }
+    }
+
+    /// The ball's diameter: the three rows' height, so it costs the group
+    /// no height of its own.
+    pub fn ball_diameter() -> f32 {
+        3.0 * crate::layout::slider_height() + 2.0 * ROW_GAP
+    }
+
+    /// What the trackball takes of the group's width: the ball and its gap.
+    /// A host measuring the rows' tracks counts it as chrome.
+    pub fn trackball_chrome() -> f32 {
+        Self::ball_diameter() + BALL_GAP
+    }
+
+    /// The ball as `(cx, cy, radius)`, when the group has one.
+    pub fn ball_circle(&self) -> Option<(f32, f32, f32)> {
+        if !self.ball {
+            return None;
+        }
+        let r = Self::ball_diameter() * 0.5;
+        Some((self.rect.x + r, self.rect.y + self.label_top() + r, r))
+    }
+
+    pub fn ball_hit(&self, px: f32, py: f32) -> bool {
+        self.ball_circle().is_some_and(|(cx, cy, r)| (px - cx).powi(2) + (py - cy).powi(2) <= r * r)
+    }
+
+    /// The vector the rows hold, in their scaled values.
+    pub fn vector(&self) -> [f32; 3] {
+        [0, 1, 2].map(|i| self.sliders[i].get_scaled_value())
+    }
+
+    /// Roll the ball by a pointer delta: `dx` turns the vector about the
+    /// vertical axis, `dy` about the horizontal one, by the angle that
+    /// much of the ball's surface subtends — so the point under the
+    /// pointer stays under it. Pure, for the tests.
+    pub fn rolled(dir: [f32; 3], dx: f32, dy: f32, radius: f32) -> [f32; 3] {
+        let (s, c) = (dx / radius).sin_cos();
+        let (x, y, z) = (dir[0] * c + dir[2] * s, dir[1], -dir[0] * s + dir[2] * c);
+        // Screen y runs down and the vector's Y up: a pull downward turns
+        // the near point toward -Y.
+        let (s, c) = (dy / radius).sin_cos();
+        let out = [x, y * c - z * s, y * s + z * c];
+        let len = (out[0] * out[0] + out[1] * out[1] + out[2] * out[2]).sqrt();
+        if len > 0.0 { out.map(|v| v / len) } else { dir }
+    }
+
+    fn ball_begin(&mut self, px: f32, py: f32) {
+        let v = self.vector();
+        let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+        let (dir, len) = if len > 1e-6 { (v.map(|c| c / len), len) } else { ([0.0, 0.0, 1.0], 1.0) };
+        self.ball_drag = Some(BallDrag { last: (px, py), dir, len });
+    }
+
+    fn ball_roll(&mut self, px: f32, py: f32) -> bool {
+        let (Some(mut drag), Some((_, _, r))) = (self.ball_drag, self.ball_circle()) else {
+            return false;
+        };
+        let (dx, dy) = (px - drag.last.0, py - drag.last.1);
+        if dx == 0.0 && dy == 0.0 {
+            return false;
+        }
+        drag.dir = Self::rolled(drag.dir, dx, dy, r);
+        drag.last = (px, py);
+        self.ball_drag = Some(drag);
+        for (s, c) in self.sliders.iter_mut().zip(drag.dir) {
+            s.set_scaled_value(c * drag.len);
+        }
+        true
+    }
+
+    /// The trackball: the ball, and the vector on it. Emitted through the
+    /// host's scene path (`ParametersBg::paint_scene_rows`) rather than
+    /// [`Paint::paint`], because a sphere is not a prim the legacy flat
+    /// views carry.
+    pub fn paint_ball(&self, ctx: &mut PaintCtx) {
+        let Some((cx, cy, r)) = self.ball_circle() else {
+            return;
+        };
+        let held = self.ball_drag.is_some();
+        let body = if held { [0.26, 0.29, 0.40, 1.0] } else { [0.19, 0.21, 0.29, 1.0] };
+        ctx.sphere(cx, cy, r, &crate::scene::material::Material::from_fill(body));
+        ctx.arc(cx, cy, r, 1.0, 0.0, std::f32::consts::TAU, [0.42, 0.45, 0.58, 0.9]);
+
+        // While held the ball shows the vector it is turning, not the one
+        // the rows rounded it to.
+        let v = match self.ball_drag {
+            Some(d) => d.dir.map(|c| c * d.len),
+            None => self.vector(),
+        };
+        let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+        // The accent's HUE at alphas of the ball's own: the configured
+        // accent carries an alpha meant for washes, and at that alpha the
+        // near side drew paler than the far one.
+        let accent = crate::color::highlight_primary_color();
+        let accent = |a: f32| [accent[0], accent[1], accent[2], a];
+        if len <= 1e-6 {
+            // No length, no direction: a dim hub and nothing on the ball.
+            ctx.circle(cx, cy, 2.5, accent(0.35));
+            return;
+        }
+        let d = v.map(|c| c / len);
+        // The tip sits on the ball's surface as seen from +Z, a little in
+        // from the rim so a vector lying in the screen plane stays on it.
+        let reach = r - 5.0;
+        let (tx, ty) = (cx + d[0] * reach, cy - d[1] * reach);
+        let near = d[2] >= 0.0;
+        let col = accent(if near { 1.0 } else { 0.35 });
+        ctx.vector(cx, cy, tx, ty, 2.0, col, crate::scene::paint::Cap::Round);
+        ctx.circle(cx, cy, 2.0, col);
+        ctx.circle(tx, ty, if near { 4.5 } else { 3.0 }, col);
+    }
+
     /// The height a labeled (`labeled`) group lays out to: the detached label band plus three slider rows and their gaps — the row-height table entry.
     pub fn preferred_height(labeled: bool) -> f32 {
         let top = if labeled { crate::layout::control_label_strip() } else { 0.0 };
@@ -78,7 +244,8 @@ impl Float3 {
     /// stores — the one formatter for every host sync.
     pub fn value_string(&self) -> String {
         let v = |i: usize| self.sliders[i].get_scaled_value();
-        format!("{:.*}:{:.*}:{:.*}", DECIMALS, v(0), DECIMALS, v(1), DECIMALS, v(2))
+        let d = self.decimals();
+        format!("{:.*}:{:.*}:{:.*}", d, v(0), d, v(1), d, v(2))
     }
 
     /// The three rows, X/Y/Z — for hosts that draw this group through the legacy flat views
@@ -98,8 +265,9 @@ impl Float3 {
     /// right of the axis-letter column. Each is exactly the rect its sub-slider is assigned.
     pub fn get_row_rects(&self) -> Vec<(f32, f32, f32, f32)> {
         let top = self.rect.y + self.label_top();
-        let x = self.rect.x + AXIS_W;
-        let w = (self.rect.width - AXIS_W).max(10.0);
+        let ball = if self.ball { Self::trackball_chrome() } else { 0.0 };
+        let x = self.rect.x + ball + AXIS_W;
+        let w = (self.rect.width - ball - AXIS_W).max(10.0);
         let h = crate::layout::slider_height();
         (0..3).map(|i| (x, top + i as f32 * (h + ROW_GAP), w, h)).collect()
     }
@@ -158,6 +326,12 @@ impl Float3 {
 }
 
 impl Adapted<Float3> {
+    /// See [`Float3::set_trackball`].
+    pub fn with_trackball(mut self, on: bool) -> Self {
+        self.set_trackball(on);
+        self
+    }
+
     pub fn with_values(mut self, values: [f32; 3]) -> Self {
         self.set_values(values);
         self
@@ -227,17 +401,22 @@ impl Paint for Float3 {
 
 impl Input for Float3 {
     fn draggable(&self, _rect: Rect) -> bool {
-        self.dragging_idx.is_some()
+        self.dragging_idx.is_some() || self.ball_drag.is_some()
     }
 
     fn is_dragging(&self) -> bool {
-        self.dragging_idx.is_some()
+        self.dragging_idx.is_some() || self.ball_drag.is_some()
     }
 
     /// A host-driven drag begins on the row under the pointer — unless a press already
-    /// started one (the pane's press path), in which case that row keeps it.
+    /// started one (the pane's press path), in which case that row keeps it. The ball
+    /// comes first: it stands beside the rows, inside their span of y.
     fn drag_begin(&mut self, px: f32, py: f32, _rect: Rect) {
-        if self.dragging_idx.is_some() {
+        if self.dragging_idx.is_some() || self.ball_drag.is_some() {
+            return;
+        }
+        if self.ball_hit(px, py) {
+            self.ball_begin(px, py);
             return;
         }
         let rows = self.get_row_rects();
@@ -251,6 +430,9 @@ impl Input for Float3 {
     }
 
     fn drag_update(&mut self, px: f32, py: f32, _rect: Rect) -> bool {
+        if self.ball_drag.is_some() {
+            return self.ball_roll(px, py);
+        }
         match self.dragging_idx {
             Some(i) => self.sliders[i].drag_update(px, py),
             None => false,
@@ -258,6 +440,7 @@ impl Input for Float3 {
     }
 
     fn drag_end(&mut self) {
+        self.ball_drag = None;
         if let Some(i) = self.dragging_idx.take() {
             self.sliders[i].drag_end();
         }
@@ -290,6 +473,12 @@ impl Input for Float3 {
                 let mut dummy = UiContext::new();
                 match state {
                     ElementState::Pressed => {
+                        // A press on the ball takes hold of it; the drag
+                        // that follows turns the vector (`drag_update`).
+                        if self.ball_hit(*px, *py) {
+                            self.ball_begin(*px, *py);
+                            return true;
+                        }
                         let rows = self.get_row_rects();
                         for (i, r) in rows.into_iter().enumerate() {
                             if *py < r.1 || *py > r.1 + r.3 {
@@ -324,6 +513,9 @@ impl Input for Float3 {
                         if self.dragging_idx.take().is_some() {
                             any = true;
                         }
+                        if self.ball_drag.take().is_some() {
+                            any = true;
+                        }
                         any
                     }
                 }
@@ -401,4 +593,83 @@ mod tests {
         f.drag_end();
         assert!(!f.is_dragging());
     }
+
+    /// The trackball stands left of the rows, as tall as they are, and
+    /// dragging it rolls the vector: a quarter turn of the ball's surface
+    /// to the right carries a vector pointing at the viewer onto +X, one
+    /// downward onto -Y, and the length is kept. The rows read to a third
+    /// decimal, and a vector of no length is given one on the first drag.
+    #[test]
+    fn the_trackball_turns_the_vector_and_keeps_its_length() {
+        let mut ctx = UiContext::new();
+        let quarter = |r: f32| r * std::f32::consts::FRAC_PI_2;
+        let group = |v: [f32; 3]| {
+            // Range -10..10: a scaled value v is (v + 10) / 20 normalized.
+            let mut f = Float3::new().with_range(-10.0, 10.0).with_values(v.map(|c| (c + 10.0) / 20.0)).with_trackball(true);
+            WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
+            f
+        };
+        let close = |a: [f32; 3], b: [f32; 3]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 2e-3);
+
+        let plain = {
+            let mut f = Float3::new();
+            WidgetHost::set_rect(&mut f, 0.0, 0.0, 400.0, Float3::preferred_height(false));
+            f.get_row_rects()[0]
+        };
+        let mut f = group([0.0, 0.0, 2.0]);
+        let (cx, cy, r) = f.ball_circle().expect("a ball");
+        assert_eq!(r * 2.0, Float3::ball_diameter());
+        let row = f.get_row_rects()[0];
+        assert_eq!(row.0, plain.0 + Float3::trackball_chrome(), "the rows start past the ball");
+        assert_eq!(row.2, plain.2 - Float3::trackball_chrome());
+        assert_eq!(f.value_string(), "0.000:0.000:2.000", "three decimals with the ball on");
+
+        // A press on the ball takes hold; the drag rolls it a quarter turn right.
+        assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, cx, cy, &mut ctx));
+        assert!(f.is_dragging());
+        assert!(f.drag_update(cx + quarter(r), cy));
+        assert!(close(f.vector(), [2.0, 0.0, 0.0]), "{:?}", f.vector());
+        f.drag_end();
+        assert!(!f.is_dragging());
+
+        // Downward: the near point turns toward -Y.
+        let mut f = group([0.0, 0.0, 2.0]);
+        f.drag_begin(cx, cy);
+        assert!(f.drag_update(cx, cy + quarter(r)));
+        assert!(close(f.vector(), [0.0, -2.0, 0.0]), "{:?}", f.vector());
+
+        // Many small moves add up to what one large one does: the drag
+        // turns its own full-precision copy, not the rounded rows.
+        let mut f = group([0.0, 0.0, 0.06]);
+        f.drag_begin(cx, cy);
+        for i in 1..=100 {
+            f.drag_update(cx + quarter(r) * i as f32 / 100.0, cy);
+        }
+        assert!(close(f.vector(), [0.06, 0.0, 0.0]), "{:?}", f.vector());
+
+        // No length: the first drag gives it one.
+        let mut f = group([0.0, 0.0, 0.0]);
+        f.drag_begin(cx, cy);
+        assert!(f.drag_update(cx + quarter(r), cy));
+        assert!(close(f.vector(), [1.0, 0.0, 0.0]), "{:?}", f.vector());
+
+        // Off the ball a press is the rows', as before.
+        let mut f = group([0.0, 0.0, 2.0]);
+        let row = f.get_row_rects()[0];
+        assert!(!f.ball_hit(row.0 + 20.0, row.1 + row.3 * 0.5));
+        assert!(f.mouse_input(MouseButton::Left, ElementState::Pressed, row.0 + 20.0, row.1 + row.3 * 0.5, &mut ctx));
+        assert!(f.is_dragging());
+        f.drag_update(row.0 + 60.0, row.1 + row.3 * 0.5);
+        assert!(f.vector()[1] == 0.0 && f.vector()[2] == 2.0, "only X moved: {:?}", f.vector());
+
+        // The ball paints a sphere and the vector on it; without one, nothing.
+        let mut pc = PaintCtx::new();
+        group([0.0, 0.0, 2.0]).paint_ball(&mut pc);
+        let prims: Vec<_> = pc.finish().items.into_iter().map(|i| i.prim).collect();
+        assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Sphere { .. })), "{prims:?}");
+        assert!(prims.iter().any(|p| matches!(p, crate::scene::paint::Prim::Vector { .. })));
+        let mut pc = PaintCtx::new();
+        Float3::new().paint_ball(&mut pc);
+        assert!(pc.finish().items.is_empty());
+    }
 }
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 417b846..10783f4 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -108,6 +108,12 @@ impl Slider {
         (self.min, self.max)
     }
 
+    /// Readout precision, for a host that changes it after construction
+    /// (a float3 group gaining a trackball reads to a third decimal).
+    pub fn set_decimals(&mut self, decimals: usize) {
+        self.decimals = decimals;
+    }
+
     /// The widest range a notch steps a flat 2% of. Wider than this, the
     /// step follows the value's magnitude instead ([`Self::notch_step`]).
     pub const FINE_SPAN: f32 = 20.0;