GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: a scroll rolls the trackball
A scroll over a float3's trackball turns the vector, as a drag does: the
ball is scrolled as content is, its surface moving the way a page under
the pointer would — a two-finger gesture in both axes at once, a wheel
notch in one — by fifteen degrees a notch, the fine handle beside the
drag's 1:1. The length is kept.
The ball has an id of its own in the scroll-gesture bookkeeping, so a
gesture that begins on it is the ball's until it ends and one a band
holds stays the band's over the ball. A scroll keeps a full-precision
copy of the vector between events, reused while the rows still hold what
it rounds to: a trackpad sends a pixel at a time, a quarter of a degree,
which on a short vector is less than the rows can hold.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 12 +++
src/widget/container/parameters_bg.rs | 29 ++++++-
src/widget/display/float3.rs | 145 +++++++++++++++++++++++++++++++++-
3 files changed, 180 insertions(+), 6 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 56a7e2b..e016f87 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -995,3 +995,15 @@ length 0.06 has seven directions. And the ball is painted by
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.
+
+**A scroll rolls the ball** (`ball_scroll`), as content is scrolled: its
+surface moves the way a page under the pointer would, a two-finger
+gesture in both axes at once and a wheel notch in one, by `SCROLL_TURN`
+(15 degrees) a notch — the fine handle beside the drag's 1:1. The ball
+has an id of its own (`ball_id`) in the scroll-gesture bookkeeping, so a
+gesture that begins on it is the ball's until it ends and one a band
+holds stays the band's over the ball; `wheel_zone` / `wheel_latched` are
+what the pane asks. A scroll keeps its own full-precision copy too
+(`fine`), reused while the rows still hold what it rounds to: a trackpad
+sends a pixel at a time, a quarter of a degree, which on a short vector
+is less than the rows can hold.
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 0be2dc8..1307be7 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -3190,9 +3190,7 @@ impl Input for ParametersBg {
let latched = (0..n).find(|&i| {
self.spinboxes[i].as_ref().is_some_and(|sb| latched_to(sb.base().id()))
|| self.sliders[i].as_ref().is_some_and(|sl| latched_to(sl.base().id()))
- || self.float3s[i]
- .as_ref()
- .is_some_and(|f| f.sliders().iter().any(|sl| latched_to(sl.base().id())))
+ || self.float3s[i].as_ref().is_some_and(|f| f.wheel_latched(ui))
});
// Under the pointer: the NEAREST control whose zone
// holds it, by the distance to the band's (or the
@@ -3223,7 +3221,8 @@ impl Input for ParametersBg {
.scroll_hit(band, px, py)
.then(|| (i, (py - (band.y + band.height * 0.5)).abs()));
}
- self.float3s[i].as_ref().and_then(|f| f.nearest_band(px, py)).map(|(_, d)| (i, d))
+ // A float3's bands, and its trackball when it has one.
+ self.float3s[i].as_ref().and_then(|f| f.wheel_zone(px, py)).map(|d| (i, d))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(i, _)| i)
@@ -3954,6 +3953,28 @@ mod tests {
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");
+
+ // A trackpad gesture beginning on the ball rolls it — the pane does
+ // not scroll — and one beginning on the label column is the pane's.
+ use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
+ let rows: Vec<(String, String, String)> = (0..12)
+ .map(|i| (format!("P{i}"), "0.000:0.000:2.000".to_string(), "float3:-10:10:trackball".to_string()))
+ .collect();
+ let mut p = ParametersBg::new();
+ ParamController::set_display_params(&mut *p, &rows);
+ WidgetHost::set_rect(&mut p, 0.0, 0.0, 500.0, 300.0);
+ assert!(p.content_h > 300.0, "the pane overflows, so it has somewhere to scroll");
+ let (cx, cy, _) = p.float3s[1].as_ref().unwrap().ball_circle().unwrap();
+ let mut ctx = UiContext::new();
+ ctx.scroll_gesture_new = true;
+ set_scroll_phase(ScrollPhase::Finger);
+ assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 60.0, y: 0.0 }), cx, cy, &mut ctx));
+ let v: Vec<f32> = p.display_params[1].1.split(':').map(|c| c.parse().unwrap()).collect();
+ assert!(v[0] > 0.4 && v[1].abs() < 2e-3, "rolled right by a notch: {v:?}");
+ assert_eq!(ctx.scroll_initiate_widget_id, Some(p.float3s[1].as_ref().unwrap().ball_id()));
+ assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
+ assert_eq!(p.display_params[0].1, "0.000:0.000:2.000", "the row above is untouched");
+ set_scroll_phase(ScrollPhase::Wheel);
}
#[test]
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index c29c4c1..89f4e8b 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -35,6 +35,9 @@ const DECIMALS: usize = 2;
const BALL_DECIMALS: usize = 3;
/// Gap between the trackball and the axis-letter column.
const BALL_GAP: f32 = 10.0;
+/// How far one wheel notch rolls the ball. A drag is 1:1 with the ball's
+/// surface; a scroll is the fine handle, a quarter turn in six notches.
+const SCROLL_TURN: f32 = std::f32::consts::PI / 12.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
@@ -58,6 +61,16 @@ pub struct Float3 {
/// vector is drawn on and turned by. See [`Float3::set_trackball`].
ball: bool,
ball_drag: Option<BallDrag>,
+ /// The ball's own id in the scroll-gesture bookkeeping
+ /// (`UiContext::scroll_initiate_widget_id`): a gesture that begins on
+ /// the ball is the ball's until it ends, as one on a band is the band's.
+ ball_id: crate::widget::WidgetId,
+ /// The vector a SCROLL is turning, at full precision, as a direction
+ /// and a length — what [`BallDrag`] is to a drag. Kept between events
+ /// for as long as the rows still hold what it rounds to
+ /// ([`Float3::fine`]); a trackpad sends a pixel at a time, and a pixel
+ /// turns a short vector by less than the rows can hold.
+ fine: Option<([f32; 3], f32)>,
}
impl Float3 {
@@ -71,6 +84,8 @@ impl Float3 {
dragging_idx: None,
ball: false,
ball_drag: None,
+ ball_id: crate::widget::WidgetId(crate::widget::NEXT_WIDGET_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)),
+ fine: None,
})
}
@@ -152,10 +167,72 @@ impl Float3 {
}
fn ball_begin(&mut self, px: f32, py: f32) {
+ let (dir, len) = self.fine();
+ self.ball_drag = Some(BallDrag { last: (px, py), dir, len });
+ }
+
+ /// The vector to turn, as a direction and a length: the full-precision
+ /// copy the last scroll left, while the rows still hold what it rounds
+ /// to — within half a readout tick and the rows' own float resolution
+ /// over their range — and the rows' vector otherwise (someone typed, or
+ /// dragged a band). A vector of no length points at the viewer with a
+ /// length of one.
+ fn fine(&self) -> ([f32; 3], f32) {
let v = self.vector();
+ if let Some((dir, len)) = self.fine {
+ let (min, max) = self.sliders[0].range();
+ let tol = 0.5 * 10f32.powi(-(self.decimals() as i32)) + (max - min).abs() * 5e-7;
+ if (0..3).all(|i| (dir[i] * len - v[i]).abs() <= tol) {
+ return (dir, len);
+ }
+ }
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 });
+ if len > 1e-6 { (v.map(|c| c / len), len) } else { ([0.0, 0.0, 1.0], 1.0) }
+ }
+
+ pub fn ball_id(&self) -> crate::widget::WidgetId {
+ self.ball_id
+ }
+
+ /// Roll the ball by a scroll: the ball is scrolled as content is, its
+ /// surface moving the way a page under the pointer would — a two-finger
+ /// gesture in both axes at once, a wheel notch in one — by
+ /// [`SCROLL_TURN`] a notch. The length is kept.
+ pub fn ball_scroll(&mut self, delta: &MouseScrollDelta) -> bool {
+ let Some((_, _, r)) = self.ball_circle() else {
+ return false;
+ };
+ let (nx, ny) = match delta {
+ MouseScrollDelta::LineDelta(x, y) => (*x, *y),
+ MouseScrollDelta::PixelDelta(pos) => (pos.x as f32 / 60.0, pos.y as f32 / 60.0),
+ };
+ if nx == 0.0 && ny == 0.0 {
+ return false;
+ }
+ let (dir, len) = self.fine();
+ let dir = Self::rolled(dir, nx * SCROLL_TURN * r, ny * SCROLL_TURN * r, r);
+ self.fine = Some((dir, len));
+ for (s, c) in self.sliders.iter_mut().zip(dir) {
+ s.set_scaled_value(c * len);
+ }
+ true
+ }
+
+ /// Whether the scroll gesture in progress is this group's — the ball's
+ /// or one of its bands'.
+ pub fn wheel_latched(&self, ui: &UiContext) -> bool {
+ !ui.scroll_gesture_new
+ && ui.scroll_initiate_widget_id.is_some_and(|id| id == self.ball_id || self.sliders.iter().any(|s| s.base().id() == id))
+ }
+
+ /// How near a scroll at `(px, py)` is to something of this group's
+ /// that takes one: on the ball, nothing is nearer; else the distance to
+ /// the nearest band whose halo holds the pointer. `None` off both.
+ pub fn wheel_zone(&self, px: f32, py: f32) -> Option<f32> {
+ if self.ball_hit(px, py) {
+ return Some(0.0);
+ }
+ self.nearest_band(px, py).map(|(_, d)| d)
}
fn ball_roll(&mut self, px: f32, py: f32) -> bool {
@@ -290,7 +367,16 @@ impl Float3 {
/// gap between rows, so two rows' halos hold any point between them —
/// and until 2026-09-28 the first in X/Y/Z order won, so a scroll over
/// the Y band turned X.
+ /// The BALL comes before the rows: a gesture it holds, or one nobody
+ /// holds that falls on it, rolls it ([`Self::ball_scroll`]).
pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ui: &mut UiContext) -> bool {
+ let ball_latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self.ball_id);
+ let band_latched = self.wheel_latched(ui) && !ball_latched;
+ if self.ball && (ball_latched || (!band_latched && self.ball_hit(px, py))) {
+ ui.scroll_initiate_widget_id = Some(self.ball_id);
+ self.ball_scroll(delta);
+ return true;
+ }
let Some(i) = self.wheel_row(px, py, ui) else {
return false;
};
@@ -662,6 +748,61 @@ mod tests {
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());
+ // A scroll rolls the ball as content is scrolled: a notch is
+ // fifteen degrees, the wheel in one axis and a two-finger gesture in
+ // both. Wheel DOWN moves content up, and the near point with it.
+ let turn = std::f32::consts::PI / 12.0;
+ let mut f = group([0.0, 0.0, 2.0]);
+ ctx.scroll_gesture_new = true;
+ ctx.scroll_initiate_widget_id = None;
+ assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), cx, cy, &mut ctx));
+ assert!(close(f.vector(), [0.0, 2.0 * turn.sin(), 2.0 * turn.cos()]), "{:?}", f.vector());
+ assert_eq!(ctx.scroll_initiate_widget_id, Some(f.ball_id()), "the ball owns the gesture");
+ // Latched: the pointer has drifted onto a band, and the ball still turns.
+ ctx.scroll_gesture_new = false;
+ let row = f.get_row_rects()[0];
+ let before = f.vector();
+ assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), row.0 + 20.0, row.1 + row.3 * 0.5, &mut ctx));
+ assert!(close(f.vector(), [0.0, 0.0, 2.0]), "rolled back: {:?} from {before:?}", f.vector());
+
+ // A trackpad sends a pixel at a time. Sixty of them to the right are
+ // one notch, on a vector short enough that a single pixel turns it
+ // by less than the rows can hold.
+ let mut f = group([0.0, 0.0, 0.06]);
+ ctx.scroll_gesture_new = true;
+ ctx.scroll_initiate_widget_id = None;
+ for _ in 0..60 {
+ assert!(f.wheel(&MouseScrollDelta::PixelDelta(crate::widget::Position { x: 1.0, y: 0.0 }), cx, cy, &mut ctx));
+ ctx.scroll_gesture_new = false;
+ }
+ assert!(close(f.vector(), [0.06 * turn.sin(), 0.0, 0.06 * turn.cos()]), "{:?}", f.vector());
+ // A typed component ends the scroll's copy: the next scroll turns
+ // what the rows hold.
+ f.sliders[1].set_scaled_value(3.0);
+ ctx.scroll_gesture_new = true;
+ f.wheel(&MouseScrollDelta::LineDelta(1.0, 0.0), cx, cy, &mut ctx);
+ assert!((f.vector()[1] - 3.0).abs() < 2e-3, "Y is what was typed: {:?}", f.vector());
+
+ // Off the ball a scroll is the bands', and a gesture a band holds
+ // stays the band's over the ball.
+ let mut f = group([0.0, 0.0, 2.0]);
+ let row = f.get_row_rects()[0];
+ ctx.scroll_gesture_new = true;
+ ctx.scroll_initiate_widget_id = None;
+ let band_x = row.0 + (row.2 - 68.0) * 0.5;
+ assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), band_x, row.1 + row.3 * 0.5, &mut ctx));
+ assert_ne!(f.vector()[0], 0.0, "the X band turned");
+ let (y, z) = (f.vector()[1], f.vector()[2]);
+ ctx.scroll_gesture_new = false;
+ assert!(f.wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), cx, cy, &mut ctx));
+ assert_eq!((f.vector()[1], f.vector()[2]), (y, z), "the ball did not take a band's gesture");
+ // Over the readouts, past the bands' halos, nothing takes a scroll;
+ // and a group without a ball has no ball to hit.
+ assert_eq!(f.wheel_zone(395.0, cy), None);
+ let mut plain = Float3::new().with_range(-10.0, 10.0);
+ WidgetHost::set_rect(&mut plain, 0.0, 0.0, 400.0, Float3::preferred_height(false));
+ assert!(!plain.ball_hit(cx, cy));
+
// 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);