git.lucas.co / cce-map
map viewer
git clone https://git.lucas.co/cce-map.git

commit5e794f5b568418782974dfd7e277b1a7633ddf3f
parentd19fd41f07
authorLucas Galante <[email protected]>
date2026-09-04 09:58
Glide the wheel zoom

A wheel notch used to step the zoom by a quarter level instantly, so a
burst of notches was a staircase. Notches now move a zoom target and tick
eases the zoom toward it around the pointer at cce-ui's wheel-glide rate
(scroll_ease; instant with smooth_scroll false), so the same map point
stays under the cursor through the glide. A trackpad finger is still
followed 1:1, and pinch, keys and Home set the zoom directly, cancelling
any glide in flight.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/main.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 67 insertions(+), 5 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index 56bee0a..8daaebb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,6 +12,7 @@ use wayland_client::QueueHandle;
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::layout::Rect;
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
+use cce_ui::widget::scroll_motion::{current_scroll_phase, scroll_settings, ScrollPhase};
 use cce_ui::widget::{ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey};
 
 use tiles::{TileKey, TileManager, MAX_ZOOM, TILE_SIZE};
@@ -29,6 +30,13 @@ struct MapApp {
     /// World coords of the window center, u east [0,1), v south [0,1].
     center: (f64, f64),
     zoom: f64,
+    /// Where the wheel is taking the zoom: each notch moves this and `tick`
+    /// eases `zoom` toward it around `zoom_anchor` at cce-ui's wheel-glide
+    /// rate (`scroll_ease`; instant with `smooth_scroll false`), so a burst
+    /// of notches is one glide rather than a staircase. A trackpad, pinch,
+    /// keys and Home set the zoom directly and pull the target along.
+    zoom_target: f64,
+    zoom_anchor: (f64, f64),
     win: (f32, f32),
     pointer: (f64, f64),
     drag: Option<(f64, f64)>,
@@ -40,7 +48,8 @@ fn world_px(zoom: f64) -> f64 {
 }
 
 impl MapApp {
-    fn zoom_by(&mut self, dz: f64, px: f64, py: f64) {
+    /// Change the zoom by `dz` keeping the world point under (px, py) fixed.
+    fn zoom_step(&mut self, dz: f64, px: f64, py: f64) {
         let old = world_px(self.zoom);
         let new_zoom = (self.zoom + dz).clamp(0.0, MAX_ZOOM as f64);
         let new = world_px(new_zoom);
@@ -52,6 +61,41 @@ impl MapApp {
         self.zoom = new_zoom;
     }
 
+    /// A direct zoom change (pinch, keys, trackpad): lands at once and
+    /// cancels any wheel glide in flight.
+    fn zoom_by(&mut self, dz: f64, px: f64, py: f64) {
+        self.zoom_step(dz, px, py);
+        self.zoom_target = self.zoom;
+    }
+
+    /// A wheel notch: retarget the glide around the pointer.
+    fn zoom_wheel(&mut self, dz: f64, px: f64, py: f64) {
+        self.zoom_anchor = (px, py);
+        self.zoom_target = (self.zoom_target + dz).clamp(0.0, MAX_ZOOM as f64);
+        if !scroll_settings().smooth {
+            let remaining = self.zoom_target - self.zoom;
+            self.zoom_step(remaining, px, py);
+        }
+    }
+
+    /// Ease the zoom toward its wheel target; true while it moved.
+    fn tick_zoom(&mut self, dt: f32) -> bool {
+        let remaining = self.zoom_target - self.zoom;
+        if remaining == 0.0 {
+            return false;
+        }
+        let (ax, ay) = self.zoom_anchor;
+        // Frame-rate independent exponential approach (cce-ui's glide),
+        // snapping the last sliver so it settles instead of trailing off.
+        let step = if remaining.abs() < 1e-3 {
+            remaining
+        } else {
+            remaining * (1.0 - (-(scroll_settings().ease_rate as f64) * dt as f64).exp())
+        };
+        self.zoom_step(step, ax, ay);
+        true
+    }
+
     fn pan_px(&mut self, dx: f64, dy: f64) {
         let scale = world_px(self.zoom);
         self.center.0 = (self.center.0 + dx / scale).rem_euclid(1.0);
@@ -73,6 +117,8 @@ impl Application for MapApp {
             tiles: TileManager::new(sender),
             center: (0.5, 0.5),
             zoom: 2.0,
+            zoom_target: 2.0,
+            zoom_anchor: (500.0, 350.0),
             win: (1000.0, 700.0),
             pointer: (0.0, 0.0),
             drag: None,
@@ -99,7 +145,11 @@ impl Application for MapApp {
         }
     }
 
-    fn tick(&mut self, _dt: f32, _needs_rebuild: &mut bool) {}
+    fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
+        if self.tick_zoom(dt) {
+            *needs_rebuild = true;
+        }
+    }
 
     fn handle_resize(&mut self, width: f32, height: f32, _scale: f64) {
         self.win = (width, height);
@@ -133,10 +183,21 @@ impl Application for MapApp {
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
         let notches = delta.notches_y() as f64;
-        if notches != 0.0 {
-            self.zoom_by(notches * WHEEL_ZOOM_STEP, pos.x as f64, pos.y as f64);
-            *needs_rebuild = true;
+        if notches == 0.0 {
+            return;
+        }
+        let dz = notches * WHEEL_ZOOM_STEP;
+        let (px, py) = (pos.x as f64, pos.y as f64);
+        // A finger on a trackpad is followed 1:1 (nothing is smoother than
+        // the hand); discrete notches glide.
+        let finger = matches!(delta, MouseScrollDelta::PixelDelta(_))
+            && matches!(current_scroll_phase(), ScrollPhase::Finger | ScrollPhase::FingerEnd);
+        if finger {
+            self.zoom_by(dz, px, py);
+        } else {
+            self.zoom_wheel(dz, px, py);
         }
+        *needs_rebuild = true;
     }
 
     fn handle_pinch(&mut self, factor: f32, pos: LogicalPosition, needs_rebuild: &mut bool) -> bool {
@@ -163,6 +224,7 @@ impl Application for MapApp {
             Key::Named(NamedKey::Home) => {
                 self.center = (0.5, 0.5);
                 self.zoom = 2.0;
+                self.zoom_target = 2.0;
             }
             _ => handled = false,
         }