From 6c42eda304966721e659762eacfb1954a40bb9ae Mon Sep 17 00:00:00 2001 From: Vanuan Date: Tue, 3 Nov 2026 04:12:39 +0200 Subject: [PATCH 1/5] surface_input: report scroll as a SurfaceEvent (#2) SurfaceEvent had no Scroll variant, so a producer binding a wheel had to go around the gateway and the enum docs linked to an item that did not exist. Register on_scroll_wheel alongside the pointer handlers, carry the pointer position so a zoom can stay anchored, and map GPUI ScrollDelta to SurfaceScroll: a pixel delta is converted to the physical pixels the rest of the gateway uses, while a line delta passes through in lines. --- crates/gpui_interop/src/gpui_interop.rs | 3 +- crates/gpui_interop/src/input.rs | 58 ++++++++++++++++++++-- crates/gpui_interop/tests/surface_input.rs | 53 ++++++++++++++++++-- 3 files changed, 106 insertions(+), 8 deletions(-) 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..a9e4dc5 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -19,7 +19,9 @@ //! 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. //! //! What it deliberately does **not** do: //! @@ -73,7 +75,7 @@ use std::rc::Rc; use gpui::{ App, AppContext as _, Bounds, DragMoveEvent, Element, ElementId, Empty, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, Modifiers, MouseButton, Pixels, Point, - Stateful, StatefulInteractiveElement, Window, + ScrollDelta, Stateful, StatefulInteractiveElement, Window, }; /// The id a surface is named with when its element carried none. @@ -114,10 +116,19 @@ pub struct SurfaceModifiers { pub platform: bool, } +/// A scroll delta, in the units the platform reported it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum SurfaceScroll { + /// A precise delta, in physical pixels from the surface's top-left corner. + 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. @@ -140,6 +151,13 @@ pub enum SurfaceEvent { button: SurfaceButton, 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, + }, } /// A surface input, adapted from GPUI's, ready for a producer. @@ -296,6 +314,24 @@ where }, ); + // 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(); @@ -376,6 +412,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..dd913f5 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -18,10 +18,13 @@ 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, 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; @@ -562,6 +565,50 @@ 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 [SurfaceInput { + event: + SurfaceEvent::Scroll { + position, + delta: SurfaceScroll::Pixels { x, y }, + .. + }, + scale: event_scale, + }] = seen.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})", + ); +} + #[gpui::test] fn a_control_under_the_surface_still_gets_the_click(cx: &mut TestAppContext) { let events = Rc::new(RefCell::new(Vec::new())); From af0b93cca9e08f12ada9744a2704e794a78287cc Mon Sep 17 00:00:00 2001 From: Vanuan Date: Tue, 3 Nov 2026 04:13:33 +0200 Subject: [PATCH 2/5] surface_input: report the up of a press that never dragged (#3) on_mouse_up_out was gated on the engine drag, but the engine founds a drag only for a left press past the 2px threshold. A right or middle press, or a left press released after a sub-threshold move, therefore never produced a PointerUp when released outside the surface, leaving the producer stuck in its pressed state. Record which surface owns the current press on the App, clear it on the up, and report an off-surface up when either the drag or the press is ours. --- crates/gpui_interop/src/input.rs | 46 ++++++++++++++--- crates/gpui_interop/tests/surface_input.rs | 58 ++++++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/crates/gpui_interop/src/input.rs b/crates/gpui_interop/src/input.rs index a9e4dc5..4a592ba 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -39,7 +39,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. @@ -64,16 +66,17 @@ //! //! 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, ScrollDelta, Stateful, StatefulInteractiveElement, Window, }; @@ -221,6 +224,22 @@ fn drag(cx: &App) -> Option<&SurfaceDrag> { cx.active_drag_value::() } +/// Which surface owns the pointer press currently in flight. +/// +/// 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. +#[derive(Default)] +struct PressedSurface(Option); + +impl Global for PressedSurface {} + +/// The surface that owns the press, if any. +fn pressed_surface(cx: &App) -> Option<&ElementId> { + cx.try_global::() + .and_then(|pressed| pressed.0.as_ref()) +} + /// 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. @@ -229,10 +248,12 @@ 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; }; + cx.set_global(PressedSurface(Some(pressed.clone()))); let button = button(event.button); let modifiers = modifiers(event.modifiers); let position = local(window.scale_factor(), bounds.origin, event.position); @@ -335,7 +356,11 @@ where // 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 pressed_surface(cx) == Some(&own) { + cx.set_global(PressedSurface(None)); + } let Some(bounds) = up.bounds.get() else { return; }; @@ -356,9 +381,14 @@ 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 = pressed_surface(cx) == Some(&own); + if !carried && !owned { return; } + if owned { + cx.set_global(PressedSurface(None)); + } let Some(bounds) = released.bounds.get() else { return; }; diff --git a/crates/gpui_interop/tests/surface_input.rs b/crates/gpui_interop/tests/surface_input.rs index dd913f5..8b34021 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -373,6 +373,64 @@ fn the_up_of_a_drag_that_left_the_surface_arrives(cx: &mut TestAppContext) { ); } +#[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(); + assert!( + events.iter().any(|event| matches!( + event, + SurfaceEvent::PointerUp { + button: SurfaceButton::Right, + .. + } + )), + "the right press is released off the surface: {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(); + assert!( + events.iter().any(|event| matches!( + event, + SurfaceEvent::PointerUp { + button: SurfaceButton::Left, + .. + } + )), + "the sub-threshold left press is released off the surface: {events:?}", + ); +} + /// Two surfaces side by side, each named, so a drag started on one says which. struct Pair { left: Rc>>, From 6ed9e91959b5466f8acc2389d72e9349a4cbf71a Mon Sep 17 00:00:00 2001 From: Vanuan Date: Tue, 3 Nov 2026 04:13:59 +0200 Subject: [PATCH 3/5] surface_input: carry the platforms click count (#4) PointerDown dropped MouseDownEvent::click_count, so a producer could not tell a double-click from two clicks without rebuilding the platform time/distance heuristic. Copy it to both PointerDown and PointerUp, matching the engine events they adapt. --- crates/gpui_interop/src/input.rs | 11 ++++++++ crates/gpui_interop/tests/surface_input.rs | 32 ++++++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/gpui_interop/src/input.rs b/crates/gpui_interop/src/input.rs index 4a592ba..1ab07ec 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -138,6 +138,9 @@ pub enum SurfaceEvent { 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. @@ -152,6 +155,8 @@ 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 wheel scrolled, or a touchpad reported a scroll gesture. @@ -257,10 +262,12 @@ where let button = button(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, } }); @@ -365,11 +372,13 @@ where 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, }); }); @@ -393,12 +402,14 @@ where 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, } }); diff --git a/crates/gpui_interop/tests/surface_input.rs b/crates/gpui_interop/tests/surface_input.rs index 8b34021..04f372a 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -18,9 +18,9 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use gpui::{ - AnyWindowHandle, Context, IntoElement, Modifiers, MouseButton, Render, ScrollDelta, - ScrollWheelEvent, TestAppContext, TouchPhase, VisualTestContext, Window, div, point, prelude::*, - px, + AnyWindowHandle, Context, IntoElement, Modifiers, MouseButton, MouseDownEvent, Render, + ScrollDelta, ScrollWheelEvent, TestAppContext, TouchPhase, VisualTestContext, Window, div, point, + prelude::*, px, }; use gpui_interop::{ SurfaceButton, SurfaceEvent, SurfaceInput, SurfacePoint, SurfaceScroll, surface_input, @@ -667,6 +667,32 @@ fn a_scroll_is_surface_local_and_physical(cx: &mut TestAppContext) { ); } +#[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, + }); + + 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 producer: {seen:?}", + ); +} + #[gpui::test] fn a_control_under_the_surface_still_gets_the_click(cx: &mut TestAppContext) { let events = Rc::new(RefCell::new(Vec::new())); From 60fb0854385b5cf58d8510d20a226ca2bc59ee3c Mon Sep 17 00:00:00 2001 From: Vanuan Date: Tue, 3 Nov 2026 04:15:07 +0200 Subject: [PATCH 4/5] surface_input: report pointer enter and leave (#5) The gateway reported moves, downs and ups, so a producer that highlights a gizmo or a navigation-cube face had no way to clear that state once the pointer left; a stationary leave can produce no further move at all. Subscribe on_hover and forward both edges as PointerEnter/PointerLeave, with modifiers but no position. The transition mirrors the engine hover state, including its suppression during a press or an active drag. --- crates/gpui_interop/src/input.rs | 38 +++++++++++-- crates/gpui_interop/tests/surface_input.rs | 63 +++++++++++++++++++--- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/gpui_interop/src/input.rs b/crates/gpui_interop/src/input.rs index 1ab07ec..bf27da3 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -21,7 +21,8 @@ //! - **Vocabulary.** [`SurfaceEvent`] and its parts are the crate's, mapped from GPUI's, so a producer //! 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. +//! 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: //! @@ -159,6 +160,18 @@ pub enum SurfaceEvent { 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, which is suppressed while a button is held or a drag + /// is in flight; a press therefore reports a leave even if the pointer has not moved. + 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. @@ -342,6 +355,19 @@ 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(); @@ -426,11 +452,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(); } diff --git a/crates/gpui_interop/tests/surface_input.rs b/crates/gpui_interop/tests/surface_input.rs index 04f372a..20b825c 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -30,6 +30,20 @@ use gpui_interop::{ 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>>, @@ -68,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 {:?}", @@ -213,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(), ); @@ -229,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(), ); @@ -373,6 +384,39 @@ 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())); @@ -642,6 +686,11 @@ fn a_scroll_is_surface_local_and_physical(cx: &mut TestAppContext) { }); let seen = seen.borrow(); + let scrolls: Vec = seen + .iter() + .filter(|input| matches!(input.event, SurfaceEvent::Scroll { .. })) + .cloned() + .collect(); let [SurfaceInput { event: SurfaceEvent::Scroll { @@ -650,7 +699,7 @@ fn a_scroll_is_surface_local_and_physical(cx: &mut TestAppContext) { .. }, scale: event_scale, - }] = seen.as_slice() + }] = scrolls.as_slice() else { panic!("expected exactly one pixel scroll: {seen:?}"); }; From 2ce1bbe15b8ac99e5da24e704575260d66e99193 Mon Sep 17 00:00:00 2001 From: Vanuan Date: Tue, 3 Nov 2026 13:28:14 +0200 Subject: [PATCH 5/5] Address the input-gateway review The example now handles SurfaceEvent::Scroll through the gateway and has arms for the hover edges, so its match is exhaustive again; the package job checks examples with the example feature. Restore the scroll sign convention and fix the delta and PointerLeave wording; document that the gateway claims the on_hover slot. Press ownership now records one entry per window, surface and button, so a left release no longer forgets a held middle press and two windows do not share it. Add tests for a line scroll, PointerUp click_count, exact off-surface up counts, and a chord. --- .github/workflows/ci.yml | 16 +- .../examples/interop_cube_drag.rs | 39 ++--- crates/gpui_interop/src/input.rs | 67 ++++++-- crates/gpui_interop/tests/surface_input.rs | 148 +++++++++++++++--- 4 files changed, 204 insertions(+), 66 deletions(-) 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/input.rs b/crates/gpui_interop/src/input.rs index bf27da3..1190732 100644 --- a/crates/gpui_interop/src/input.rs +++ b/crates/gpui_interop/src/input.rs @@ -55,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 @@ -63,6 +63,10 @@ //! 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 @@ -79,7 +83,7 @@ use std::rc::Rc; use gpui::{ App, AppContext as _, Bounds, DragMoveEvent, Element, ElementId, Empty, Global, GlobalElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, Modifiers, MouseButton, Pixels, Point, - ScrollDelta, Stateful, StatefulInteractiveElement, Window, + ScrollDelta, Stateful, StatefulInteractiveElement, Window, WindowId, }; /// The id a surface is named with when its element carried none. @@ -121,9 +125,12 @@ pub struct SurfaceModifiers { } /// 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 from the surface's top-left corner. + /// 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 }, @@ -169,8 +176,9 @@ pub enum SurfaceEvent { PointerEnter { modifiers: SurfaceModifiers }, /// The pointer left the surface's bounds. /// - /// This mirrors the engine's hover state, which is suppressed while a button is held or a drag - /// is in flight; a press therefore reports a leave even if the pointer has not moved. + /// 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 { @@ -242,20 +250,45 @@ fn drag(cx: &App) -> Option<&SurfaceDrag> { cx.active_drag_value::() } -/// Which surface owns the pointer press currently in flight. +/// 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. +/// 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(Option); +struct PressedSurface(Vec<(WindowId, ElementId, MouseButton)>); impl Global for PressedSurface {} -/// The surface that owns the press, if any. -fn pressed_surface(cx: &App) -> Option<&ElementId> { - cx.try_global::() - .and_then(|pressed| pressed.0.as_ref()) +/// 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 @@ -271,8 +304,8 @@ where let Some(bounds) = down.bounds.get() else { return; }; - cx.set_global(PressedSurface(Some(pressed.clone()))); 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; @@ -391,8 +424,8 @@ where let up = surface.clone(); let own = id.clone(); let element = element.capture_any_mouse_up(move |event, window, cx| { - if pressed_surface(cx) == Some(&own) { - cx.set_global(PressedSurface(None)); + 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; @@ -417,12 +450,12 @@ where let own = id.clone(); element = element.on_mouse_up_out(released_button, move |event, window, cx| { let carried = drag(cx).is_some_and(|drag| drag.origin == own); - let owned = pressed_surface(cx) == Some(&own); + let owned = surface_pressed(cx, window.window_handle().window_id(), &own, event.button); if !carried && !owned { return; } if owned { - cx.set_global(PressedSurface(None)); + release_surface(cx, window.window_handle().window_id(), &own, event.button); } let Some(bounds) = released.bounds.get() else { return; diff --git a/crates/gpui_interop/tests/surface_input.rs b/crates/gpui_interop/tests/surface_input.rs index 20b825c..eb8f4e7 100644 --- a/crates/gpui_interop/tests/surface_input.rs +++ b/crates/gpui_interop/tests/surface_input.rs @@ -18,9 +18,9 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; use gpui::{ - AnyWindowHandle, Context, IntoElement, Modifiers, MouseButton, MouseDownEvent, Render, - ScrollDelta, ScrollWheelEvent, TestAppContext, TouchPhase, 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, @@ -435,15 +435,21 @@ fn the_up_of_a_right_press_arrives_outside_the_surface(cx: &mut TestAppContext) cx.simulate_mouse_up(beyond, MouseButton::Right, Modifiers::none()); let events = events.borrow(); - assert!( - events.iter().any(|event| matches!( - event, - SurfaceEvent::PointerUp { - button: SurfaceButton::Right, - .. - } - )), - "the right press is released off the surface: {events:?}", + 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:?}", ); } @@ -463,18 +469,60 @@ fn the_up_of_a_sub_threshold_left_press_arrives_outside_the_surface(cx: &mut Tes cx.simulate_mouse_up(beyond, MouseButton::Left, Modifiers::none()); let events = events.borrow(); - assert!( - events.iter().any(|event| matches!( - event, - SurfaceEvent::PointerUp { - button: SurfaceButton::Left, - .. - } - )), - "the sub-threshold left press is released off the surface: {events:?}", + 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>>, @@ -714,6 +762,49 @@ fn a_scroll_is_surface_local_and_physical(cx: &mut TestAppContext) { (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] @@ -731,6 +822,12 @@ fn a_double_click_keeps_the_platforms_click_count(cx: &mut TestAppContext) { 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!( @@ -738,7 +835,14 @@ fn a_double_click_keeps_the_platforms_click_count(cx: &mut TestAppContext) { input.event, SurfaceEvent::PointerDown { click_count: 2, .. } )), - "the platform's double-click count reaches the producer: {seen:?}", + "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:?}", ); }