From a10a5b58430396fd73df774ca8f1499a193a0b88 Mon Sep 17 00:00:00 2001 From: Jason Finch Date: Sat, 28 Feb 2026 14:26:08 +1000 Subject: [PATCH] feat: Add Shift+Click and Ctrl+Click range selection for package lists Enable standard Windows Extended selection mode on all three ItemsView package lists (List, Grid, Icons). Users can now Shift+Click or Shift+Arrow to select a contiguous range, Ctrl+Click to select non-contiguous items, and press Space to toggle checkboxes on the entire selection at once. --- src/UniGetUI/Controls/PackageWrapper.cs | 3 + .../SoftwarePages/AbstractPackagesPage.xaml | 12 +- .../AbstractPackagesPage.xaml.cs | 146 ++++++++++++++++-- 3 files changed, 141 insertions(+), 20 deletions(-) diff --git a/src/UniGetUI/Controls/PackageWrapper.cs b/src/UniGetUI/Controls/PackageWrapper.cs index ca9edc465b..a34f80531d 100644 --- a/src/UniGetUI/Controls/PackageWrapper.cs +++ b/src/UniGetUI/Controls/PackageWrapper.cs @@ -90,6 +90,9 @@ public void PackageItemContainer_DoubleTapped(object sender, DoubleTappedRoutedE public void PackageItemContainer_PreviewKeyDown(object sender, KeyRoutedEventArgs e) => _page.PackageItemContainer_PreviewKeyDown(sender, e); + public void PackageItemContainer_Tapped(object sender, TappedRoutedEventArgs e) + => _page.PackageItemContainer_Tapped(sender, e); + public void PackageItemContainer_RightTapped(object sender, RightTappedRoutedEventArgs e) => _page.PackageItemContainer_RightTapped(sender, e); diff --git a/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml b/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml index 0a04e5745b..244b263e93 100644 --- a/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml +++ b/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml @@ -35,6 +35,7 @@ Package="{x:Bind Package}" PreviewKeyDown="{x:Bind PackageItemContainer_PreviewKeyDown}" RightTapped="{x:Bind PackageItemContainer_RightTapped}" + Tapped="{x:Bind PackageItemContainer_Tapped}" Wrapper="{x:Bind Self}"> + Layout="{StaticResource Layout_List}" + SelectionMode="Extended" /> + Layout="{StaticResource Layout_Grid}" + SelectionMode="Extended" /> + Layout="{StaticResource Layout_Icons}" + SelectionMode="Extended" /> diff --git a/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml.cs b/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml.cs index 490b6747d4..c8e6186bf0 100644 --- a/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml.cs +++ b/src/UniGetUI/Pages/SoftwarePages/AbstractPackagesPage.xaml.cs @@ -207,6 +207,16 @@ protected string NoPackages_SubtitleText private string TypeQuery = ""; private int LastKeyDown; private readonly int QUERY_SEPARATION_TIME = 1000; // 500ms between keypresses starts a new query + private int _shiftSelectAnchorIndex = -1; + private int _lastNavigationIndex = -1; + private readonly HashSet _selectedIndices = []; + + private void ResetSelectionState() + { + _selectedIndices.Clear(); + _shiftSelectAnchorIndex = -1; + _lastNavigationIndex = -1; + } protected AbstractPackagesPage(PackagesPageData data) { @@ -592,12 +602,32 @@ protected async Task LoadPackages(ReloadReason reason) } } - private void SelectAndScrollTo(int index, bool focus) + private void SelectAndScrollTo(int index, bool focus, bool extendSelection = false) { if (index < 0 || index >= FilteredPackages.Count) return; - CurrentPackageList.Select(index); + _selectedIndices.Clear(); + if (extendSelection && _shiftSelectAnchorIndex >= 0 && _shiftSelectAnchorIndex < FilteredPackages.Count) + { + CurrentPackageList.DeselectAll(); + int start = Math.Min(_shiftSelectAnchorIndex, index); + int end = Math.Max(_shiftSelectAnchorIndex, index); + for (int i = start; i <= end; i++) + { + CurrentPackageList.Select(i); + _selectedIndices.Add(i); + } + } + else + { + CurrentPackageList.DeselectAll(); + CurrentPackageList.Select(index); + _shiftSelectAnchorIndex = index; + _selectedIndices.Add(index); + } + + _lastNavigationIndex = index; double position; if (CurrentPackageList.Layout is StackLayout) @@ -626,7 +656,10 @@ private void SelectAndScrollTo(int index, bool focus) )); } - if (focus) Focus(FilteredPackages[index].Package); + // Skip Focus during extended selection — calling Focus(FocusState.Keyboard) + // on the new container triggers ItemsView's internal selection handling, + // which clobbers the range selection in Extended mode. + if (focus && !extendSelection) Focus(FilteredPackages[index].Package); } private void Focus(IPackage packageToFocus, int retryCount = 0) @@ -799,6 +832,8 @@ protected void ApplyTextAndIconsToToolbar( /// public void FilterPackages(bool forceQueryUpdate = false) { + ResetSelectionState(); + var previousSelection = CurrentPackageList.SelectedItem as PackageWrapper; List visibleSources = []; @@ -965,6 +1000,7 @@ public void SortPackagesBy(ObservablePackageCollection.Sorter sorter) if(sorter == FilteredPackages.CurrentSorter) FilteredPackages.Descending = !FilteredPackages.Descending; FilteredPackages.SetSorter(sorter); FilteredPackages.Sort(); + ResetSelectionState(); UpdateSortingMenu(); } @@ -972,6 +1008,7 @@ public void SortPackagesBy(bool ascendent) { FilteredPackages.Descending = !ascendent; FilteredPackages.Sort(); + ResetSelectionState(); UpdateSortingMenu(); } @@ -1106,20 +1143,70 @@ public void FocusPackageList() => CurrentPackageList.Focus(FocusState.Programmatic); + private void SelectSingleIndex(int index) + { + CurrentPackageList.DeselectAll(); + CurrentPackageList.Select(index); + _shiftSelectAnchorIndex = index; + _lastNavigationIndex = index; + _selectedIndices.Clear(); + _selectedIndices.Add(index); + } + public async Task ShowContextMenu(PackageWrapper wrapper) { - CurrentPackageList.Select(wrapper.Index); + SelectSingleIndex(wrapper.Index); await Task.Delay(20); if(_lastContextMenuButtonTapped is not null) (CurrentPackageList.ContextFlyout as BetterMenu)?.ShowAt(_lastContextMenuButtonTapped, new FlyoutShowOptions { Placement = FlyoutPlacementMode.RightEdgeAlignedTop }); WhenShowingContextMenu(wrapper.Package); } + public void PackageItemContainer_Tapped(object sender, TappedRoutedEventArgs e) + { + if (sender is PackageItemContainer container && container.Package is not null) + { + int idx = container.Wrapper.Index; + bool isShiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift) + .HasFlag(CoreVirtualKeyStates.Down); + bool isCtrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control) + .HasFlag(CoreVirtualKeyStates.Down); + + if (isCtrlPressed && !isShiftPressed) + { + // Ctrl+Click: toggle this item in/out of selection. + // ItemsView Extended mode handles the visual toggle natively. + if (!_selectedIndices.Remove(idx)) + _selectedIndices.Add(idx); + } + else if (isShiftPressed) + { + if (_shiftSelectAnchorIndex < 0 || _shiftSelectAnchorIndex >= FilteredPackages.Count) + _shiftSelectAnchorIndex = idx; + + // Shift+Click: select range from anchor to clicked item. + // ItemsView handles the visual range natively. + _selectedIndices.Clear(); + int start = Math.Min(_shiftSelectAnchorIndex, idx); + int end = Math.Max(_shiftSelectAnchorIndex, idx); + for (int i = start; i <= end; i++) + _selectedIndices.Add(i); + } + else + { + // Plain click: always collapse to single selection. + // ItemsView Extended mode won't deselect the range when + // clicking an already-selected item, so force it here. + SelectSingleIndex(idx); + } + } + } + public void PackageItemContainer_RightTapped(object sender, RightTappedRoutedEventArgs e) { if (sender is PackageItemContainer container && container.Package is not null) { - CurrentPackageList.Select(container.Wrapper.Index); + SelectSingleIndex(container.Wrapper.Index); container.Focus(FocusState.Keyboard); WhenShowingContextMenu(container.Package); } @@ -1129,7 +1216,7 @@ public void PackageItemContainer_DoubleTapped(object sender, DoubleTappedRoutedE { if (sender is PackageItemContainer container && container.Package is not null) { - CurrentPackageList.Select(container.Wrapper.Index); + SelectSingleIndex(container.Wrapper.Index); container.Focus(FocusState.Keyboard); TEL_InstallReferral referral = TEL_InstallReferral.ALREADY_INSTALLED; @@ -1273,17 +1360,28 @@ public void PackageItemContainer_PreviewKeyDown(object sender, KeyRoutedEventArg return; } - int index = FilteredPackages.IndexOf(packageItemContainer.Wrapper); + bool IS_SHIFT_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + bool IS_CONTROL_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + bool IS_ALT_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftMenu).HasFlag(CoreVirtualKeyStates.Down); + IS_ALT_PRESSED |= InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightMenu).HasFlag(CoreVirtualKeyStates.Down); + + // Use tracked navigation index when valid (e.g. after Shift+Arrow where + // focus intentionally stayed on the anchor item). Fall back to sender index. + int senderIndex = FilteredPackages.IndexOf(packageItemContainer.Wrapper); + int index = _lastNavigationIndex >= 0 && _lastNavigationIndex < FilteredPackages.Count + ? _lastNavigationIndex + : senderIndex; + switch (e.Key) { case VirtualKey.Up when index > 0: - SelectAndScrollTo(index - 1, true); e.Handled = true; break; + SelectAndScrollTo(index - 1, true, IS_SHIFT_PRESSED); e.Handled = true; break; case VirtualKey.Down when index < FilteredPackages.Count - 1: - SelectAndScrollTo(index + 1, true); e.Handled = true; break; + SelectAndScrollTo(index + 1, true, IS_SHIFT_PRESSED); e.Handled = true; break; case VirtualKey.Home when index > 0: - SelectAndScrollTo(0, true); e.Handled = true; break; + SelectAndScrollTo(0, true, IS_SHIFT_PRESSED); e.Handled = true; break; case VirtualKey.End when index < FilteredPackages.Count - 1: - SelectAndScrollTo(FilteredPackages.Count - 1, true); e.Handled = true; break; + SelectAndScrollTo(FilteredPackages.Count - 1, true, IS_SHIFT_PRESSED); e.Handled = true; break; } if (e.KeyStatus.WasKeyDown) @@ -1294,11 +1392,6 @@ public void PackageItemContainer_PreviewKeyDown(object sender, KeyRoutedEventArg IPackage? package = packageItemContainer.Package; - bool IS_CONTROL_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); - //bool IS_SHIFT_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); - bool IS_ALT_PRESSED = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftMenu).HasFlag(CoreVirtualKeyStates.Down); - IS_ALT_PRESSED |= InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightMenu).HasFlag(CoreVirtualKeyStates.Down); - if (e.Key == VirtualKey.Enter && package is not null) { if (IS_ALT_PRESSED) @@ -1325,7 +1418,25 @@ public void PackageItemContainer_PreviewKeyDown(object sender, KeyRoutedEventArg } else if (e.Key == VirtualKey.Space && package is not null) { - package.IsChecked = !package.IsChecked; + if (_selectedIndices.Count > 1) + { + // Toggle checkboxes for all selected items (contiguous or not) + int currentIndex = _lastNavigationIndex >= 0 && _lastNavigationIndex < FilteredPackages.Count + ? _lastNavigationIndex + : senderIndex; + if (currentIndex < 0 || currentIndex >= FilteredPackages.Count) + return; + bool newState = !FilteredPackages[currentIndex].Package.IsChecked; + foreach (int i in _selectedIndices) + { + if (i >= 0 && i < FilteredPackages.Count) + FilteredPackages[i].IsChecked = newState; + } + } + else + { + packageItemContainer.Wrapper.IsChecked = !packageItemContainer.Wrapper.IsChecked; + } e.Handled = true; } } @@ -1406,6 +1517,7 @@ private void ViewModeSelector_SelectionChanged(object sender, SelectionChangedEv { Settings.SetDictionaryItem(Settings.K.PackageListViewMode, PAGE_NAME, ViewModeSelector.SelectedIndex); GenerateHeaderBarTitles(); + ResetSelectionState(); } FrameworkElement _lastContextMenuButtonTapped = null!;