diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 19d103a74..001404765 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -26,6 +26,7 @@ use vectorcraft_text::FontClass; use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets; +use crate::widgets::{ArmedWheelField, WheelStream, WheelStreamOwner}; /// What happened in the menu this frame. #[derive(Clone, Debug, PartialEq)] @@ -443,54 +444,6 @@ struct WheelField { layer: egui::LayerId, } -/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. -pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; - -/// The field the pointer was last moved onto, while that field stays where it was and uncovered. -#[derive(Clone, Copy)] -struct ArmedWheelField { - id: egui::Id, - rect: egui::Rect, - pass: u64, -} - -#[derive(Clone, Copy, PartialEq)] -enum WheelStreamOwner { - Field(egui::Id), - Elsewhere, -} - -/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. -#[derive(Clone, Copy, Default)] -struct WheelStream { - last: Option, - owner: Option, -} - -impl WheelStream { - /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. - fn note_input(&mut self, now: f64) { - if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { - self.owner = None; - } - self.last = Some(now); - } - - /// Leave the rest of the stream to the panel when `field` holds it. - fn release(&mut self, field: egui::Id) { - if self.owner == Some(WheelStreamOwner::Field(field)) { - self.owner = Some(WheelStreamOwner::Elsewhere); - } - } - - /// Leave the rest of the stream to the panel when any field holds it. - fn release_any(&mut self) { - if let Some(WheelStreamOwner::Field(field)) = self.owner { - self.release(field); - } - } -} - #[derive(Clone)] struct QueuedWheel { #[cfg(not(target_arch = "wasm32"))] @@ -944,6 +897,7 @@ pub(crate) fn take_raw_wheel(ctx: &egui::Context, raw: &mut egui::RawInput) { fields.pointer = pointer; ctx.data_mut(|d| d.insert_temp(key, fields)); if captured { + ctx.data_mut(|d| d.insert_temp(widgets::taken_from_raw_id(raw.viewport_id), now)); ctx.request_repaint(); } } diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 87c3feb06..a05479437 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -407,6 +407,45 @@ fn stream_context(top: f32) -> (egui::Context, egui::Pos2) { (ctx, point) } +/// A wheel stream the family field took stays its own when the pointer moves onto a number field +/// beside it: the number field steps only after the wheel pauses for the stream gap. +#[test] +fn a_stream_the_family_field_took_leaves_a_number_field_beside_it() { + let ctx = egui::Context::default(); + let value = std::cell::Cell::new(10.0); + let (family, number) = (std::cell::Cell::new(egui::Pos2::ZERO), std::cell::Cell::new(egui::Pos2::ZERO)); + let frame = |time: f64, events: Vec| { + let mut raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + super::take_raw_wheel(&ctx, &mut raw); + let mut notches = 0; + let mut out = ctx.run_ui(raw, |ui| { + let resp = ui.put(egui::Rect::from_min_size(egui::pos2(10.0, 40.0), egui::vec2(120.0, 24.0)), egui::Button::new("Family")); + family.set(resp.rect.center()); + for turn in super::remember_wheel_field(ui, &resp, true) { + if let super::WheelInput::Notches(n) = turn { + notches += n; + } + } + ui.add_space(40.0); + let top = ui.cursor().top(); + if let Some(v) = crate::widgets::plain_field(ui, "size", value.get(), "", 0, 80.0) { + value.set(v); + } + number.set(egui::pos2(ui.min_rect().left() + 20.0, (top + ui.min_rect().bottom()) / 2.0)); + }); + out.textures_delta.clear(); + notches + }; + for i in 0..3 { + frame(100.0 + f64::from(i), vec![]); + } + assert_eq!(frame(110.0, vec![egui::Event::PointerMoved(family.get()), plain_turn()]), 1, "the family field took the turn"); + frame(110.1, vec![egui::Event::PointerMoved(number.get()), plain_turn()]); + assert_eq!(value.get(), 10.0, "the stream began on the family field"); + frame(110.1 + crate::widgets::WHEEL_STREAM_GAP + 0.1, vec![plain_turn()]); + assert_eq!(value.get(), 9.0, "a new stream"); +} + /// A field that moved under a pointer that stayed put leaves the wheel, including a reported /// pointer position that did not change, until the pointer really moves. #[test] @@ -516,7 +555,7 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { let (ctx, point) = stream_context(40.0); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (0, 1)); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + super::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + crate::widgets::WHEEL_STREAM_GAP / 2.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the same stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 200.0, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "a new stream, with the pointer still on the field"); @@ -528,14 +567,14 @@ fn a_pointer_resting_on_its_field_keeps_the_wheel_across_streams() { fn a_wheel_stream_that_began_elsewhere_stays_there_until_a_gap() { let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; let (left, _, _) = stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), plain_turn()]); assert_eq!(left, 1, "the stream began off the field"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + step, vec![egui::Event::PointerMoved(point), plain_turn()]); assert_eq!((left, notches), (1, 0), "a nudge onto the field does not take a running stream"); let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step, vec![plain_turn()]); assert_eq!((left, notches), (1, 0), "each event renews the stream"); - let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + super::WHEEL_STREAM_GAP, vec![plain_turn()]); + let (left, notches, _) = stream_frame(&ctx, Some(40.0), 110.0 + 2.0 * step + crate::widgets::WHEEL_STREAM_GAP, vec![plain_turn()]); assert_eq!((left, notches), (0, 1), "the stream ended, and the pointer was moved onto the field"); } @@ -585,7 +624,7 @@ fn trackpad_momentum_stays_with_its_stream() { use egui::{MouseWheelSource::Momentum, MouseWheelSource::Trackpad, TouchPhase}; let (ctx, point) = stream_context(40.0); let away = point + egui::vec2(0.0, 200.0); - let step = super::WHEEL_STREAM_GAP * 0.8; + let step = crate::widgets::WHEEL_STREAM_GAP * 0.8; stream_frame(&ctx, Some(40.0), 110.0, vec![egui::Event::PointerMoved(away), stream_turn(TouchPhase::Move, Trackpad, -1.0)]); // The fingers lift with no delta, the pointer moves onto the field, and momentum follows. stream_frame(&ctx, Some(40.0), 110.0 + step, vec![stream_turn(TouchPhase::End, Trackpad, 0.0)]); diff --git a/crates/ui-egui/src/panel_docking/native_tests.rs b/crates/ui-egui/src/panel_docking/native_tests.rs index de0bc1db7..db1e6be36 100644 --- a/crates/ui-egui/src/panel_docking/native_tests.rs +++ b/crates/ui-egui/src/panel_docking/native_tests.rs @@ -1054,7 +1054,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_overlay = None; h.frame(vec![]); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(1.0, 0.0)), Event::MouseWheel { @@ -1088,7 +1088,7 @@ fn wheel_over_a_floating_character_font_field_changes_the_font() { h.host.borrow_mut().child_transparent_overlay = None; h.host.borrow_mut().child_pass_through_overlay = Some((viewport, field.expand(10.0))); h.frames(2); - h.host.borrow_mut().time += crate::font_menu::WHEEL_STREAM_GAP; + h.host.borrow_mut().time += crate::widgets::WHEEL_STREAM_GAP; h.host.borrow_mut().windows.get_mut(&viewport).unwrap().events.extend([ Event::PointerMoved(pointer + vec2(2.0, 0.0)), Event::MouseWheel { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 431f2f982..ea79c1d5c 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -410,7 +410,7 @@ fn wheel_events_use_the_pointer_position_when_they_occurred() { assert_eq!(first_style(&app, id).font_family, "Inter", "the wheel was outside the field"); assert_eq!(app.session.active().unwrap().history.undo.len(), before); assert!(ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { .. }))), "outside wheel input is left for its owner"); - let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::font_menu::WHEEL_STREAM_GAP); + let after_gap = |ctx: &egui::Context| Some(ctx.input(|i| i.time) + crate::widgets::WHEEL_STREAM_GAP); let later = after_gap(&ctx); panel_frame_at(&ctx, &mut app, later, vec![wheel(line, -1.0, Default::default()), egui::Event::PointerMoved(outside)], "Inter", &draw); assert_ne!(first_style(&app, id).font_family, "Inter", "the wheel was over the field before the pointer left"); diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d83938555..45801c22e 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -1007,6 +1007,239 @@ fn the_wheel_scrolls_the_panel_unless_over_a_numeric_field() { } } +/// A wheel notch of `dy` lines (up positive) from `source`, with no pointer movement. +fn notch(dy: f32, source: egui::MouseWheelSource) -> Event { + Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta: vec2(0.0, dy), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, source } +} + +/// A notch down from a mouse wheel. +fn notch_down() -> Event { + notch(-1.0, egui::MouseWheelSource::Wheel) +} + +/// A panel scrolled with a still pointer keeps scrolling when a number field slides under the +/// pointer, and the field keeps its value. The field used to take each notch that came while it +/// was under the pointer. +#[test] +fn scrolling_a_panel_past_a_number_field_under_a_still_pointer_keeps_the_value() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + ui.add_space(60.0); + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + f.frame(vec![Event::PointerMoved(Pos2::new(20.0, 20.0))], &draw); + let mut stepped = None; + for _ in 0..30 { + stepped = stepped.or(f.frame(vec![notch_down()], &draw)); + } + for _ in 0..20 { + f.frame(vec![], &draw); + } + assert_eq!((stepped, value.get()), (None, 10.0), "the field kept its value"); + assert!(offset.get() > 200.0, "the panel scrolled the field past the pointer: {}", offset.get()); +} + +/// A number field that slid under a still pointer takes the wheel only after the pointer is moved +/// onto it; an event at the same position is not a move. Until then the panel scrolls. +#[test] +fn a_number_field_that_slid_under_a_still_pointer_steps_after_the_pointer_moves() { + let mut f = Field::new(); + let top = Cell::new(60.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + ui.add_space(top.get()); + widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)) + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 20.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + top.set(0.0); + f.frame(vec![], &draw); + assert!(f.rect.get().contains(at), "the field is under the pointer"); + for _ in 0..2 { + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the pointer did not move"); + } + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), None, "the same position"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + let moved = Pos2::new(21.0, 20.0); + assert_eq!(f.frame(vec![Event::PointerMoved(moved), notch_down()], &draw), Some(9.0)); + // The field moves again under the pointer, in the frame of a turn: the turn is not the field's, + // and the field waits for another move. + top.set(2.0); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved in the frame of the turn"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the field moved"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at), notch_down()], &draw), Some(8.0)); +} + +/// A wheel stream stays with what it began on until it pauses for the stream gap: one that began +/// off the field never steps it, one that left the field (the pointer moved off it or out of the +/// window) does not come back to it, and trackpad momentum never starts one on the field. +#[test] +fn a_number_field_wheel_stream_stays_with_what_it_began_on() { + use egui::MouseWheelSource::{Momentum, Trackpad}; + let mut f = Field::new(); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + f.frame(vec![], &draw); + let (on, off) = (f.rect.get().center(), Pos2::new(300.0, 150.0)); + let gap = |f: &mut Field| f.time += crate::widgets::WHEEL_STREAM_GAP; + // Began on the field, left it, came back within the stream. + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), Some(9.0)); + assert_eq!(f.frame(vec![Event::PointerMoved(off), notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left its field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(8.0), "a new stream"); + // Began off the field. + f.frame(vec![Event::PointerMoved(off)], &draw); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "began off the field"); + gap(&mut f); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(7.0)); + // The pointer left the window. + assert_eq!(f.frame(vec![Event::PointerGone], &draw), None); + assert_eq!(f.frame(vec![Event::PointerMoved(on), notch_down()], &draw), None, "left the window"); + // Momentum after a pause starts no stream on the field, and the stream it starts stays off it. + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Momentum)], &draw), None, "momentum"); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), None, "the momentum's stream"); + gap(&mut f); + assert_eq!(f.frame(vec![notch(-1.0, Trackpad)], &draw), Some(6.0)); + assert_eq!(value.get(), 6.0); +} + +/// A frame whose turns begin on the field and go on after the pointer moved off it scrolls the +/// panel with all of them and leaves the value. +#[test] +fn a_frame_with_turns_on_and_off_a_number_field_scrolls_the_panel() { + let mut f = Field::new(); + let offset = Cell::new(0.0); + let value = Cell::new(10.0); + let draw = |ui: &mut egui::Ui| { + let out = egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + ui.add_space(600.0); + v + }); + offset.set(out.state.clamped_offset().y); + out.inner + }; + f.frame(vec![], &draw); + let (on, off) = (Pos2::new(20.0, 12.0), Pos2::new(20.0, 80.0)); + let events = vec![Event::PointerMoved(on), notch_down(), Event::PointerMoved(off), notch_down()]; + assert_eq!(f.frame(events, &draw), None); + assert!(offset.get() > 0.0, "the panel scrolled in the same frame"); + assert_eq!(value.get(), 10.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] +fn a_number_field_that_missed_a_pass_waits_for_a_pointer_move() { + for other in [false, true] { + let mut f = Field::new(); + let shown = Cell::new(true); + let draw = |ui: &mut egui::Ui| { + let v = if shown.get() { widgets::plain_field(ui, "f", 10.0, "", 0, 80.0) } else { None }; + if other { + ui.add_space(100.0); + widgets::plain_field(ui, "other", 1.0, "", 0, 80.0); + } + v + }; + f.frame(vec![], &draw); + let at = Pos2::new(20.0, 12.0); + f.frame(vec![Event::PointerMoved(at)], &draw); + shown.set(false); + f.frame(vec![], &draw); + shown.set(true); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "missed a pass, other field {other}"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0), "other field {other}"); + } +} + +/// Two number fields, `a` above `b`; the frame returns `b`'s new value, and `a`'s goes in `a`. +fn two_fields<'a>(a: &'a Cell, b: &'a Cell, rects: &'a Cell<[Rect; 2]>) -> impl Fn(&mut egui::Ui) -> Option + 'a { + move |ui: &mut egui::Ui| { + if let Some(v) = widgets::plain_field(ui, "a", a.get(), "", 0, 80.0) { + a.set(v); + } + let first = ui.min_rect(); + ui.add_space(40.0); + let top = ui.cursor().top(); + let v = widgets::plain_field(ui, "b", b.get(), "", 0, 80.0).inspect(|&v| b.set(v)); + rects.set([first, Rect::from_x_y_ranges(first.x_range(), top..=ui.min_rect().bottom())]); + v + } +} + +/// A stream that began on one number field scrolls the panel when the pointer moves onto another +/// field, until the wheel pauses for the stream gap or the pointer is pressed on the other field. +#[test] +fn a_press_on_a_number_field_gives_it_the_running_wheel_stream() { + let mut f = Field::new(); + let (a, b, rects) = (Cell::new(10.0), Cell::new(20.0), Cell::new([Rect::NOTHING; 2])); + let draw = two_fields(&a, &b, &rects); + f.frame(vec![], &draw); + let [on_a, on_b] = rects.get().map(|r| r.center()); + f.frame(vec![Event::PointerMoved(on_a), notch_down()], &draw); + assert_eq!(a.get(), 9.0, "the stream began on a"); + assert_eq!(f.frame(vec![Event::PointerMoved(on_b), notch_down()], &draw), None, "moved onto b within the stream"); + let press = |pressed| Event::PointerButton { pos: on_b, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(19.0), "pressed on b"); + assert_eq!(a.get(), 9.0); + // A press on the field that holds the stream keeps it there. + f.frame(vec![press(true), press(false)], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), Some(18.0)); +} + +/// A window or overlay that comes and goes over a still pointer, with no input while it is up, +/// leaves the number field under it waiting for the pointer to move onto it again. +#[test] +fn a_number_field_covered_by_an_overlay_waits_for_a_pointer_move() { + let mut f = Field::new(); + let value = Cell::new(10.0); + let covered = Cell::new(false); + let draw = |ui: &mut egui::Ui| { + let v = widgets::plain_field(ui, "f", value.get(), "", 0, 80.0).inspect(|&v| value.set(v)); + if covered.get() { + let cover = ui.min_rect().expand(5.0); + egui::Area::new(egui::Id::unique("numfields overlay")).order(egui::Order::Foreground).fixed_pos(cover.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(cover.size(), egui::Sense::click()); + }); + } + v + }; + f.frame(vec![], &draw); + let at = f.rect.get().center(); + f.frame(vec![Event::PointerMoved(at)], &draw); + covered.set(true); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + covered.set(false); + f.frame(vec![], &draw); + f.frame(vec![], &draw); + assert_eq!(f.frame(vec![notch_down()], &draw), None, "the overlay covered the field"); + f.time += crate::widgets::WHEEL_STREAM_GAP; + assert_eq!(f.frame(vec![Event::PointerMoved(at + vec2(1.0, 0.0)), notch_down()], &draw), Some(9.0)); +} + /// A wheel turn over an unfocused field steps it without consuming an arrow key in the same frame. #[test] fn hover_wheel_keeps_arrow_keys_for_the_keyboard_focus() { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f3243c75..c70cca173 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -20,6 +20,7 @@ mod numeric; mod panel_chrome; mod text; mod toggles; +mod wheel_stream; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, @@ -72,6 +73,9 @@ pub use text::{ text_presets_named, }; pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; +#[cfg(test)] +pub(crate) use wheel_stream::WHEEL_STREAM_GAP; +pub(crate) use wheel_stream::{ArmedWheelField, WheelStream, WheelStreamOwner, taken_from_raw_id}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index f6eabe0e4..3e4f2119f 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -5,6 +5,7 @@ use super::panel_chrome::{ use super::text::{ after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus, }; +use super::{ArmedWheelField, WheelStream, WheelStreamOwner}; use super::{CornerRadius, RangeInclusive, Rect, Response, Sense, Stroke, StrokeKind, Tokens, Ui, Unit, id_with, pos2, scrub, vec2}; /// Preferences › Units › Numbers Without Units Are Points (#394; on until set): the app sets it @@ -247,9 +248,10 @@ pub(super) fn shown_alike(show: impl Fn(f64) -> String) -> impl Fn(f64, f64) -> /// a weight stops at 0, a miter limit at 1), and a plain step also stops at each of `stops` /// ([`step_through`]). The new value is applied at once, as in /// Illustrator's panels and dialogs, and the new text replaces `buf`, all selected so typing -/// replaces it when focused. Returns the new number when a step was taken. While the pointer is -/// over an enabled field the wheel is the field's (the panel under it doesn't scroll), without -/// taking keyboard focus. Arrow keys still require keyboard focus. +/// replaces it when focused. Returns the new number when a step was taken. An enabled field takes +/// the wheel without taking keyboard focus, and the panel under it doesn't scroll, when the wheel +/// stream began over the field and the pointer was moved onto it, or pressed on it, after the +/// field last moved or was covered ([`number_wheel_turns`]). Arrow keys still require keyboard focus. fn step_value( ui: &Ui, resp: &Response, @@ -266,8 +268,10 @@ fn step_value( } let focused = ui.memory(|m| m.has_focus(resp.id)); use egui::{Event, Key, MouseWheelUnit}; - let wheel = resp.contains_pointer(); - if !focused && !wheel { + let (taken, owns) = number_wheel_turns(ui, resp); + let wheel = !taken.is_empty(); + let hold = wheel || owns && resp.contains_pointer(); + if !focused && !hold { return None; } // Wheel turns in notches: a line each, or `line` points of a trackpad or a fine wheel; what is @@ -277,14 +281,18 @@ fn step_value( // 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| { 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. + let mine = taken.iter().position(|t| t == e).map(|at| taken.swap_remove(at)).is_some(); let (Event::Key { modifiers, .. } | Event::MouseWheel { modifiers, .. }) = e else { return true }; let Some(&(mods, size)) = STEPS.iter().find(|(m, _)| modifiers.matches_logically(*m)) else { return true }; 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 wheel => { + Event::MouseWheel { unit, delta, .. } if mine => { // Shift may turn the wheel sideways (the Mac does). let d = if modifiers.shift { delta.x + delta.y } else { delta.y }; notches += match unit { @@ -302,7 +310,7 @@ fn step_value( } false }); - if wheel { + if hold { // The rest of a turn egui spreads over the next frames scrolls nothing either. i.smooth_scroll_delta = egui::Vec2::ZERO; } @@ -335,6 +343,194 @@ fn step_value( Some(v) } +/// An enabled number field as the wheel finds it. +#[derive(Clone, Copy)] +struct WheelTarget { + id: egui::Id, + rect: Rect, + layer: egui::LayerId, +} + +/// Where the pointer was, the field it was last moved onto, and the running wheel stream. +#[derive(Clone, Copy, Default)] +struct WheelWalk { + pointer: Option, + armed: Option, + stream: WheelStream, +} + +/// The number fields' wheel state in one viewport. +#[derive(Clone, Default)] +struct NumberWheel { + /// The pass whose events `walk` and `taken` cover. + pass: Option, + /// The enabled number fields drawn in the pass before `pass`. + before: Vec, + /// The enabled number fields drawn so far in `pass`. + drawn: Vec, + walk: WheelWalk, + /// Each wheel event of `pass` that a field takes, with the field. + taken: Vec<(egui::Id, egui::Event)>, +} + +/// The wheel events of this pass that number field `resp` takes, and whether the field holds the +/// wheel stream after them. The first field drawn in a pass reads the pass's events for every +/// field, using where the fields were drawn in the pass before. A field takes a wheel stream only +/// when the stream begins with the pointer over it and the pointer was moved onto it, or pressed +/// on it, after the field last moved or was covered by a window or overlay. A stream that began +/// anywhere else stays there until it ends ([`WheelStream`]) or the pointer is pressed on a field, +/// and a stream that leaves its field does not return to it. Trackpad momentum never starts a +/// stream on a field. +fn number_wheel_turns(ui: &Ui, resp: &Response) -> (Vec, bool) { + let ctx = ui.ctx(); + let key = egui::Id::unique("number-field-wheel").with(ctx.viewport_id()); + let pass = ctx.cumulative_pass_nr(); + let font_key = super::taken_from_raw_id(ctx.viewport_id()); + let fresh = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if state.pass == Some(pass) { + return None; + } + let follows = state.pass.is_some_and(|last| last + 1 == pass); + state.before = if follows { std::mem::take(&mut state.drawn) } else { Vec::new() }; + state.drawn.clear(); + state.pass = Some(pass); + if !follows { + state.walk.pointer = None; + state.walk.armed = None; + state.walk.stream.release_any(); + } + // Turns the font family field took from the raw input belong to its stream. + let font_took = d.remove_temp::(font_key); + let state = d.get_temp_mut_or_default::(key); + if let Some(at) = font_took { + state.walk.stream.note_taken_elsewhere(at); + } + // A field that missed a pass may have moved. + if let Some(armed) = state.walk.armed + && !state.before.iter().any(|t| t.id == armed.id) + { + state.walk.armed = None; + state.walk.stream.release(armed.id); + } + Some((state.walk, state.before.clone())) + }); + let walked = fresh.map(|(walk, before)| { + let (events, now) = ui.input(|i| (i.events.clone(), i.time)); + walk_wheel(ctx, walk, &events, &before, now, pass) + }); + let target = WheelTarget { id: resp.id, rect: resp.interact_rect, layer: resp.layer_id }; + let (armed_here, pointer) = ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if let Some((walk, taken)) = walked { + state.walk = walk; + state.taken = taken; + } + (state.walk.armed.is_some_and(|armed| armed.id == resp.id), state.walk.pointer) + }); + // A window or overlay over the pointer covers the armed field even when no input arrives. + let covered = armed_here && pointer.is_some_and(|p| target.rect.contains(p) && ctx.layer_id_at(p) != Some(target.layer)); + ui.data_mut(|d| { + let state = d.get_temp_mut_or_default::(key); + if let Some(armed) = &mut state.walk.armed + && armed.id == resp.id + { + if armed.rect == target.rect && !covered { + armed.pass = pass; + } else { + // The field moved or is covered, so the pass's turns stay with the panel. + state.walk.armed = None; + state.walk.stream.release(resp.id); + state.taken.retain(|&(id, _)| id != resp.id); + } + } + state.drawn.push(target); + let mut mine = Vec::new(); + state.taken.retain(|(id, event)| { + if *id == resp.id { + mine.push(event.clone()); + } + *id != resp.id + }); + (mine, state.walk.stream.owner == Some(WheelStreamOwner::Field(resp.id))) + }) +} + +/// `walk` after `events`, with `fields` where the pointer finds them, at input time `now`; and the +/// wheel events a field takes (see [`NumberWheel::taken`]). +fn walk_wheel( + ctx: &egui::Context, + mut walk: WheelWalk, + events: &[egui::Event], + fields: &[WheelTarget], + now: f64, + pass: u64, +) -> (WheelWalk, Vec<(egui::Id, egui::Event)>) { + let under = |p: Option| { + let p = p?; + let layer = ctx.layer_id_at(p); + fields.iter().rev().find(|t| t.rect.contains(p) && layer == Some(t.layer)).copied() + }; + let mut taken = Vec::new(); + let mut left = false; + for event in events { + match event { + egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } => { + let pressed = matches!(event, egui::Event::PointerButton { pressed: true, .. }); + if walk.pointer != Some(*p) || pressed { + let field = under(Some(*p)); + walk.armed = field.map(|t| ArmedWheelField { id: t.id, rect: t.rect, pass }); + if let Some(WheelStreamOwner::Field(id)) = walk.stream.owner + && field.is_none_or(|t| t.id != id) + { + walk.stream.release(id); + } + // A press on a field gives it the rest of a running stream. + if pressed + && let Some(t) = field + && walk.stream.owner != Some(WheelStreamOwner::Field(t.id)) + { + walk.stream.owner = None; + } + } + walk.pointer = Some(*p); + } + egui::Event::PointerGone => { + walk.pointer = None; + walk.armed = None; + walk.stream.release_any(); + } + egui::Event::MouseWheel { delta, source, .. } => { + walk.stream.note_input(now); + let field = under(walk.pointer); + if *delta != egui::Vec2::ZERO { + let starts = field + .filter(|t| walk.armed.is_some_and(|armed| armed.id == t.id) && *source != egui::MouseWheelSource::Momentum) + .map_or(WheelStreamOwner::Elsewhere, |t| WheelStreamOwner::Field(t.id)); + let owner = *walk.stream.owner.get_or_insert(starts); + if field.is_none_or(|t| owner != WheelStreamOwner::Field(t.id)) { + walk.stream.owner = Some(WheelStreamOwner::Elsewhere); + } + } + if let Some(t) = field + && walk.stream.owner == Some(WheelStreamOwner::Field(t.id)) + { + taken.push((t.id, event.clone())); + } else { + left |= *delta != egui::Vec2::ZERO; + } + } + _ => {} + } + } + // egui smooths a pass's wheel input as a whole before any field runs, so a field cannot take + // part of it: a pass whose wheel input also scrolls something else is left whole to the panel. + if left { + taken.clear(); + } + (walk, taken) +} + /// 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). diff --git a/crates/ui-egui/src/widgets/wheel_stream.rs b/crates/ui-egui/src/widgets/wheel_stream.rs new file mode 100644 index 000000000..826b4dfe8 --- /dev/null +++ b/crates/ui-egui/src/widgets/wheel_stream.rs @@ -0,0 +1,66 @@ +//! Wheel streams for the fields that step on the wheel (number fields and the closed font family +//! field). A field takes a wheel stream only when the stream begins with the pointer over it and +//! the pointer was moved onto it after the field last moved or was covered by a window or overlay. + +/// The seconds without wheel input that end a wheel stream. This is VectorCraft's own value. +pub(crate) const WHEEL_STREAM_GAP: f64 = 0.5; + +/// Where the closed font family field, which takes its wheel events from the raw input, keeps the +/// input time of the last one it took in `viewport`, so number fields see that stream too. +pub(crate) fn taken_from_raw_id(viewport: egui::ViewportId) -> egui::Id { + egui::Id::unique("wheel-taken-from-raw").with(viewport) +} + +/// The field the pointer was last moved onto, while that field stays where it was and uncovered. +#[derive(Clone, Copy)] +pub(crate) struct ArmedWheelField { + pub(crate) id: egui::Id, + pub(crate) rect: egui::Rect, + pub(crate) pass: u64, +} + +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum WheelStreamOwner { + Field(egui::Id), + Elsewhere, +} + +/// One run of wheel input with no gap of [`WHEEL_STREAM_GAP`] in it, trackpad momentum included. +#[derive(Clone, Copy, Default)] +pub(crate) struct WheelStream { + last: Option, + pub(crate) owner: Option, +} + +impl WheelStream { + /// Note wheel input at `now`; a gap since the last input starts a new stream with no owner. + pub(crate) fn note_input(&mut self, now: f64) { + if !self.last.is_some_and(|last| (0.0..WHEEL_STREAM_GAP).contains(&(now - last))) { + self.owner = None; + } + self.last = Some(now); + } + + /// Note wheel input at `at` that another kind of field took: the stream it belongs to is not + /// one of this kind's fields. + pub(crate) fn note_taken_elsewhere(&mut self, at: f64) { + if self.last.is_none_or(|last| last <= at) { + self.note_input(at); + } + self.owner = Some(WheelStreamOwner::Elsewhere); + } + + /// Leave the rest of the stream to the panel when `field` holds it. + pub(crate) fn release(&mut self, field: egui::Id) { + if self.owner == Some(WheelStreamOwner::Field(field)) { + self.owner = Some(WheelStreamOwner::Elsewhere); + } + } + + /// Leave the rest of the stream to the panel when any field holds it. + pub(crate) fn release_any(&mut self) { + if let Some(WheelStreamOwner::Field(field)) = self.owner { + self.release(field); + } + } +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e800e5388..7ce1da6bc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -809,7 +809,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. + - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the hovered field without requiring focus (#485, #5949: a notch a step; the panel under it then stays put, and a disabled field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. A field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere, such as a panel scrolled under a still pointer, stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field (VectorCraft's own rule and value). - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). - A plain step of a number field (Up/Down or a wheel notch) goes to the next whole unit in its direction and a Ctrl/Cmd step goes to the next tenth, so 5.1 mm steps up to 6 mm and down to 5 mm (#2881). A Shift step adds or takes ten and keeps the fraction. - In the number, text and color fields of the panels and the Control bar, Shift+Enter (Shift+Return) applies the value and keeps the field active with its value selected; a text box of several lines takes it as a line break. In a dialog, Shift+Enter applies the value and confirms the dialog, as Enter does. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d2f3ff352..103b0d10b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -269,7 +269,7 @@ tools, never checked side by side) isn't captured row by row. | Wheel over a closed font-family field in Character, Properties or Control | done | #5253: applies the next or previous family to selected text, one undo step per notch; uses saved font filters, ignores search text and stops at the list's ends. Escape and pointer departure keep the change. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input. This rule and its 0.5 s are VectorCraft's choices. Other font-picking menus and modifier-wheel input keep their existing behavior. | | Arrow keys nudge by Keyboard Increment (0.001–1296 pt); Shift ×10; Alt copies | done | | | Fields commit what was typed on a click away or a move to another field, as Enter does; Escape discards it | done | #1787 | -| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370 | +| Number fields: Up/Down step, Shift ×10, Cmd/Ctrl ÷10, wheel over a hovered field without a click; steps, the wheel and label scrubs stop at the field's bounds (a stroke weight at 0, a miter limit at 1) | done | Shift+arrow in the Stroke weight field doesn't step by 10 (#991); bounds #3370. The field takes the wheel only when the pointer was moved onto it after the field last moved or was covered by a window or overlay, and the wheel stream began there; a stream that began elsewhere stays there until 0.5 s pass with no wheel input or the pointer is pressed on a field, so a panel scrolled under a still pointer keeps scrolling past the field. This rule and its 0.5 s are VectorCraft's choices. | | Numeric spinner arrows step once on a click and keep repeating while held with the mouse | done | #6307; headless test `a_held_spinner_arrow_repeats` holds past egui's click timeout and checks release. | | Arithmetic in fields (`+ - * /`, and `%` of the value before it: `3cm*50%`, `50pt+25%`) and unit suffixes (`3 mm`, `1in`, `2p6`) | done | `Unit::parse`; mixed units in one expression not verified | | Numeric fields: focus click selects the whole value; dragging selects from the pressed character | partial | #1326; select-all waits for click release; px/pt/plain/optional fields, both directions and replacement text tested; native API input, 17 px partial replacement, Undo/Redo, saved-file round trip and screenshots verified; Tab, double-click, arrows and wheel preserved; short movements still classified as clicks can select all and remain open |