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

commitb82c6bfeea39c116f002bb27b6006083f4b12ddc
parente129370a75
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:36
feat(network): a scroll over the plateless network orbits unless it begins on a node

With the plate off the graph spans the window and took every scroll by its
rect, so a trackpad could not orbit the camera at all. A scroll gesture
over the plateless network now goes to the graph only when a node was
under the pointer as it began, and to the scene otherwise. The target is
held to the lift, a pause or a pointer move, since panning slides the node
out from under a pointer that does not move.

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

 CLAUDE.md   | 10 +++++++++
 src/app.rs  | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 src/main.rs | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 135 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 9bc522a..40c6b1f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2789,6 +2789,16 @@ That makes the pane's RECT useless as a hit test, and three things route off it:
   and space+left mean nothing to the scene, so the network keeps them across
   its whole span; a graph you could not pan by dragging because its own surface
   stopped being drawn would be a strange thing to ship.
+- **The wheel** is decided per GESTURE (since 2026-10-06,
+  `State::overlay_wheel_to_graph`): one begun on a node pans the graph, one
+  begun anywhere else orbits the camera, as a click would. Until then the
+  graph took every scroll by its window-wide rect, so with the plate off a
+  trackpad could not orbit at all. The target is held (`State::overlay_wheel`)
+  to the finger's lift, a pause of `OVERLAY_WHEEL_GAP` or the pointer
+  moving: a pan slides the node out from under a pointer that does not
+  move, and the rest of the swipe would otherwise turn the camera. The
+  ctrl zoom follows the same target.
+  `a_scroll_over_the_plateless_network_orbits_unless_it_begins_on_a_node`.
 - **`cursor_in_viewport`** becomes the complement: the body, minus what the
   network holds, minus the floating panes.
 
diff --git a/src/app.rs b/src/app.rs
index f1592ff..581f494 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -577,6 +577,21 @@ pub enum NetworkMenuAction {
     Separator,
 }
 
+/// The target a scroll gesture over the plateless network was given at its
+/// first event (`State::overlay_wheel_to_graph`).
+#[derive(Debug, Clone, Copy)]
+pub struct OverlayWheel {
+    /// The graph pans; otherwise the scene's camera orbits.
+    pub graph: bool,
+    pub at: (f32, f32),
+    pub last: Instant,
+}
+
+/// How long a pause ends a scroll gesture for `OverlayWheel`: longer than
+/// the gap between a trackpad's events, shorter than a deliberate second
+/// swipe.
+pub const OVERLAY_WHEEL_GAP: std::time::Duration = std::time::Duration::from_millis(250);
+
 /// A mouse drag that moves the whole selection, not just the node under the
 /// pointer.
 ///
@@ -3094,6 +3109,12 @@ pub struct State {
     /// grid cursor goes if the menu is turned to the network's, so Add Node
     /// places at the cell pointed at, as the plated network's press does.
     pub network_menu_cell: Option<(i32, i32)>,
+    /// Which of the two a scroll gesture over the plateless network goes
+    /// to — the graph (a node was under the pointer when it began) or the
+    /// scene — with where and when its last event came. Held for the whole
+    /// gesture: a pan slides the node out from under a pointer that does
+    /// not move, and the rest of the swipe must not become an orbit.
+    pub overlay_wheel: Option<OverlayWheel>,
     /// The plate corner menu — same `context_menu` thread-local again; the slot
     /// says which plate's control opened it (and doubles as the pressed state
     /// the corner control paints with).
@@ -4403,6 +4424,27 @@ impl State {
         !self.over_floating_pane_at(px, py) && self.graph().node_at(px, py).is_some()
     }
 
+    /// Whether this wheel event over the plateless network is the graph's
+    /// (it pans) or the scene's (the camera orbits): the graph's when a
+    /// node was under the pointer as the GESTURE began, as a click is.
+    /// Decided at the gesture's first event and held to its end — the lift,
+    /// a pause of `OVERLAY_WHEEL_GAP`, or the pointer moving off where it
+    /// was — since panning slides the node from under the pointer.
+    pub(crate) fn overlay_wheel_to_graph(&mut self, phase: cce_ui::widget::scroll_motion::ScrollPhase) -> bool {
+        let now = Instant::now();
+        let at = (self.cursor_x, self.cursor_y);
+        let held = self.overlay_wheel.filter(|w| {
+            now.duration_since(w.last) < OVERLAY_WHEEL_GAP && (w.at.0 - at.0).abs() < 4.0 && (w.at.1 - at.1).abs() < 4.0
+        });
+        let graph = held.map_or_else(|| self.overlay_claims(at.0, at.1), |w| w.graph);
+        self.overlay_wheel = if phase == cce_ui::widget::scroll_motion::ScrollPhase::FingerEnd {
+            None
+        } else {
+            Some(OverlayWheel { graph, at, last: now })
+        };
+        graph
+    }
+
     /// Whether (px, py) is inside the network's AREA — the region it is laid
     /// out over, whatever it has drawn there.
     ///
@@ -8714,6 +8756,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             network_menu_actions: Vec::new(),
             network_menu_from: None,
             network_menu_cell: None,
+            overlay_wheel: None,
             sim_cache: crate::geometry::SimCache::default(),
             playbar_cache_key: None,
             page_image: None,
@@ -11091,7 +11134,16 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                     }
                     return true;
                 }
