1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 14:49:06 -04:00

Add Sense::scroll

This commit is contained in:
lucasmerlin
2026-03-11 18:46:12 +01:00
parent 8b90dc60c6
commit 362cf8f227
8 changed files with 445 additions and 30 deletions

View File

@@ -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.

View File

@@ -683,6 +683,9 @@ struct Prepared {
/// The response from dragging the background (if enabled)
background_drag_response: Option<Response>,
/// The response from the scroll-sensing widget (registered early for correct hit-test ordering)
scroll_response: Option<Response>,
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;
}
}
}

View File

@@ -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 {

View File

@@ -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<WidgetRect>,
/// The topmost widget that senses leftward scroll events under the pointer.
pub scroll_left: Option<WidgetRect>,
/// The topmost widget that senses rightward scroll events under the pointer.
pub scroll_right: Option<WidgetRect>,
/// The topmost widget that senses upward scroll events under the pointer.
pub scroll_up: Option<WidgetRect>,
/// The topmost widget that senses downward scroll events under the pointer.
pub scroll_down: Option<WidgetRect>,
}
/// 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

View File

@@ -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<Id>,
/// 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<Id>,
}
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<Item = &'a Id>) {
@@ -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,
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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();
}
});
}