diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index 0e1e794b8d..689a0e3a04 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1145,6 +1145,234 @@ fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { assert_eq!(value.get(), 10.0); } +/// The rest of a notch that a number field took, which egui spreads over the next frames, scrolls +/// no panel after the pointer leaves the field: neither the field's own panel nor a panel drawn +/// before it. A notch turned after that scrolls the panel under the pointer by one whole notch. +#[test] +fn the_rest_of_a_notch_a_number_field_took_scrolls_no_panel_after_the_pointer_leaves() { + for to_other_panel in [false, true] { + let mut f = Field::new(); + let (left, right, value, field) = (Cell::new(0.0), Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + ui.horizontal_top(|ui| { + let out = egui::ScrollArea::vertical().id_salt("left").max_height(120.0).show(ui, |ui| { + ui.allocate_space(vec2(100.0, 600.0)); + }); + left.set(out.state.clamped_offset().y); + let out = egui::ScrollArea::vertical().id_salt("right").max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + right.set(out.state.clamped_offset().y); + out.inner + }) + .inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = if to_other_panel { Pos2::new(50.0, 60.0) } else { Pos2::new(on.x, field.get().bottom() + 40.0) }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((left.get(), right.get(), value.get()), (0.0, 0.0, 9.0), "pointer moved to the other panel: {to_other_panel}"); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let (scrolled, still) = if to_other_panel { (left.get(), right.get()) } else { (right.get(), left.get()) }; + assert!((scrolled - line).abs() < 0.01 && still == 0.0, "other panel {to_other_panel}: {scrolled} {still}"); + assert_eq!(value.get(), 9.0); + } +} + +/// A wheel touch that starts and never ends (some mouse wheels send no end) stops counting as a +/// touch once no wheel event comes for a while, as egui stops it, so the rest of a notch a number +/// field takes after that still scrolls no panel after the pointer leaves. +#[test] +fn a_wheel_touch_with_no_end_does_not_let_the_rest_of_a_later_notch_scroll_the_panel() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + ui.add_space(600.0); + v + }) + .inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let start = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::Start, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![start], &draw); + for _ in 0..10 { + f.frame(vec![], &draw); + } + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off)], &draw); + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((offset.get(), value.get()), (0.0, 8.0)); +} + +/// A whole wheel touch (start, turn and end) in one pass that a number field takes leaves nothing +/// for egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_whole_touch_in_one_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + for _ in 0..20 { + f.frame(vec![], &draw); + } + let touch = |phase| Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: if phase == egui::TouchPhase::Move { vec2(0.0, -1.0) } else { egui::Vec2::ZERO }, + phase, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + let events = vec![touch(egui::TouchPhase::Start), touch(egui::TouchPhase::Move), touch(egui::TouchPhase::End)]; + assert_eq!(f.frame(events, &draw), Some(8.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 8.0); +} + +/// A turn and the end of the wheel stream in one pass that a number field takes leave nothing for +/// egui to let out later, so the next notch on the panel scrolls it by one whole notch. +#[test] +fn a_turn_ended_in_its_own_pass_on_a_number_field_leaves_the_next_panel_notch_whole() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let on = field.get().center(); + let off = Pos2::new(on.x, field.get().bottom() + 40.0); + let end = Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::Vec2::ZERO, + phase: egui::TouchPhase::End, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down(), end], &draw), Some(9.0)); + f.frame(vec![Event::PointerMoved(off), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + assert!((offset.get() - line).abs() < 0.01, "{} {line}", offset.get()); + assert_eq!(value.get(), 9.0); +} + +/// A small turn the other way on the panel while the rest of a notch a number field took is still +/// being let out scrolls the panel by the whole turn. +#[test] +fn a_turn_the_other_way_while_a_number_field_notch_runs_out_scrolls_the_panel_by_the_whole_turn() { + let mut f = Field::new(); + let (offset, value, field) = (Cell::new(0.0), Cell::new(10.0), Cell::new(Rect::NOTHING)); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(150.0); + let v = ui.vertical(|ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + field.set(ui.min_rect()); + v + }); + ui.add_space(600.0); + v.inner + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![], &draw); + let line = f.ctx.options(|o| o.input_options.line_scroll_speed); + let above = |field: Rect| Pos2::new(field.center().x, field.top() - 30.0); + f.frame(vec![Event::PointerMoved(Pos2::new(field.get().center().x, 10.0)), notch_down(), notch_down()], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + let scrolled = offset.get(); + assert!((scrolled - 2.0 * line).abs() < 0.01, "{scrolled}"); + f.frame(vec![Event::PointerMoved(field.get().center())], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(9.0)); + let up = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, 10.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, + }; + f.frame(vec![Event::PointerMoved(above(field.get())), up], &draw); + for _ in 0..30 { + f.frame(vec![], &draw); + } + assert!((offset.get() - (scrolled - 10.0)).abs() < 0.01, "{} {scrolled}", offset.get()); + assert_eq!(value.get(), 9.0); +} + /// A number field that missed a pass (its panel was hidden) waits for the pointer to move onto it /// again before it takes the wheel, with or without another number field drawn meanwhile. #[test] diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index bf25ce58bd..3eec21b928 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -302,13 +302,29 @@ fn step_value( } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is // left of a notch carries over to the next turn. - let (line, notch_id) = (ui.ctx().options(|o| o.input_options.line_scroll_speed), resp.id.with("wheel")); + let (options, notch_id) = (ui.ctx().options(|o| o.input_options), resp.id.with("wheel")); + let line = options.line_scroll_speed; let mut notches: f32 = if wheel { ui.data(|d| d.get_temp(notch_id)).unwrap_or(0.0) } else { 0.0 }; + let tail_key = wheel_tail_id(ui.ctx().viewport_id()); + let mut tail: WheelTail = if wheel { ui.data(|d| d.get_temp(tail_key)).unwrap_or_default() } else { WheelTail::default() }; + let pass = ui.ctx().cumulative_pass_nr(); + let stored: Option<(u64, Vec<(Event, egui::Vec2)>)> = if wheel { ui.data(|d| d.get_temp(wheel_turns_id(tail_key))) } else { None }; + let mut measured = stored.filter(|(at, _)| *at == pass).map(|(_, turns)| turns); + let mut turns = Vec::new(); + let mut took = egui::Vec2::ZERO; // Each press and each turn of whole notches in the order they came: its modifiers and size from // [`STEPS`], and how many steps up (down when negative). let mut steps: Vec<(egui::Modifiers, f64, f64)> = Vec::new(); let mut taken = taken; ui.input_mut(|i| { + let (page, dt) = (i.viewport_rect().height(), i.stable_dt); + // In a pass [`WheelTailPlugin`] did not run in (it is added in this one), the field + // measures the pass's turns itself. + turns = match measured.take() { + Some(turns) => turns, + None if wheel => wheel_turns(&mut tail, &i.events, &options, page, i.time), + None => Vec::new(), + }; i.events.retain(|e| { // Each taken event is matched once by its value, so wheel events that other code adds // or removes before the field runs do not shift which events the field takes. @@ -318,7 +334,15 @@ fn step_value( let n = match e { Event::Key { key: Key::ArrowUp, pressed: true, .. } if focused => 1.0, Event::Key { key: Key::ArrowDown, pressed: true, .. } if focused => -1.0, - Event::MouseWheel { unit, delta, .. } if mine => { + Event::MouseWheel { unit, delta, phase, .. } if mine => { + if *phase == egui::TouchPhase::Move { + // Each turn of the pass was measured in the order egui read them; a turn + // other code added after that falls back on the touch state. + took += match turns.iter().position(|(t, _)| t == e) { + Some(at) => turns.remove(at).1, + None => unsmoothed_points(&options, page, tail.touch, *unit, *delta, *modifiers), + }; + } // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -340,9 +364,16 @@ fn step_value( // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } + // What egui has not let out of the turns taken here yet stays out of scrolling after the + // pointer leaves the field ([`WheelTailPlugin`]). + tail.owed += took - tail_release(took, dt); }); if wheel { - ui.data_mut(|d| d.insert_temp(notch_id, notches)); + ui.data_mut(|d| { + d.insert_temp(notch_id, notches); + d.insert_temp(tail_key, tail); + d.insert_temp(wheel_turns_id(tail_key), (pass, turns)); + }); } if steps.is_empty() { return None; @@ -442,6 +473,9 @@ fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec, bool) { Some((state.walk, state.before.clone())) }); let walked = fresh.map(|(walk, before)| { + if ctx.with_plugin(|_: &mut WheelTailPlugin| ()).is_none() { + ctx.add_plugin(WheelTailPlugin); + } let (events, now) = ui.input(|i| (i.events.clone(), i.time)); walk_wheel(ctx, walk, &events, &before, now, pass) }); @@ -557,6 +591,137 @@ fn walk_wheel( (walk, taken) } +/// The wheel turns that number fields took in one viewport and egui has not let out yet (points), +/// whether a trackpad touch is running there, and the time and source of the last wheel event. +#[derive(Clone, Copy, Default)] +struct WheelTail { + owed: egui::Vec2, + touch: bool, + last: f64, + trackpad: bool, +} + +impl WheelTail { + /// Ends a touch that egui ends at `time` because no wheel event came for a while (its + /// `WheelState::is_scroll_action_over`): 1 s for a trackpad, 0.15 s for any other source. + fn expire_touch(&mut self, time: f64) { + let timeout = if self.trackpad { 1.0 } else { 0.150 }; + if self.touch && timeout < time - self.last { + self.touch = false; + } + } +} + +fn wheel_tail_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("number-field-wheel-tail").with(viewport) +} + +/// Where the wheel turns of a pass are kept with their [`unsmoothed_points`] and the pass number, +/// from [`wheel_turns`]. +fn wheel_turns_id(tail: egui::Id) -> egui::Id { + tail.with("turns") +} + +/// Reads the wheel events of a pass at `time` in order, as egui does, into `tail`'s touch state +/// (a start begins a touch; an end or cancel ends it and drops what is owed; a touch with no event +/// for a while ends), and returns each turn with its [`unsmoothed_points`] at that point. +fn wheel_turns(tail: &mut WheelTail, events: &[egui::Event], options: &egui::InputOptions, page: f32, time: f64) -> Vec<(egui::Event, egui::Vec2)> { + let mut turns = Vec::new(); + for event in events { + if let egui::Event::MouseWheel { unit, delta, phase, modifiers, source } = event { + tail.expire_touch(time); + tail.last = time; + tail.trackpad = *source == egui::MouseWheelSource::Trackpad; + match phase { + egui::TouchPhase::Start => tail.touch = true, + egui::TouchPhase::End | egui::TouchPhase::Cancel => { + tail.touch = false; + tail.owed = egui::Vec2::ZERO; + // egui drops what the pass's earlier turns left as well. + turns.iter_mut().for_each(|(_, points)| *points = egui::Vec2::ZERO); + } + egui::TouchPhase::Move => turns.push((event.clone(), unsmoothed_points(options, page, tail.touch, *unit, *delta, *modifiers))), + } + } + } + tail.expire_touch(time); + turns +} + +/// The points of wheel turn `delta` in `unit` that egui spreads over the next passes instead of +/// scrolling at once (its `WheelState::on_wheel_event`): a turn sideways with the horizontal +/// scroll modifier, up and down with the vertical one, and nothing for a zoom turn, a touch or a +/// turn under 8 points. `page` is the viewport's height. `modifiers` are the turn's own: egui keeps a +/// touch's modifiers only inside the touch, where nothing is spread out. +fn unsmoothed_points( + options: &egui::InputOptions, + page: f32, + touch: bool, + unit: egui::MouseWheelUnit, + delta: egui::Vec2, + modifiers: egui::Modifiers, +) -> egui::Vec2 { + let mut d = match unit { + egui::MouseWheelUnit::Point => delta, + egui::MouseWheelUnit::Line => options.line_scroll_speed * delta, + egui::MouseWheelUnit::Page => page * delta, + }; + let (sideways, upright) = (modifiers.matches_any(options.horizontal_scroll_modifier), modifiers.matches_any(options.vertical_scroll_modifier)); + if sideways && !upright { + d = egui::vec2(d.x + d.y, 0.0); + } + if upright && !sideways { + d = egui::vec2(0.0, d.x + d.y); + } + let smooth = touch || unit == egui::MouseWheelUnit::Point && d.length() < 8.0; + if smooth || modifiers.matches_any(options.zoom_modifier) { egui::Vec2::ZERO } else { d } +} + +/// What egui lets out in a pass `dt` seconds long of `owed` points of wheel input it has not let +/// out yet (its `WheelState::after_events`): all of an axis under a point, otherwise the share that +/// lets out 90% in 0.1 s. +fn tail_release(owed: egui::Vec2, dt: f32) -> egui::Vec2 { + let t = egui::emath::exponential_smooth_factor(0.90, 0.1, dt.min(0.1)); + let axis = |v: f32| if v.abs() < 1.0 { v } else { t * v }; + egui::vec2(axis(owed.x), axis(owed.y)) +} + +/// Takes out of each pass's scrolling what egui lets out of the wheel turns number fields took +/// ([`WheelTail`]), before any panel scrolls, so the rest of a turn a field took scrolls nothing +/// after the pointer leaves the field. A turn's end or cancel drops what is left. It also measures +/// each wheel turn of the pass with the touch state egui had at that turn, for [`step_value`]. +#[derive(Default)] +struct WheelTailPlugin; + +impl egui::Plugin for WheelTailPlugin { + fn debug_name(&self) -> &'static str { + "number field wheel tail" + } + + fn on_begin_pass(&mut self, ui: &mut Ui) { + let key = wheel_tail_id(ui.ctx().viewport_id()); + let (options, pass) = (ui.ctx().options(|o| o.input_options), ui.ctx().cumulative_pass_nr()); + let mut tail = ui.data(|d| d.get_temp::(key)).unwrap_or_default(); + let mut turns = Vec::new(); + ui.input_mut(|i| { + turns = wheel_turns(&mut tail, &i.events, &options, i.viewport_rect().height(), i.time); + if tail.owed == egui::Vec2::ZERO { + return; + } + let release = tail_release(tail.owed, i.stable_dt); + tail.owed -= release; + // A zoom turn takes what egui lets out, so nothing of it reaches scrolling. + if i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() == 1.0 { + i.smooth_scroll_delta -= release; + } + }); + ui.data_mut(|d| { + d.insert_temp(key, tail); + d.insert_temp(wheel_turns_id(key), (pass, turns)); + }); + } +} + /// A [`num_field`] for a width or height (Transform panel, Properties panel, Control bar): Ctrl/Cmd+Enter /// also commits it and ends its editing, as Enter does, and [`enter_modifiers`] reports Command. A /// percentage typed alone (`50%`) is that share of the size shown, as in Illustrator (#2401).