From ea99d30e3bc9bc3b2a658fc57a9d4fd99ac3b32e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 07:15:01 -0400 Subject: [PATCH] =?UTF-8?q?Canvas=20=C2=B7=20the=20wheel=20scrolls=20as=20?= =?UTF-8?q?scroll=20boxes=20do;=20pans=20hold=20at=20the=20press?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A wheel without a modifier now slides the content by its delta, as gpui's scroll boxes do; it moved the other way. The wheel reads the live viewport and skips an end with no delta, and a drag pans from the press by the pointer's whole way with the viewport held at the press, so events that land in one frame no longer overwrite each other. Tests: a plain wheel moves the content up with it; three wheels and an empty end add up, and four moves pan their sum; a small frame fits at a zoom of one. Co-Authored-By: Claude Opus 5.5 --- TASKS.md | 1 + src/canvas/plane.rs | 34 ++++++++++++++++-------------- src/canvas/tests.rs | 50 +++++++++++++++++++++++++++++++++++++++++++++ src/canvas/view.rs | 3 +++ 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/TASKS.md b/TASKS.md index dfabb1e..3beb899 100644 --- a/TASKS.md +++ b/TASKS.md @@ -373,3 +373,4 @@ Per-component lines live in `tasks/`. Tags there: | T31c | 2 | PASS | | | T31d | 1 | FAIL | a focused segment past the strip's fold stayed hidden; the tracker lost focus as Start and Stop swapped; Stop and Skip went untested; percent did not refuse content | | T31d | 2 | PASS | | +| T32a | 1 | FAIL | the plain wheel panned against every scroll box; the handlers panned from the render's viewport, so events within a frame overwrote each other; the fit's cap at one went untested | diff --git a/src/canvas/plane.rs b/src/canvas/plane.rs index 8339c0f..7b0d0d0 100644 --- a/src/canvas/plane.rs +++ b/src/canvas/plane.rs @@ -22,11 +22,11 @@ struct Pan { owner: EntityId, } -/// The canvas's box and the pointer of a pan under way. +/// The canvas's box, and the pointer and viewport at the press of a pan under way. #[derive(Default)] struct Plane { bounds: Bounds, - grip: Option>, + grip: Option<(Point, Viewport)>, } /// The view pixels of a canvas frame. @@ -400,6 +400,7 @@ impl RenderOnce for InfiniteCanvas { let owner = plane.entity_id(); let (wheel, pan, drop) = (set.clone(), set, plane.clone()); let (scrolled, dragged, pressed) = (plane.clone(), plane.clone(), plane); + let (live, held) = (local.clone(), local.clone()); div() .id(id) .relative() @@ -416,34 +417,37 @@ impl RenderOnce for InfiniteCanvas { (lines.x * line, lines.y * line) } }; + if delta == (0.0, 0.0) { + return; + } let pointer = event.position - bounds.origin; let about = (f32::from(pointer.x), f32::from(pointer.y)); - let now = view; + let now = live.read(cx).value; let next = match event.modifiers.platform || event.modifiers.control { true => now.zoomed((-delta.1 / 240.0).exp(), about), - false => now.panned((-delta.0, -delta.1)), + false => now.panned(delta), }; cx.stop_propagation(); wheel(next, window, cx); }) .on_mouse_down(gpui::MouseButton::Left, move |event, _, cx| { - pressed.update(cx, |plane, _| plane.grip = Some(event.position)) + let now = held.read(cx).value; + pressed.update(cx, |plane, _| plane.grip = Some((event.position, now))) }) .on_drag(Pan { owner }, |_, _, _, cx| cx.new(|_| EmptyView)) .on_drag_move::(move |event, window, cx| { if event.drag(cx).owner != owner { return; } - let pointer = event.event.position; - let last = dragged.update(cx, |plane, _| plane.grip.replace(pointer)); - if let Some(last) = last { - let delta = pointer - last; - pan( - view.panned((f32::from(delta.x), f32::from(delta.y))), - window, - cx, - ); - } + let Some((press, start)) = dragged.read(cx).grip else { + return; + }; + let way = event.event.position - press; + pan( + start.panned((f32::from(way.x), f32::from(way.y))), + window, + cx, + ); }) .on_drop(move |_: &Pan, _, cx| drop.update(cx, |plane, _| plane.grip = None)) .child(painted) diff --git a/src/canvas/tests.rs b/src/canvas/tests.rs index acfdc82..44cca55 100644 --- a/src/canvas/tests.rs +++ b/src/canvas/tests.rs @@ -155,3 +155,53 @@ fn a_press_on_the_map_centers_the_view_there(cx: &mut TestAppContext) { "the view centers on the point pressed: {middle:?}" ); } + +fn wheel(at: (f32, f32), dy: f32, modifiers: Modifiers) -> ScrollWheelEvent { + ScrollWheelEvent { + position: point(px(at.0), px(at.1)), + delta: ScrollDelta::Pixels(point(px(0.0), px(dy))), + modifiers, + touch_phase: TouchPhase::Moved, + } +} + +#[gpui::test] +fn a_plain_wheel_moves_the_content_as_a_scroll_box_does(cx: &mut TestAppContext) { + let (host, cx) = stage(plane, cx); + cx.simulate_event(wheel((100.0, 50.0), -50.0, Modifiers::none())); + settle(cx); + let view = *heard(&host, cx).last().expect("a scroll"); + assert_eq!( + view.to_view((0.0, 0.0)), + (0.0, -50.0), + "the content went up with the wheel" + ); +} + +#[gpui::test] +fn wheels_and_moves_add_up(cx: &mut TestAppContext) { + let (host, cx) = stage(plane, cx); + for dy in [-10.0, -10.0, -10.0, 0.0] { + cx.simulate_event(wheel((100.0, 50.0), dy, Modifiers::none())); + } + settle(cx); + let view = *heard(&host, cx).last().expect("a scroll"); + assert_eq!( + view.y, 30.0, + "three wheels add up, and an end that moves nothing leaves it" + ); + let at = |x: f32, y: f32| point(px(x), px(y)); + cx.simulate_mouse_move(at(200.0, 150.0), None, Modifiers::none()); + cx.simulate_mouse_down(at(200.0, 150.0), MouseButton::Left, Modifiers::none()); + for x in [190.0, 180.0, 170.0, 160.0] { + cx.simulate_mouse_move(at(x, 150.0), Some(MouseButton::Left), Modifiers::none()); + } + cx.simulate_mouse_up(at(160.0, 150.0), MouseButton::Left, Modifiers::none()); + settle(cx); + let view = *heard(&host, cx).last().expect("a pan"); + assert_eq!( + (view.x, view.y), + (40.0, 30.0), + "the drag pans from the press by the pointer's whole way" + ); +} diff --git a/src/canvas/view.rs b/src/canvas/view.rs index 9ed41df..4963dc4 100644 --- a/src/canvas/view.rs +++ b/src/canvas/view.rs @@ -159,6 +159,9 @@ mod tests { let view = Viewport::fitting(Frame::new(0.0, 0.0, 800.0, 400.0), (440.0, 440.0), 20.0); assert_eq!(view.zoom, 0.5); assert_eq!(view.to_view((400.0, 200.0)), (220.0, 220.0)); + let small = Viewport::fitting(Frame::new(0.0, 0.0, 100.0, 50.0), (440.0, 440.0), 20.0); + assert_eq!(small.zoom, 1.0, "no nearer than a zoom of one"); + assert_eq!(small.to_view((50.0, 25.0)), (220.0, 220.0)); } #[test]