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

commit70b81b725ace2c64407b28e1b8cb47871d979c88
parentbab75a92eb
authorLucas Galante <[email protected]>
date2026-09-04 09:58
refactor(scroll): let the Graph glide and coast the pan, drop the app-side kinetic model

The Graph widget now runs the wheel through cce-ui's ScrollMotion (notches
glide, a trackpad tracks 1:1 and its flick coasts), so the designer's own
velocity estimate — read back from the widget's per-event origin deltas and
re-integrated in tick_frame — double-animated every wheel and read the
toolkit's partial per-frame steps as gesture speed. The wheel path now only
adopts the origin the Graph moved to, and tick_frame reads it back while
Graph::tick is animating. The trackpad bookkeeping (is_scrolling_trackpad,
scroll_accum_*, last_scroll_time, last_frame) and the WindowEvent wheel
phase / TouchPhase enum it gated on go with it.

Two things stay: the drag-release fling (middle / space+left drag) still
integrates pan_velocity_* under graph_scroll_friction — it is a drag, not a
wheel — and the unrouted wheel fallback over the network pane still pans
instantly with graph_scroll_speed, minus its velocity blend.

tick_frame also drains ui_context.tick_receivers after ticking every slot by
hand: the runner ticks that roster again right after Application::tick,
which ran the Graph, Spreadsheet and TextBox ScrollMotions twice per frame.

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

 src/app.rs         | 174 +++++++++++++++++++++--------------------------------
 src/application.rs |   7 +--
 src/project.rs     |   9 ---
 src/window.rs      |   4 +-
 4 files changed, 73 insertions(+), 121 deletions(-)

