Merge pull request #4675 from storytold/fix/spinner-auto-repeat

Spinners: a held arrow repeats after a pause (#4267)
This commit is contained in:
BFlat
2026-10-30 11:22:39 +07:00
committed by GitHub
3 changed files with 70 additions and 3 deletions
+41
View File
@@ -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");
}
+27 -2
View File
@@ -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<bool> {
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
+2 -1
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Control bar: a Replace menu swaps the selected symbol instances' symbol (#4121); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Spinners: a held arrow repeats after a pause (#4267); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -361,6 +361,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | Spinners: a held arrow repeats after a pause (#4267); scores and estimates unchanged |
| 2026-10-30 | minor | Control bar: a Replace menu swaps the selected symbol instances' symbol (#4121); scores and estimates unchanged |
| 2026-10-30 | minor | Color Mixer popover: the Color panel's menu with its color modes (#4249); scores and estimates unchanged |
| 2026-10-30 | minor | Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J) moves two open path ends to their midpoint and joins them there, the joined anchor a corner or a smooth point as its dialog's Points choice asks, in one undo step; one row done; scores and estimates unchanged. |