From dc7b0d0c903556508582dc64b21e1b819154f4be Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 02:31:55 -0800 Subject: [PATCH 1/2] Layers: layer and object rows held near the list's top or bottom edge scroll a long list While layer or object rows are dragged in the Layers panel, holding the pointer within 28 points of the list's top or bottom edge scrolls the list that way, faster nearer the edge, and it keeps 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 edge-scroll helper moves to panels.rs and both panels use it. Scrolling stops on release, when the pointer leaves the list's width, and for other drags such as the eye or lock column. --- crates/ui-egui/src/panels.rs | 20 +++ crates/ui-egui/src/panels/layers.rs | 10 +- crates/ui-egui/src/panels/layers/tests.rs | 150 ++++++++++++++++++++++ crates/ui-egui/src/panels/libraries.rs | 20 +-- docs/target-app-parity.md | 3 +- 5 files changed, 182 insertions(+), 21 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index cbad3dd34f..340e355f3a 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -521,6 +521,26 @@ pub(crate) fn rows_take_delete(ctx: &egui::Context) -> bool { has_row_keys(ctx, layers::ROW_KEYS) || has_row_keys(ctx, appearance::ROW_KEYS) } +/// How near a scrolled list's top or bottom edge a drag scrolls it ([`scroll_at_edges`]). +const DRAG_SCROLL_EDGE: f32 = 28.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. +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)); + ui.ctx().request_repaint(); + } +} + /// [`crate::i18n::label_or_name`] in the UI language. pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { crate::i18n::label_or_name(crate::i18n::current(), s, built_in) diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 0ac14d243d..73d6171701 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -19,7 +19,8 @@ //! to Outline and the row's layer to Preview (`layer.outlineOthers`), or switches every layer to //! Preview when the others are in Outline (`layer.previewAll`). //! - Dragging rows moves them above, below or into a layer or group (`layer.move`, a drop line or -//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer. Layers +//! a box shows where); Alt copies them. A dimmed copy of the row follows the pointer, and rows +//! held near the top or bottom edge of the list scroll it, faster nearer the edge. Layers //! dropped in the empty space under the last row go below the bottom layer. Dropped on the trash //! they are deleted, on New Layer duplicated. //! - Deleting rows from the panel (the trash, rows dropped on it, Delete Selection, Delete and @@ -304,6 +305,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { for r in list.get(range).unwrap_or_default() { row(ui, &view, r, &mut out); } + // Rows held near the list's top or bottom edge scroll it, so a row out of view can + // take the drop. + if egui::DragAndDrop::payload::(ui.ctx()).is_some_and(|d| matches!(*d, LayersDrag::Rows(_))) + && ui.input(|i| i.pointer.primary_down()) + { + super::scroll_at_edges(ui); + } }, ) }); diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 2143bd1ee3..90091dceeb 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -1232,6 +1232,156 @@ fn dragging_rows_moves_them_into_other_layers_and_alt_copies() { let _ = (layer, c); } +/// 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, alt: bool) -> (HashMap, 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)); + out.textures_delta.clear(); + // The rows' eye column dividers are drawn clipped to the list's view. + let border = Tokens::get(ctx).input_border; + let view = out + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::Shape::LineSegment { stroke, .. } if stroke.color == border && c.clip_rect.height() < screen.height() => Some(c.clip_rect), + _ => None, + }) + .unwrap_or(egui::Rect::NOTHING); + let mut names = HashMap::new(); + for c in &out.shapes { + if let egui::Shape::Text(t) = &c.shape + && t.galley.text().starts_with("Layer ") + && view.contains_rect(c.clip_rect) + { + let center = t.pos + t.galley.size() / 2.0; + if c.clip_rect.contains(center) { + names.insert(t.galley.text().to_string(), center); + } + } + } + (names, view) +} + +/// A document of 30 empty layers named `Layer 1` (the bottom one) to `Layer 30` (the top one): +/// more rows than [`short_frame`]'s list shows. +fn thirty_layers() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + for i in 2..=30 { + app.session.execute("layer.new", &json!({"top": true, "name": format!("Layer {i}")})).unwrap(); + } + app +} + +/// The layers' names, bottom first. +fn layer_names(app: &VectorcraftApp) -> Vec { + app.session.active().unwrap().doc.layers.iter().map(|l| l.display_name()).collect() +} + +/// Hold the button still at `at` (a row is held) until `name`'s row is fully in view; → its center. +fn hold_until(app: &mut VectorcraftApp, ctx: &egui::Context, at: egui::Pos2, name: &str, alt: bool) -> Option { + short_frame(app, ctx, vec![egui::Event::PointerMoved(at)], alt); + for _ in 0..500 { + let (names, view) = short_frame(app, ctx, vec![], alt); + if let Some(&c) = names.get(name) + && c.y - ROW_MEDIUM / 2.0 >= view.top() + && c.y + ROW_MEDIUM / 2.0 <= view.bottom() + { + return Some(c); + } + } + None +} + +#[test] +fn rows_held_near_the_bottom_or_top_edge_scroll_the_list_to_a_row_out_of_view() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + let (names, view) = short_frame(&mut app, &ctx, vec![], false); + assert!(names.contains_key("Layer 30") && !names.contains_key("Layer 1"), "the list overflows: {names:?}"); + let before = (undo(&app), layer_names(&app)); + // Layer 30 held still just above the list's bottom edge: the list scrolls down to Layer 1. + 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 edge = egui::pos2(grab.x, view.bottom() - 3.0); + let target = hold_until(&mut app, &ctx, edge, "Layer 1", false).expect("Layer 1 scrolled into view while the row was held"); + assert_eq!((undo(&app), layer_names(&app)), before, "scrolling alone changes nothing"); + // Dropped on the upper edge of Layer 1's row: above it. + let drop = target - vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], false); + short_frame(&mut app, &ctx, vec![button(drop, false)], false); + let names = layer_names(&app); + assert_eq!(&names[..2], ["Layer 1", "Layer 30"], "{names:?}"); + assert_eq!(undo(&app), before.0 + 1); + // Released, the list stays where it is with the pointer in the top band. + let (rows, _) = short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(drop.x, view.top() + 3.0))], false); + for _ in 0..10 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "no scrolling after the release"); + } + // Layer 2, Alt-dragged to the top edge: the list scrolls back up, and dropped on the lower edge + // of Layer 29's row a copy goes below it. + let grab = rows["Layer 2"]; + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab)], true); + short_frame(&mut app, &ctx, vec![button(grab, true)], true); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(grab - vec2(0.0, 6.0))], true); + let (_, view) = short_frame(&mut app, &ctx, vec![], true); + let target = hold_until(&mut app, &ctx, egui::pos2(grab.x, view.top() + 3.0), "Layer 29", true).expect("Layer 29 scrolled into view"); + let drop = target + vec2(0.0, ROW_MEDIUM * 0.4); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(drop)], true); + short_frame(&mut app, &ctx, vec![button(drop, false)], true); + let names = layer_names(&app); + assert_eq!(names.len(), 31); + assert_eq!((names[28].as_str(), names[30].as_str()), ("Layer 28", "Layer 29"), "{names:?}"); + assert!(names[29].starts_with("Layer 2"), "the copy, below Layer 29: {names:?}"); + assert_eq!(names[2], "Layer 2", "the original stays"); +} + +#[test] +fn the_list_scrolls_only_for_rows_held_over_it() { + let mut app = thirty_layers(); + let ctx = egui::Context::default(); + let (rows, view) = short_frame(&mut app, &ctx, vec![], false); + // The pointer in the bottom band with no button down: nothing scrolls. + let edge = egui::pos2(150.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(edge)], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "hovering scrolls nothing"); + } + // A row held in the bottom band beside the list (right of its view): nothing scrolls, and + // the release there moves nothing. + let before = layer_names(&app); + let grab = rows["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 beside = egui::pos2(view.right() + 2.0, view.bottom() - 3.0); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(beside)], false); + // (Layer 30's dimmed copy follows the pointer, so another row is compared.) + assert!(rows.contains_key("Layer 27")); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0.get("Layer 27"), rows.get("Layer 27"), "held beside the list"); + } + short_frame(&mut app, &ctx, vec![button(beside, false)], false); + short_frame(&mut app, &ctx, vec![], false); + assert_eq!(layer_names(&app), before); + // The eye column dragged down into the band: a column drag, which scrolls nothing. + let eye = egui::pos2(12.0, grab.y); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye)], false); + short_frame(&mut app, &ctx, vec![button(eye, true)], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(eye + vec2(0.0, 6.0))], false); + short_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(egui::pos2(12.0, view.bottom() - 3.0))], false); + for _ in 0..20 { + assert_eq!(short_frame(&mut app, &ctx, vec![], false).0, rows, "a column drag"); + } + short_frame(&mut app, &ctx, vec![button(egui::pos2(12.0, view.bottom() - 3.0), false)], false); +} + /// The centres of the bottom bar's buttons, right to left from under the right end of its top /// divider: Delete, New Layer, New Sublayer, Make/Release Clipping Mask, Locate Object. fn bar_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { diff --git a/crates/ui-egui/src/panels/libraries.rs b/crates/ui-egui/src/panels/libraries.rs index 78ce76da32..cb62803a04 100644 --- a/crates/ui-egui/src/panels/libraries.rs +++ b/crates/ui-egui/src/panels/libraries.rs @@ -49,8 +49,6 @@ const LIST_VIEW: &str = "library-list-view"; const BAR: f32 = 42.0; /// The least height the list takes. const MIN_LIST: f32 = 140.0; -/// How near the list's top or bottom edge a drag scrolls it. -const EDGE: f32 = 28.0; /// The current library: its id and a copy of it (the panel menu acts on it after reading). fn current(app: &VectorcraftApp) -> Option<(String, Library)> { @@ -311,7 +309,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } }); if items.grab.is_some() { - scroll_at_edges(ui); + super::scroll_at_edges(ui); } }); out.inner_rect @@ -658,22 +656,6 @@ fn no_group_strip(ui: &mut Ui) { ui.add_space(6.0); } -/// While something is dragged near the top or bottom edge of the scrolled list (`ui`: its -/// contents), scroll that way, faster nearer the edge. -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..EDGE).contains(&depth) { 2.0 + 10.0 * (1.0 - depth / 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)); - ui.ctx().request_repaint(); - } -} - /// The drag in progress held over a section of the list (`sections`, seen through `clip`), away /// from the items that take one: an item goes into that section's group (the ungrouped section: /// out of its group), and something new is added there, the section outlined; a group goes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..83d10986bc 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -510,7 +510,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type core | 9 | 80% | Middle Eastern features beyond bidi layout, Paragraph Direction, digit types and Kashidas (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); paint-only harakat keep joining with conditional GDEF-mark colour ownership, but fonts without GDEF mark classes and some AAT/`morx` ligatures can still leave marks painted as their letters; Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), dynamic symbols' `.ai` round trip, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type and on-canvas dynamic part paint (gradient/pattern, opacity, blend) are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 | | Image Trace, graphs, image tools | 3 | 72% | graph Column designs (Graph Design, Marker designs and series colours set with Group Selection are done), Create Object Mosaic, per-feature stroke/fill choice and varying widths in Image Trace's Create Strokes (Vector Halftone and the Image Trace View modes, Palette and Create Strokes are done) | 6–10 | -| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | +| Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812; distance and angle labels while drawing, #2983) | 2–4; status-bar X/Y uses the active ruler origin and General units | | File formats | 6 | 85% | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke, adding no text (#6971; scores and estimates unchanged); Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | @@ -832,6 +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`). - 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`. From 7232875e9f9a5e9193f04e1b38ff3ce16a5842d7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:53:01 -0800 Subject: [PATCH 2/2] 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. --- crates/ui-egui/src/panels.rs | 18 ++++++--- crates/ui-egui/src/panels/layers/tests.rs | 48 ++++++++++++++++++++++- docs/target-app-parity.md | 2 +- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 340e355f3a..62538d8f2f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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(); } } diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs index 90091dceeb..2b2b609ae2 100644 --- a/crates/ui-egui/src/panels/layers/tests.rs +++ b/crates/ui-egui/src/panels/layers/tests.rs @@ -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, alt: bool) -> (HashMap, 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, + alt: bool, + dt: f32, +) -> (HashMap, 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(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 83d10986bc..19ad4320b4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`.