graphic design tool
git clone https://git.lucas.co/cce-designer.git
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};