diff --git a/crates/base/src/lib.rs b/crates/base/src/lib.rs index ec0d71d570..2442124716 100644 --- a/crates/base/src/lib.rs +++ b/crates/base/src/lib.rs @@ -170,7 +170,8 @@ pub use resizable::{ pub use root::{Root, RootPlugin}; pub use scroll_bounce::{ScrollBounce, ScrollBounceMotion}; pub use scrollable_mask::{ - HorizontalScrollClaim, RoundedFrameCover, ScrollableMask, last_horizontal_scroll_claim, + HorizontalScrollClaim, RoundedFrameCover, ScrollableMask, horizontal_strip_can_scroll_at, + last_horizontal_scroll_claim, }; pub use scrollbar::{ Scrollbar, ScrollbarAxis, ScrollbarEntrance, ScrollbarHandle, ScrollbarMode, ScrollbarMotion, diff --git a/crates/base/src/root.rs b/crates/base/src/root.rs index 633899d79d..0bc3e96cbb 100644 --- a/crates/base/src/root.rs +++ b/crates/base/src/root.rs @@ -247,6 +247,9 @@ impl Render for Root { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { #[cfg(feature = "test-support")] crate::test_support::begin_frame(window); + // New frame: drop the horizontal strips this window registered last + // frame; the masks painted in this one register again. + crate::scrollable_mask::begin_horizontal_strip_frame(window.window_handle().window_id()); for plugin in &self.plugins { (plugin.prepare)(window, cx); } diff --git a/crates/base/src/scrollable_mask.rs b/crates/base/src/scrollable_mask.rs index e1c4c66358..0c2307f0cb 100644 --- a/crates/base/src/scrollable_mask.rs +++ b/crates/base/src/scrollable_mask.rs @@ -19,6 +19,58 @@ thread_local! { const { Cell::new(None) }; } +thread_local! { + /// Horizontal strips painted in each window's latest frame, with the room + /// each had to scroll then. See [`horizontal_strip_can_scroll_at`]. + static HORIZONTAL_STRIPS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// One horizontal [`ScrollableMask`] as painted in its window's latest frame. +#[derive(Clone, Copy, Debug)] +struct HorizontalStrip { + window: gpui::WindowId, + /// Visible bounds in window coordinates (clipped by the content mask). + bounds: Bounds, + /// The content can still move right (reveal what is on the left). + can_scroll_right: bool, + /// The content can still move left (reveal what is on the right). + can_scroll_left: bool, +} + +/// Forgets the strips `window` painted last frame. [`Root`](crate::Root) calls +/// it at the start of every render, so the list only ever holds the strips of +/// the frame on screen. +pub(crate) fn begin_horizontal_strip_frame(window: gpui::WindowId) { + HORIZONTAL_STRIPS.with(|strips| strips.borrow_mut().retain(|s| s.window != window)); +} + +/// Whether a horizontal strip (a markdown table, …) under `point` in `window` +/// can still scroll its content in `direction`: `> 0` means right (a rightward +/// swipe revealing the left side), `< 0` left, `0` either way. +/// +/// Unlike [`last_horizontal_scroll_claim`], which only tells after the strip +/// moved, this answers before the swipe starts, so a host pan gesture over +/// GPUI content (e.g. a swipe that opens a side drawer) can decide on touch +/// down whether to leave the swipe to the strip. Reflects the frame on screen; +/// read on the thread that dispatches input. +pub fn horizontal_strip_can_scroll_at( + window: gpui::WindowId, + point: Point, + direction: i32, +) -> bool { + HORIZONTAL_STRIPS.with(|strips| { + strips.borrow().iter().any(|s| { + s.window == window + && s.bounds.contains(&point) + && match direction.signum() { + 1 => s.can_scroll_right, + -1 => s.can_scroll_left, + _ => s.can_scroll_right || s.can_scroll_left, + } + }) + }) +} + /// Offsets closer than this count as unchanged, so a sub-pixel residue left at /// an edge does not read as room to scroll. const CLAIM_EPSILON: Pixels = px(0.5); @@ -488,6 +540,28 @@ impl Element for ScrollableMask { }) .unwrap_or_default(); + // Register this strip with its room to scroll, for hosts that decide on + // touch down whether a swipe belongs to it (see + // `horizontal_strip_can_scroll_at`). Bounds are clipped by the + // enclosing content mask, so a strip scrolled out of a list viewport + // does not claim points it no longer covers. + if is_horizontal { + let visible = bounds.intersect(&window.content_mask().bounds); + if !visible.size.width.is_zero() && !visible.size.height.is_zero() { + let axis_max = (self.scroll_handle.content_size().width + - self.scroll_handle.viewport_bounds().size.width) + .max(px(0.)); + let x = self.scroll_handle.offset().x.clamp(-axis_max, px(0.)); + let strip = HorizontalStrip { + window: window.window_handle().window_id(), + bounds: visible, + can_scroll_right: x < -CLAIM_EPSILON, + can_scroll_left: x > -axis_max + CLAIM_EPSILON, + }; + HORIZONTAL_STRIPS.with(|strips| strips.borrow_mut().push(strip)); + } + } + window.with_content_mask(Some(ContentMask { bounds }), |window| { if let Some(color) = self.debug { window.paint_quad(PaintQuad { @@ -1128,6 +1202,45 @@ mod tests { } } + #[gpui::test] + fn horizontal_strip_reports_room_to_scroll_at_a_point(cx: &mut TestAppContext) { + let scroll_handle = ScrollHandle::new(); + let cx = setup_horizontal_area_test(cx, &scroll_handle); + let window = cx.update(|window, _| window.window_handle().window_id()); + // The test view has no `Root`, so start each frame by hand. + let redraw = |cx: &mut VisualTestContext| { + cx.update(|window, cx| { + begin_horizontal_strip_frame(window.window_handle().window_id()); + _ = window.draw(cx); + }); + }; + redraw(cx); + let inside = point(px(10.), px(10.)); + + // At the left edge: room only to the left (the content is wider). + assert!(!horizontal_strip_can_scroll_at(window, inside, 1)); + assert!(horizontal_strip_can_scroll_at(window, inside, -1)); + assert!(horizontal_strip_can_scroll_at(window, inside, 0)); + // Outside the strip nothing claims the point. + assert!(!horizontal_strip_can_scroll_at(window, point(px(150.), px(10.)), 0)); + + // Scrolled to the middle: room both ways. + scroll_handle.set_offset(point(px(-100.), px(0.))); + redraw(cx); + assert!(horizontal_strip_can_scroll_at(window, inside, 1)); + assert!(horizontal_strip_can_scroll_at(window, inside, -1)); + + // At the right edge: room only to the right. + scroll_handle.set_offset(point(px(-200.), px(0.))); + redraw(cx); + assert!(horizontal_strip_can_scroll_at(window, inside, 1)); + assert!(!horizontal_strip_can_scroll_at(window, inside, -1)); + + // A new frame forgets the last one's strips until they paint again. + begin_horizontal_strip_frame(window); + assert!(!horizontal_strip_can_scroll_at(window, inside, 0)); + } + fn setup_horizontal_area_test<'a>( cx: &'a mut TestAppContext, scroll_handle: &ScrollHandle,