diff --git a/crates/ui-egui/src/widgets/look_tests.rs b/crates/ui-egui/src/widgets/look_tests.rs index 6ec7112cb..3f2241576 100644 --- a/crates/ui-egui/src/widgets/look_tests.rs +++ b/crates/ui-egui/src/widgets/look_tests.rs @@ -427,3 +427,44 @@ fn a_popover_stays_open_for_a_click_on_its_anchor_or_during_a_panel_drag() { let open = [open_after_click(far, None), open_after_click(None, None), open_after_click(far, panel_drag())]; assert_eq!(open, [false, true, true]); } + +/// #4267: a click on a spinner arrow steps once; an arrow held down steps again and again after a +/// pause, and letting go of it doesn't step once more. +#[test] +fn a_held_spinner_arrow_repeats() { + let ctx = themed(Default::default()); + let value = std::cell::Cell::new(5.0); + let mut add = |ui: &mut Ui| { + ui.horizontal(|ui| { + if let Some(v) = spin_plain(ui, "repeat", value.get(), "", 0, 100.0, 1.0, 0.0, &[]) { + value.set(v); + } + }); + }; + let shapes = frame(&ctx, 0.0, vec![], &mut add); + let spinner = shapes + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) if r.rect.height() > 20.0 && r.fill != Color32::TRANSPARENT => Some(r.rect), + _ => None, + }) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .expect("the spinner box"); + let up = spinner.center_top() + vec2(0.0, 6.0); + let button = |pressed| egui::Event::PointerButton { pos: up, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // A click: one step, on release. + frame(&ctx, 0.1, vec![egui::Event::PointerMoved(up), button(true)], &mut add); + frame(&ctx, 0.2, vec![button(false)], &mut add); + assert_eq!(value.get(), 6.0, "a click steps once"); + // Held: nothing more until the pause is over, then a step every repeat. + frame(&ctx, 1.0, vec![button(true)], &mut add); + frame(&ctx, 1.3, vec![], &mut add); + assert_eq!(value.get(), 6.0, "no step before the pause"); + for (i, t) in [1.5, 1.6, 1.7].into_iter().enumerate() { + frame(&ctx, t, vec![], &mut add); + assert_eq!(value.get(), 7.0 + i as f64, "a step every repeat at {t}"); + } + frame(&ctx, 1.75, vec![button(false)], &mut add); + frame(&ctx, 1.8, vec![], &mut add); + assert_eq!(value.get(), 9.0, "letting go doesn't step once more"); +} diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index 360c9ab5e..e815d75fe 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -620,8 +620,10 @@ fn spin_generic( ui.painter().line_segment([m + vec2(0.0, d), m + vec2(3.0, -d)], Stroke::new(1.2, c)); } sresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tl!("Step up or down"))); - // A blank field (values that differ) has nothing to step from. - if sresp.clicked() + // A blank field (values that differ) has nothing to step from. A click steps once; an + // arrow held down steps again and again after a pause, as a spin button does (#4267). + let repeat = held_repeat(ui, &sresp); + if (repeat == Some(true) || (sresp.clicked() && repeat.is_none())) && let Some(p) = sresp.interact_pointer_pos() && let Some(v) = value { @@ -653,6 +655,29 @@ fn spin_generic( out } +/// How long a spinner arrow is held before it repeats, and how often it repeats then, in seconds. +const REPEAT_DELAY: f64 = 0.45; +const REPEAT_EVERY: f64 = 0.06; + +/// A spinner arrow (`resp`) held down. `None` until it has been held for [`REPEAT_DELAY`]: a +/// click then steps once, on release. After that, `Some(true)` on each frame it steps again +/// (every [`REPEAT_EVERY`]) and `Some(false)` on the frames between and on the release, which +/// must not step once more. +fn held_repeat(ui: &Ui, resp: &egui::Response) -> Option { + let id = resp.id.with("repeat"); + let now = ui.input(|i| i.time); + let state: Option<(f64, f64, bool)> = ui.data(|d| d.get_temp(id)); + if !resp.is_pointer_button_down_on() { + ui.data_mut(|d| d.remove::<(f64, f64, bool)>(id)); + return state.filter(|(_, _, repeated)| *repeated).map(|_| false); + } + let (start, last, repeated) = state.unwrap_or((now, now, false)); + let due = now - start >= REPEAT_DELAY && now - last >= REPEAT_EVERY; + ui.data_mut(|d| d.insert_temp(id, (start, if due { now } else { last }, repeated || due))); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(30)); + (repeated || due).then_some(due) +} + /// Spinner buttons follow the same modifier sizes as ↑/↓ in a focused number field: /// Shift ×10, Ctrl/Cmd ÷10, otherwise the caller's normal step, which also stops at each of /// `stops` ([`step_through`]). A stop that `show` writes with the same text as the value counts as