Skip to content

[Controls] Add drag-to-select marquee to GroupedTileListBox - #602

Merged
Xavier Fortin (xfortin-devolutions) merged 6 commits into
masterfrom
GroupedTileListBox-add-drag-selection
Jul 28, 2026
Merged

[Controls] Add drag-to-select marquee to GroupedTileListBox#602
Xavier Fortin (xfortin-devolutions) merged 6 commits into
masterfrom
GroupedTileListBox-add-drag-selection

Conversation

@xfortin-devolutions

@xfortin-devolutions Xavier Fortin (xfortin-devolutions) commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

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

  • Activation — active only when SelectionMode includes Multiple, and gated by a new opt-out EnableRectangleSelection property (default true). Single-select usages are unaffected.
  • Modifiers — plain drag replaces the selection with the rectangle's contents; Ctrl/Cmd+drag adds (union). A plain click on empty space clears the selection (Explorer-style); Escape or lost pointer-capture reverts to the exact pre-drag selection (collection, primary, and range anchor).
  • Auto-scroll — dragging past the top/bottom edge scrolls with a ramped speed and keeps extending the selection.

Implementation notes

  • Overlay — a viewport-fixed Canvas template part (PART_SelectionRectangle) shares the ScrollViewer's origin, so it needs no coordinate reconciliation and never scrolls with content. Accent-tinted brushes are theme-aware via SystemAccentColor; the template lives only in the default theme (the per-theme files use BasedOn), so one edit covers all themes.
  • Hit-testing is analytic, not container-based — a marquee is a 2D region test (a narrow vertical drag must select only one column, not a contiguous range), and items are virtualized, so off-screen tiles must still be testable. Rectangles are computed analytically from the layout.
  • Single source of truth for tile geometry — a shared EnumerateItemLayout walk (headers, rows, inter-group spacing) is consumed by both the marquee hit-test and CalculateScrollOffsetForItem, so scroll positioning and selection can't drift apart.
  • Selection is applied by diff-reconciling 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)

  • Collection mutation mid-dragOnCollectionChanged now aborts an in-progress marquee, 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.
  • Secondary mouse buttons — a marquee now starts only on the initiating left-button press (and only when none is already in progress) and finishes only on that button's release, so a secondary button pressed/released mid-drag no longer resets or prematurely commits it.
  • Exact-order cancel — cancelling a marquee restores the pre-drag selection in its exact original order.
  • Right-click preserves multi-selection — right-clicking an item that is already selected no longer collapses the selection to that single item, so a context menu can act on every selected item; right-clicking an unselected item still selects it (replacing), and the press is left unhandled so the menu can open.

Testing

  • Devolutions.AvaloniaControls and SampleApp build clean.
  • ✅ App loads the demo page without runtime/template errors.
  • ⚠️ Visual baselines: the GroupedTileListBoxDemo baselines need regenerating (per-OS, via CI) — the Scenario 5 description reflow and the new dataset change that page at rest. The overlay itself is IsVisible=false when 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).

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.
Copilot AI review requested due to automatic review settings July 27, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 orderedTarget order. 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.

Comment thread samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml
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.
Copilot AI review requested due to automatic review settings July 27, 2026 18:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

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.
Copilot AI review requested due to automatic review settings July 27, 2026 19:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

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.
Copilot AI review requested due to automatic review settings July 27, 2026 19:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

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>
Copilot AI review requested due to automatic review settings July 27, 2026 20:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 GroupBy results and a List for every group on every pointer move and 16 ms auto-scroll tick, and HitTestItems continues 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();

Comment thread samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml

@randy-but-a-ro randy-but-a-ro Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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]"
}

@xfortin-devolutions
Xavier Fortin (xfortin-devolutions) merged commit b5950e8 into master Jul 28, 2026
1 check passed
@xfortin-devolutions
Xavier Fortin (xfortin-devolutions) deleted the GroupedTileListBox-add-drag-selection branch July 28, 2026 11:58
Xavier Fortin (xfortin-devolutions) added a commit that referenced this pull request Jul 29, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants