Merge pull request #1873 from TrevorBrown-dev/fix/scrub-speed

M1: scrubbing a field faster or slower by the pointer's height, with a notice of the speed (#1837)
This commit is contained in:
BFlat
2026-11-07 13:28:57 +08:00
committed by GitHub
6 changed files with 139 additions and 8 deletions
+6
View File
@@ -363,6 +363,12 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Edit, "Duplicate by dragging", "⌥ drag", "Alt+drag"),
(Transform, "Nudge (Shift: big nudge)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"),
(
Transform,
"Scrub a field faster (2x) or slower (1/2, 1/4)",
"Move up or down while dragging its label",
"Move up or down while dragging its label",
),
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
+6
View File
@@ -409,6 +409,12 @@ name = "English"
"Decoration style" = "Decoration style"
"Decoration" = "Decoration"
"Deep select" = "Deep select"
"Scrub a field faster (2x) or slower (1/2, 1/4)" = "Scrub a field faster (2x) or slower (1/2, 1/4)"
"Move up or down while dragging its label" = "Move up or down while dragging its label"
"Scrubbing at 2x" = "Scrubbing at 2x"
"Scrubbing at 1x" = "Scrubbing at 1x"
"Scrubbing at 1/2" = "Scrubbing at 1/2"
"Scrubbing at 1/4" = "Scrubbing at 1/4"
"Delete {n} variables" = { one = "Delete {n} variable", other = "Delete {n} variables" }
"Move out of any group" = "Move out of any group"
"Move to group {group}" = "Move to group {group}"
+4
View File
@@ -418,6 +418,10 @@ impl FigApp {
self.end_pen_outside_design();
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
// A scrub that changed speed (the pointer moved up or down) says so at the bottom.
if let Some(notice) = ctx.data_mut(|d| d.remove_temp::<&'static str>(widgets::scrub_notice_id())) {
self.toast(i18n::tr(notice));
}
self.toast_ui(&ctx);
// A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field
// next frame; while the panel isn't drawn (the interface hidden or minimized, the panel
+77
View File
@@ -266,6 +266,25 @@ fn scrub(u: &mut Ui, name: &str, dx: f32, modifiers: Modifiers) {
drag(u, from, dx, modifiers);
}
/// Press at field `name`'s label, move `rise` points up (down when negative), then `dx` sideways
/// in small steps at that height, and let go.
fn scrub_at_height(u: &mut Ui, name: &str, rise: f32, dx: f32) {
let from = label(u, name);
let m = Modifiers::NONE;
let press = |pressed, pos| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m };
u.frame_with(vec![Event::PointerMoved(from), press(true, from)], m);
for k in 1..=5 {
u.frame_with(vec![Event::PointerMoved(pos2(from.x, from.y - rise * k as f32 / 5.0))], m);
}
let up = pos2(from.x, from.y - rise);
for k in 1..=10 {
u.frame_with(vec![Event::PointerMoved(pos2(up.x + dx * k as f32 / 10.0, up.y))], m);
}
let to = pos2(up.x + dx, up.y);
u.frame_with(vec![Event::PointerMoved(to), press(false, to)], m);
u.frame(vec![]);
}
/// Press at `from`, move `dx` points sideways in small steps with `modifiers` held, then let go.
fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) {
let press = |pressed| Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed, modifiers };
@@ -282,6 +301,64 @@ fn drag(u: &mut Ui, from: Pos2, dx: f32, modifiers: Modifiers) {
u.frame(vec![]);
}
/// Scrub speed follows the pointer's height, with a notice of the speed (#1837): more than 100
/// points above where the scrub began is 2x, that far below 1/2, twice as far 1/4.
#[test]
fn moving_up_or_down_while_scrubbing_changes_the_speed_and_says_so() {
let (mut u, a, _) = setup();
let toast = |u: &Ui| u.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
// (rise in points, how far 40 points of scrub move X, the notice)
for (rise, want, notice) in [(150.0, 80.0, "2x"), (0.0, 40.0, ""), (-150.0, 20.0, "1/2"), (-300.0, 10.0, "1/4"), (40.0, 40.0, "")] {
let x0 = geom(&u, a).0;
u.app.ui.toast = None;
scrub_at_height(&mut u, "X", rise, 40.0);
assert_eq!(geom(&u, a).0 - x0, want, "rise {rise}");
if notice.is_empty() {
assert!(toast(&u).is_empty(), "no notice at the usual speed: {}", toast(&u));
} else {
assert!(toast(&u).contains(notice), "rise {rise}: the notice says {}", toast(&u));
}
}
// Speeds compose with the modifiers' rates: 2x with Shift is twenty steps a point.
let x0 = geom(&u, a).0;
let from = label(&mut u, "X");
let shift = Modifiers { shift: true, ..Modifiers::NONE };
let press = |pressed, pos| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: shift };
u.frame_with(vec![Event::PointerMoved(from), press(true, from)], shift);
let up = pos2(from.x, from.y - 150.0);
u.frame_with(vec![Event::PointerMoved(up)], shift);
for k in 1..=5 {
u.frame_with(vec![Event::PointerMoved(pos2(up.x + 2.0 * k as f32, up.y))], shift);
}
let to = pos2(up.x + 10.0, up.y);
u.frame_with(vec![Event::PointerMoved(to), press(false, to)], shift);
u.frame(vec![]);
assert_eq!(geom(&u, a).0 - x0, 200.0, "ten points at 2x with Shift");
// The pointer reported gone from the window (egui's PointerGone) with the button still down
// keeps the speed it had: no notice of 1x, and the scrub goes on at 2x once it is back.
let x0 = geom(&u, a).0;
let from = label(&mut u, "X");
let m = Modifiers::NONE;
let press = |pressed, pos| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: m };
u.frame_with(vec![Event::PointerMoved(from), press(true, from)], m);
let up = pos2(from.x, from.y - 150.0);
u.frame_with(vec![Event::PointerMoved(up)], m);
u.frame_with(vec![Event::PointerMoved(pos2(up.x + 10.0, up.y))], m);
assert!(toast(&u).contains("2x"), "{}", toast(&u));
u.app.ui.toast = None;
u.frame_with(vec![Event::PointerGone], m);
u.frame_with(vec![Event::PointerMoved(pos2(up.x + 20.0, up.y))], m);
u.frame_with(vec![Event::PointerMoved(pos2(up.x + 30.0, up.y))], m);
assert!(toast(&u).is_empty(), "no notice while the pointer was gone or back at the same height: {}", toast(&u));
// Back down to the press height and gone in the same frame: that last position counts (1x).
u.frame_with(vec![Event::PointerMoved(pos2(up.x + 30.0, from.y)), Event::PointerGone], m);
assert!(toast(&u).contains("1x"), "{}", toast(&u));
let to = pos2(up.x + 30.0, up.y);
u.frame_with(vec![Event::PointerMoved(to), press(false, to)], m);
u.frame(vec![]);
assert_eq!(geom(&u, a).0 - x0, 40.0, "ten points before and ten after the pointer was gone, at 2x both");
}
#[test]
fn scrubbing_the_label_moves_the_value_and_is_one_undo_step() {
let (mut u, a, _) = setup();
+43 -6
View File
@@ -5,7 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Str
use crate::a11y::{self, Tip};
pub use crate::field_math::{Rel, Typed, eval, parse, stepped};
use crate::i18n::tr;
use crate::i18n::{tr, tr_noop};
use crate::icons::{self, Icon};
use crate::theme::{Tokens, regular, semibold};
pub(crate) mod board_picker;
@@ -125,6 +125,30 @@ pub fn scrub_amount(dx: f64, step: f64, shift: bool, alt: bool) -> f64 {
}
}
/// How far above where a scrub began the pointer has to be for the fast speed (points); the same
/// below for the slow one and twice that for the slowest.
pub const SCRUB_BAND: f64 = 100.0;
/// The scrubbing speed by the pointer's height, `rise` points above where the scrub began (below
/// when negative): a factor on the scrub and the notice for it. Higher on the screen is faster,
/// lower is slower, in four speeds (2x, 1x, 1/2 and 1/4).
pub fn scrub_speed(rise: f64) -> (f64, &'static str) {
if rise > SCRUB_BAND {
(2.0, tr_noop("Scrubbing at 2x"))
} else if rise < -2.0 * SCRUB_BAND {
(0.25, tr_noop("Scrubbing at 1/4"))
} else if rise < -SCRUB_BAND {
(0.5, tr_noop("Scrubbing at 1/2"))
} else {
(1.0, tr_noop("Scrubbing at 1x"))
}
}
/// Where a scrub leaves the notice of a changed speed for the window to show as a toast.
pub fn scrub_notice_id() -> Id {
Id::unique("scrub_speed_notice")
}
/// The value a scrub that began at `from` and has moved `amount` (see [`scrub_amount`]) shows:
/// whole steps of `quantum` away from where it began.
pub fn scrubbed(from: f64, amount: f64, quantum: f64) -> f64 {
@@ -141,8 +165,9 @@ pub(crate) fn scrub(ui: &Ui, id: Id, response: &Response, value: Option<f64>, st
let scrub_id = id.with("scrub");
if response.dragged() || response.drag_stopped() {
let began = response.drag_started();
let (v0, mut amount): (f64, f64) = ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0));
let (dx, shift, alt) = ui.input(|i| {
let (v0, mut amount, last_speed, last_rise): (f64, f64, &'static str, f64) =
ui.data(|d| d.get_temp(scrub_id)).filter(|_| !began).unwrap_or((v, 0.0, "", 0.0));
let (dx, rise, shift, alt) = ui.input(|i| {
let p = &i.pointer;
// As it begins: all the way from the press (a drag is one only after a few points).
let moved = if began { p.hover_pos().zip(p.press_origin()).map_or(0.0, |(at, from)| at.x - from.x) } else { p.delta().x };
@@ -151,12 +176,24 @@ pub(crate) fn scrub(ui: &Ui, id: Id, response: &Response, value: Option<f64>, st
// does too. Elsewhere the scrub just stops there.
let raw = p.motion().map_or(0.0, |m| m.x / i.pixels_per_point);
let at_edge = moved == 0.0 && p.hover_pos().is_none_or(|at| at.x <= 1.0 || at.x >= i.content_rect().max.x - 1.0);
(f64::from(if at_edge { raw } else { moved }), i.modifiers.shift, i.modifiers.alt && fine_on_alt)
// How far above where the scrub began the pointer is: the speed (`scrub_speed`). Gone
// from the window this frame, the pointer's last position still counts (a move and
// the leaving in one frame); None once egui has no position for it.
let rise = p.hover_pos().or(p.interact_pos()).zip(p.press_origin()).map(|(at, from)| f64::from(from.y - at.y));
(f64::from(if at_edge { raw } else { moved }), rise, i.modifiers.shift, i.modifiers.alt && fine_on_alt)
});
// Gone from the window (egui says so, the button still down), the pointer keeps the speed
// it had rather than falling back to the usual one.
let rise = rise.unwrap_or(last_rise);
let (speed, notice) = scrub_speed(rise);
// The frame the button goes up moves nothing: the scrub ends where the last one left it.
if response.dragged() {
amount += scrub_amount(dx, step, shift, alt);
ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount)));
amount += scrub_amount(dx, step, shift, alt) * speed;
// A change of speed is said once (not the usual speed a scrub starts at).
if notice != last_speed && !(last_speed.is_empty() && speed == 1.0) {
ui.data_mut(|d| d.insert_temp(scrub_notice_id(), notice));
}
ui.data_mut(|d| d.insert_temp(scrub_id, (v0, amount, notice, rise)));
}
let quantum = if alt { step * 0.1 } else { step };
return Some(FieldEdit::Scrub(scrubbed(v0, amount, quantum), response.drag_stopped()));
+3 -2
View File
File diff suppressed because one or more lines are too long