[Controls] Add drag-to-select marquee to GroupedTileListBox - #602
Conversation
Add Windows Explorer-style rubber-band selection: dragging over empty space selects the tiles the rectangle covers, with edge auto-scroll. - Overlay rectangle drawn in a viewport-fixed Canvas template part; accent-tinted brushes are theme-aware via SystemAccentColor. - EnableRectangleSelection opt-out property (default true); active only when SelectionMode includes Multiple, so single-select is unaffected. - Analytic hit-testing over all items works on virtualized/off-screen tiles; a shared EnumerateItemLayout walk is the single source of truth for tile geometry, reused by CalculateScrollOffsetForItem. - Plain drag replaces the selection; Ctrl/Cmd+drag adds (union). Escape and lost capture restore the pre-drag selection, primary, and anchor. - DispatcherTimer edge auto-scroll with ramped speed. - SampleApp Scenario 5 gains a dedicated multi-group dataset and an updated description to exercise the feature.
There was a problem hiding this comment.
Pull request overview
Adds marquee multi-selection with auto-scroll to GroupedTileListBox.
Changes:
- Implements rectangle selection, cancellation, and analytical hit-testing.
- Adds a themed selection overlay.
- Expands SampleApp’s multi-selection scenario.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
GroupedTileListBox.axaml.cs |
Implements marquee behavior and geometry. |
GroupedTileListBox.axaml |
Adds the selection overlay. |
GroupedTileListBoxViewModel.cs |
Generates a large grouped dataset. |
GroupedTileListBoxDemo.axaml |
Updates the interactive demo. |
Comments suppressed due to low confidence (3)
src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs:1737
- This accepts any press that occurs while the left button is already held. For example, a right-button press during a left-button marquee resets the pending gesture and its anchor. Check that this event itself is the left-button press.
PointerPoint point = e.GetCurrentPoint(this.scrollViewer);
if (!point.Properties.IsLeftButtonPressed)
{
return;
src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs:1788
- Any button release currently commits the active marquee and releases capture. Releasing a secondary mouse button while the left button remains down therefore ends the drag prematurely; only the release paired with the initiating left press should finish it.
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs:1995
- This only removes obsolete entries and appends newcomers; it does not restore
orderedTargetorder. For example, cancelling from original[A, B, C]after a marquee leaves[A, C]produces[A, C, B], so Escape does not restore the exact pre-drag collection promised by this change.
// Add newcomers, preserving target order.
foreach (object item in orderedTarget)
{
if (!ContainsByReference(selectedItems, item))
{
selectedItems.Add(item);
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Address review feedback on the drag-to-select marquee: - Abort the marquee in OnCollectionChanged so an in-place ItemsSource mutation can't leave a stale baseline/anchor that re-adds a removed item on the next move or on Escape. - Start a marquee only on the initiating left-button press (and while none is already in progress), and finish only on that button's release, so secondary buttons pressed/released mid-drag no longer reset or prematurely commit it. - Restore the exact pre-drag selection order when a marquee is cancelled.
Right-clicking an item that is already part of a multi-selection no longer collapses the selection to that single item, so a context menu can act on every selected item. A right-click on an item that is not selected still selects it (replacing), matching Windows Explorer. The press is left unhandled so the context menu can still open.
Address a second round of review feedback: - EndMarquee now re-derives SelectedItem/SelectedIndex, the anchor, and container visuals after EnsureAlwaysSelectedInvariant, so a plain drag that ends empty under AlwaysSelected no longer leaves the collection populated while the primary is null/-1. - UpdateMarquee clamps the selection endpoint to the viewport before converting to content space, so a captured pointer dragged past the edge extends the selection as auto-scroll reveals rows instead of jumping ahead by the out-of-bounds distance. The raw pointer position is kept for the auto-scroll speed ramp. - OnDetachedFromVisualTree cancels an active marquee (reverting to the pre-drag selection) instead of aborting, honoring the lost-capture cancellation contract; a merely-pending press still aborts.
Address a third round of review feedback: - OnItemsSourceChanged now reconciles the selection against the new source (like OnCollectionChanged), so replacing ItemsSource — or aborting a marquee on that path — no longer leaves SelectedItems / SelectedItem referencing items absent from the new source. - ApplySelectionSet appends newcomers using a reference-equality set updated as it goes, replacing the per-item ContainsByReference scan. This makes applying an N-item marquee O(N) instead of O(N^2) on every pointer move / auto-scroll tick.
Address a fourth round of review feedback: - Finish the marquee on the initiating left button's release even when Avalonia reports it via PointerMoved (LeftButtonReleased) because another button is still held and PointerReleased is deferred to the last button. The completion logic is shared between OnPointerReleased and OnPointerMoved via CompletePointerGesture. - A Ctrl/Cmd (union) marquee with no hits is now a true no-op: it keeps the pre-drag primary and range anchor instead of promoting the last baseline item, so dragging over a header/gap no longer changes SelectedItem or subsequent Shift-range behavior. ApplySelectionSet takes an explicit anchor for this. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs:2198
- This grouped path rebuilds
GroupByresults and aListfor every group on every pointer move and 16 ms auto-scroll tick, andHitTestItemscontinues through every item even when the marquee covers only a few visible rows. That makes drag cost proportional to the entire source on the UI thread, defeating virtualization for large lists. Cache/invalidate row geometry or index rows by Y so each update only examines groups/rows intersecting the marquee.
foreach (IGrouping<string, object> group in this.GetOrderedGroups(this.ItemsSource.Cast<object>().GroupBy(groupSelector)))
{
List<object> groupItems = group.ToList();
There was a problem hiding this comment.
🤖 Pull request was approved automatically: the AI review is complete and all its review threads are resolved. 🎉
Integration Details
{
"deliveryId": "fd2903c0-8a01-11f1-8581-c81c50406153",
"headSha": "1eb8ece3fe44658019e3521956f11eefe10f8c6f",
"reviewer": "copilot-pull-request-reviewer[bot]"
}…olours (#607) ## Summary Extracts the `GroupedTileListBox` drag-to-select rubber-band into a dedicated, reusable `RectangleSelectionMarquee` templated control, and gives each theme control over the marquee's fill and border colours. Previously the marquee was an inline `Border` in the `GroupedTileListBox` template, tinted with `SystemAccentColor` via brushes defined locally in `GroupedTileListBox.axaml`. This meant the marquee looked identical across all themes and couldn't be restyled independently. This PR: - Adds `RectangleSelectionMarquee` (`TemplatedControl`) with its own `ControlTheme` in `Devolutions.AvaloniaControls`, registered through `DefaultControlTemplates.axaml`. - Replaces the inline `Border` in `GroupedTileListBox` with the new control (template part type updated accordingly). - Introduces `RectangleSelectionMarqueeFill` / `RectangleSelectionMarqueeBorderBrush` resources per theme: - **DevExpress** and **Linux (Yaru)**: accent-tinted (`SystemAccentColor`), matching the previous Explorer-style rubber-band. - **macOS**: neutral black/white tint (light/dark) for a more native AppKit-style selection rectangle. Builds on the drag-to-select marquee introduced in #602. ## Changes - `RectangleSelectionMarquee.axaml` / `.axaml.cs` — new control + default template - `GroupedTileListBox.axaml` / `.axaml.cs` — use the new control; drop local brush definitions - `DefaultControlTemplates.axaml` — register the new control theme - `ThemeResources.axaml` (DevExpress, Linux, macOS) — per-theme marquee brushes
Summary
Adds Windows Explorer-style rubber-band (marquee) selection to
GroupedTileListBox, building on the multi-selection support from #598. Dragging over empty space draws a rectangle and selects every tile it covers, with auto-scroll when the pointer reaches the top/bottom edge.Behavior
SelectionModeincludesMultiple, and gated by a new opt-outEnableRectangleSelectionproperty (defaulttrue). Single-select usages are unaffected.Implementation notes
Canvastemplate part (PART_SelectionRectangle) shares theScrollViewer's origin, so it needs no coordinate reconciliation and never scrolls with content. Accent-tinted brushes are theme-aware viaSystemAccentColor; the template lives only in the default theme (the per-theme files useBasedOn), so one edit covers all themes.EnumerateItemLayoutwalk (headers, rows, inter-group spacing) is consumed by both the marquee hit-test andCalculateScrollOffsetForItem, so scroll positioning and selection can't drift apart.SelectedItems(reference equality, existing re-entrancy guard), and deliberately bypasses auto-scroll-on-selection so it doesn't fight the marquee's own scrolling.Demo
SampleApp Scenario 5 ("Multiple Selection") now uses a dedicated multi-group dataset (8 groups, uneven counts ≈ 426 items) so groups span many rows and the list scrolls — exercising cross-group rectangles and edge auto-scroll. Its description was updated with the new gesture.
Follow-up fixes (review feedback + edge cases)
OnCollectionChangednow aborts an in-progress marquee, so an in-placeItemsSourcemutation can't leave a stale baseline/anchor that re-adds a removed item on the next move or on Escape.Testing
Devolutions.AvaloniaControlsandSampleAppbuild clean.GroupedTileListBoxDemobaselines need regenerating (per-OS, via CI) — the Scenario 5 description reflow and the new dataset change that page at rest. The overlay itself isIsVisible=falsewhen idle, so it causes no other baseline changes.Please give the marquee an interactive pass (drag across group headers; drag to an edge to confirm auto-scroll into later groups; Ctrl/Cmd+drag union; Escape revert; right-click within a multi-selection).