From 5c58b4e95dbe0c0fdfc2ff85d88fcb750750f6da Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 09:39:48 +0000 Subject: [PATCH 1/2] feat(gpui): add container_query element Add a CSS-style container query element whose size comes from its style and the space offered by its parent. Once that size is known, the provided closure builds children from the measured size. Defaults to filling the parent; contents cannot influence the container. Export it from the public facade and adapt the Holy Grail grid_layout example. Zed-Origin: 49ad06c1b4047b018b1622e1cf94cafcacd39247 Co-authored-by: freefcw --- crates/gpui-compat/examples/grid_layout.rs | 102 +++++---- crates/gpui/src/elements/container_query.rs | 222 ++++++++++++++++++++ crates/gpui/src/elements/mod.rs | 2 + 3 files changed, 272 insertions(+), 54 deletions(-) create mode 100644 crates/gpui/src/elements/container_query.rs diff --git a/crates/gpui-compat/examples/grid_layout.rs b/crates/gpui-compat/examples/grid_layout.rs index f285497..95bf38d 100644 --- a/crates/gpui-compat/examples/grid_layout.rs +++ b/crates/gpui-compat/examples/grid_layout.rs @@ -1,66 +1,60 @@ use gpui::{ - App, Application, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, div, prelude::*, - px, rgb, size, + App, Application, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, + div, prelude::*, px, rgb, size, }; // https://en.wikipedia.org/wiki/Holy_grail_(web_design) +// +// Resize the window: the layout is chosen by `container_query` based on the +// measured size of the container, collapsing to a single stacked column when +// it becomes too narrow for the three-column grid. struct HolyGrailExample {} impl Render for HolyGrailExample { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let block = |color: Hsla| { - div() - .size_full() - .bg(color) - .border_1() - .border_dashed() - .rounded_md() - .border_color(gpui::white()) - .items_center() - }; + container_query(|container_size, _window, _cx| { + let block = |color: Hsla| { + div() + .size_full() + .bg(color) + .border_1() + .border_dashed() + .rounded_md() + .border_color(gpui::white()) + .items_center() + }; - div() - .gap_1() - .grid() - .bg(rgb(0x505050)) - .size(px(500.0)) - .shadow_lg() - .border_1() - .size_full() - .grid_cols(5) - .grid_rows(5) - .child( - block(gpui::white()) - .row_span(1) - .col_span_full() - .child("Header"), - ) - .child( - block(gpui::red()) - .col_span(1) - .h_56() - .child("Table of contents"), - ) - .child( - block(gpui::green()) - .col_span(3) - .row_span(3) - .child("Content"), - ) - .child( - block(gpui::blue()) - .col_span(1) - .row_span(3) - .child("AD :(") - .text_color(gpui::white()), - ) - .child( - block(gpui::black()) - .row_span(1) - .col_span_full() - .text_color(gpui::white()) - .child("Footer"), - ) + let header = block(gpui::white()).child(format!("Header — {}", container_size.width)); + let table_of_contents = block(gpui::red()).child("Table of contents"); + let content = block(gpui::green()).child("Content"); + let ad = block(gpui::blue()).child("AD :(").text_color(gpui::white()); + let footer = block(gpui::black()) + .text_color(gpui::white()) + .child("Footer"); + + let container = div().gap_1().bg(rgb(0x505050)).shadow_lg().size_full(); + + if container_size.width < px(400.) { + container + .flex() + .flex_col() + .child(header.h_12().flex_none()) + .child(table_of_contents.h_20().flex_none()) + .child(content.flex_1()) + .child(ad.h_20().flex_none()) + .child(footer.h_12().flex_none()) + } else { + container + .grid() + .grid_cols(5) + .grid_rows(5) + .child(header.row_span(1).col_span_full()) + .child(table_of_contents.col_span(1).h_56()) + .child(content.col_span(3).row_span(3)) + .child(ad.col_span(1).row_span(3)) + .child(footer.row_span(1).col_span_full()) + } + }) } } diff --git a/crates/gpui/src/elements/container_query.rs b/crates/gpui/src/elements/container_query.rs new file mode 100644 index 0000000..9b0940c --- /dev/null +++ b/crates/gpui/src/elements/container_query.rs @@ -0,0 +1,222 @@ +//! A container query element, in the spirit of CSS container queries. +//! The element's own size is determined solely by its style and the space +//! offered by its parent. + +use refineable::Refineable as _; + +use crate::{ + AnyElement, App, AvailableSpace, Bounds, Element, ElementId, GlobalElementId, + InspectorElementId, IntoElement, LayoutId, Pixels, Size, Style, StyleRefinement, Styled, + Window, relative, +}; + +/// Construct a container query element with the given render callback. +/// The callback receives the size the element was assigned during layout and +/// returns the contents to display within it. +/// +/// By default the element fills its parent (equivalent to `.size_full()`); +/// use the [`Styled`] methods to size it differently. Because the contents +/// don't exist until after layout, they cannot influence the element's size. +/// +/// # Example +/// +/// ``` +/// # use gpui::{container_query, div, px, IntoElement, ParentElement}; +/// container_query(|size, _window, _cx| { +/// if size.width < px(240.) { +/// div().child("Narrow layout") +/// } else { +/// div().child("Wide layout") +/// } +/// }); +/// ``` +pub fn container_query( + render: impl 'static + FnOnce(Size, &mut Window, &mut App) -> E, +) -> ContainerQuery +where + E: IntoElement, +{ + let mut base_style = StyleRefinement::default(); + base_style.size.width = Some(relative(1.).into()); + base_style.size.height = Some(relative(1.).into()); + + ContainerQuery { + render: Some(Box::new(|size, window, cx| { + render(size, window, cx).into_any_element() + })), + style: base_style, + } +} + +/// A container query element, created with [`container_query`]. +pub struct ContainerQuery { + render: Option, &mut Window, &mut App) -> AnyElement>>, + style: StyleRefinement, +} + +impl Element for ContainerQuery { + type RequestLayoutState = (); + type PrepaintState = Option; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let mut style = Style::default(); + style.refine(&self.style); + let layout_id = window.request_layout(style, [], cx); + (layout_id, ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + let render = self.render.take()?; + let mut child = render(bounds.size, window, cx); + child.layout_as_root(bounds.size.map(AvailableSpace::Definite), window, cx); + child.prepaint_at(bounds.origin, window, cx); + Some(child) + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(child) = prepaint { + child.paint(window, cx); + } + } +} + +impl IntoElement for ContainerQuery { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Styled for ContainerQuery { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +#[cfg(test)] +mod tests { + use crate::{ + Context, IntoElement, ParentElement, Pixels, Render, Size, Styled, TestAppContext, Window, + container_query, div, px, size, + }; + use std::cell::Cell; + use std::rc::Rc; + + struct ContainerQueryView { + last_size: Rc>>>, + } + + impl Render for ContainerQueryView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let last_size = self.last_size.clone(); + container_query(move |container_size, _window, _cx| { + last_size.set(Some(container_size)); + if container_size.width < px(400.) { + div().size_full().child("narrow") + } else { + div().size_full().child("wide") + } + }) + } + } + + #[gpui::test] + fn container_query_builds_children_from_measured_size(cx: &mut TestAppContext) { + let last_size = Rc::new(Cell::new(None)); + let (_, cx) = cx.add_window_view({ + let last_size = last_size.clone(); + move |_, _| ContainerQueryView { last_size } + }); + + cx.simulate_resize(size(px(640.), px(480.))); + let wide = last_size + .get() + .expect("container_query should run after the wide resize"); + assert!( + wide.width >= px(400.), + "wide window should offer a container at least 400px wide, got {}", + wide.width + ); + + cx.simulate_resize(size(px(320.), px(480.))); + let narrow = last_size + .get() + .expect("container_query should run after the narrow resize"); + assert!( + narrow.width < px(400.), + "narrow window should offer a container under 400px wide, got {}", + narrow.width + ); + assert_ne!( + wide, narrow, + "resizing the window should rebuild children from a new measured size" + ); + } + + #[gpui::test] + fn container_query_honors_explicit_size(cx: &mut TestAppContext) { + let last_size = Rc::new(Cell::new(None)); + struct SizedQuery { + last_size: Rc>>>, + } + + impl Render for SizedQuery { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + let last_size = self.last_size.clone(); + container_query(move |container_size, _window, _cx| { + last_size.set(Some(container_size)); + div().size_full() + }) + .w(px(240.)) + .h(px(80.)) + } + } + + let (_, cx) = cx.add_window_view({ + let last_size = last_size.clone(); + move |_, _| SizedQuery { last_size } + }); + cx.simulate_resize(size(px(800.), px(600.))); + assert_eq!( + last_size.get(), + Some(size(px(240.), px(80.))), + "container_query should measure from its own style, not only the parent offer" + ); + } +} diff --git a/crates/gpui/src/elements/mod.rs b/crates/gpui/src/elements/mod.rs index 51d5e38..9a0ab97 100644 --- a/crates/gpui/src/elements/mod.rs +++ b/crates/gpui/src/elements/mod.rs @@ -1,6 +1,7 @@ mod anchored; mod animation; mod canvas; +mod container_query; mod deferred; mod div; mod image_cache; @@ -15,6 +16,7 @@ mod uniform_list; pub use anchored::*; pub use animation::*; pub use canvas::*; +pub use container_query::*; pub use deferred::*; pub use div::*; pub use image_cache::*; From 5dac1407b64be5fbe353e31ec1e92bca91b7e73e Mon Sep 17 00:00:00 2001 From: Jun He Date: Sat, 29 Aug 2026 09:39:48 +0000 Subject: [PATCH 2/2] feat(gpui): lock div scrolling to the start axis Port editor-quality sticky-axis scrolling onto the existing restrict_scroll_to_axis flag. Precise trackpad gestures lock to the axis they start on and only unlock when the opposite axis is strong enough. Line-based wheel remapping and allow_concurrent_scroll keep their previous semantics for callers who already set the style. Zed-Origin: 79cc17c216cf62d5deec7b3eed986d0f652d1c9a Co-authored-by: freefcw --- crates/gpui/src/elements/div.rs | 217 ++++++++++++++++++++++++++++---- crates/gpui/src/interactive.rs | 200 ++++++++++++++++++++++++++++- crates/gpui/src/style.rs | 12 +- 3 files changed, 401 insertions(+), 28 deletions(-) diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index ec14611..a933b39 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -21,9 +21,9 @@ use crate::{ Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, - Overflow, ParentElement, PinchEvent, Pixels, Point, Render, ScrollWheelEvent, SharedString, - Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, - point, px, size, + OngoingScroll, Overflow, ParentElement, PinchEvent, Pixels, Point, Render, ScrollWheelEvent, + SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, Visibility, Window, + WindowControlArea, point, px, size, }; use collections::HashMap; use refineable::Refineable; @@ -1345,6 +1345,14 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Restrict scrolling of this element to the axis of the input gesture. + /// + /// See [`Style::restrict_scroll_to_axis`](crate::Style::restrict_scroll_to_axis) for details. + fn restrict_scroll_to_axis(mut self) -> Self { + self.interactivity().base_style.restrict_scroll_to_axis = Some(true); + self + } + /// Set the space to be reserved for rendering the scrollbar. /// /// This will only affect the layout of the element when overflow for this element is set to @@ -1802,6 +1810,7 @@ pub struct Interactivity { pub(crate) tracked_scroll_handle: Option, pub(crate) scroll_anchor: Option, pub(crate) scroll_offset: Option>>>, + pub(crate) ongoing_scroll: Option>>, pub(crate) group: Option, /// The base style of the element, before any modifications are applied /// by focus, active, etc. @@ -1942,7 +1951,9 @@ impl Interactivity { } if let Some(scroll_handle) = self.tracked_scroll_handle.as_ref() { - self.scroll_offset = Some(scroll_handle.0.borrow().offset.clone()); + let scroll_handle_state = scroll_handle.0.borrow(); + self.scroll_offset = Some(scroll_handle_state.offset.clone()); + self.ongoing_scroll = Some(scroll_handle_state.ongoing_scroll.clone()); } else if (self.base_style.overflow.x == Some(Overflow::Scroll) || self.base_style.overflow.y == Some(Overflow::Scroll)) && let Some(element_state) = element_state.as_mut() @@ -1953,6 +1964,12 @@ impl Interactivity { .get_or_insert_with(Rc::default) .clone(), ); + self.ongoing_scroll = Some( + element_state + .ongoing_scroll + .get_or_insert_with(|| Rc::new(RefCell::new(OngoingScroll::default()))) + .clone(), + ); } let style = self.compute_style_internal(None, element_state.as_mut(), window, cx); @@ -2834,6 +2851,7 @@ impl Interactivity { _cx: &mut App, ) { if let Some(scroll_offset) = self.scroll_offset.clone() { + let ongoing_scroll = self.ongoing_scroll.clone(); let overflow = style.overflow; let allow_concurrent_scroll = style.allow_concurrent_scroll; let restrict_scroll_to_axis = style.restrict_scroll_to_axis; @@ -2844,24 +2862,35 @@ impl Interactivity { if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) { let mut scroll_offset = scroll_offset.borrow_mut(); let old_scroll_offset = *scroll_offset; - let delta = event.delta.pixel_delta(line_height); - - let mut delta_x = Pixels::ZERO; - if overflow.x == Overflow::Scroll { - if !delta.x.is_zero() { - delta_x = delta.x; - } else if !restrict_scroll_to_axis && overflow.y != Overflow::Scroll { - delta_x = delta.y; - } + let mut delta = event.delta.pixel_delta(line_height); + + if restrict_scroll_to_axis + && event.delta.precise() + && let Some(ongoing_scroll) = &ongoing_scroll + { + ongoing_scroll + .borrow_mut() + .filter(&mut delta, event.touch_phase); } - let mut delta_y = Pixels::ZERO; - if overflow.y == Overflow::Scroll { - if !delta.y.is_zero() { - delta_y = delta.y; - } else if !restrict_scroll_to_axis && overflow.x != Overflow::Scroll { - delta_y = delta.x; + + let mut delta_x = match overflow.x { + Overflow::Scroll if !delta.x.is_zero() => delta.x, + Overflow::Scroll + if !restrict_scroll_to_axis && overflow.y != Overflow::Scroll => + { + delta.y } - } + _ => Pixels::ZERO, + }; + let mut delta_y = match overflow.y { + Overflow::Scroll if !delta.y.is_zero() => delta.y, + Overflow::Scroll + if !restrict_scroll_to_axis && overflow.x != Overflow::Scroll => + { + delta.x + } + _ => Pixels::ZERO, + }; if !allow_concurrent_scroll && !delta_x.is_zero() && !delta_y.is_zero() { if delta_x.abs() > delta_y.abs() { delta_y = Pixels::ZERO; @@ -3081,6 +3110,7 @@ pub struct InteractiveElementState { pub(crate) hover_state: Option>>, pub(crate) pending_mouse_down: Option>>>, pub(crate) scroll_offset: Option>>>, + ongoing_scroll: Option>>, pub(crate) active_tooltip: Option>>>, pub(crate) prev_bounds: Option>, } @@ -3596,6 +3626,7 @@ impl ScrollAnchor { #[derive(Default, Debug)] struct ScrollHandleState { offset: Rc>>, + ongoing_scroll: Rc>, bounds: Bounds, max_offset: Size, child_bounds: Vec>, @@ -3810,7 +3841,7 @@ mod tests { use super::*; use crate::{ AnyWindowHandle, AppContext as _, Context, InputEvent as _, MouseButton, Render, - TestAppContext, fallback_prompt_renderer, hsla, point, + ScrollDelta, ScrollWheelEvent, TestAppContext, fallback_prompt_renderer, hsla, point, size, }; use std::{ cell::{Cell, RefCell}, @@ -4132,4 +4163,148 @@ mod tests { assert_eq!(bounds("cell-2").origin.x, px(300.)); assert_eq!(bounds("cell-2").size.width, px(50.)); } + + #[test] + fn restrict_scroll_to_axis_sets_style_flag() { + let mut element = div().id("axis-lock").restrict_scroll_to_axis(); + assert_eq!( + element + .element + .interactivity + .base_style + .restrict_scroll_to_axis, + Some(true) + ); + } + + struct AxisLockedScrollView { + handle: ScrollHandle, + } + + impl Render for AxisLockedScrollView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .id("axis-lock-scroller") + .size_full() + .overflow_scroll() + .restrict_scroll_to_axis() + .track_scroll(&self.handle) + .child(div().size(px(2000.))) + } + } + + #[gpui::test] + fn restrict_scroll_to_axis_locks_precise_gestures_to_start_axis(cx: &mut TestAppContext) { + let handle = ScrollHandle::new(); + let (_, cx) = cx.add_window_view({ + let handle = handle.clone(); + move |_, _| AxisLockedScrollView { handle } + }); + cx.simulate_resize(size(px(200.), px(200.))); + + cx.update(|window, cx| { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(50.), px(50.)), + delta: ScrollDelta::Pixels(point(px(-20.), px(-4.))), + touch_phase: crate::TouchPhase::Started, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }); + cx.run_until_parked(); + let after_start = handle.offset(); + assert!(after_start.x < px(0.), "horizontal start should scroll x"); + assert_eq!( + after_start.y, + px(0.), + "sticky start axis should drop the weaker vertical component" + ); + + cx.update(|window, cx| { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(50.), px(50.)), + delta: ScrollDelta::Pixels(point(px(-3.), px(-2.))), + touch_phase: crate::TouchPhase::Moved, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }); + cx.run_until_parked(); + let after_continue = handle.offset(); + assert!(after_continue.x < after_start.x); + assert_eq!(after_continue.y, px(0.)); + + cx.update(|window, cx| { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(50.), px(50.)), + delta: ScrollDelta::Pixels(point(px(-2.), px(-20.))), + touch_phase: crate::TouchPhase::Moved, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }); + cx.run_until_parked(); + let after_unlock = handle.offset(); + assert!( + after_unlock.y < px(0.), + "a strong perpendicular delta should break the axis lock" + ); + } + + #[gpui::test] + fn restrict_scroll_to_axis_style_flag_still_blocks_axis_remap(cx: &mut TestAppContext) { + let handle = ScrollHandle::new(); + struct VerticalOnly { + handle: ScrollHandle, + } + impl Render for VerticalOnly { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + let mut scroller = div() + .id("vertical-only") + .size_full() + .overflow_y_scroll() + .track_scroll(&self.handle) + .child(div().h(px(2000.)).w_full()); + scroller.style().restrict_scroll_to_axis = Some(true); + scroller + } + } + + let (_, cx) = cx.add_window_view({ + let handle = handle.clone(); + move |_, _| VerticalOnly { handle } + }); + cx.simulate_resize(size(px(200.), px(200.))); + + cx.update(|window, cx| { + window.dispatch_event( + ScrollWheelEvent { + position: point(px(50.), px(50.)), + delta: ScrollDelta::Pixels(point(px(-20.), px(0.))), + ..Default::default() + } + .to_platform_input(), + cx, + ); + }); + cx.run_until_parked(); + assert_eq!( + handle.offset(), + point(px(0.), px(0.)), + "existing restrict_scroll_to_axis callers must not have horizontal input remapped onto a vertical-only scroller" + ); + } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index fa9decb..6771417 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,9 +1,15 @@ use crate::{ - Bounds, Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, - Window, point, seal::Sealed, + Axis, Bounds, Capslock, Context, Empty, IntoElement, IsZero, Keystroke, Modifiers, Pixels, + Point, Render, Window, point, px, seal::Sealed, }; use smallvec::SmallVec; -use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; +use std::{ + any::Any, + fmt::Debug, + ops::Deref, + path::PathBuf, + time::{Duration, Instant}, +}; /// An event from a platform input source. pub trait InputEvent: Sealed + 'static { @@ -495,6 +501,77 @@ impl ScrollDelta { } } +const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); + +/// Tracks the dominant axis across the events in a scroll gesture. +#[derive(Clone, Copy, Debug, Default)] +pub struct OngoingScroll { + last_event: Option, + axis: Option, +} + +impl OngoingScroll { + /// Filters the given delta to the dominant axis of the current scroll gesture. + /// + /// Gestures are delimited by their touch phase when available, with a timeout + /// fallback for platforms that only emit [`TouchPhase::Moved`]. + pub fn filter(&mut self, delta: &mut Point, touch_phase: TouchPhase) { + self.filter_at(delta, touch_phase, Instant::now()) + } + + fn filter_at(&mut self, delta: &mut Point, touch_phase: TouchPhase, now: Instant) { + const UNLOCK_PERCENT: f32 = 1.9; + const UNLOCK_LOWER_BOUND: Pixels = px(6.); + + if matches!(touch_phase, TouchPhase::Ended) { + self.last_event = None; + self.axis = None; + return; + } + + let x = delta.x.abs(); + let y = delta.y.abs(); + if x.is_zero() && y.is_zero() { + if matches!(touch_phase, TouchPhase::Started) { + self.last_event = None; + self.axis = None; + } + return; + } + + let starts_new_gesture = matches!(touch_phase, TouchPhase::Started) + || self + .last_event + .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION); + let mut axis = self.axis; + if starts_new_gesture { + axis = if x <= y { + Some(Axis::Vertical) + } else { + Some(Axis::Horizontal) + }; + } else if x.max(y) >= UNLOCK_LOWER_BOUND { + match axis { + Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => { + axis = None; + } + Some(Axis::Horizontal) if y > x && y >= x * UNLOCK_PERCENT => { + axis = None; + } + _ => {} + } + } + + self.last_event = Some(now); + self.axis = axis; + match axis { + Some(Axis::Vertical) => delta.x = Pixels::ZERO, + Some(Axis::Horizontal) => delta.y = Pixels::ZERO, + None => {} + } + } +} + /// A mouse exit event from the platform, generated when the mouse leaves the window. #[derive(Clone, Debug, Default)] pub struct MouseExitEvent { @@ -731,3 +808,120 @@ mod test { assert!(test_view.read_with(cx, |test_view, _| test_view.saw_action)); } } + +#[cfg(test)] +mod ongoing_scroll_tests { + use super::*; + + #[test] + fn ongoing_scroll_locks_to_dominant_axis() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now); + assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); + assert_eq!(horizontal_delta, point(px(10.), px(0.))); + + let mut continued_delta = point(px(3.), px(2.)); + ongoing_scroll.filter_at( + &mut continued_delta, + TouchPhase::Moved, + now + Duration::from_millis(1), + ); + assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); + assert_eq!(continued_delta, point(px(3.), px(0.))); + } + + #[test] + fn ongoing_scroll_unlocks_when_direction_changes() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now); + + let mut vertical_delta = point(px(2.), px(10.)); + ongoing_scroll.filter_at( + &mut vertical_delta, + TouchPhase::Moved, + now + Duration::from_millis(1), + ); + assert_eq!(ongoing_scroll.axis, None); + assert_eq!(vertical_delta, point(px(2.), px(10.))); + } + + #[test] + fn ongoing_scroll_starts_new_gesture_at_timeout_boundary() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now); + + let mut vertical_delta = point(px(2.), px(10.)); + ongoing_scroll.filter_at( + &mut vertical_delta, + TouchPhase::Moved, + now + SCROLL_EVENT_SEPARATION, + ); + assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical)); + assert_eq!(vertical_delta, point(px(0.), px(10.))); + } + + #[test] + fn ongoing_scroll_ignores_zero_delta_and_resets_when_ended() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now); + + let mut zero_delta = Point::default(); + ongoing_scroll.filter_at( + &mut zero_delta, + TouchPhase::Ended, + now + Duration::from_millis(1), + ); + assert_eq!(ongoing_scroll.axis, None); + + let mut vertical_delta = point(px(2.), px(3.)); + ongoing_scroll.filter_at( + &mut vertical_delta, + TouchPhase::Moved, + now + Duration::from_millis(2), + ); + assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical)); + assert_eq!(vertical_delta, point(px(0.), px(3.))); + } + + #[test] + fn ongoing_scroll_ignores_zero_delta_movement() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Started, now); + + let mut zero_delta = Point::default(); + ongoing_scroll.filter_at( + &mut zero_delta, + TouchPhase::Moved, + now + SCROLL_EVENT_SEPARATION, + ); + + let mut vertical_delta = point(px(2.), px(10.)); + ongoing_scroll.filter_at( + &mut vertical_delta, + TouchPhase::Moved, + now + SCROLL_EVENT_SEPARATION, + ); + assert_eq!(ongoing_scroll.axis, Some(Axis::Vertical)); + assert_eq!(vertical_delta, point(px(0.), px(10.))); + } + + #[test] + fn ongoing_scroll_supports_moved_only_platforms() { + let now = Instant::now(); + let mut ongoing_scroll = OngoingScroll::default(); + let mut horizontal_delta = point(px(10.), px(2.)); + ongoing_scroll.filter_at(&mut horizontal_delta, TouchPhase::Moved, now); + assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); + assert_eq!(horizontal_delta, point(px(10.), px(0.))); + } +} diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index a7b3b11..e16a51e 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -194,11 +194,15 @@ pub struct Style { pub scrollbar_width: AbsoluteLength, /// Whether both x and y axis should be scrollable at the same time. pub allow_concurrent_scroll: bool, - /// Whether scrolling should be restricted to the axis indicated by the mouse wheel. + /// Whether scrolling should be restricted to the input gesture's axis. /// - /// This means that: - /// - The mouse wheel alone will only ever scroll the Y axis. - /// - Holding `Shift` and using the mouse wheel will scroll the X axis. + /// Pixel-based scroll gestures are locked to their initially dominant axis. The lock may be + /// released when the gesture changes direction strongly. Touch phases delimit gestures when + /// available, with a timeout fallback for platforms that only emit moved events. + /// + /// This also prevents input from being remapped to another axis. For example, horizontal input + /// will not scroll a container that only has vertical overflow enabled. Mouse wheel platforms + /// typically report ordinary wheel input on the Y axis and Shift-modified input on the X axis. /// /// ## Motivation ///