diff --git a/src/app.rs b/src/app.rs
index 4de3d82..0848b09 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -48,14 +48,6 @@ use cce_ui::vk::{SceneDraw, TextSpan};
 use cce_ui::engine::Vertex;
 use crate::window::WindowEvent;
 
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum TouchPhase {
-    Started,
-    Moved,
-    Ended,
-    Cancelled,
-}
-
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
 pub struct ModifiersState {
     pub ctrl: bool,
@@ -975,7 +967,6 @@ pub struct State {
     pub spreadsheet_pin: Option<usize>,
     pub node_clipboard: Option<FsNode>,
     pub last_click: Option<(Instant, usize)>,
-    pub last_frame: Instant,
 
     pub shortcut_manager: ShortcutManager,
     pub pending_action: Option<Action>,
@@ -1062,6 +1053,10 @@ pub struct State {
 
     pub pan_x: f32,
     pub pan_y: f32,
+    /// Drag-release fling only (middle / space+left drag over the network
+    /// pane): tracked while `is_panning`, integrated by the kinetic slide in
+    /// `tick_frame`. Wheel and trackpad panning no longer feed it — that
+    /// motion is the Graph widget's own `ScrollMotion` (glide / coast).
     pub pan_velocity_x: f32,
     pub pan_velocity_y: f32,
     pub last_frame_pan_x: f32,
@@ -1078,10 +1073,6 @@ pub struct State {
     pub show_parameters: bool,
     pub show_spreadsheet: bool,
     pub show_playbar: bool,
-    pub is_scrolling_trackpad: bool,
-    pub last_scroll_time: Instant,
-    pub scroll_accum_x: f32,
-    pub scroll_accum_y: f32,
     pub last_spreadsheet_node_name: Option<String>,
     pub last_spreadsheet_node_params: Option<Vec<(String, String)>>,
     pub grid_thickness: f32,
@@ -3200,9 +3191,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         self.pan_velocity_y = 0.0;
         self.last_frame_pan_x = 0.0;
         self.last_frame_pan_y = 0.0;
-        self.is_scrolling_trackpad = false;
-        self.scroll_accum_x = 0.0;
-        self.scroll_accum_y = 0.0;
         self.grid_cursor_col = 0;
         self.grid_cursor_row = 0;
         self.sync_grid_settings();
@@ -3645,7 +3633,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             spreadsheet_pin: None,
             node_clipboard: None,
             last_click: None,
-            last_frame: Instant::now(),
             shortcut_manager,
             pending_action: None,
             exit_requested: false,
@@ -3709,16 +3696,15 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             show_parameters: true,
             show_spreadsheet: false,
             show_playbar: false,
-            is_scrolling_trackpad: false,
-            last_scroll_time: Instant::now(),
-            scroll_accum_x: 0.0,
-            scroll_accum_y: 0.0,
             last_spreadsheet_node_name: None,
             last_spreadsheet_node_params: None,
             grid_thickness: settings.viewport.grid_thickness,
             focused_pane: LEFT_MENUBAR_IDX,
             // Config-owned; update_inertial_settings overwrites these from
-            // config.kdl's input.inertial right after construction.
+            // config.kdl's input.inertial right after construction. They
+            // shape the drag-release fling and the unrouted wheel fallback
+            // only — routed wheel/trackpad panning is tuned by cce-ui's
+            // smooth-scroll settings (input.kdl) inside the Graph widget.
             graph_scroll_speed: 1.0,
             graph_inertial_scroll: true,
             graph_scroll_friction: 0.90,
@@ -3974,6 +3960,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         
         // Scroll behavior is config-owned (input.inertial in config.kdl) —
         // state.kdl carries no copy, so config edits always take effect.
+        // Graph-side these shape the drag-release fling and the unrouted
+        // wheel fallback; the routed wheel/trackpad pan is the Graph widget's
+        // ScrollMotion, tuned by cce-ui's smooth-scroll keys in input.kdl.
         self.graph_scroll_speed = speed;
         self.graph_inertial_scroll = enabled;
         self.graph_scroll_friction = friction;
@@ -4031,9 +4020,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
     pub fn zoom(&mut self, factor: f32, center: Option<(f32, f32)>) {
         self.pan_velocity_x = 0.0;
         self.pan_velocity_y = 0.0;
-        self.is_scrolling_trackpad = false;
-        self.scroll_accum_x = 0.0;
-        self.scroll_accum_y = 0.0;
         let old_gx = self.grid_size_x;
         let old_gy = self.grid_size_y;
 
@@ -4919,7 +4905,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
     pub fn handle_event(&mut self, event: &WindowEvent) -> bool {
         match event {
-            WindowEvent::MouseWheel { delta, phase, .. } => {
+            WindowEvent::MouseWheel { delta } => {
                 let in_network_pane = self.in_network_pane();
                 // eprintln!("DEBUG MOUSEWHEEL: delta={:?}, phase={:?}, cursor=({}, {}), in_network_pane={}", delta, phase, self.cursor_x, self.cursor_y, in_network_pane);
                 let node_area_y = self.positions[CONTENT_IDX].1;
@@ -4977,32 +4963,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                                 let (gx, gy) = cce_ui::widget::GraphController::grid_origin(
                                     w.as_any().downcast_ref::<Graph>().expect("CONTENT_IDX must be a Graph"),
                                 );
-                                let prev_pan_x = self.pan_x;
-                                let prev_pan_y = self.pan_y;
+                                // The Graph owns wheel→pan motion (cce-ui's
+                                // ScrollMotion: notches glide, a trackpad
+                                // tracks 1:1 and its flick coasts). Adopt
+                                // wherever it moved the origin; the glide and
+                                // coast advance in Graph::tick and are read
+                                // back in tick_frame. A wheel also ends any
+                                // drag-release fling still sliding.
                                 self.pan_x = gx - active_node_area_x;
                                 self.pan_y = gy - active_node_area_y;
-                                
-                                let dx = prev_pan_x - self.pan_x;
-                                let dy = prev_pan_y - self.pan_y;
-                                
-                                let dt_scroll = Instant::now().duration_since(self.last_frame).as_secs_f32().min(0.1);
-                                let vel_x = if dt_scroll > 1e-4 { -dx / dt_scroll } else { -dx * 60.0 };
-                                let vel_y = if dt_scroll > 1e-4 { -dy / dt_scroll } else { -dy * 60.0 };
-                                self.pan_velocity_x = self.pan_velocity_x * 0.4 + vel_x * 0.6;
-                                self.pan_velocity_y = self.pan_velocity_y * 0.4 + vel_y * 0.6;
-
-                                self.is_scrolling_trackpad = match delta {
-                                    MouseScrollDelta::LineDelta(_, _) => false,
-                                    MouseScrollDelta::PixelDelta(_) => match phase {
-                                        TouchPhase::Started | TouchPhase::Moved => true,
-                                        TouchPhase::Ended | TouchPhase::Cancelled => false,
-                                    }
-                                };
-                                if self.is_scrolling_trackpad {
-                                    self.last_scroll_time = Instant::now();
-                                    self.scroll_accum_x += dx;
-                                    self.scroll_accum_y += dy;
-                                }
+                                self.pan_velocity_x = 0.0;
+                                self.pan_velocity_y = 0.0;
                                 needs_sync_grid = true;
                             }
                             break;
@@ -5047,37 +5018,24 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                             false
                         }
                     } else {
-                        match delta {
+                        // The pane hit but the Graph widget did not take the
+                        // wheel (circular-pane hit shape wider than its rect,
+                        // or a hidden graph): a plain instant pan.
+                        let (dx, dy) = match delta {
                             MouseScrollDelta::LineDelta(x, y) => {
-                                let dx = *x * 30.0 * self.graph_scroll_speed;
-                                let dy = *y * 30.0 * self.graph_scroll_speed;
-                                self.pan_x -= dx;
-                                self.pan_y -= dy;
-                                self.is_scrolling_trackpad = false;
-                                let dt_scroll = Instant::now().duration_since(self.last_frame).as_secs_f32().min(0.1);
-                                let vel_x = if dt_scroll > 1e-4 { -dx / dt_scroll } else { -dx * 60.0 };
-                                let vel_y = if dt_scroll > 1e-4 { -dy / dt_scroll } else { -dy * 60.0 };
-                                self.pan_velocity_x = self.pan_velocity_x * 0.4 + vel_x * 0.6;
-                                self.pan_velocity_y = self.pan_velocity_y * 0.4 + vel_y * 0.6;
-                                self.sync_grid_settings();
-                                true
-                            }
-                            MouseScrollDelta::PixelDelta(pos) => {
-                                let dx = (pos.x as f32 / self.scale as f32) * self.graph_scroll_speed;
-                                let dy = (pos.y as f32 / self.scale as f32) * self.graph_scroll_speed;
-                                self.pan_x -= dx;
-                                self.pan_y -= dy;
-                                self.is_scrolling_trackpad = match phase {
-                                    TouchPhase::Started | TouchPhase::Moved => true,
-                                    TouchPhase::Ended | TouchPhase::Cancelled => false,
-                                };
-                                self.last_scroll_time = Instant::now();
-                                self.scroll_accum_x += dx;
-                                self.scroll_accum_y += dy;
-                                self.sync_grid_settings();
-                                true
+                                (*x * 30.0 * self.graph_scroll_speed, *y * 30.0 * self.graph_scroll_speed)
                             }
-                        }
+                            MouseScrollDelta::PixelDelta(pos) => (
+                                (pos.x as f32 / self.scale as f32) * self.graph_scroll_speed,
+                                (pos.y as f32 / self.scale as f32) * self.graph_scroll_speed,
+                            ),
+                        };
+                        self.pan_x -= dx;
+                        self.pan_y -= dy;
+                        self.pan_velocity_x = 0.0;
+                        self.pan_velocity_y = 0.0;
+                        self.sync_grid_settings();
+                        true
                     }
                 } else if in_viewport && self.modifiers.control_key() {
                     // Ctrl+wheel zoom over the 3D viewport. The routed pass
@@ -5309,9 +5267,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 if *btn_state == ElementState::Pressed {
                     self.pan_velocity_x = 0.0;
                     self.pan_velocity_y = 0.0;
-                    self.is_scrolling_trackpad = false;
-                    self.scroll_accum_x = 0.0;
-                    self.scroll_accum_y = 0.0;
 
                     self.viewport_mut().reset_velocity();
                 }
@@ -6192,9 +6147,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         if let Some((dc, dr)) = delta {
                             self.pan_velocity_x = 0.0;
                             self.pan_velocity_y = 0.0;
-                            self.is_scrolling_trackpad = false;
-                            self.scroll_accum_x = 0.0;
-                            self.scroll_accum_y = 0.0;
                             if is_alt_key {
                                 let active_nodes = self.current_dir().children.len();
                                 let node_idx_at_cursor = self.current_dir().children.iter().take(active_nodes).position(|child| {
@@ -6452,7 +6404,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
     /// a rebuild. The render half lives in [`State::stage_frame`].
     pub fn tick_frame(&mut self, dt: f32) -> bool {
         let now = Instant::now();
-        self.last_frame = now;
 
         // Drop-target glow animation: exponential smoothing toward the live
         // target — the position GLIDES between cells, alpha fades in while a
@@ -6584,22 +6535,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.last_frame_pan_y = self.pan_y;
         }
 
-        // Trackpad scrolling velocity tracking & timeout detection
-        if self.is_scrolling_trackpad {
-            if now.duration_since(self.last_scroll_time).as_secs_f32() > 0.05 {
-                self.is_scrolling_trackpad = false;
-            } else if dt > 1e-5 {
-                let vel_x = -self.scroll_accum_x / dt;
-                let vel_y = -self.scroll_accum_y / dt;
-                self.pan_velocity_x = self.pan_velocity_x * 0.4 + vel_x * 0.6;
-                self.pan_velocity_y = self.pan_velocity_y * 0.4 + vel_y * 0.6;
-            }
-            self.scroll_accum_x = 0.0;
-            self.scroll_accum_y = 0.0;
-        }
-
         let mut tick_changed = false;
         let mut param_ticked = false;
+        let mut graph_ticked = false;
         let ctx = &mut self.ui_context;
         for i in 0..WIDGET_COUNT {
             if self.slots.get_dyn_mut(i).tick(dt, ctx) {
@@ -6607,6 +6545,33 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 if i == PARAM_IDX {
                     param_ticked = true;
                 }
+                if i == CONTENT_IDX {
+                    graph_ticked = true;
+                }
+            }
+        }
+        // Every slot (and, through the adapter, its embedded children) was
+        // just ticked by hand. The runner ticks the context's tick_receivers
+        // right after Application::tick, which would tick the Graph,
+        // Spreadsheet and every TextBox a second time per frame — their
+        // ScrollMotion integrates dt, so a double tick runs each glide and
+        // coast at double speed. Drain the roster so the runner's pass is a
+        // no-op; paint re-registers the slots before the next frame.
+        self.ui_context.tick_receivers.clear();
+        if graph_ticked {
+            // Graph::tick advanced its wheel glide / trackpad coast: adopt the
+            // origin it moved to, as the wheel path does, so a later
+            // sync_grid_settings cannot snap the canvas back to a stale pan.
+            let (ax, ay, _, _) = self.positions[CONTENT_IDX];
+            let (gx, gy) = self.graph().grid_origin();
+            let (nx, ny) = (gx - ax, gy - ay);
+            if nx != self.pan_x || ny != self.pan_y {
+                self.pan_x = nx;
+                self.pan_y = ny;
+                self.sync_grid_settings();
+                self.rebuild_positions();
+                self.apply_layout();
+                self.update_panel_bounds();
             }
         }
         // Live-streaming param widgets (the color picker's --stream lines)
@@ -6631,8 +6596,10 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
         self.update_recent_files_layout();
 
-        // Panning kinetic slide
-        if !self.is_panning && !self.is_scrolling_trackpad && (self.pan_velocity_x.abs() > 0.01 || self.pan_velocity_y.abs() > 0.01) {
+        // Drag-release fling: the velocity a middle / space+left drag had
+        // when the button went up keeps the canvas sliding under friction.
+        // (Wheel and trackpad motion coast inside the Graph widget instead.)
+        if !self.is_panning && (self.pan_velocity_x.abs() > 0.01 || self.pan_velocity_y.abs() > 0.01) {
             if !self.graph_inertial_scroll {
                 self.pan_velocity_x = 0.0;
                 self.pan_velocity_y = 0.0;
@@ -6705,9 +6672,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         if panned {
             self.pan_velocity_x = 0.0;
             self.pan_velocity_y = 0.0;
-            self.is_scrolling_trackpad = false;
-            self.scroll_accum_x = 0.0;
-            self.scroll_accum_y = 0.0;
             self.sync_grid_settings();
             if let Some(idx) = self.drag_widget {
                 self.slots.get_dyn_mut(idx).set_modifiers(self.modifiers.control_key(), self.modifiers.shift_key(), self.modifiers.alt_key());
diff --git a/src/application.rs b/src/application.rs
index 09f8f69..8c277a1 100644
--- a/src/application.rs
+++ b/src/application.rs
@@ -16,7 +16,7 @@ use cce_ui::widget::{ElementState, KeyEvent, MouseButton, MouseScrollDelta};
 use wayland_client::QueueHandle;
 
 use crate::api::start_mcp_server;
-use crate::app::{CustomEvent, PendingWindowDrag, State, TouchPhase};
+use crate::app::{CustomEvent, PendingWindowDrag, State};
 use crate::slots::LEFT_MENUBAR_IDX;
 use crate::window::{LocalPosition, WindowEvent};
 
@@ -293,10 +293,7 @@ impl Application for State {
         self.sync_modifiers_from_ctx();
         self.cursor_x = pos.x;
         self.cursor_y = pos.y;
-        let ev = WindowEvent::MouseWheel {
-            delta: delta.clone(),
-            phase: TouchPhase::Moved,
-        };
+        let ev = WindowEvent::MouseWheel { delta: delta.clone() };
         if self.process_window_event(ev) {
             *needs_rebuild = true;
         }
diff --git a/src/project.rs b/src/project.rs
index 0ca3f2a..33a56e0 100644
--- a/src/project.rs
+++ b/src/project.rs
@@ -322,9 +322,6 @@ impl State {
             self.pan_velocity_y = 0.0;
             self.last_frame_pan_x = self.pan_x;
             self.last_frame_pan_y = self.pan_y;
-            self.is_scrolling_trackpad = false;
-            self.scroll_accum_x = 0.0;
-            self.scroll_accum_y = 0.0;
             self.current_path = proj.view_state.current_path;
 
             let sel = proj.view_state.selected_node;
@@ -380,9 +377,6 @@ impl State {
         self.pan_velocity_y = 0.0;
         self.last_frame_pan_x = self.pan_x;
         self.last_frame_pan_y = self.pan_y;
-        self.is_scrolling_trackpad = false;
-        self.scroll_accum_x = 0.0;
-        self.scroll_accum_y = 0.0;
         self.current_path = proj.view_state.current_path;
 
         let sel = proj.view_state.selected_node;
@@ -471,9 +465,6 @@ impl State {
         self.pan_velocity_y = 0.0;
         self.last_frame_pan_x = 0.0;
         self.last_frame_pan_y = 0.0;
-        self.is_scrolling_trackpad = false;
-        self.scroll_accum_x = 0.0;
-        self.scroll_accum_y = 0.0;
         self.current_path.clear();
         self.grid_cursor_col = 0;
         self.grid_cursor_row = 0;
diff --git a/src/window.rs b/src/window.rs
index 7e45284..2fa02a2 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -10,7 +10,7 @@ use std::path::Path;
 
 use cce_ui::widget::WidgetHost;
 use crate::shortcut::Action;
-use crate::app::{State, CustomEvent, McpAction, TouchPhase, get_next_visible_pane, Project, ParamDef};
+use crate::app::{State, CustomEvent, McpAction, get_next_visible_pane, Project, ParamDef};
 use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX, PARAM_MENUBAR_IDX, SPREADSHEET_MENUBAR_IDX, HEADER_IDX, PARAM_IDX, WIDGET_COUNT};
 
 #[derive(Debug, Clone, Copy)]
@@ -20,7 +20,7 @@ pub struct LocalPosition {
 }
 
 pub enum WindowEvent {
-    MouseWheel { delta: cce_ui::widget::MouseScrollDelta, phase: TouchPhase },
+    MouseWheel { delta: cce_ui::widget::MouseScrollDelta },
     CursorMoved { position: LocalPosition },
     MouseInput { state: cce_ui::widget::ElementState, button: cce_ui::widget::MouseButton },
     KeyboardInput { event: cce_ui::widget::KeyEvent },