Layers: rows held at the list's edge scroll it at the same speed at every frame rate

A row held in the 28-point band at the list's top or bottom edge scrolled
the list by a fixed distance each frame, so the list moved farther in the
same time on a display that redraws more often.

`scroll_at_edges` now scrolls by the time each frame took: 60 points a
second at the band's inner side and 340 at the edge. A stalled frame counts
a tenth of a second at most. The scroll is applied without the scroll
animation. The Libraries panel's list uses the same function.

Test: `panels::layers` test
`a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate`
holds a row for half a second in 15 frames and in 60 frames and compares
where the rows come to rest.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 04:53:01 -08:00
parent dc7b0d0c90
commit 7232875e9f
3 changed files with 61 additions and 7 deletions
+13 -5
View File
@@ -524,19 +524,27 @@ pub(crate) fn rows_take_delete(ctx: &egui::Context) -> bool {
/// How near a scrolled list's top or bottom edge a drag scrolls it ([`scroll_at_edges`]).
const DRAG_SCROLL_EDGE: f32 = 28.0;
/// The speed of that scrolling in points per second: at the band's inner side, and added to it at
/// the list's edge.
const DRAG_SCROLL_SPEED: (f32, f32) = (60.0, 280.0);
/// While something is dragged near the top or bottom edge of a scrolled list (`ui`: its contents),
/// scroll that way, faster nearer the edge, and draw the next frame so a pointer held still keeps
/// it scrolling. The pointer must be over the list's width.
/// it scrolling. The pointer must be over the list's width. Each frame scrolls by the time it
/// took (a stalled frame counts a tenth of a second at most), so the speed is the same at every
/// frame rate.
pub(crate) fn scroll_at_edges(ui: &mut Ui) {
let Some(pos) = ui.ctx().pointer_hover_pos() else { return };
let view = ui.clip_rect();
if !view.x_range().contains(pos.x) {
return;
}
let speed = |depth: f32| if (0.0..DRAG_SCROLL_EDGE).contains(&depth) { 2.0 + 10.0 * (1.0 - depth / DRAG_SCROLL_EDGE) } else { 0.0 };
let delta = speed(pos.y - view.top()) - speed(view.bottom() - pos.y);
if delta != 0.0 {
ui.scroll_with_delta(vec2(0.0, delta));
let (slow, more) = DRAG_SCROLL_SPEED;
let speed = |depth: f32| if (0.0..DRAG_SCROLL_EDGE).contains(&depth) { slow + more * (1.0 - depth / DRAG_SCROLL_EDGE) } else { 0.0 };
let per_second = speed(pos.y - view.top()) - speed(view.bottom() - pos.y);
if per_second != 0.0 {
let dt = ui.input(|i| i.stable_dt).min(0.1);
ui.scroll_with_delta_animation(vec2(0.0, per_second * dt), egui::style::ScrollAnimation::none());
ui.ctx().request_repaint();
}
}
+47 -1
View File
@@ -1235,10 +1235,21 @@ fn dragging_rows_moves_them_into_other_layers_and_alt_copies() {
/// One headless frame of the panel on a short screen (300 × 260) with `events` (Alt held when
/// `alt`): for every row name painted inside the list's view, its center, and the view.
fn short_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>, alt: bool) -> (HashMap<String, egui::Pos2>, egui::Rect) {
timed_short_frame(app, ctx, events, alt, 1.0 / 60.0)
}
/// [`short_frame`] for a frame that comes `dt` seconds after the one before it.
fn timed_short_frame(
app: &mut VectorcraftApp,
ctx: &egui::Context,
events: Vec<egui::Event>,
alt: bool,
dt: f32,
) -> (HashMap<String, egui::Pos2>, egui::Rect) {
let mut events = events;
events.insert(0, egui::Event::ModifiersChanged(egui::Modifiers { alt, ..Default::default() }));
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(300.0, 260.0));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show(app, ui));
let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), predicted_dt: dt, ..Default::default() }, |ui| show(app, ui));
out.textures_delta.clear();
// The rows' eye column dividers are drawn clipped to the list's view.
let border = Tokens::get(ctx).input_border;
@@ -1342,6 +1353,41 @@ fn rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view()
assert_eq!(names[2], "Layer 2", "the original stays");
}
#[test]
fn a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate() {
// Layer 30 held just above the list's bottom edge for half a second, drawn in `frames` frames,
// then moved beside the list, where the scrolling stops: → where the rows in view came to rest.
let held = |frames: u16| {
let mut app = thirty_layers();
let ctx = egui::Context::default();
let (names, view) = short_frame(&mut app, &ctx, vec![], false);
let grab = names["Layer 30"];
short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], false);
short_frame(&mut app, &ctx, vec![button(grab, true)], false);
short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab + vec2(0.0, 6.0))], false);
let dt = 0.5 / f32::from(frames);
timed_short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(grab.x, view.bottom() - 3.0))], false, dt);
for _ in 1..frames {
timed_short_frame(&mut app, &ctx, vec![], false, dt);
}
short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(view.right() + 2.0, view.bottom() - 3.0))], false);
let mut rows = HashMap::new();
for _ in 0..120 {
rows = short_frame(&mut app, &ctx, vec![], false).0;
}
// (Layer 30's dimmed copy follows the pointer.)
rows.remove("Layer 30");
rows
};
let (at_30, at_120) = (held(15), held(60));
assert!(!at_30.contains_key("Layer 29") && !at_30.is_empty(), "the list scrolled: {at_30:?}");
assert_eq!(at_30.len(), at_120.len(), "{at_30:?} {at_120:?}");
for (name, a) in &at_30 {
let b = at_120.get(name).unwrap_or_else(|| panic!("{name} is in view at 30 frames a second only: {at_120:?}"));
assert!((a.y - b.y).abs() < 2.0, "{name}: {a:?} at 30 frames a second, {b:?} at 120");
}
}
#[test]
fn the_list_scrolls_only_for_rows_held_over_it() {
let mut app = thirty_layers();
+1 -1
View File
@@ -832,7 +832,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent.
- A layer whose Layer Options › Print is off shows its name italic in the Layers panel, and while its row is dragged, as the reference application shows non-printing layers (#5739); a printing layer stays upright and ticking Print again restores the upright name (`panels::layers` test `a_layer_that_does_not_print_has_its_name_italic`).
- In the Layers panel only the primary mouse button drags. A drag with the right or middle button on a row, a guide row, the selection square, the eye or lock column or a target circle moves, copies, hides, locks and transfers nothing and records no undo step, and a right-click still opens the row's menu (`panels::layers` test `only_the_primary_button_drags_rows_guides_art_columns_and_appearances`).
- Rows dragged in the Layers panel and held within 28 points of the list's top or bottom edge scroll a long list that way, faster nearer the edge, and keep scrolling while the pointer stays still, so a row out of view can take the drop (a move, or a copy with Alt); the Libraries panel's list scrolls by the same rule. A pointer beside the list, a drag of the eye or lock column and a release stop the scrolling, and scrolling changes nothing in the document. The band and speeds are VectorCraft's own (`panels::layers` tests `rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view` and `the_list_scrolls_only_for_rows_held_over_it`).
- Rows dragged in the Layers panel and held within 28 points of the list's top or bottom edge scroll a long list that way, faster nearer the edge (60 points a second at the band's inner side, 340 at the edge, at every frame rate), and keep scrolling while the pointer stays still, so a row out of view can take the drop (a move, or a copy with Alt); the Libraries panel's list scrolls by the same rule. A pointer beside the list, a drag of the eye or lock column and a release stop the scrolling, and scrolling changes nothing in the document. The band and speeds are VectorCraft's own (`panels::layers` tests `rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view`, `a_held_row_scrolls_the_list_as_far_in_the_same_time_at_any_frame_rate` and `the_list_scrolls_only_for_rows_held_over_it`).
- Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
- A floating panel's OS window hides while a tool group's flyout or the All Tools drawer is open in the main window, as it does under a modal dialog or a menu, and shows again when the flyout or the drawer closes (#5813; `a_tool_flyout_hides_the_palette_windows_until_it_closes`).
- On the desktop a floating panel in an OS window stores the monitor it is on and reopens there at the next launch. When that monitor is no longer connected, the panel opens on the main window's monitor at the same offset (#5582). Outside macOS, a desktop whose monitors have different scale factors keeps opening floating panels on the main window's monitor. Test: `panel_docking::native_tests::a_floating_panel_reopens_on_the_monitor_where_it_was_left`.