-                let in_network_pane = self.in_network_pane();
+                // Over the plateless network the gesture's target decides,
+                // not what is under the pointer now (`overlay_wheel_to_graph`):
+                // a scroll begun on a node pans the graph, one begun on empty
+                // space orbits the camera.
+                let overlay = self.network_overlay();
+                let in_network_pane = if overlay {
+                    self.overlay_wheel_to_graph(cce_ui::widget::scroll_motion::current_scroll_phase())
+                } else {
+                    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;
 
@@ -11144,6 +11196,11 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
                         if i == VIEWPORT_IDX {
                             continue;
                         }
+                        // The plateless network spans the window: it takes
+                        // only a gesture begun on one of its nodes.
+                        if overlay && !in_network_pane && (i == CONTENT_IDX || i == NETWORK_PANEL_IDX) {
+                            continue;
+                        }
                         // Under its rows a plateless params pane is scene.
                         if i == PARAM_IDX && !self.params_claims(self.cursor_x, self.cursor_y) {
                             continue;
diff --git a/src/main.rs b/src/main.rs
index 44e3192..921f2ff 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -710,6 +710,73 @@ mod tests {
         assert!(!state.viewport_menu_rows_of(None).1.contains(&A::NetworkPage));
     }
 
+    /// With the network's plate off its graph spans the window, so a scroll
+    /// cannot go to it by rect: a gesture begun on empty space orbits the
+    /// camera, one begun on a node pans the graph, and the target is held
+    /// to the gesture's end — a pan slides the node out from under the
+    /// pointer, and the rest of the swipe must not become an orbit.
+    #[test]
+    fn a_scroll_over_the_plateless_network_orbits_unless_it_begins_on_a_node() {
+        use crate::slots::{LEFT_MENUBAR_IDX, RIGHT_MENUBAR_IDX};
+        use crate::window::{LocalPosition, WindowEvent};
+        use cce_ui::widget::scroll_motion::ScrollPhase;
+        use cce_ui::widget::{MouseScrollDelta, Position};
+        let mut state = State::new(false);
+        state.resize(1600.0, 900.0, 1.0);
+        state.rebuild_positions();
+        state.apply_layout();
+        assert!(state.network_overlay(), "the plate is off by default");
+        state.set_active_camera("Default Camera");
+        let at = |state: &mut State, x: f32, y: f32| {
+            state.handle_event(&WindowEvent::CursorMoved { position: LocalPosition { x: x as f64, y: y as f64 } });
+        };
+        let wheel = |state: &mut State| {
+            state.handle_event(&WindowEvent::MouseWheel { delta: MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }) });
+        };
+        let orbit = |state: &State| (state.viewport().rotation_x, state.viewport().rotation_y);
+
+        // Empty space: the camera turns and the graph stays.
+        let (cx, cy, cw, ch) = state.positions[crate::slots::CONTENT_IDX];
+        let (ex, ey) = (cx + cw * 0.3, cy + ch * 0.6);
+        assert!(state.graph().node_at(ex, ey).is_none() && !state.over_floating_pane_at(ex, ey));
+        at(&mut state, ex, ey);
+        let (turned, pan) = (orbit(&state), (state.pan_x, state.pan_y));
+        wheel(&mut state);
+        assert_ne!(orbit(&state), turned, "a scroll over empty space orbits");
+        assert_eq!((state.pan_x, state.pan_y), pan, "and does not pan the graph");
+        assert_eq!(state.focused_pane, RIGHT_MENUBAR_IDX);
+
+        // On a node: the graph's, not the camera's.
+        state.overlay_wheel = None;
+        let find_node = |state: &State| state
+            .current_dir()
+            .children
+            .iter()
+            .map(|n| state.cell_center(n.position.0 as i32, n.position.1 as i32))
+            .find(|&(x, y)| !state.over_floating_pane_at(x, y) && state.graph().node_at(x, y).is_some())
+            .expect("a node in the clear");
+        let (nx, ny) = find_node(&state);
+        at(&mut state, nx, ny);
+        let turned = orbit(&state);
+        wheel(&mut state);
+        assert_eq!(orbit(&state), turned, "a scroll on a node does not orbit");
+        assert_eq!(state.focused_pane, LEFT_MENUBAR_IDX, "it is the network's");
+
+        // The latch: the node slides from under the pointer, and the
+        // gesture is still the graph's to its lift; the next one is not.
+        // (That scroll panned the graph: find the node again.)
+        let (nx, ny) = find_node(&state);
+        at(&mut state, nx, ny);
+        state.overlay_wheel = None;
+        assert!(state.overlay_wheel_to_graph(ScrollPhase::Finger));
+        state.pan_x += cw;
+        state.sync_grid_settings();
+        assert!(state.graph().node_at(nx, ny).is_none(), "the node moved away");
+        assert!(state.overlay_wheel_to_graph(ScrollPhase::Finger), "held mid-gesture");
+        assert!(state.overlay_wheel_to_graph(ScrollPhase::FingerEnd), "held to the lift");
+        assert!(!state.overlay_wheel_to_graph(ScrollPhase::Finger), "a new gesture over empty space is the scene's");
+    }
+
     #[test]
     fn spreadsheet_header_press_reaches_the_widget_not_the_camera() {
         use crate::slots::{SPREADSHEET_IDX, SPREADSHEET_MENUBAR_IDX, VIEWPORT_IDX};