gpui: Support hitbox blocking mouse interaction except scrolling (#31712)

tl;dr: This adds `.block_mouse_except_scroll()` which should typically
be used instead of `.occlude()` for cases when the mouse shouldn't
interact with elements drawn below an element. The rationale for
treating scroll events differently:

* Mouse move / click / styles / tooltips are for elements the user is
interacting with directly.
* Mouse scroll events are about finding the current outer scroll
container.

Most use of `occlude` should probably be switched to this, but I figured
I'd derisk this change by minimizing behavior changes to just the 3 uses
of `block_mouse_except_scroll`.

GPUI changes:

* Added `InteractiveElement::block_mouse_except_scroll()`, and removes
`stop_mouse_events_except_scroll()`

* Added `Hitbox::should_handle_scroll()` to be used when handling scroll
wheel events.

* `Window::insert_hitbox` now takes `HitboxBehavior` instead of
`occlude: bool`.

    - `false` for that bool is now `HitboxBehavior::Normal`.

    - `true` for that bool is now `HitboxBehavior::BlockMouse`.
    
    - The new mode is `HitboxBehavior::BlockMouseExceptScroll`.

* Removes `Default` impl for `HitboxId` since applications should not
manually create `HitboxId(0)`.

Release Notes:

- N/A
This commit is contained in:
Michael Sloan 2025-05-29 15:41:15 -06:00 committed by GitHub
parent 2abc5893c1
commit 9086784038
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 231 additions and 99 deletions

View file

@ -413,14 +413,42 @@ pub(crate) struct CursorStyleRequest {
pub(crate) style: CursorStyle,
}
/// An identifier for a [Hitbox].
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)]
pub struct HitboxId(usize);
#[derive(Default, Eq, PartialEq)]
pub(crate) struct HitTest {
pub(crate) ids: SmallVec<[HitboxId; 8]>,
pub(crate) hover_hitbox_count: usize,
}
/// An identifier for a [Hitbox] which also includes [HitboxBehavior].
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct HitboxId(u64);
impl HitboxId {
/// Checks if the hitbox with this id is currently hovered.
pub fn is_hovered(&self, window: &Window) -> bool {
window.mouse_hit_test.0.contains(self)
/// Checks if the hitbox with this ID is currently hovered. Except when handling
/// `ScrollWheelEvent`, this is typically what you want when determining whether to handle mouse
/// events or paint hover styles.
///
/// See [`Hitbox::is_hovered`] for details.
pub fn is_hovered(self, window: &Window) -> bool {
let hit_test = &window.mouse_hit_test;
for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) {
if self == *id {
return true;
}
}
return false;
}
/// Checks if the hitbox with this ID contains the mouse and should handle scroll events.
/// Typically this should only be used when handling `ScrollWheelEvent`, and otherwise
/// `is_hovered` should be used. See the documentation of `Hitbox::is_hovered` for details about
/// this distinction.
pub fn should_handle_scroll(self, window: &Window) -> bool {
window.mouse_hit_test.ids.contains(&self)
}
fn next(mut self) -> HitboxId {
HitboxId(self.0.wrapping_add(1))
}
}
@ -435,19 +463,98 @@ pub struct Hitbox {
pub bounds: Bounds<Pixels>,
/// The content mask when the hitbox was inserted.
pub content_mask: ContentMask<Pixels>,
/// Whether the hitbox occludes other hitboxes inserted prior.
pub opaque: bool,
/// Flags that specify hitbox behavior.
pub behavior: HitboxBehavior,
}
impl Hitbox {
/// Checks if the hitbox is currently hovered.
/// Checks if the hitbox is currently hovered. Except when handling `ScrollWheelEvent`, this is
/// typically what you want when determining whether to handle mouse events or paint hover
/// styles.
///
/// This can return `false` even when the hitbox contains the mouse, if a hitbox in front of
/// this sets `HitboxBehavior::BlockMouse` (`InteractiveElement::occlude`) or
/// `HitboxBehavior::BlockMouseExceptScroll` (`InteractiveElement::block_mouse_except_scroll`).
///
/// Handling of `ScrollWheelEvent` should typically use `should_handle_scroll` instead.
/// Concretely, this is due to use-cases like overlays that cause the elements under to be
/// non-interactive while still allowing scrolling. More abstractly, this is because
/// `is_hovered` is about element interactions directly under the mouse - mouse moves, clicks,
/// hover styling, etc. In contrast, scrolling is about finding the current outer scrollable
/// container.
pub fn is_hovered(&self, window: &Window) -> bool {
self.id.is_hovered(window)
}
/// Checks if the hitbox contains the mouse and should handle scroll events. Typically this
/// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be
/// used. See the documentation of `Hitbox::is_hovered` for details about this distinction.
///
/// This can return `false` even when the hitbox contains the mouse, if a hitbox in front of
/// this sets `HitboxBehavior::BlockMouse` (`InteractiveElement::occlude`).
pub fn should_handle_scroll(&self, window: &Window) -> bool {
self.id.should_handle_scroll(window)
}
}
#[derive(Default, Eq, PartialEq)]
pub(crate) struct HitTest(SmallVec<[HitboxId; 8]>);
/// How the hitbox affects mouse behavior.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub enum HitboxBehavior {
/// Normal hitbox mouse behavior, doesn't affect mouse handling for other hitboxes.
#[default]
Normal,
/// All hitboxes behind this hitbox will be ignored and so will have `hitbox.is_hovered() ==
/// false` and `hitbox.should_handle_scroll() == false`. Typically for elements this causes
/// skipping of all mouse events, hover styles, and tooltips. This flag is set by
/// [`InteractiveElement::occlude`].
///
/// For mouse handlers that check those hitboxes, this behaves the same as registering a
/// bubble-phase handler for every mouse event type:
///
/// ```
/// window.on_mouse_event(move |_: &EveryMouseEventTypeHere, phase, window, cx| {
/// if phase == DispatchPhase::Capture && hitbox.is_hovered(window) {
/// cx.stop_propagation();
/// }
/// }
/// ```
///
/// This has effects beyond event handling - any use of hitbox checking, such as hover
/// styles and tooltops. These other behaviors are the main point of this mechanism. An
/// alternative might be to not affect mouse event handling - but this would allow
/// inconsistent UI where clicks and moves interact with elements that are not considered to
/// be hovered.
BlockMouse,
/// All hitboxes behind this hitbox will have `hitbox.is_hovered() == false`, even when
/// `hitbox.should_handle_scroll() == true`. Typically for elements this causes all mouse
/// interaction except scroll events to be ignored - see the documentation of
/// [`Hitbox::is_hovered`] for details. This flag is set by
/// [`InteractiveElement::block_mouse_except_scroll`].
///
/// For mouse handlers that check those hitboxes, this behaves the same as registering a
/// bubble-phase handler for every mouse event type **except** `ScrollWheelEvent`:
///
/// ```
/// window.on_mouse_event(move |_: &EveryMouseEventTypeExceptScroll, phase, window, _cx| {
/// if phase == DispatchPhase::Bubble && hitbox.should_handle_scroll(window) {
/// cx.stop_propagation();
/// }
/// }
/// ```
///
/// See the documentation of [`Hitbox::is_hovered`] for details of why `ScrollWheelEvent` is
/// handled differently than other mouse events. If also blocking these scroll events is
/// desired, then a `cx.stop_propagation()` handler like the one above can be used.
///
/// This has effects beyond event handling - this affects any use of `is_hovered`, such as
/// hover styles and tooltops. These other behaviors are the main point of this mechanism.
/// An alternative might be to not affect mouse event handling - but this would allow
/// inconsistent UI where clicks and moves interact with elements that are not considered to
/// be hovered.
BlockMouseExceptScroll,
}
/// An identifier for a tooltip.
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
@ -578,16 +685,26 @@ impl Frame {
}
pub(crate) fn hit_test(&self, position: Point<Pixels>) -> HitTest {
let mut set_hover_hitbox_count = false;
let mut hit_test = HitTest::default();
for hitbox in self.hitboxes.iter().rev() {
let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds);
if bounds.contains(&position) {
hit_test.0.push(hitbox.id);
if hitbox.opaque {
hit_test.ids.push(hitbox.id);
if !set_hover_hitbox_count
&& hitbox.behavior == HitboxBehavior::BlockMouseExceptScroll
{
hit_test.hover_hitbox_count = hit_test.ids.len();
set_hover_hitbox_count = true;
}
if hitbox.behavior == HitboxBehavior::BlockMouse {
break;
}
}
}
if !set_hover_hitbox_count {
hit_test.hover_hitbox_count = hit_test.ids.len();
}
hit_test
}
@ -638,7 +755,7 @@ pub struct Window {
pub(crate) image_cache_stack: Vec<AnyImageCache>,
pub(crate) rendered_frame: Frame,
pub(crate) next_frame: Frame,
pub(crate) next_hitbox_id: HitboxId,
next_hitbox_id: HitboxId,
pub(crate) next_tooltip_id: TooltipId,
pub(crate) tooltip_bounds: Option<TooltipBounds>,
next_frame_callbacks: Rc<RefCell<Vec<FrameCallback>>>,
@ -927,7 +1044,7 @@ impl Window {
rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())),
next_frame_callbacks,
next_hitbox_id: HitboxId::default(),
next_hitbox_id: HitboxId(0),
next_tooltip_id: TooltipId::default(),
tooltip_bounds: None,
dirty_views: FxHashSet::default(),
@ -2870,17 +2987,17 @@ impl Window {
/// to determine whether the inserted hitbox was the topmost.
///
/// This method should only be called as part of the prepaint phase of element drawing.
pub fn insert_hitbox(&mut self, bounds: Bounds<Pixels>, opaque: bool) -> Hitbox {
pub fn insert_hitbox(&mut self, bounds: Bounds<Pixels>, behavior: HitboxBehavior) -> Hitbox {
self.invalidator.debug_assert_prepaint();
let content_mask = self.content_mask();
let id = self.next_hitbox_id;
self.next_hitbox_id.0 += 1;
let mut id = self.next_hitbox_id;
self.next_hitbox_id = self.next_hitbox_id.next();
let hitbox = Hitbox {
id,
bounds,
content_mask,
opaque,
behavior,
};
self.next_frame.hitboxes.push(hitbox.clone());
hitbox
@ -4042,7 +4159,7 @@ impl Window {
inspector.update(cx, |inspector, _cx| {
if let Some(depth) = inspector.pick_depth.as_mut() {
*depth += delta_y.0 / SCROLL_PIXELS_PER_LAYER;
let max_depth = self.mouse_hit_test.0.len() as f32 - 0.5;
let max_depth = self.mouse_hit_test.ids.len() as f32 - 0.5;
if *depth < 0.0 {
*depth = 0.0;
} else if *depth > max_depth {
@ -4067,9 +4184,9 @@ impl Window {
) -> Option<(HitboxId, crate::InspectorElementId)> {
if let Some(pick_depth) = inspector.pick_depth {
let depth = (pick_depth as i64).try_into().unwrap_or(0);
let max_skipped = self.mouse_hit_test.0.len().saturating_sub(1);
let max_skipped = self.mouse_hit_test.ids.len().saturating_sub(1);
let skip_count = (depth as usize).min(max_skipped);
for hitbox_id in self.mouse_hit_test.0.iter().skip(skip_count) {
for hitbox_id in self.mouse_hit_test.ids.iter().skip(skip_count) {
if let Some(inspector_id) = frame.inspector_hitboxes.get(hitbox_id) {
return Some((*hitbox_id, inspector_id.clone()));
}