diff --git a/crates/egui/src/containers/scene.rs b/crates/egui/src/containers/scene.rs index 093452289..b50840ebf 100644 --- a/crates/egui/src/containers/scene.rs +++ b/crates/egui/src/containers/scene.rs @@ -197,7 +197,7 @@ impl Scene { UiBuilder::new() .layer_id(scene_layer_id) .max_rect(Rect::from_min_size(Pos2::ZERO, self.max_inner_size)) - .sense(self.sense), + .sense(self.sense | Sense::scroll()), ); let mut pan_response = local_ui.response(); @@ -245,7 +245,7 @@ impl Scene { { let pointer_in_scene = to_global.inverse() * mouse_pos; let zoom_delta = ui.input(|i| i.zoom_delta()); - let pan_delta = ui.input(|i| i.smooth_scroll_delta()); + let pan_delta = resp.scroll_delta(); // Most of the time we can return early. This is also important to // avoid `ui_from_scene` to change slightly due to floating point errors. diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 2616fb414..9766b9d1f 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -683,6 +683,9 @@ struct Prepared { /// The response from dragging the background (if enabled) background_drag_response: Option, + /// The response from the scroll-sensing widget (registered early for correct hit-test ordering) + scroll_response: Option, + animated: bool, } @@ -870,6 +873,45 @@ impl ScrollArea { None }; + // Register a scroll-sensing widget BEFORE the content so that inner ScrollAreas + // (registered later) are on top in the widget list and win hit-testing. + let scroll_response = { + let scroll_sense = if scroll_source.mouse_wheel && ui.is_enabled() { + let mut sense = Sense::empty(); + for d in 0..2 { + if !direction_enabled[d] || !state.content_is_too_large[d] { + continue; + } + let can_scroll_start = state.offset[d] > 0.0; + let can_scroll_end = !state.scroll_stuck_to_end[d]; + if d == 0 { + // horizontal + if can_scroll_start { + sense |= Sense::SCROLL_LEFT; + } + if can_scroll_end { + sense |= Sense::SCROLL_RIGHT; + } + } else { + // vertical + if can_scroll_start { + sense |= Sense::SCROLL_UP; + } + if can_scroll_end { + sense |= Sense::SCROLL_DOWN; + } + } + } + sense + } else { + Sense::hover() + }; + // Use the interact_rect from the previous frame (like background_drag_response). + state + .interact_rect + .map(|rect| ui.interact(rect, id.with("scroll"), scroll_sense)) + }; + // Scroll with an animation if we have a target offset (that hasn't been cleared by the code // above). for d in 0..2 { @@ -922,6 +964,7 @@ impl ScrollArea { stick_to_end, saved_scroll_target, background_drag_response, + scroll_response, animated, } } @@ -1049,6 +1092,7 @@ impl Prepared { stick_to_end, saved_scroll_target, background_drag_response, + scroll_response, animated, } = self; @@ -1174,39 +1218,61 @@ impl Prepared { && ui.ctx().dragged_id().is_none() || is_dragging_background; - if scroll_source.mouse_wheel && ui.is_enabled() && is_hovering_outer_rect { + // Use the scroll response registered in begin() for correct hit-test ordering. + if let Some(scroll_response) = &scroll_response { let always_scroll_enabled_direction = ui.style().always_scroll_the_only_direction && direction_enabled[0] != direction_enabled[1]; + + // Per-axis: only consume scroll delta if this widget is the scroll target for that axis. + let is_scrolled_axis = [ + scroll_response.scrolled_horizontal(), + scroll_response.scrolled_vertical(), + ]; + for d in 0..2 { - if direction_enabled[d] { - let scroll_delta = ui.input(|input| { + if !direction_enabled[d] { + continue; + } + + // When always_scroll_enabled_direction is set, a single-axis scroll area + // consumes both axes of scroll delta. We need the scroll target for either axis. + let is_target = if always_scroll_enabled_direction { + is_scrolled_axis[0] || is_scrolled_axis[1] + } else { + is_scrolled_axis[d] + }; + + if !is_target { + continue; + } + + let scroll_delta = ui.input(|input| { + if always_scroll_enabled_direction { + // no bidirectional scrolling; allow horizontal scrolling without pressing shift + input.smooth_scroll_delta()[0] + input.smooth_scroll_delta()[1] + } else { + input.smooth_scroll_delta()[d] + } + }); + let scroll_delta = scroll_delta * wheel_scroll_multiplier[d]; + + let scrolling_up = state.offset[d] > 0.0 && scroll_delta > 0.0; + let scrolling_down = state.offset[d] < max_offset[d] && scroll_delta < 0.0; + + if scrolling_up || scrolling_down { + state.offset[d] -= scroll_delta; + + // Clear scroll delta so no parent scroll will use it: + ui.input_mut(|input| { if always_scroll_enabled_direction { - // no bidirectional scrolling; allow horizontal scrolling without pressing shift - input.smooth_scroll_delta()[0] + input.smooth_scroll_delta()[1] + input.smooth_scroll_delta = Vec2::ZERO; } else { - input.smooth_scroll_delta()[d] + input.smooth_scroll_delta[d] = 0.0; } }); - let scroll_delta = scroll_delta * wheel_scroll_multiplier[d]; - let scrolling_up = state.offset[d] > 0.0 && scroll_delta > 0.0; - let scrolling_down = state.offset[d] < max_offset[d] && scroll_delta < 0.0; - - if scrolling_up || scrolling_down { - state.offset[d] -= scroll_delta; - - // Clear scroll delta so no parent scroll will use it: - ui.input_mut(|input| { - if always_scroll_enabled_direction { - input.smooth_scroll_delta = Vec2::ZERO; - } else { - input.smooth_scroll_delta[d] = 0.0; - } - }); - - state.scroll_stuck_to_end[d] = false; - state.offset_target[d] = None; - } + state.scroll_stuck_to_end[d] = false; + state.offset_target[d] = None; } } } diff --git a/crates/egui/src/context.rs b/crates/egui/src/context.rs index 55e3d75e1..5cba12a0e 100644 --- a/crates/egui/src/context.rs +++ b/crates/egui/src/context.rs @@ -1442,6 +1442,14 @@ impl Context { Flags::DRAG_STOPPED, Some(id) == viewport.interact_widgets.drag_stopped, ); + res.flags.set( + Flags::SCROLLED_HORIZONTAL, + Some(id) == viewport.interact_widgets.scrolled_horizontal, + ); + res.flags.set( + Flags::SCROLLED_VERTICAL, + Some(id) == viewport.interact_widgets.scrolled_vertical, + ); } let clicked = Some(id) == viewport.interact_widgets.clicked; @@ -2471,6 +2479,8 @@ impl Context { drag_stopped: _, contains_pointer, hovered, + scrolled_horizontal: _, + scrolled_vertical: _, } = interact_widgets; if true { @@ -2522,6 +2532,10 @@ impl Context { contains_pointer, click, drag, + scroll_left: _, + scroll_right: _, + scroll_up: _, + scroll_down: _, } = hits; if false { diff --git a/crates/egui/src/hit_test.rs b/crates/egui/src/hit_test.rs index 8fe962b36..371c33691 100644 --- a/crates/egui/src/hit_test.rs +++ b/crates/egui/src/hit_test.rs @@ -35,6 +35,18 @@ pub struct WidgetHits { /// /// This is the top one under the pointer, or closest one of the top-most. pub drag: Option, + + /// The topmost widget that senses leftward scroll events under the pointer. + pub scroll_left: Option, + + /// The topmost widget that senses rightward scroll events under the pointer. + pub scroll_right: Option, + + /// The topmost widget that senses upward scroll events under the pointer. + pub scroll_up: Option, + + /// The topmost widget that senses downward scroll events under the pointer. + pub scroll_down: Option, } /// Find the top or closest widgets to the given position, @@ -132,6 +144,10 @@ pub fn hit_test( if !w.enabled { w.sense -= Sense::CLICK; w.sense -= Sense::DRAG; + w.sense -= Sense::SCROLL_LEFT; + w.sense -= Sense::SCROLL_RIGHT; + w.sense -= Sense::SCROLL_UP; + w.sense -= Sense::SCROLL_DOWN; } } @@ -154,6 +170,40 @@ pub fn hit_test( let mut hits = hit_test_on_close(&close, pos); + // Find the topmost widgets that sense scroll per direction. + hits.scroll_left = find_closest_within( + close + .iter() + .copied() + .filter(|w| w.sense.senses_scroll_left()), + pos, + 0.0, + ); + hits.scroll_right = find_closest_within( + close + .iter() + .copied() + .filter(|w| w.sense.senses_scroll_right()), + pos, + 0.0, + ); + hits.scroll_up = find_closest_within( + close + .iter() + .copied() + .filter(|w| w.sense.senses_scroll_up()), + pos, + 0.0, + ); + hits.scroll_down = find_closest_within( + close + .iter() + .copied() + .filter(|w| w.sense.senses_scroll_down()), + pos, + 0.0, + ); + hits.contains_pointer = close .iter() .filter(|widget| widget.interact_rect.contains(pos)) @@ -190,6 +240,34 @@ pub fn hit_test( ); restore_widget_rect(wr); } + if let Some(wr) = &mut hits.scroll_left { + debug_assert!( + wr.sense.senses_scroll_left(), + "We should only return scroll hits if they sense scroll" + ); + restore_widget_rect(wr); + } + if let Some(wr) = &mut hits.scroll_right { + debug_assert!( + wr.sense.senses_scroll_right(), + "We should only return scroll hits if they sense scroll" + ); + restore_widget_rect(wr); + } + if let Some(wr) = &mut hits.scroll_up { + debug_assert!( + wr.sense.senses_scroll_up(), + "We should only return scroll hits if they sense scroll" + ); + restore_widget_rect(wr); + } + if let Some(wr) = &mut hits.scroll_down { + debug_assert!( + wr.sense.senses_scroll_down(), + "We should only return scroll hits if they sense scroll" + ); + restore_widget_rect(wr); + } } hits diff --git a/crates/egui/src/interaction.rs b/crates/egui/src/interaction.rs index bfac38da7..75715a454 100644 --- a/crates/egui/src/interaction.rs +++ b/crates/egui/src/interaction.rs @@ -54,6 +54,18 @@ pub struct InteractionSnapshot { /// This is usually a larger set than [`Self::hovered`], /// and can be used for e.g. drag-and-drop zones. pub contains_pointer: IdSet, + + /// The widget that should receive horizontal scroll events this frame. + /// + /// This is the topmost horizontal-scroll-sensing widget under the pointer, + /// but only set when `smooth_scroll_delta.x` is non-zero. + pub scrolled_horizontal: Option, + + /// The widget that should receive vertical scroll events this frame. + /// + /// This is the topmost vertical-scroll-sensing widget under the pointer, + /// but only set when `smooth_scroll_delta.y` is non-zero. + pub scrolled_vertical: Option, } impl InteractionSnapshot { @@ -66,6 +78,8 @@ impl InteractionSnapshot { drag_stopped, hovered, contains_pointer, + scrolled_horizontal, + scrolled_vertical, } = self; fn id_ui<'a>(ui: &mut crate::Ui, widgets: impl IntoIterator) { @@ -102,6 +116,14 @@ impl InteractionSnapshot { ui.label("contains_pointer"); id_ui(ui, contains_pointer); ui.end_row(); + + ui.label("scrolled_horizontal"); + id_ui(ui, scrolled_horizontal); + ui.end_row(); + + ui.label("scrolled_vertical"); + id_ui(ui, scrolled_vertical); + ui.end_row(); }); } } @@ -287,6 +309,24 @@ pub(crate) fn interact( hovered }; + // Scroll: pick the directional scroll target based on scroll delta sign. + // In egui, positive smooth_scroll_delta.y means content moves down (scrolling toward top), + // and positive smooth_scroll_delta.x means content moves right (scrolling toward left). + let scrolled_horizontal = if input.smooth_scroll_delta.x > 0.0 { + hits.scroll_left.map(|w| w.id) + } else if input.smooth_scroll_delta.x < 0.0 { + hits.scroll_right.map(|w| w.id) + } else { + None + }; + let scrolled_vertical = if input.smooth_scroll_delta.y > 0.0 { + hits.scroll_up.map(|w| w.id) + } else if input.smooth_scroll_delta.y < 0.0 { + hits.scroll_down.map(|w| w.id) + } else { + None + }; + InteractionSnapshot { clicked, long_touched, @@ -295,5 +335,7 @@ pub(crate) fn interact( drag_stopped, hovered, contains_pointer, + scrolled_horizontal, + scrolled_vertical, } } diff --git a/crates/egui/src/response.rs b/crates/egui/src/response.rs index 495d5dcdf..4d59acdc0 100644 --- a/crates/egui/src/response.rs +++ b/crates/egui/src/response.rs @@ -135,8 +135,14 @@ bitflags::bitflags! { /// for instance if an existing slider value was clamped to the given range. const CHANGED = 1<<11; + /// This widget is receiving horizontal scroll events this frame. + const SCROLLED_HORIZONTAL = 1<<12; + + /// This widget is receiving vertical scroll events this frame. + const SCROLLED_VERTICAL = 1<<13; + /// Should this container be closed? - const CLOSE = 1<<12; + const CLOSE = 1<<14; } } @@ -396,6 +402,59 @@ impl Response { self.drag_stopped() && self.ctx.input(|i| i.pointer.button_released(button)) } + /// Is this widget receiving scroll events on any axis this frame? + /// + /// This is determined by hit-testing: the topmost widget under the pointer + /// that senses scroll (via [`Sense::scroll`]) receives the scroll events. + /// Horizontal and vertical scroll are resolved independently, so an inner + /// widget may handle one axis while an outer widget handles the other. + /// + /// See also: [`Self::scroll_delta`]. + #[inline] + pub fn scrolled(&self) -> bool { + self.flags + .intersects(Flags::SCROLLED_HORIZONTAL | Flags::SCROLLED_VERTICAL) + } + + /// Is this widget receiving horizontal scroll events this frame? + #[inline] + pub fn scrolled_horizontal(&self) -> bool { + self.flags.contains(Flags::SCROLLED_HORIZONTAL) + } + + /// Is this widget receiving vertical scroll events this frame? + #[inline] + pub fn scrolled_vertical(&self) -> bool { + self.flags.contains(Flags::SCROLLED_VERTICAL) + } + + /// The smooth scroll delta for this widget, filtered to the axes it is receiving. + /// + /// Returns [`Vec2::ZERO`] if this widget is not being scrolled. + /// Each axis is independently zero if this widget is not the scroll target for that axis. + /// Use [`Sense::scroll`] to opt in to receiving scroll events. + /// + /// See also: [`Self::scrolled`]. + #[inline] + pub fn scroll_delta(&self) -> Vec2 { + if !self.scrolled() { + return Vec2::ZERO; + } + let delta = self.ctx.input(|i| i.smooth_scroll_delta); + Vec2::new( + if self.scrolled_horizontal() { + delta.x + } else { + 0.0 + }, + if self.scrolled_vertical() { + delta.y + } else { + 0.0 + }, + ) + } + /// If dragged, how many points were we dragged in since last frame? #[inline] pub fn drag_delta(&self) -> Vec2 { diff --git a/crates/egui/src/sense.rs b/crates/egui/src/sense.rs index c3b3af7f2..e20b9aa23 100644 --- a/crates/egui/src/sense.rs +++ b/crates/egui/src/sense.rs @@ -19,6 +19,18 @@ bitflags::bitflags! { /// Anything interactive + labels that can be focused /// for the benefit of screen readers. const FOCUSABLE = 1<<2; + + /// This widget wants to receive leftward scroll events. + const SCROLL_LEFT = 1<<3; + + /// This widget wants to receive rightward scroll events. + const SCROLL_RIGHT = 1<<4; + + /// This widget wants to receive upward scroll events. + const SCROLL_UP = 1<<5; + + /// This widget wants to receive downward scroll events. + const SCROLL_DOWN = 1<<6; } } @@ -34,6 +46,18 @@ impl std::fmt::Debug for Sense { if self.is_focusable() { write!(f, " focusable")?; } + if self.senses_scroll_left() { + write!(f, " scroll_left")?; + } + if self.senses_scroll_right() { + write!(f, " scroll_right")?; + } + if self.senses_scroll_up() { + write!(f, " scroll_up")?; + } + if self.senses_scroll_down() { + write!(f, " scroll_down")?; + } write!(f, " }}") } } @@ -82,6 +106,48 @@ impl Sense { Self::CLICK | Self::FOCUSABLE | Self::DRAG } + /// Sense scroll events in all four directions. + #[inline] + pub fn scroll() -> Self { + Self::SCROLL_LEFT | Self::SCROLL_RIGHT | Self::SCROLL_UP | Self::SCROLL_DOWN + } + + /// Sense only horizontal scroll events (left and right). + #[inline] + pub fn scroll_horizontal() -> Self { + Self::SCROLL_LEFT | Self::SCROLL_RIGHT + } + + /// Sense only vertical scroll events (up and down). + #[inline] + pub fn scroll_vertical() -> Self { + Self::SCROLL_UP | Self::SCROLL_DOWN + } + + /// Sense only leftward scroll events. + #[inline] + pub fn scroll_left() -> Self { + Self::SCROLL_LEFT + } + + /// Sense only rightward scroll events. + #[inline] + pub fn scroll_right() -> Self { + Self::SCROLL_RIGHT + } + + /// Sense only upward scroll events. + #[inline] + pub fn scroll_up() -> Self { + Self::SCROLL_UP + } + + /// Sense only downward scroll events. + #[inline] + pub fn scroll_down() -> Self { + Self::SCROLL_DOWN + } + /// Returns true if we sense either clicks or drags. #[inline] pub fn interactive(&self) -> bool { @@ -102,4 +168,46 @@ impl Sense { pub fn is_focusable(&self) -> bool { self.contains(Self::FOCUSABLE) } + + /// Does this sense any scroll events? + #[inline] + pub fn senses_scroll(&self) -> bool { + self.intersects(Self::SCROLL_LEFT | Self::SCROLL_RIGHT | Self::SCROLL_UP | Self::SCROLL_DOWN) + } + + /// Does this sense horizontal scroll events (left or right)? + #[inline] + pub fn senses_scroll_horizontal(&self) -> bool { + self.intersects(Self::SCROLL_LEFT | Self::SCROLL_RIGHT) + } + + /// Does this sense vertical scroll events (up or down)? + #[inline] + pub fn senses_scroll_vertical(&self) -> bool { + self.intersects(Self::SCROLL_UP | Self::SCROLL_DOWN) + } + + /// Does this sense leftward scroll events? + #[inline] + pub fn senses_scroll_left(&self) -> bool { + self.contains(Self::SCROLL_LEFT) + } + + /// Does this sense rightward scroll events? + #[inline] + pub fn senses_scroll_right(&self) -> bool { + self.contains(Self::SCROLL_RIGHT) + } + + /// Does this sense upward scroll events? + #[inline] + pub fn senses_scroll_up(&self) -> bool { + self.contains(Self::SCROLL_UP) + } + + /// Does this sense downward scroll events? + #[inline] + pub fn senses_scroll_down(&self) -> bool { + self.contains(Self::SCROLL_DOWN) + } } diff --git a/crates/egui_demo_lib/src/demo/scrolling.rs b/crates/egui_demo_lib/src/demo/scrolling.rs index cee525aaa..eebd97a56 100644 --- a/crates/egui_demo_lib/src/demo/scrolling.rs +++ b/crates/egui_demo_lib/src/demo/scrolling.rs @@ -1,6 +1,6 @@ use egui::{ - Align, Align2, Color32, DragValue, NumExt as _, Rect, ScrollArea, Sense, Slider, TextStyle, - TextWrapMode, Ui, Vec2, Widget as _, pos2, scroll_area::ScrollBarVisibility, + pos2, scroll_area::ScrollBarVisibility, Align, Align2, Color32, DragValue, NumExt as _, Rect, ScrollArea, Sense, + Slider, TextStyle, TextWrapMode, Ui, Vec2, Widget as _, }; #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -13,6 +13,7 @@ enum ScrollDemo { LargeCanvas, StickToEnd, Bidirectional, + Nested, } #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -61,6 +62,7 @@ impl crate::View for Scrolling { ); ui.selectable_value(&mut self.demo, ScrollDemo::StickToEnd, "Stick to end"); ui.selectable_value(&mut self.demo, ScrollDemo::Bidirectional, "Bidirectional"); + ui.selectable_value(&mut self.demo, ScrollDemo::Nested, "Nested"); }); ui.separator(); match self.demo { @@ -87,6 +89,9 @@ impl crate::View for Scrolling { } }); } + ScrollDemo::Nested => { + nested_scroll_demo(ui); + } } } } @@ -392,3 +397,46 @@ impl crate::View for ScrollStickTo { ui.request_repaint(); } } + +fn nested_scroll_demo(ui: &mut Ui) { + ui.label( + "Nested scroll areas: only the inner-most scroll area under the pointer receives scroll events.", + ); + ui.add_space(4.0); + + let outer_row_height = 100.0; + let inner_row_height = 20.0; + + ScrollArea::vertical() + .id_salt("outer") + .auto_shrink(false) + .show_rows(ui, outer_row_height, 100, |ui, range| { + ui.style_mut().interaction.selectable_labels = false; + for outer_row in range { + egui::Frame::group(ui.style()).show(ui, |ui| { + ScrollArea::horizontal() + .id_salt(format!("outer_row_{outer_row}")) + .show(ui, |ui| { + ui.horizontal(|ui| { + for col in 0..20 { + ui.vertical(|ui| { + ui.set_width(100.0); + ui.label(format!("Column {}", col + 1)); + ScrollArea::vertical() + .id_salt(format!("inner_vert_{col}_{outer_row}")) + .max_height(outer_row_height) + .show_rows(ui, inner_row_height, 100, |ui, range| { + for inner_row in range { + ui.label(format!("Col {} Row {}", col + 1, inner_row + 1)); + } + }); + }); + } + }); + }); + }); + + ui.separator(); + } + }); +}