diff --git a/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml b/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml index ba8c0568..4c0b6c1e 100644 --- a/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml +++ b/samples/SampleApp/DemoPages/GroupedTileListBoxDemo.axaml @@ -304,6 +304,7 @@ SelectionMode="Multiple" with SelectedItems bound two-way. Pointer: click replaces, Ctrl/Cmd+click toggles, Shift+click selects a range. + Drag on empty space to marquee-select (Ctrl/Cmd+drag adds to the selection); drag past the top/bottom edge to auto-scroll. Keyboard: arrows move, Shift+arrows extend the range, Ctrl/Cmd+Space toggles the current tile, Ctrl/Cmd+A selects all. @@ -344,11 +345,10 @@ MultiSelectedItems { get; } = new(); + // Scenario 5 source. Deliberately large and multi-grouped (many groups, many items per group, + // uneven counts so last rows are partial) so marquee drag-selection, cross-group rectangles, and + // edge auto-scroll can all be exercised. Kept separate from GroupedItems so Scenario 1 is unaffected. + public List MultiSelectGroupedItems { get; } = GenerateMultiSelectItems(); + + private static List GenerateMultiSelectItems() + { + // (group label, item count) — varied counts on purpose to produce partial last rows. + (string Group, int Count)[] groups = + { + ("Group A", 42), + ("Group B", 55), + ("Group C", 63), + ("Group D", 48), + ("Group E", 70), + ("Group F", 39), + ("Group G", 58), + ("Group H", 51), + }; + + var items = new List(); + foreach ((string group, int count) in groups) + { + for (int i = 1; i <= count; i++) + { + items.Add(new FoodItem($"{group} · {i:D2}", group)); + } + } + + return items; + } + // Scenario 1: Named groups public List GroupedItems { get; } = new() { diff --git a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml index 863384f0..109894c9 100644 --- a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml +++ b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml @@ -2,6 +2,13 @@ xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + + + + @@ -29,10 +36,25 @@ BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="{TemplateBinding CornerRadius}" ClipToBounds="True"> - + + + + + + + diff --git a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs index b5ef973d..0b8eb232 100644 --- a/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs +++ b/src/Devolutions.AvaloniaControls/Controls/GroupedTileListBox/GroupedTileListBox.axaml.cs @@ -21,6 +21,7 @@ namespace Devolutions.AvaloniaControls.Controls; /// Items are displayed in a wrapping grid with uniform tile sizes. /// [TemplatePart("PART_ScrollViewer", typeof(ScrollViewer), IsRequired = true)] +[TemplatePart("PART_SelectionRectangle", typeof(Border))] [RequiresUnreferencedCode("BindingEvaluator require preserved types")] public class GroupedTileListBox : TemplatedControl { @@ -73,6 +74,11 @@ public class GroupedTileListBox : TemplatedControl nameof(AutoScrollToSelectedItem), defaultValue: true); + public static readonly StyledProperty EnableRectangleSelectionProperty = + AvaloniaProperty.Register( + nameof(EnableRectangleSelection), + defaultValue: true); + public static readonly StyledProperty?> GroupSelectorProperty = AvaloniaProperty.Register?>( "GroupSelector"); @@ -106,6 +112,29 @@ public class GroupedTileListBox : TemplatedControl private ItemsRepeater? itemsRepeater; private ScrollViewer? scrollViewer; private Control? content; + private Border? selectionRectangle; + + // --- Marquee (drag-to-select rectangle) state --- + // Pixels the pointer must travel from the press point before a press becomes a marquee drag + // (so a plain click on empty space doesn't start one). + private const double MarqueeDragThreshold = 4.0; + + // Distance from the top/bottom viewport edge that triggers auto-scroll, and the largest scroll + // step applied per timer tick at maximum edge depth. + private const double MarqueeAutoScrollEdge = 24.0; + private const double MarqueeAutoScrollMaxStep = 20.0; + + private bool marqueePending; + private bool marqueeActive; + private bool marqueeCtrlHeld; + private Point marqueeViewportPress; + private Point marqueeViewportLastPoint; + private Point marqueeContentAnchor; + private List marqueeBaseline = new(); + private List marqueeOriginal = new(); + private object? marqueeOriginalPrimary; + private object? marqueeOriginalAnchor; + private DispatcherTimer? marqueeScrollTimer; private int selectedIndex = -1; private object? selectedItem; @@ -254,6 +283,18 @@ public bool AutoScrollToSelectedItem set => this.SetValue(AutoScrollToSelectedItemProperty, value); } + /// + /// Gets or sets whether dragging a rectangle over empty space selects the tiles it covers + /// (Windows Explorer-style marquee selection). Defaults to true. Only has an effect when + /// includes ; set to false + /// to opt out while still allowing multi-selection via click/keyboard. + /// + public bool EnableRectangleSelection + { + get => this.GetValue(EnableRectangleSelectionProperty); + set => this.SetValue(EnableRectangleSelectionProperty, value); + } + /// /// Gets or sets the function used to determine the group for each item. /// @@ -320,6 +361,9 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) this.scrollViewer = e.NameScope.Get("PART_ScrollViewer"); + // Optional: templates that omit the marquee overlay simply have no rubber-band visual. + this.selectionRectangle = e.NameScope.Find("PART_SelectionRectangle"); + // Always create ItemsRepeater(s) dynamically in UpdateItemsRepeater this.UpdateItemsRepeater(); @@ -344,6 +388,18 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e { base.OnDetachedFromVisualTree(e); + // Detaching interrupts an active drag (it revokes pointer capture), so honor the cancellation + // contract and revert to the pre-drag selection — nothing reconciles it afterward the way a + // source change would. A merely-pending press has no selection change to revert. + if (this.marqueeActive) + { + this.CancelMarquee(); + } + else + { + this.AbortMarquee(); + } + // Unsubscribe from collection changes to prevent memory leaks if (this.collectionChangedSource is not null) { @@ -354,6 +410,9 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e private void OnItemsSourceChanged(AvaloniaPropertyChangedEventArgs _) { + // Item geometry is about to change; drop any in-progress marquee. + this.AbortMarquee(); + // Unsubscribe from old collection if (this.collectionChangedSource is not null) { @@ -371,6 +430,11 @@ private void OnItemsSourceChanged(AvaloniaPropertyChangedEventArgs _) } this.UpdateItemsRepeater(); + + // Match OnCollectionChanged: the new source may not contain the previously selected items (and + // a marquee we just aborted leaves its transient selection behind), so drop selected items that + // are absent from the new source and re-derive the primary. + this.ReconcileSelectionWithItemsSource(); } /// @@ -380,6 +444,11 @@ private void OnItemsSourceChanged(AvaloniaPropertyChangedEventArgs _) /// private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { + // Item geometry and membership are about to change; drop any in-progress marquee so its stale + // baseline/anchor can't re-add a removed item on the next move or on Escape. Runs before the + // reconcile below, which then drops vanished items from SelectedItems cleanly. + this.AbortMarquee(); + // Invalidate cached index mappings - they'll rebuild on next access this.InvalidateIndexMappings(); @@ -689,6 +758,19 @@ private void OnContainerPointerPressed(object? sender, PointerPressedEventArgs e return; } + // Right-click preserves an existing multi-selection so a context menu can act on every selected + // item; it only selects (replacing) when the target isn't already part of the selection. Left + // unhandled so the context menu can still open. + if (e.GetCurrentPoint(container).Properties.PointerUpdateKind == PointerUpdateKind.RightButtonPressed) + { + if (!this.IsItemSelected(item)) + { + this.SelectItem(item); + } + + return; + } + if (this.SelectionMode.HasFlag(SelectionMode.Multiple)) { bool toggle = HasToggleModifier(e.KeyModifiers) || this.SelectionMode.HasFlag(SelectionMode.Toggle); @@ -806,6 +888,9 @@ private void PromotePrimary(object item) => this.BeginSelectionUpdate(() => private void OnSelectionModeChanged(AvaloniaPropertyChangedEventArgs _) => this.BeginSelectionUpdate(() => { + // A mode switch mid-drag invalidates the marquee (e.g. Multiple was just cleared). + this.AbortMarquee(); + // Leaving multiple selection: collapse down to the primary item only. if (!this.SelectionMode.HasFlag(SelectionMode.Multiple) && (this.SelectedItems?.Count ?? 0) > 1) { @@ -959,6 +1044,14 @@ protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); + // Escape aborts an in-progress marquee, restoring the pre-drag selection. + if (this.marqueeActive && e.Key == Key.Escape) + { + this.CancelMarquee(); + e.Handled = true; + return; + } + if (e.Handled || this.ItemsSource is null) { return; @@ -1509,61 +1602,18 @@ private double CalculateScrollOffsetForItem(object item) return 0; } - int visualIndex = this.GetVisualIndexFromItem(item); - if (visualIndex < 0) + // Reuse the shared layout walk so scroll positioning and marquee hit-testing can never + // disagree about where a tile sits vertically. rowTop is in the same padding-excluded + // "offset space" this method has always returned. + foreach ((object candidate, _, double rowTop) in this.EnumerateItemLayout()) { - return 0; - } - - int itemsPerRow = this.CalculateItemsPerRow(); - if (itemsPerRow <= 0) - { - return 0; - } - - Func? groupSelector = this.ResolveGroupSelector(); - if (!this.useGrouping || groupSelector is null) - { - // Simple calculation for non-grouped mode - int row = visualIndex / itemsPerRow; - return row * (this.ItemHeight + this.ItemSpacing); - } - - IEnumerable> orderedGroups = this.GetOrderedGroups(this.ItemsSource.Cast().GroupBy(groupSelector)); - - double offset = 0; - int itemsSoFar = 0; - - foreach (IGrouping group in orderedGroups) - { - int groupItemCount = group.Count(); - bool hasHeader = group.Key is string groupName && !string.IsNullOrEmpty(groupName); - - // Add header height for this group (only if it has a visible header) - if (hasHeader) - { - offset += (this.cachedHeaderHeight ?? 0) + this.ItemSpacing; - } - - if (itemsSoFar + groupItemCount > visualIndex) + if (ReferenceEquals(candidate, item)) { - // Target item is in this group - calculate position within this group - int itemIndexInGroup = visualIndex - itemsSoFar; - int rowInGroup = itemIndexInGroup / itemsPerRow; - offset += rowInGroup * (this.ItemHeight + this.ItemSpacing); - - break; + return rowTop; } - - // Add remaining group height (items + spacing) - int rowsInGroup = (int)Math.Ceiling((double)groupItemCount / itemsPerRow); - offset += rowsInGroup * (this.ItemHeight + this.ItemSpacing); - offset += this.ItemSpacing; // Group spacing - - itemsSoFar += groupItemCount; } - return offset; + return 0; } /// @@ -1692,6 +1742,580 @@ private void ScrollToElement(Control element) } } + // --------------------------------------------------------------------------------------------- + // Marquee (drag-to-select rectangle) — Windows Explorer-style rubber-band selection. + // Active only when EnableRectangleSelection && SelectionMode includes Multiple. + // --------------------------------------------------------------------------------------------- + + private bool IsRectangleSelectionEnabled() => + this.EnableRectangleSelection + && this.SelectionMode.HasFlag(SelectionMode.Multiple) + && this.scrollViewer is not null; + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + + // A press that reaches the control (rather than being handled by a tile's + // OnContainerPointerPressed) landed on empty space — the natural marquee origin. + if (e.Handled || !this.IsRectangleSelectionEnabled() || this.scrollViewer is null) + { + return; + } + + PointerPoint point = e.GetCurrentPoint(this.scrollViewer); + + // Start only on the left-button press that begins the gesture. Ignore secondary-button presses + // arriving while the left button is already held (or while a marquee is already in progress), + // so they can't reset an in-progress drag. + if (point.Properties.PointerUpdateKind != PointerUpdateKind.LeftButtonPressed + || this.marqueePending + || this.marqueeActive) + { + return; + } + + this.marqueePending = true; + this.marqueeActive = false; + this.marqueeCtrlHeld = HasToggleModifier(e.KeyModifiers); + this.marqueeViewportPress = point.Position; + this.marqueeViewportLastPoint = point.Position; + + // Capture the pointer so the whole press -> move -> release sequence is delivered to this + // control even when the drag runs over tiles or past the control's edge (which it must, to + // reach the auto-scroll edge zone). Released on pointer-up; OnPointerCaptureLost cancels the + // marquee if the OS revokes capture mid-drag. + e.Pointer.Capture(this); + e.Handled = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + + if ((!this.marqueePending && !this.marqueeActive) || this.scrollViewer is null) + { + return; + } + + // Avalonia raises PointerMoved (not PointerReleased) with LeftButtonReleased when the left + // button is released while another button stays held; PointerReleased is deferred to the last + // button. Finish the gesture here so it still ends on the initiating button's release. + if (e.GetCurrentPoint(this.scrollViewer).Properties.PointerUpdateKind == PointerUpdateKind.LeftButtonReleased) + { + this.CompletePointerGesture(e); + return; + } + + Point viewportPoint = e.GetPosition(this.scrollViewer); + this.marqueeViewportLastPoint = viewportPoint; + + // Only promote a pending press to an actual marquee once the pointer moves far enough, + // so a plain click on empty space stays a click. + if (this.marqueePending && !this.marqueeActive) + { + Vector delta = viewportPoint - this.marqueeViewportPress; + if (Math.Abs(delta.X) >= MarqueeDragThreshold || Math.Abs(delta.Y) >= MarqueeDragThreshold) + { + this.BeginMarquee(); + } + } + + if (this.marqueeActive) + { + this.UpdateMarquee(viewportPoint); + } + + e.Handled = true; + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + + // Only the release of the initiating left button ends the gesture; a secondary button released + // while the left button is still held must not commit or cancel an in-progress marquee. + if (e.InitialPressMouseButton != MouseButton.Left) + { + return; + } + + this.CompletePointerGesture(e); + } + + /// + /// Finishes a marquee gesture: commits an active marquee (or clears the selection on a plain + /// empty-space click), clears the pending flag, and releases pointer capture. Invoked from + /// and from when Avalonia signals the + /// left-button release via a move event (left released while another button is still held). + /// + private void CompletePointerGesture(PointerEventArgs e) + { + if (this.marqueeActive) + { + // Commit the marquee selection. + this.EndMarquee(); + e.Handled = true; + } + else if (this.marqueePending) + { + // Plain click on empty space clears the selection (Explorer); Ctrl/Cmd click leaves it. + if (!this.marqueeCtrlHeld) + { + this.ClearSelection(); + } + + e.Handled = true; + } + + this.marqueePending = false; + + if (ReferenceEquals(e.Pointer.Captured, this)) + { + e.Pointer.Capture(null); + } + } + + protected override void OnPointerCaptureLost(PointerCaptureLostEventArgs e) + { + base.OnPointerCaptureLost(e); + + // Genuine interruption (not our own release, which clears marqueeActive first): revert. + this.CancelMarquee(); + this.marqueePending = false; + } + + private void BeginMarquee() + { + if (this.scrollViewer is null) + { + return; + } + + this.marqueePending = false; + this.marqueeActive = true; + + // Snapshot the pre-drag selection so Escape / lost-capture can restore it exactly: the whole + // collection plus the primary and range anchor (neither is necessarily the last item). + this.marqueeOriginal = this.SelectedItems is { } current + ? [..current.Cast()] + : []; + this.marqueeOriginalPrimary = this.selectedItem; + this.marqueeOriginalAnchor = this.anchorItem; + + // Baseline the marquee builds upon: Ctrl/Cmd adds to the existing selection (union); + // a plain drag replaces it (empty baseline). + this.marqueeBaseline = this.marqueeCtrlHeld + ? [..this.marqueeOriginal] + : []; + + // Anchor is fixed in content space so auto-scroll keeps extending the rectangle correctly. + this.marqueeContentAnchor = this.marqueeViewportPress + this.scrollViewer.Offset; + + // Take focus so Escape reaches OnKeyDown during the drag. + this.Focus(); + + this.StartMarqueeAutoScroll(); + this.UpdateMarquee(this.marqueeViewportLastPoint); + } + + private void UpdateMarquee(Point currentViewport) + { + if (this.scrollViewer is null) + { + return; + } + + Vector offset = this.scrollViewer.Offset; + + // Clamp the selection endpoint to the viewport: while the pointer is captured it can sit far + // outside the control, and feeding that out-of-bounds distance into the content rect would select + // rows well past the visible edge instead of letting auto-scroll reveal (and select) them + // progressively. marqueeViewportLastPoint stays raw so it still drives the auto-scroll speed. + Point clampedViewport = new( + Math.Clamp(currentViewport.X, 0, this.scrollViewer.Viewport.Width), + Math.Clamp(currentViewport.Y, 0, this.scrollViewer.Viewport.Height)); + Point currentContent = clampedViewport + offset; + + double x1 = Math.Min(this.marqueeContentAnchor.X, currentContent.X); + double y1 = Math.Min(this.marqueeContentAnchor.Y, currentContent.Y); + double x2 = Math.Max(this.marqueeContentAnchor.X, currentContent.X); + double y2 = Math.Max(this.marqueeContentAnchor.Y, currentContent.Y); + + Rect contentMarquee = new(x1, y1, x2 - x1, y2 - y1); + + // The overlay is drawn in viewport space: the same rectangle translated back by the scroll offset. + this.ShowSelectionRectangle(new Rect(contentMarquee.Position - offset, contentMarquee.Size)); + + this.ApplyMarqueeSelection(this.HitTestItems(contentMarquee)); + } + + private void EndMarquee() + { + this.StopMarqueeAutoScroll(); + this.HideSelectionRectangle(); + this.marqueeActive = false; + this.marqueePending = false; + + // A plain drag that ended up empty must still honor AlwaysSelected. If the invariant re-adds an + // item, re-derive the primary/anchor and refresh visuals so we don't finish with SelectedItems + // holding an item while SelectedItem/SelectedIndex are still null/-1. + this.BeginSelectionUpdate(() => + { + this.EnsureAlwaysSelectedInvariant(); + + if (this.selectedItem is null || !this.IsItemSelected(this.selectedItem)) + { + object? primary = this.GetLastSelectedItem(); + this.selectedItem = primary; + this.selectedIndex = primary is null ? -1 : this.GetIndexOfItem(primary); + this.anchorItem = primary; + this.SetCurrentValue(SelectedItemProperty, this.selectedItem); + this.SetCurrentValue(SelectedIndexProperty, this.selectedIndex); + this.UpdateRealizedContainerSelection(); + } + }); + + this.marqueeBaseline = []; + this.marqueeOriginal = []; + this.marqueeOriginalPrimary = null; + this.marqueeOriginalAnchor = null; + } + + private void CancelMarquee() + { + if (!this.marqueeActive) + { + return; + } + + this.marqueeActive = false; + this.marqueePending = false; + this.StopMarqueeAutoScroll(); + this.HideSelectionRectangle(); + + // Restore exactly the pre-drag selection — membership AND order, plus primary and range anchor. + // A remove+append reconcile (ApplySelectionSet) can leave surviving items out of their original + // order, so rebuild the collection from the snapshot instead. + this.RestoreSelectionExactly(this.marqueeOriginal, this.marqueeOriginalPrimary, this.marqueeOriginalAnchor); + + this.marqueeBaseline = []; + this.marqueeOriginal = []; + this.marqueeOriginalPrimary = null; + this.marqueeOriginalAnchor = null; + } + + /// + /// Aborts an in-progress marquee without restoring the pre-drag selection. Used when the underlying + /// data or attachment changes and the surrounding code already reconciles the selection. + /// + private void AbortMarquee() + { + this.StopMarqueeAutoScroll(); + this.HideSelectionRectangle(); + this.marqueeActive = false; + this.marqueePending = false; + this.marqueeBaseline = []; + this.marqueeOriginal = []; + this.marqueeOriginalPrimary = null; + this.marqueeOriginalAnchor = null; + } + + /// + /// Applies the marquee's target selection: baseline ∪ hitItems, with the last hit item as the + /// primary. Recomputed from scratch on every pointer move / auto-scroll tick. + /// + private void ApplyMarqueeSelection(List hitItems) + { + List target = new(this.marqueeBaseline.Count + hitItems.Count); + HashSet seen = new(ReferenceEqualityComparer.Instance); + + foreach (object item in this.marqueeBaseline) + { + if (seen.Add(item)) + { + target.Add(item); + } + } + + foreach (object item in hitItems) + { + if (seen.Add(item)) + { + target.Add(item); + } + } + + if (hitItems.Count == 0 && this.marqueeCtrlHeld) + { + // Union (Ctrl/Cmd) drag with nothing under the rectangle is a no-op over the baseline: keep + // the pre-drag primary and range anchor rather than promoting the last baseline item. + this.ApplySelectionSet(target, this.marqueeOriginalPrimary, this.marqueeOriginalAnchor); + return; + } + + // Has hits → last hit is primary/anchor; a replace drag with no hits clears to null. + object? primary = hitItems.Count > 0 ? hitItems[^1] : null; + + this.ApplySelectionSet(target, primary, primary); + } + + /// + /// Reconciles to the membership of + /// (reference equality) by dropping obsolete entries and appending newcomers in target order, then + /// sets the primary item. Minimizes collection churn for the live drag; surviving items keep their + /// current relative order (use when exact order matters). Does + /// not auto-scroll (the marquee drives scrolling itself). + /// + private void ApplySelectionSet(IReadOnlyList orderedTarget, object? primary, object? anchor) => this.BeginSelectionUpdate(() => + { + if (this.SelectedItems is not { } selectedItems) + { + return; + } + + HashSet targetSet = new(orderedTarget, ReferenceEqualityComparer.Instance); + + // Drop items no longer in the target. + for (int i = selectedItems.Count - 1; i >= 0; i--) + { + if (selectedItems[i] is not { } existing || !targetSet.Contains(existing)) + { + selectedItems.RemoveAt(i); + } + } + + // Append newcomers in target order. Membership is tracked in a set updated as we go, so this + // stays O(N) instead of rescanning the growing collection for each item (O(N^2) per move/tick). + HashSet present = new(selectedItems.Cast(), ReferenceEqualityComparer.Instance); + foreach (object item in orderedTarget) + { + if (present.Add(item)) + { + selectedItems.Add(item); + } + } + + this.selectedItem = primary; + this.selectedIndex = primary is null ? -1 : this.GetIndexOfItem(primary); + this.anchorItem = anchor; + + this.SetCurrentValue(SelectedItemProperty, this.selectedItem); + this.SetCurrentValue(SelectedIndexProperty, this.selectedIndex); + + this.UpdateRealizedContainerSelection(); + }); + + /// + /// Restores to exactly (same items, same + /// order) and sets the given primary and range anchor. Used to revert a cancelled marquee; rebuilds + /// the collection so surviving items can't be left out of their original order. + /// + private void RestoreSelectionExactly(IReadOnlyList original, object? primary, object? anchor) => this.BeginSelectionUpdate(() => + { + if (this.SelectedItems is not { } selectedItems) + { + return; + } + + selectedItems.Clear(); + foreach (object item in original) + { + selectedItems.Add(item); + } + + this.selectedItem = primary; + this.selectedIndex = primary is null ? -1 : this.GetIndexOfItem(primary); + this.anchorItem = anchor; + + this.SetCurrentValue(SelectedItemProperty, this.selectedItem); + this.SetCurrentValue(SelectedIndexProperty, this.selectedIndex); + + this.UpdateRealizedContainerSelection(); + }); + + /// + /// Returns every item whose content-space rectangle intersects . + /// Works on virtualized (non-realized) items because rectangles are computed analytically. + /// + private List HitTestItems(Rect contentMarquee) + { + List hits = new(); + foreach ((object item, Rect rect) in this.EnumerateItemContentRects()) + { + if (contentMarquee.Intersects(rect)) + { + hits.Add(item); + } + } + + return hits; + } + + /// + /// Walks every item in visual order and yields its column plus the Y offset of the top of its row, + /// in padding-excluded "offset space": zero for the first row, then accumulating group-header + /// heights, row heights, and inter-group spacing exactly as the WrapLayout(s) arrange the tiles. + /// This is the single source of truth for the control's vertical tile geometry — both scroll + /// positioning () and marquee hit-testing + /// () consume it, so the two cannot drift apart. + /// + private IEnumerable<(object item, int column, double rowTop)> EnumerateItemLayout() + { + if (this.ItemsSource is null) + { + yield break; + } + + int itemsPerRow = this.CalculateItemsPerRow(); + if (itemsPerRow <= 0) + { + yield break; + } + + double stepY = this.ItemHeight + this.ItemSpacing; + Func? groupSelector = this.ResolveGroupSelector(); + + if (!this.useGrouping || groupSelector is null) + { + int i = 0; + foreach (object item in this.ItemsSource) + { + if (item is not null) + { + yield return (item, i % itemsPerRow, (i / itemsPerRow) * stepY); + } + + i++; + } + + yield break; + } + + double baseY = 0; + foreach (IGrouping group in this.GetOrderedGroups(this.ItemsSource.Cast().GroupBy(groupSelector))) + { + List groupItems = group.ToList(); + bool hasHeader = !string.IsNullOrEmpty(group.Key); + + if (hasHeader) + { + baseY += (this.cachedHeaderHeight ?? 0) + this.ItemSpacing; + } + + // Columns restart per group (each group has its own WrapLayout). + for (int i = 0; i < groupItems.Count; i++) + { + yield return (groupItems[i], i % itemsPerRow, baseY + ((i / itemsPerRow) * stepY)); + } + + int rowsInGroup = (int)Math.Ceiling((double)groupItems.Count / itemsPerRow); + baseY += (rowsInGroup * stepY) + this.ItemSpacing; + } + } + + /// + /// Returns every item together with its rectangle in content-space coordinates (i.e. including the + /// control Padding), derived from the shared walk. Works on + /// virtualized (non-realized) items because rectangles are computed analytically. + /// + private IEnumerable<(object item, Rect contentRect)> EnumerateItemContentRects() + { + double stepX = this.ItemWidth + this.ItemSpacing; + double padLeft = this.Padding.Left; + double padTop = this.Padding.Top; + double width = this.ItemWidth; + double height = this.ItemHeight; + + foreach ((object item, int column, double rowTop) in this.EnumerateItemLayout()) + { + double x = padLeft + (column * stepX); + double y = padTop + rowTop; + yield return (item, new Rect(x, y, width, height)); + } + } + + private void ShowSelectionRectangle(Rect viewportRect) + { + if (this.selectionRectangle is null) + { + return; + } + + Canvas.SetLeft(this.selectionRectangle, viewportRect.X); + Canvas.SetTop(this.selectionRectangle, viewportRect.Y); + this.selectionRectangle.Width = viewportRect.Width; + this.selectionRectangle.Height = viewportRect.Height; + this.selectionRectangle.IsVisible = true; + } + + private void HideSelectionRectangle() + { + if (this.selectionRectangle is not null) + { + this.selectionRectangle.IsVisible = false; + } + } + + private void StartMarqueeAutoScroll() + { + if (this.marqueeScrollTimer is null) + { + this.marqueeScrollTimer = new DispatcherTimer + { + Interval = TimeSpan.FromMilliseconds(16), + }; + this.marqueeScrollTimer.Tick += this.OnMarqueeAutoScrollTick; + } + + this.marqueeScrollTimer.Start(); + } + + private void StopMarqueeAutoScroll() => this.marqueeScrollTimer?.Stop(); + + private void OnMarqueeAutoScrollTick(object? sender, EventArgs e) + { + if (!this.marqueeActive || this.scrollViewer is null) + { + this.StopMarqueeAutoScroll(); + return; + } + + double viewportHeight = this.scrollViewer.Viewport.Height; + double y = this.marqueeViewportLastPoint.Y; + + // Ramp the step by how deep into the edge zone the pointer is (natural acceleration). + double delta = 0; + if (y < MarqueeAutoScrollEdge) + { + double depth = Math.Min(MarqueeAutoScrollEdge, MarqueeAutoScrollEdge - y); + delta = -(depth / MarqueeAutoScrollEdge) * MarqueeAutoScrollMaxStep; + } + else if (y > viewportHeight - MarqueeAutoScrollEdge) + { + double depth = Math.Min(MarqueeAutoScrollEdge, y - (viewportHeight - MarqueeAutoScrollEdge)); + delta = (depth / MarqueeAutoScrollEdge) * MarqueeAutoScrollMaxStep; + } + + if (delta == 0) + { + return; + } + + double maxOffset = Math.Max(0, this.scrollViewer.Extent.Height - viewportHeight); + double newY = Math.Clamp(this.scrollViewer.Offset.Y + delta, 0, maxOffset); + + if (Math.Abs(newY - this.scrollViewer.Offset.Y) < 0.01) + { + return; // Already pinned at the extent. + } + + this.scrollViewer.Offset = new Vector(this.scrollViewer.Offset.X, newY); + + // Re-run the hit test so items revealed by the scroll get selected/deselected. + this.UpdateMarquee(this.marqueeViewportLastPoint); + } + private void SelectItem(object? item) => this.BeginSelectionUpdate(() => { if (item is null)