diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0401ee3..086fbad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,11 @@ # One arm per platform the crate produces on. Neither needs a display: the Windows arm opens a hidden # Direct3D window, and the Linux arm is a bare Vulkan device its tests allocate and export on. # -# The Linux example (`examples/interop_linux.rs`) is deliberately not built here. Its `example` -# feature turns on the facade's platform bundle, which compiles the windowing stack and needs the -# system graphics libraries a bare runner does not carry; the library and its tests use none of it, -# so keeping the example out is what lets this job run on a stock image. +# The Linux examples are not built in the `linux-interop` job. Their `example` feature turns on the +# facade's platform bundle, which compiles the windowing stack and needs the system graphics libraries +# a bare runner does not carry; the library and its tests use none of it, so keeping the examples out +# is what lets that job run on a stock image. They are checked in `package`, which already installs +# those libraries. name: CI on: @@ -84,6 +85,13 @@ jobs: - uses: actions/checkout@v4 - uses: ./.github/actions/linux-deps - uses: actions-rust-lang/setup-rust-toolchain@v1 + - name: steps::checkout_core + shell: bash + run: git clone --depth 1 --single-branch --branch bite_1.25.202 https://github.com/bite-gpui/bite-gpui.git ../bite-gpui + # The examples carry `required-features = ["example"]`, so no test job builds them. This + # caught the non-exhaustive `SurfaceEvent` match when the vocabulary grew. + - name: steps::cargo_check_examples + run: cargo check -p gpui_interop --examples --features example - name: steps::stage_publishable_tree run: python3 scripts/prepare-publish.py "$RUNNER_TEMP/gpui-interop-publish" - name: steps::cargo_publish_dry_run diff --git a/crates/gpui_interop/examples/interop_cube_drag.rs b/crates/gpui_interop/examples/interop_cube_drag.rs index 392ba03..797ca7b 100644 --- a/crates/gpui_interop/examples/interop_cube_drag.rs +++ b/crates/gpui_interop/examples/interop_cube_drag.rs @@ -17,9 +17,8 @@ //! factor alongside, and its `SurfaceEvent` is the crate's vocabulary rather than GPUI's. A drag starts //! on the viewport, which founds the gateway's own drag, so the cube keeps turning while the pointer is //! off past the edge — and because the gateway refreshes the window after each event, the producer only -//! has to change its own state. Not every event takes that road: the wheel, which zooms the cube, is the -//! demo's own `on_scroll_wheel`, because the gateway's vocabulary carries no scroll yet and the zoom is -//! the view's rather than the producer's. +//! has to change its own state. The wheel arrives the same way: `SurfaceEvent::Scroll` carries the +//! delta, so the zoom is the producer's too. //! //! # Three layers, and why the frame is transparent //! @@ -73,10 +72,10 @@ mod demo { use std::rc::Rc; use gpui::{ - AnyElement, App, Bounds, Context, IntoElement, Render, ScrollDelta, Window, WindowBounds, + AnyElement, App, Bounds, Context, IntoElement, Render, Window, WindowBounds, WindowOptions, div, gpu_canvas, prelude::*, px, rgb, rgba, size, }; - use gpui_interop::{SurfaceEvent, SurfaceInput, SurfacePoint, surface_input}; + use gpui_interop::{SurfaceEvent, SurfaceInput, SurfacePoint, SurfaceScroll, surface_input}; use crate::cube::{Cube, TRANSPARENT}; @@ -215,7 +214,6 @@ mod demo { spinning: Rc>, ) -> AnyElement { let drawing = cube.clone(); - let zooming = cube.clone(); let covering = cube.clone(); let surface = gpu_canvas(move |gpu| { let mut cube = drawing.borrow_mut(); @@ -247,23 +245,7 @@ mod demo { .as_ref() .is_some_and(|cube| cube.covers(uv)) }) - // The wheel zooms the *cube*, so it is the demo's own gpui handler and not the gateway's: the - // gateway's vocabulary carries no scroll yet, and the zoom is the view's rather than the - // producer's. One notch moves the eye by a fixed proportion, so zooming out to see the layer - // behind the cube and zooming back in feel the same at either end. - .on_scroll_wheel(move |event, window, _cx| { - let notches = match event.delta { - ScrollDelta::Lines(lines) => lines.y, - ScrollDelta::Pixels(pixels) => pixels.y.as_f32() / PIXELS_PER_LINE, - }; - let mut cube = zooming.borrow_mut(); - let Some(cube) = cube.as_mut() else { - // Nothing to zoom before the first paint has built the cube. - return; - }; - cube.zoom(ZOOM_PER_NOTCH.powf(notches)); - window.refresh(); - }) + // No background of its own: the surface is composited over the layer beneath it, and where // the producer left the frame transparent that layer shows through. .absolute() @@ -296,6 +278,17 @@ mod demo { grab.set(false); last.set(None); } + // One notch moves the eye by a fixed proportion, so zooming out to see the layer + // behind the cube and zooming back in feel the same at either end. + SurfaceEvent::Scroll { delta, .. } => { + let notches = match delta { + SurfaceScroll::Lines { y, .. } => y, + SurfaceScroll::Pixels { y, .. } => y / PIXELS_PER_LINE, + }; + cube.zoom(ZOOM_PER_NOTCH.powf(notches)); + } + // The cube has no hover state. + SurfaceEvent::PointerEnter { .. } | SurfaceEvent::PointerLeave { .. } => {} } }; diff --git a/crates/gpui_interop/src/gpui_interop.rs b/crates/gpui_interop/src/gpui_interop.rs index 86c2a64..8f254a5 100644 --- a/crates/gpui_interop/src/gpui_interop.rs +++ b/crates/gpui_interop/src/gpui_interop.rs @@ -121,7 +121,8 @@ pub use adapter::{Adapter, Unavailable}; pub use guest::{Guest, GuestFrame, GuestOptions}; #[cfg(feature = "gpui")] pub use input::{ - SurfaceButton, SurfaceEvent, SurfaceInput, SurfaceModifiers, SurfacePoint, surface_input, + SurfaceButton, SurfaceEvent, SurfaceInput, SurfaceModifiers, SurfacePoint, SurfaceScroll, + surface_input, }; #[cfg(target_os = "linux")] pub use linux::{ diff --git a/crates/gpui_interop/src/input.rs b/crates/gpui_interop/src/input.rs index 19170b1..1190732 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -19,7 +19,10 @@ //! window's device-pixel ratio). A producer that wants logical surface-local coordinates divides the //! position by `scale`. //! - **Vocabulary.** [`SurfaceEvent`] and its parts are the crate's, mapped from GPUI's, so a producer -//! never names `MouseMoveEvent`. +//! never names `MouseMoveEvent`. A precise scroll delta is converted to physical pixels like every +//! other distance; a line delta is passed through in lines, because a line has no pixel size until +//! the consumer chooses one. Hover has its own enter/leave events, so a producer can clear state +//! without waiting for a move it may never get. //! //! What it deliberately does **not** do: //! @@ -37,7 +40,9 @@ //! once a pointer-down is followed by a move past the engine's threshold, held on the `App` rather //! than in any element so it survives the redraws it causes, and delivered to every //! [`InteractiveElement::on_drag_move`] whose type it matches. The gateway uses it rather than -//! capturing the pointer itself, and so keeps no gesture state of its own across a frame: +//! capturing the pointer itself; the only gesture state it keeps across a frame is which surface +//! owns the current press, so that an up outside can be reported even when the engine never turned +//! the press into a drag (a right or middle press, or a move short of the threshold): //! //! - It registers an `on_drag` on the wrapped element whose value is the surface's own, with an empty //! preview — the producer paints the surface, so there is nothing to draw under the cursor. @@ -50,7 +55,7 @@ //! surface's own drag reports the up wherever the pointer is (`on_mouse_up_out`), and an ordinary //! gesture reports it from the hovered handler as before. //! -//! # The one authoring consequence: an id +//! # What the gateway takes from the element //! //! The drag model keys an element's state by an `ElementId`, so the surface element must have one. //! [`surface_input`] names an element the caller left unnamed, so a lone surface needs nothing — but @@ -58,22 +63,27 @@ //! parent names them (`gpu_canvas(..).id("pane-3")`), and the name is what picks that surface's drag //! out of the ones passing over it. //! +//! The gateway also claims the element's `on_hover` and `on_drag` slots, each of which holds one +//! listener. A caller that needs its own listener for either wraps the surface rather than adding one +//! after [`surface_input`]. Hover *styles* (`.hover(..)`) are a separate mechanism and still work. +//! //! # Pairing down and up is the producer's //! //! The gateway reports what GPUI routes to the surface's bounds: a move *over* the surface arrives //! even while another element holds the gesture, with `pressed` set. A producer that wants only its -//! own gesture therefore tracks whether it saw the [`SurfaceEvent::PointerDown`] — the gateway does -//! not, because it holds no state across a frame. The one asymmetry is the up of a gesture that began -//! elsewhere and ended over the surface, which arrives without a matching down. +//! own gesture therefore tracks whether it saw the [`SurfaceEvent::PointerDown`]. The gateway keeps +//! only enough state to pair the *up*: it records which surface owned the press, so a release outside +//! the surface still reaches that surface when no drag was founded. The one asymmetry is the up of a +//! gesture that began elsewhere and ended over the surface, which arrives without a matching down. use std::cell::Cell; use std::panic::Location; use std::rc::Rc; use gpui::{ - App, AppContext as _, Bounds, DragMoveEvent, Element, ElementId, Empty, GlobalElementId, + App, AppContext as _, Bounds, DragMoveEvent, Element, ElementId, Empty, Global, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, Modifiers, MouseButton, Pixels, Point, - Stateful, StatefulInteractiveElement, Window, + ScrollDelta, Stateful, StatefulInteractiveElement, Window, WindowId, }; /// The id a surface is named with when its element carried none. @@ -114,16 +124,31 @@ pub struct SurfaceModifiers { pub platform: bool, } +/// A scroll delta, in the units the platform reported it. +/// +/// The sign is the engine's: a positive `y` is up, which is already the opposite of the browser's +/// `deltaY`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SurfaceScroll { + /// A precise delta, in physical pixels. + Pixels { x: f32, y: f32 }, + /// An inexact delta in lines, as the platform reported it. + Lines { x: f32, y: f32 }, +} + /// A producer-facing input event over a surface, in surface-local physical pixels. /// -/// The event's own position is the pointer's; only a [`Scroll`](Self::Scroll) event, or a gesture a -/// producer reads from absolute positions, needs it. +/// The event's own position is the pointer's; a [`Scroll`](Self::Scroll) event carries the wheel's +/// delta, while a gesture a producer reads from absolute positions needs the position alone. #[derive(Clone, Copy, Debug, PartialEq)] pub enum SurfaceEvent { /// A pointer button went down over the surface. PointerDown { position: SurfacePoint, button: SurfaceButton, + /// How many clicks the platform counted in this press — 2 for the second click of a + /// double-click, as the platform's own time and distance rule decided it. + click_count: usize, modifiers: SurfaceModifiers, }, /// The pointer moved over the surface — while a button is held (`pressed`) or merely hovering. @@ -138,6 +163,28 @@ pub enum SurfaceEvent { PointerUp { position: SurfacePoint, button: SurfaceButton, + /// How many clicks the platform counted in the press this up completes. + click_count: usize, + modifiers: SurfaceModifiers, + }, + /// The pointer entered the surface's bounds. + /// + /// There is no position: the event says the pointer came, not where it is. A producer that needs + /// the position reads it from the next [`PointerMove`](Self::PointerMove). The transition is the + /// engine's hover state, so it also fires when a layout change moves the surface under a + /// stationary pointer. + PointerEnter { modifiers: SurfaceModifiers }, + /// The pointer left the surface's bounds. + /// + /// This mirrors the engine's hover state: while a button is held it is false, so the first move + /// during a press reports a leave even though the pointer is still inside. Leaving the window is + /// the other way it fires. + PointerLeave { modifiers: SurfaceModifiers }, + /// The wheel scrolled, or a touchpad reported a scroll gesture. + Scroll { + /// Where the pointer was, so a zoom can stay anchored under it. + position: SurfacePoint, + delta: SurfaceScroll, modifiers: SurfaceModifiers, }, } @@ -203,6 +250,47 @@ fn drag(cx: &App) -> Option<&SurfaceDrag> { cx.active_drag_value::() } +/// The presses in flight, one entry per window, surface and button. +/// +/// Unlike the drag, a press exists before the engine has decided to found one. Remembering it is +/// what lets the gateway report the up of a right/middle press, or of a left press that never moved +/// past the drag threshold, when that up happens outside the surface. A chord keeps one entry per +/// button, so releasing one does not forget another, and the entry carries the window so two windows +/// do not share a press. +#[derive(Default)] +struct PressedSurface(Vec<(WindowId, ElementId, MouseButton)>); + +impl Global for PressedSurface {} + +/// Whether `surface` owns `button` in `window`. +fn surface_pressed(cx: &App, window: WindowId, surface: &ElementId, button: MouseButton) -> bool { + cx.try_global::().is_some_and(|pressed| { + pressed + .0 + .iter() + .any(|(w, s, b)| *w == window && s == surface && *b == button) + }) +} + +/// Record that `surface` owns `button` in `window`. +fn press_surface(cx: &mut App, window: WindowId, surface: ElementId, button: MouseButton) { + let pressed = cx.default_global::(); + if !pressed + .0 + .iter() + .any(|(w, s, b)| *w == window && s == &surface && *b == button) + { + pressed.0.push((window, surface, button)); + } +} + +/// Forget that `surface` owned `button` in `window`. +fn release_surface(cx: &mut App, window: WindowId, surface: &ElementId, button: MouseButton) { + cx.default_global::() + .0 + .retain(|(w, s, b)| !(*w == window && s == surface && *b == button)); +} + /// Subscribe GPUI's pointer handlers on `element`, translating each to a [`SurfaceInput`] through /// `surface`. `id` is the element's, which the surface's own drag carries so each surface forwards /// only its own. @@ -211,17 +299,21 @@ where E: InteractiveElement + Element> + 'static, { let down = surface.clone(); - let element = element.on_any_mouse_down(move |event, window, _cx| { + let pressed = id.clone(); + let element = element.on_any_mouse_down(move |event, window, cx| { let Some(bounds) = down.bounds.get() else { return; }; let button = button(event.button); + press_surface(cx, window.window_handle().window_id(), pressed.clone(), event.button); let modifiers = modifiers(event.modifiers); let position = local(window.scale_factor(), bounds.origin, event.position); + let click_count = event.click_count; deliver(&down, window, position, |position| { SurfaceEvent::PointerDown { position, button, + click_count, modifiers, } }); @@ -296,19 +388,56 @@ where }, ); + // Hover has both edges, including one caused by a layout change under a stationary pointer, which + // is what a producer needs to clear a highlight without waiting for a move it may never get. + let hovered = surface.clone(); + let element = element.on_hover(move |is_hovered: &bool, window, _cx| { + let modifiers = modifiers(window.modifiers()); + let event = if *is_hovered { + SurfaceEvent::PointerEnter { modifiers } + } else { + SurfaceEvent::PointerLeave { modifiers } + }; + deliver_event(&hovered, window, event); + }); + + // The wheel: the engine routes it to the surface's element like any other pointer event, so its + // position is surface-local and its delta is all the gateway has to translate. + let scrolled = surface.clone(); + let element = element.on_scroll_wheel(move |event, window, _cx| { + let Some(bounds) = scrolled.bounds.get() else { + return; + }; + let scale = window.scale_factor(); + let position = local(scale, bounds.origin, event.position); + let delta = scroll(event.delta, scale); + let modifiers = modifiers(event.modifiers); + deliver(&scrolled, window, position, |position| SurfaceEvent::Scroll { + position, + delta, + modifiers, + }); + }); + // The up over the surface: the ordinary case, and a drag released where it began. Under capture // phase and hovered, so it is the one that reports a plain click. let up = surface.clone(); - let element = element.capture_any_mouse_up(move |event, window, _cx| { + let own = id.clone(); + let element = element.capture_any_mouse_up(move |event, window, cx| { + if surface_pressed(cx, window.window_handle().window_id(), &own, event.button) { + release_surface(cx, window.window_handle().window_id(), &own, event.button); + } let Some(bounds) = up.bounds.get() else { return; }; let button = button(event.button); + let click_count = event.click_count; let modifiers = modifiers(event.modifiers); let position = local(window.scale_factor(), bounds.origin, event.position); deliver(&up, window, position, |position| SurfaceEvent::PointerUp { position, button, + click_count, modifiers, }); }); @@ -320,19 +449,26 @@ where let released = surface.clone(); let own = id.clone(); element = element.on_mouse_up_out(released_button, move |event, window, cx| { - if drag(cx).is_none_or(|drag| drag.origin != own) { + let carried = drag(cx).is_some_and(|drag| drag.origin == own); + let owned = surface_pressed(cx, window.window_handle().window_id(), &own, event.button); + if !carried && !owned { return; } + if owned { + release_surface(cx, window.window_handle().window_id(), &own, event.button); + } let Some(bounds) = released.bounds.get() else { return; }; let button = button(event.button); + let click_count = event.click_count; let modifiers = modifiers(event.modifiers); let position = local(window.scale_factor(), bounds.origin, event.position); deliver(&released, window, position, |position| { SurfaceEvent::PointerUp { position, button, + click_count, modifiers, } }); @@ -349,11 +485,13 @@ fn deliver( position: SurfacePoint, event: impl FnOnce(SurfacePoint) -> SurfaceEvent, ) { + deliver_event(surface, window, event(position)); +} + +/// Hand the producer an event that already carries everything it needs. +fn deliver_event(surface: &Rc, window: &mut Window, event: SurfaceEvent) { let scale = window.scale_factor(); - (surface.handler)(SurfaceInput { - event: event(position), - scale, - }); + (surface.handler)(SurfaceInput { event, scale }); window.refresh(); } @@ -376,6 +514,20 @@ fn button(button: MouseButton) -> SurfaceButton { } } +/// GPUI's scroll delta, as a producer-facing one. +fn scroll(delta: ScrollDelta, scale: f32) -> SurfaceScroll { + match delta { + ScrollDelta::Pixels(delta) => SurfaceScroll::Pixels { + x: delta.x.as_f32() * scale, + y: delta.y.as_f32() * scale, + }, + ScrollDelta::Lines(delta) => SurfaceScroll::Lines { + x: delta.x, + y: delta.y, + }, + } +} + /// The guest of GPUI's modifiers, as a producer-facing set. fn modifiers(modifiers: Modifiers) -> SurfaceModifiers { SurfaceModifiers { diff --git a/crates/gpui_interop/tests/surface_input.rs b/crates/gpui_interop/tests/surface_input.rs index b14c29c..eb8f4e7 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -18,15 +18,32 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use gpui::{ - AnyWindowHandle, Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, - VisualTestContext, Window, div, point, prelude::*, px, + AnyWindowHandle, Context, IntoElement, Modifiers, MouseButton, MouseDownEvent, MouseUpEvent, + Render, ScrollDelta, ScrollWheelEvent, TestAppContext, TouchPhase, VisualTestContext, Window, + div, point, prelude::*, px, +}; +use gpui_interop::{ + SurfaceButton, SurfaceEvent, SurfaceInput, SurfacePoint, SurfaceScroll, surface_input, }; -use gpui_interop::{SurfaceButton, SurfaceEvent, SurfaceInput, SurfacePoint, surface_input}; /// The surface's edge, and the padding that insets it in the window. const EDGE: f32 = 50.0; const PAD: f32 = 10.0; +/// The events that are not hover transitions, for tests whose subject is the pointer. +fn pointer_events(events: &[SurfaceEvent]) -> Vec { + events + .iter() + .filter(|event| { + !matches!( + event, + SurfaceEvent::PointerEnter { .. } | SurfaceEvent::PointerLeave { .. } + ) + }) + .cloned() + .collect() +} + /// A view whose handler records everything the gateway hands the producer. struct Recording { seen: Rc>>, @@ -65,7 +82,7 @@ fn the_gateway_hands_a_producer_surface_local_physical_pixels(cx: &mut TestAppCo cx.simulate_mouse_up(dragged, MouseButton::Left, Modifiers::none()); let seen = seen.borrow(); - let events: Vec = seen.iter().map(|input| input.event).collect(); + let events = pointer_events(&seen.iter().map(|input| input.event).collect::>()); assert!( seen.iter().all(|input| input.scale == scale), "every event carries the window's scale factor ({scale}), got {:?}", @@ -210,10 +227,7 @@ fn a_caption_over_the_surface_does_not_claim_the_pointer(cx: &mut TestAppContext // under it is still what the pointer is over. cx.simulate_mouse_move(over_caption(), None::, Modifiers::none()); assert!( - matches!( - events.borrow().as_slice(), - [SurfaceEvent::PointerMove { .. }] - ), + matches!(pointer_events(&events.borrow()).as_slice(), [SurfaceEvent::PointerMove { .. }]), "a move over the caption reaches the surface: {:?}", events.borrow(), ); @@ -226,7 +240,7 @@ fn a_control_over_the_surface_takes_its_own_events(cx: &mut TestAppContext) { // A move over the button does not reach the surface — the button is in front of it. cx.simulate_mouse_move(over_button(), None::, Modifiers::none()); assert!( - events.borrow().is_empty(), + pointer_events(&events.borrow()).is_empty(), "the button is in front, so the surface sees nothing: {:?}", events.borrow(), ); @@ -370,6 +384,145 @@ fn the_up_of_a_drag_that_left_the_surface_arrives(cx: &mut TestAppContext) { ); } +#[gpui::test] +fn hover_reports_enter_and_leave(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Bounded { events: recorded })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + let inside = point(px(100.), px(100.)); + let beyond = point(px(300.), px(250.)); + cx.simulate_mouse_move(inside, None::, Modifiers::none()); + cx.simulate_mouse_move(beyond, None::, Modifiers::none()); + + let edges: Vec = events + .borrow() + .iter() + .filter(|event| { + matches!( + event, + SurfaceEvent::PointerEnter { .. } | SurfaceEvent::PointerLeave { .. } + ) + }) + .cloned() + .collect(); + assert!( + matches!( + edges.as_slice(), + [SurfaceEvent::PointerEnter { .. }, SurfaceEvent::PointerLeave { .. }] + ), + "hover reports both edges, in order: {edges:?}", + ); +} + +#[gpui::test] +fn the_up_of_a_right_press_arrives_outside_the_surface(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Bounded { events: recorded })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + // A right press never founds a drag, so the engine has no gesture to release; without press + // ownership the producer would never see the up. + let inside = point(px(100.), px(100.)); + let beyond = point(px(300.), px(250.)); + cx.simulate_mouse_move(inside, None::, Modifiers::none()); + cx.simulate_mouse_down(inside, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_move(beyond, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_up(beyond, MouseButton::Right, Modifiers::none()); + + let events = events.borrow(); + let releases = events + .iter() + .filter(|event| { + matches!( + event, + SurfaceEvent::PointerUp { + button: SurfaceButton::Right, + .. + } + ) + }) + .count(); + assert_eq!( + releases, 1, + "the right press is released off the surface exactly once: {events:?}", + ); +} + +#[gpui::test] +fn the_up_of_a_sub_threshold_left_press_arrives_outside_the_surface(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Bounded { events: recorded })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + // A left press that never moves past the 2px drag threshold has no engine drag either. + let inside = point(px(100.), px(100.)); + let beyond = point(px(300.), px(250.)); + cx.simulate_mouse_move(inside, None::, Modifiers::none()); + cx.simulate_mouse_down(inside, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(beyond, MouseButton::Left, Modifiers::none()); + + let events = events.borrow(); + let releases = events + .iter() + .filter(|event| { + matches!( + event, + SurfaceEvent::PointerUp { + button: SurfaceButton::Left, + .. + } + ) + }) + .count(); + assert_eq!( + releases, 1, + "the sub-threshold left press is released off the surface exactly once: {events:?}", + ); +} + +#[gpui::test] +fn a_chord_keeps_each_buttons_press(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let recorded = events.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Bounded { events: recorded })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + // A CAD orbit: middle held while the left button is also pressed, both released outside. + let inside = point(px(100.), px(100.)); + let beyond = point(px(300.), px(250.)); + cx.simulate_mouse_move(inside, None::, Modifiers::none()); + cx.simulate_mouse_down(inside, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_down(inside, MouseButton::Middle, Modifiers::none()); + cx.simulate_mouse_move(beyond, MouseButton::Middle, Modifiers::none()); + cx.simulate_mouse_up(beyond, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(beyond, MouseButton::Middle, Modifiers::none()); + + let events = events.borrow(); + for button in [SurfaceButton::Left, SurfaceButton::Middle] { + let releases = events + .iter() + .filter(|event| { + matches!( + event, + SurfaceEvent::PointerUp { + button: released, + .. + } if *released == button + ) + }) + .count(); + assert_eq!(releases, 1, "{button:?} is released exactly once: {events:?}"); + } +} + /// Two surfaces side by side, each named, so a drag started on one says which. struct Pair { left: Rc>>, @@ -562,6 +715,137 @@ impl Render for Behind { } } +#[gpui::test] +fn a_scroll_is_surface_local_and_physical(cx: &mut TestAppContext) { + let seen = Rc::new(RefCell::new(Vec::new())); + let recording = seen.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Recording { seen: recording })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let scale = cx.update(|window, _| window.scale_factor()); + + let at = point(px(35.), px(35.)); + let delta = point(px(4.), px(-6.)); + cx.simulate_event(ScrollWheelEvent { + position: at, + delta: ScrollDelta::Pixels(delta), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + + let seen = seen.borrow(); + let scrolls: Vec = seen + .iter() + .filter(|input| matches!(input.event, SurfaceEvent::Scroll { .. })) + .cloned() + .collect(); + let [SurfaceInput { + event: + SurfaceEvent::Scroll { + position, + delta: SurfaceScroll::Pixels { x, y }, + .. + }, + scale: event_scale, + }] = scrolls.as_slice() + else { + panic!("expected exactly one pixel scroll: {seen:?}"); + }; + + assert_eq!(*event_scale, scale, "the scroll carries the window's scale"); + assert!( + (position.x - (35.0 - PAD) * scale).abs() < 0.01 + && (position.y - (35.0 - PAD) * scale).abs() < 0.01, + "the scroll position is surface-local physical pixels: {position:?}", + ); + assert!( + (x - 4.0 * scale).abs() < 0.01 && (y.abs() - 6.0 * scale).abs() < 0.01, + "the pixel delta is converted to physical pixels: ({x}, {y})", + ); + + // The harness's scale is 2, so the multiplication above is not a no-op. + assert_eq!(scale, 2.0, "the scroll test exercises a non-unit scale"); +} + +#[gpui::test] +fn a_line_scroll_passes_through_unscaled(cx: &mut TestAppContext) { + let seen = Rc::new(RefCell::new(Vec::new())); + let recording = seen.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Recording { seen: recording })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(35.), px(35.)), + delta: ScrollDelta::Lines(point(2.0, -3.0)), + modifiers: Modifiers::none(), + touch_phase: TouchPhase::Moved, + }); + + let seen = seen.borrow(); + let scrolls: Vec = seen + .iter() + .filter(|input| matches!(input.event, SurfaceEvent::Scroll { .. })) + .cloned() + .collect(); + let [SurfaceInput { + event: + SurfaceEvent::Scroll { + delta: SurfaceScroll::Lines { x, y }, + .. + }, + .. + }] = scrolls.as_slice() + else { + panic!("expected exactly one line scroll: {seen:?}"); + }; + + assert_eq!( + (*x, *y), + (2.0, -3.0), + "a line delta passes through without the scale factor" + ); +} + +#[gpui::test] +fn a_double_click_keeps_the_platforms_click_count(cx: &mut TestAppContext) { + let seen = Rc::new(RefCell::new(Vec::new())); + let recording = seen.clone(); + let window = AnyWindowHandle::from(cx.add_window(move |_, _| Recording { seen: recording })); + let mut cx = VisualTestContext::from_window(window, cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + + cx.simulate_event(MouseDownEvent { + position: point(px(35.), px(35.)), + button: MouseButton::Left, + modifiers: Modifiers::none(), + click_count: 2, + first_mouse: false, + }); + cx.simulate_event(MouseUpEvent { + position: point(px(35.), px(35.)), + button: MouseButton::Left, + modifiers: Modifiers::none(), + click_count: 2, + }); + + let seen = seen.borrow(); + assert!( + seen.iter().any(|input| matches!( + input.event, + SurfaceEvent::PointerDown { click_count: 2, .. } + )), + "the platform's double-click count reaches the down: {seen:?}", + ); + assert!( + seen.iter().any(|input| matches!( + input.event, + SurfaceEvent::PointerUp { click_count: 2, .. } + )), + "and the up it completes: {seen:?}", + ); +} + #[gpui::test] fn a_control_under_the_surface_still_gets_the_click(cx: &mut TestAppContext) { let events = Rc::new(RefCell::new(Vec::new()));