Support vertical-only scrolling by holding down Alt (#7124)

* Closes https://github.com/emilk/egui/issues/7120

You can now zoom only the X axis by holding down shift, and zoom only
the Y axis by holding down ALT.

In summary

* `Shift`: horizontal
* `Alt`: vertical
* `Ctrl`: zoom (`Cmd` on Mac)

Thus follows:
* `scroll`: pan both axis (at least for trackpads and mice with two-axis
scroll)
* `Shift + scroll`: pan only horizontal axis
* `Alt + scroll`: pan only vertical axis
* `Ctrl + scroll`: zoom all axes
* `Ctrl + Shift + scroll`: zoom only horizontal axis
* `Ctrl + Alt + scroll`: zoom only vertical axis

This is provided the application uses `zoom_delta_2d` for its zooming
needs.

The modifiers are exposed in `InputOptions`, but it is strongly
recommended that you do not change them.

## Testing
Unfortunately we have no nice way of testing this in egui.
But I've tested it in `egui_plot`.
This commit is contained in:
Emil Ernerfeldt 2025-06-07 19:18:13 +02:00 committed by GitHub
parent 1d5b011793
commit 53098fad7b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 106 additions and 41 deletions

View File

@ -986,6 +986,12 @@ impl std::ops::BitOrAssign for Modifiers {
} }
} }
impl Modifiers {
pub fn ui(&self, ui: &mut crate::Ui) {
ui.label(ModifierNames::NAMES.format(self, ui.ctx().os().is_mac()));
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Names of different modifier keys. /// Names of different modifier keys.

View File

@ -41,6 +41,23 @@ pub struct InputOptions {
/// The new pointer press must come within this many seconds from previous pointer release /// The new pointer press must come within this many seconds from previous pointer release
/// for double click (or when this value is doubled, triple click) to count. /// for double click (or when this value is doubled, triple click) to count.
pub max_double_click_delay: f64, pub max_double_click_delay: f64,
/// When this modifier is down, all scroll events are treated as zoom events.
///
/// The default is CTRL/CMD, and it is STRONGLY recommended to NOT change this.
pub zoom_modifier: Modifiers,
/// When this modifier is down, all scroll events are treated as horizontal scrolls,
/// and when combined with [`Self::zoom_modifier`] it will result in zooming
/// on only the horizontal axis.
///
/// The default is SHIFT, and it is STRONGLY recommended to NOT change this.
pub horizontal_scroll_modifier: Modifiers,
/// When this modifier is down, all scroll events are treated as vertical scrolls,
/// and when combined with [`Self::zoom_modifier`] it will result in zooming
/// on only the vertical axis.
pub vertical_scroll_modifier: Modifiers,
} }
impl Default for InputOptions { impl Default for InputOptions {
@ -59,6 +76,9 @@ impl Default for InputOptions {
max_click_dist: 6.0, max_click_dist: 6.0,
max_click_duration: 0.8, max_click_duration: 0.8,
max_double_click_delay: 0.3, max_double_click_delay: 0.3,
zoom_modifier: Modifiers::COMMAND,
horizontal_scroll_modifier: Modifiers::SHIFT,
vertical_scroll_modifier: Modifiers::ALT,
} }
} }
} }
@ -72,6 +92,9 @@ impl InputOptions {
max_click_dist, max_click_dist,
max_click_duration, max_click_duration,
max_double_click_delay, max_double_click_delay,
zoom_modifier,
horizontal_scroll_modifier,
vertical_scroll_modifier,
} = self; } = self;
crate::Grid::new("InputOptions") crate::Grid::new("InputOptions")
.num_columns(2) .num_columns(2)
@ -100,7 +123,6 @@ impl InputOptions {
); );
ui.end_row(); ui.end_row();
ui.label("Max click duration"); ui.label("Max click duration");
ui.add( ui.add(
crate::DragValue::new(max_click_duration) crate::DragValue::new(max_click_duration)
@ -120,6 +142,19 @@ impl InputOptions {
) )
.on_hover_text("Max time interval for double click to count"); .on_hover_text("Max time interval for double click to count");
ui.end_row(); ui.end_row();
ui.label("zoom_modifier");
zoom_modifier.ui(ui);
ui.end_row();
ui.label("horizontal_scroll_modifier");
horizontal_scroll_modifier.ui(ui);
ui.end_row();
ui.label("vertical_scroll_modifier");
vertical_scroll_modifier.ui(ui);
ui.end_row();
}); });
} }
} }
@ -263,7 +298,7 @@ pub struct InputState {
/// Input state management configuration. /// Input state management configuration.
/// ///
/// This gets copied from `egui::Options` at the start of each frame for convenience. /// This gets copied from `egui::Options` at the start of each frame for convenience.
input_options: InputOptions, options: InputOptions,
} }
impl Default for InputState { impl Default for InputState {
@ -291,7 +326,7 @@ impl Default for InputState {
modifiers: Default::default(), modifiers: Default::default(),
keys_down: Default::default(), keys_down: Default::default(),
events: Default::default(), events: Default::default(),
input_options: Default::default(), options: Default::default(),
} }
} }
} }
@ -303,7 +338,7 @@ impl InputState {
mut new: RawInput, mut new: RawInput,
requested_immediate_repaint_prev_frame: bool, requested_immediate_repaint_prev_frame: bool,
pixels_per_point: f32, pixels_per_point: f32,
input_options: InputOptions, options: InputOptions,
) -> Self { ) -> Self {
profiling::function_scope!(); profiling::function_scope!();
@ -323,7 +358,7 @@ impl InputState {
for touch_state in self.touch_states.values_mut() { for touch_state in self.touch_states.values_mut() {
touch_state.begin_pass(time, &new, self.pointer.interact_pos); touch_state.begin_pass(time, &new, self.pointer.interact_pos);
} }
let pointer = self.pointer.begin_pass(time, &new, input_options); let pointer = self.pointer.begin_pass(time, &new, options);
let mut keys_down = self.keys_down; let mut keys_down = self.keys_down;
let mut zoom_factor_delta = 1.0; // TODO(emilk): smoothing for zoom factor let mut zoom_factor_delta = 1.0; // TODO(emilk): smoothing for zoom factor
@ -356,15 +391,22 @@ impl InputState {
} => { } => {
let mut delta = match unit { let mut delta = match unit {
MouseWheelUnit::Point => *delta, MouseWheelUnit::Point => *delta,
MouseWheelUnit::Line => input_options.line_scroll_speed * *delta, MouseWheelUnit::Line => options.line_scroll_speed * *delta,
MouseWheelUnit::Page => screen_rect.height() * *delta, MouseWheelUnit::Page => screen_rect.height() * *delta,
}; };
if modifiers.shift { let is_horizontal = modifiers.matches_any(options.horizontal_scroll_modifier);
// Treat as horizontal scrolling. let is_vertical = modifiers.matches_any(options.vertical_scroll_modifier);
if is_horizontal && !is_vertical {
// Treat all scrolling as horizontal scrolling.
// Note: one Mac we already get horizontal scroll events when shift is down. // Note: one Mac we already get horizontal scroll events when shift is down.
delta = vec2(delta.x + delta.y, 0.0); delta = vec2(delta.x + delta.y, 0.0);
} }
if !is_horizontal && is_vertical {
// Treat all scrolling as vertical scrolling.
delta = vec2(0.0, delta.x + delta.y);
}
raw_scroll_delta += delta; raw_scroll_delta += delta;
@ -378,14 +420,14 @@ impl InputState {
MouseWheelUnit::Line | MouseWheelUnit::Page => false, MouseWheelUnit::Line | MouseWheelUnit::Page => false,
}; };
let is_zoom = modifiers.ctrl || modifiers.mac_cmd || modifiers.command; let is_zoom = modifiers.matches_any(options.zoom_modifier);
#[expect(clippy::collapsible_else_if)] #[expect(clippy::collapsible_else_if)]
if is_zoom { if is_zoom {
if is_smooth { if is_smooth {
smooth_scroll_delta_for_zoom += delta.y; smooth_scroll_delta_for_zoom += delta.x + delta.y;
} else { } else {
unprocessed_scroll_delta_for_zoom += delta.y; unprocessed_scroll_delta_for_zoom += delta.x + delta.y;
} }
} else { } else {
if is_smooth { if is_smooth {
@ -439,7 +481,7 @@ impl InputState {
} }
zoom_factor_delta *= zoom_factor_delta *=
(input_options.scroll_zoom_speed * smooth_scroll_delta_for_zoom).exp(); (options.scroll_zoom_speed * smooth_scroll_delta_for_zoom).exp();
} }
} }
@ -473,7 +515,7 @@ impl InputState {
keys_down, keys_down,
events: new.events.clone(), // TODO(emilk): remove clone() and use raw.events events: new.events.clone(), // TODO(emilk): remove clone() and use raw.events
raw: new, raw: new,
input_options, options,
} }
} }
@ -488,10 +530,13 @@ impl InputState {
self.screen_rect self.screen_rect
} }
/// Zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture). /// Uniform zoom scale factor this frame (e.g. from ctrl-scroll or pinch gesture).
/// * `zoom = 1`: no change /// * `zoom = 1`: no change
/// * `zoom < 1`: pinch together /// * `zoom < 1`: pinch together
/// * `zoom > 1`: pinch spread /// * `zoom > 1`: pinch spread
///
/// If your application supports non-proportional zooming,
/// then you probably want to use [`Self::zoom_delta_2d`] instead.
#[inline(always)] #[inline(always)]
pub fn zoom_delta(&self) -> f32 { pub fn zoom_delta(&self) -> f32 {
// If a multi touch gesture is detected, it measures the exact and linear proportions of // If a multi touch gesture is detected, it measures the exact and linear proportions of
@ -521,10 +566,29 @@ impl InputState {
// the distances of the finger tips. It is therefore potentially more accurate than // the distances of the finger tips. It is therefore potentially more accurate than
// `zoom_factor_delta` which is based on the `ctrl-scroll` event which, in turn, may be // `zoom_factor_delta` which is based on the `ctrl-scroll` event which, in turn, may be
// synthesized from an original touch gesture. // synthesized from an original touch gesture.
self.multi_touch().map_or_else( if let Some(multi_touch) = self.multi_touch() {
|| Vec2::splat(self.zoom_factor_delta), multi_touch.zoom_delta_2d
|touch| touch.zoom_delta_2d, } else {
) let mut zoom = Vec2::splat(self.zoom_factor_delta);
let is_horizontal = self
.modifiers
.matches_any(self.options.horizontal_scroll_modifier);
let is_vertical = self
.modifiers
.matches_any(self.options.vertical_scroll_modifier);
if is_horizontal && !is_vertical {
// Horizontal-only zooming.
zoom.y = 1.0;
}
if !is_horizontal && is_vertical {
// Vertical-only zooming.
zoom.x = 1.0;
}
zoom
}
} }
/// How long has it been (in seconds) since the use last scrolled? /// How long has it been (in seconds) since the use last scrolled?
@ -550,10 +614,10 @@ impl InputState {
// We need to wake up and check for press-and-hold for the context menu. // We need to wake up and check for press-and-hold for the context menu.
if let Some(press_start_time) = self.pointer.press_start_time { if let Some(press_start_time) = self.pointer.press_start_time {
let press_duration = self.time - press_start_time; let press_duration = self.time - press_start_time;
if self.input_options.max_click_duration.is_finite() if self.options.max_click_duration.is_finite()
&& press_duration < self.input_options.max_click_duration && press_duration < self.options.max_click_duration
{ {
let secs_until_menu = self.input_options.max_click_duration - press_duration; let secs_until_menu = self.options.max_click_duration - press_duration;
return Some(Duration::from_secs_f64(secs_until_menu)); return Some(Duration::from_secs_f64(secs_until_menu));
} }
} }
@ -914,7 +978,7 @@ pub struct PointerState {
/// Input state management configuration. /// Input state management configuration.
/// ///
/// This gets copied from `egui::Options` at the start of each frame for convenience. /// This gets copied from `egui::Options` at the start of each frame for convenience.
input_options: InputOptions, options: InputOptions,
} }
impl Default for PointerState { impl Default for PointerState {
@ -937,23 +1001,18 @@ impl Default for PointerState {
last_last_click_time: f64::NEG_INFINITY, last_last_click_time: f64::NEG_INFINITY,
last_move_time: f64::NEG_INFINITY, last_move_time: f64::NEG_INFINITY,
pointer_events: vec![], pointer_events: vec![],
input_options: Default::default(), options: Default::default(),
} }
} }
} }
impl PointerState { impl PointerState {
#[must_use] #[must_use]
pub(crate) fn begin_pass( pub(crate) fn begin_pass(mut self, time: f64, new: &RawInput, options: InputOptions) -> Self {
mut self,
time: f64,
new: &RawInput,
input_options: InputOptions,
) -> Self {
let was_decidedly_dragging = self.is_decidedly_dragging(); let was_decidedly_dragging = self.is_decidedly_dragging();
self.time = time; self.time = time;
self.input_options = input_options; self.options = options;
self.pointer_events.clear(); self.pointer_events.clear();
@ -974,7 +1033,7 @@ impl PointerState {
if let Some(press_origin) = self.press_origin { if let Some(press_origin) = self.press_origin {
self.has_moved_too_much_for_a_click |= self.has_moved_too_much_for_a_click |=
press_origin.distance(pos) > self.input_options.max_click_dist; press_origin.distance(pos) > self.options.max_click_dist;
} }
self.last_move_time = time; self.last_move_time = time;
@ -1013,10 +1072,10 @@ impl PointerState {
let clicked = self.could_any_button_be_click(); let clicked = self.could_any_button_be_click();
let click = if clicked { let click = if clicked {
let double_click = (time - self.last_click_time) let double_click =
< self.input_options.max_double_click_delay; (time - self.last_click_time) < self.options.max_double_click_delay;
let triple_click = (time - self.last_last_click_time) let triple_click = (time - self.last_last_click_time)
< (self.input_options.max_double_click_delay * 2.0); < (self.options.max_double_click_delay * 2.0);
let count = if triple_click { let count = if triple_click {
3 3
} else if double_click { } else if double_click {
@ -1320,7 +1379,7 @@ impl PointerState {
} }
if let Some(press_start_time) = self.press_start_time { if let Some(press_start_time) = self.press_start_time {
if self.time - press_start_time > self.input_options.max_click_duration { if self.time - press_start_time > self.options.max_click_duration {
return false; return false;
} }
} }
@ -1356,7 +1415,7 @@ impl PointerState {
&& !self.has_moved_too_much_for_a_click && !self.has_moved_too_much_for_a_click
&& self.button_down(PointerButton::Primary) && self.button_down(PointerButton::Primary)
&& self.press_start_time.is_some_and(|press_start_time| { && self.press_start_time.is_some_and(|press_start_time| {
self.time - press_start_time > self.input_options.max_click_duration self.time - press_start_time > self.options.max_click_duration
}) })
} }
@ -1416,7 +1475,7 @@ impl InputState {
modifiers, modifiers,
keys_down, keys_down,
events, events,
input_options: _, options: _,
} = self; } = self;
ui.style_mut() ui.style_mut()
@ -1502,7 +1561,7 @@ impl PointerState {
last_last_click_time, last_last_click_time,
pointer_events, pointer_events,
last_move_time, last_move_time,
input_options: _, options: _,
} = self; } = self;
ui.label(format!("latest_pos: {latest_pos:?}")); ui.label(format!("latest_pos: {latest_pos:?}"));

View File

@ -194,7 +194,7 @@ impl TouchState {
let zoom_delta = state.current.avg_distance / state_previous.avg_distance; let zoom_delta = state.current.avg_distance / state_previous.avg_distance;
let zoom_delta2 = match state.pinch_type { let zoom_delta_2d = match state.pinch_type {
PinchType::Horizontal => Vec2::new( PinchType::Horizontal => Vec2::new(
state.current.avg_abs_distance2.x / state_previous.avg_abs_distance2.x, state.current.avg_abs_distance2.x / state_previous.avg_abs_distance2.x,
1.0, 1.0,
@ -213,7 +213,7 @@ impl TouchState {
start_pos: state.start_pointer_pos, start_pos: state.start_pointer_pos,
num_touches: self.active_touches.len(), num_touches: self.active_touches.len(),
zoom_delta, zoom_delta,
zoom_delta_2d: zoom_delta2, zoom_delta_2d,
rotation_delta: normalized_angle(state.current.heading - state_previous.heading), rotation_delta: normalized_angle(state.current.heading - state_previous.heading),
translation_delta: state.current.avg_pos - state_previous.avg_pos, translation_delta: state.current.avg_pos - state_previous.avg_pos,
force: state.current.avg_force, force: state.current.avg_force,

View File

@ -496,7 +496,7 @@ pub use self::{
epaint::text::TextWrapMode, epaint::text::TextWrapMode,
grid::Grid, grid::Grid,
id::{Id, IdMap}, id::{Id, IdMap},
input_state::{InputState, MultiTouchInfo, PointerState}, input_state::{InputOptions, InputState, MultiTouchInfo, PointerState},
layers::{LayerId, Order}, layers::{LayerId, Order},
layout::*, layout::*,
load::SizeHint, load::SizeHint,