mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-05 18:57:19 +02:00
[CmdPal] Add Sections and Separators for List Pages and Grid Pages (#43952)
<!-- Enter a brief description/summary of your PR here. What does it fix/what does it change/how was it tested (even manually, if necessary)? --> ## Summary of the Pull Request This pull request adds sections and separators to ListPages and GridPages <!-- Please review the items on the PR checklist before submitting--> ## PR Checklist - [x] Closes: #38267 <!-- - [ ] Closes: #yyy (add separate lines for additional resolved issues) --> - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx <!-- Provide a more detailed description of the PR, other things fixed, or any additional comments/features here --> ## Detailed Description of the Pull Request / Additional comments Since `CollectionViewSource` was causing performance issues and @zadjii-msft asked for a new approach, I came up with this idea, heavily inspired by how separators work on the `ContextMenu`, `FiltersDropDown` and `Details`. The way this is currently working is: Any ListItem where `Section` is not null and `Command` is null, is considered a Separator. On my tests, this seems to be working fine. Tried to make this work without changes to the API, but I think this needs to be discussed. ### Some of the possible enhancements to existing extensions ### Search apps <img width="792" height="523" alt="Screenshot 2025-11-27 173618" src="https://github.com/user-attachments/assets/f9f9a64d-3ec1-4f7e-922b-997a3a4d074d" /> ### Window Walker <img width="785" height="518" alt="Screenshot 2025-11-27 173728" src="https://github.com/user-attachments/assets/230f647d-210a-4b60-9068-c8fff890d2c9" /> ### Winget <img width="809" height="497" alt="Screenshot 2025-11-27 174006" src="https://github.com/user-attachments/assets/547529c1-7600-4438-8c3e-e872e0327650" /> ### Search files <img width="819" height="536" alt="image" src="https://github.com/user-attachments/assets/e86accc0-3f85-412d-8fb0-914a5479baff" /> ### Grid Pages <img width="804" height="964" alt="Screenshot 2025-11-27 174055" src="https://github.com/user-attachments/assets/a3bba7db-95df-47ec-9cfb-f38775ab960e" /> <!-- Describe how you validated the behavior. Add automated tests wherever possible, but list manual validation steps taken as well --> ## Validation Steps Performed
This commit is contained in:
@@ -28,6 +28,8 @@
|
||||
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
|
||||
|
||||
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridViewItem">
|
||||
@@ -90,6 +92,8 @@
|
||||
</Style>
|
||||
|
||||
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridViewItem">
|
||||
@@ -168,8 +172,17 @@
|
||||
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
|
||||
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
|
||||
Medium="{StaticResource MediumGridItemViewModelTemplate}"
|
||||
Section="{StaticResource ListSectionViewModelTemplate}"
|
||||
Separator="{StaticResource ListSeparatorViewModelTemplate}"
|
||||
Small="{StaticResource SmallGridItemViewModelTemplate}" />
|
||||
|
||||
<cmdpalUI:ListItemTemplateSelector
|
||||
x:Key="ListItemTemplateSelector"
|
||||
x:DataType="coreViewModels:ListItemViewModel"
|
||||
ListItem="{StaticResource ListItemViewModelTemplate}"
|
||||
Section="{StaticResource ListSectionViewModelTemplate}"
|
||||
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
|
||||
|
||||
<cmdpalUI:GridItemContainerStyleSelector
|
||||
x:Key="GridItemContainerStyleSelector"
|
||||
Gallery="{StaticResource GalleryGridViewItemStyle}"
|
||||
@@ -241,12 +254,46 @@
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Rectangle
|
||||
Grid.Column="1"
|
||||
Height="1"
|
||||
Margin="0,2,0,2"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Grid
|
||||
Margin="0"
|
||||
VerticalAlignment="Center"
|
||||
cpcontrols:WrapPanel.IsFullLine="True"
|
||||
ColumnSpacing="8"
|
||||
IsTabStop="False"
|
||||
IsTapEnabled="True">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Foreground="{ThemeResource TextFillColorDisabled}"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Section}" />
|
||||
<Rectangle
|
||||
Grid.Column="1"
|
||||
Height="1"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- Grid item templates for visual grid representation -->
|
||||
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<StackPanel
|
||||
Width="60"
|
||||
Height="60"
|
||||
Padding="8,16"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
|
||||
@@ -265,7 +312,6 @@
|
||||
Foreground="{ThemeResource TextFillColorPrimary}"
|
||||
SourceKey="{x:Bind Icon, Mode=OneWay}"
|
||||
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
|
||||
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -399,7 +445,7 @@
|
||||
IsDoubleTapEnabled="True"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="Items_ItemClick"
|
||||
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
|
||||
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
RightTapped="Items_RightTapped"
|
||||
SelectionChanged="Items_SelectionChanged">
|
||||
@@ -411,7 +457,7 @@
|
||||
<controls:Case Value="True">
|
||||
<GridView
|
||||
x:Name="ItemsGrid"
|
||||
Padding="8"
|
||||
Padding="16,0"
|
||||
ContextCanceled="Items_OnContextCanceled"
|
||||
ContextRequested="Items_OnContextRequested"
|
||||
DoubleTapped="Items_DoubleTapped"
|
||||
@@ -423,10 +469,14 @@
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
RightTapped="Items_RightTapped"
|
||||
SelectionChanged="Items_SelectionChanged">
|
||||
<GridView.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<cpcontrols:WrapPanel HorizontalSpacing="8" Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</GridView.ItemsPanel>
|
||||
<GridView.ItemContainerTransitions>
|
||||
<TransitionCollection />
|
||||
</GridView.ItemContainerTransitions>
|
||||
<GridView.ItemContainerStyle />
|
||||
</GridView>
|
||||
</controls:Case>
|
||||
</controls:SwitchPresenter>
|
||||
|
||||
@@ -76,12 +76,18 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
ViewModel = listViewModel;
|
||||
|
||||
if (e.NavigationMode == NavigationMode.Back
|
||||
|| (e.NavigationMode == NavigationMode.New && ItemView.Items.Count > 0))
|
||||
if (e.NavigationMode == NavigationMode.Back)
|
||||
{
|
||||
// Upon navigating _back_ to this page, immediately select the
|
||||
// first item in the list
|
||||
ItemView.SelectedIndex = 0;
|
||||
// Must dispatch the selection to run at a lower priority; otherwise, GetFirstSelectableIndex
|
||||
// may return an incorrect index because item containers are not yet rendered.
|
||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||
if (firstUsefulIndex != -1)
|
||||
{
|
||||
ItemView.SelectedIndex = firstUsefulIndex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// RegisterAll isn't AOT compatible
|
||||
@@ -128,6 +134,29 @@ public sealed partial class ListPage : Page,
|
||||
GC.Collect();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the index of the first item in the list that is not a separator.
|
||||
/// Returns -1 if the list is empty or only contains separators.
|
||||
/// </summary>
|
||||
private int GetFirstSelectableIndex()
|
||||
{
|
||||
var items = ItemView.Items;
|
||||
if (items is null || items.Count == 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
if (!IsSeparator(items[i]))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
|
||||
private void Items_ItemClick(object sender, ItemClickEventArgs e)
|
||||
{
|
||||
@@ -183,19 +212,33 @@ public sealed partial class ListPage : Page,
|
||||
// here, then in Page_ItemsUpdated trying to select that cached item if
|
||||
// it's in the list (otherwise, clear the cache), but that seems
|
||||
// aggressively BODGY for something that mostly just works today.
|
||||
if (ItemView.SelectedItem is not null)
|
||||
if (ItemView.SelectedItem is not null && !IsSeparator(ItemView.SelectedItem))
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
var items = ItemView.Items;
|
||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||
var shouldScroll = false;
|
||||
|
||||
if (e.RemovedItems.Count > 0)
|
||||
{
|
||||
shouldScroll = true;
|
||||
}
|
||||
else if (ItemView.SelectedIndex > firstUsefulIndex)
|
||||
{
|
||||
shouldScroll = true;
|
||||
}
|
||||
|
||||
if (shouldScroll)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
|
||||
// Automation notification for screen readers
|
||||
var listViewPeer = Microsoft.UI.Xaml.Automation.Peers.ListViewAutomationPeer.CreatePeerForElement(ItemView);
|
||||
if (listViewPeer is not null && li is not null)
|
||||
{
|
||||
var notificationText = li.Title;
|
||||
|
||||
UIHelper.AnnounceActionForAccessibility(
|
||||
ItemsList,
|
||||
notificationText,
|
||||
li.Title,
|
||||
"CommandPaletteSelectedItemChanged");
|
||||
}
|
||||
}
|
||||
@@ -271,14 +314,7 @@ public sealed partial class ListPage : Page,
|
||||
else
|
||||
{
|
||||
// For list views, use simple linear navigation
|
||||
if (ItemView.SelectedIndex < ItemView.Items.Count - 1)
|
||||
{
|
||||
ItemView.SelectedIndex++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ItemView.SelectedIndex = 0;
|
||||
}
|
||||
NavigateDown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,15 +327,7 @@ public sealed partial class ListPage : Page,
|
||||
}
|
||||
else
|
||||
{
|
||||
// For list views, use simple linear navigation
|
||||
if (ItemView.SelectedIndex > 0)
|
||||
{
|
||||
ItemView.SelectedIndex--;
|
||||
}
|
||||
else
|
||||
{
|
||||
ItemView.SelectedIndex = ItemView.Items.Count - 1;
|
||||
}
|
||||
NavigateUp();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,7 +394,10 @@ public sealed partial class ListPage : Page,
|
||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
if (ItemView.SelectedItem is not null)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +412,10 @@ public sealed partial class ListPage : Page,
|
||||
if (indexes.Value.CurrentIndex != indexes.Value.TargetIndex)
|
||||
{
|
||||
ItemView.SelectedIndex = indexes.Value.TargetIndex;
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
if (ItemView.SelectedItem is not null)
|
||||
{
|
||||
ItemView.ScrollIntoView(ItemView.SelectedItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,17 +558,65 @@ public sealed partial class ListPage : Page,
|
||||
// ItemView_SelectionChanged again to give us another chance to change
|
||||
// the selection from null -> something. Better to just update the
|
||||
// selection once, at the end of all the updating.
|
||||
if (ItemView.SelectedItem is null)
|
||||
// The selection logic must be deferred to the DispatcherQueue
|
||||
// to ensure the UI has processed the updated ItemsSource binding,
|
||||
// preventing ItemView.Items from appearing empty/null immediately after update.
|
||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
ItemView.SelectedIndex = 0;
|
||||
}
|
||||
var items = ItemView.Items;
|
||||
|
||||
// Always reset the selected item when the top-level list page changes
|
||||
// its items
|
||||
if (!sender.IsNested)
|
||||
{
|
||||
ItemView.SelectedIndex = 0;
|
||||
}
|
||||
// If the list is null or empty, clears the selection and return
|
||||
if (items is null || items.Count == 0)
|
||||
{
|
||||
ItemView.SelectedIndex = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// Finds the first item that is not a separator
|
||||
var firstUsefulIndex = GetFirstSelectableIndex();
|
||||
|
||||
// If there is only separators in the list, don't select anything.
|
||||
if (firstUsefulIndex == -1)
|
||||
{
|
||||
ItemView.SelectedIndex = -1;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldUpdateSelection = false;
|
||||
|
||||
// If it's a top level list update we force the reset to the top useful item
|
||||
if (!sender.IsNested)
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
|
||||
// No current selection or current selection is null
|
||||
else if (ItemView.SelectedItem is null)
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
|
||||
// The current selected item is a separator
|
||||
else if (IsSeparator(ItemView.SelectedItem))
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
|
||||
// The selected item does not exist in the new list
|
||||
else if (!items.Contains(ItemView.SelectedItem))
|
||||
{
|
||||
shouldUpdateSelection = true;
|
||||
}
|
||||
|
||||
if (shouldUpdateSelection)
|
||||
{
|
||||
if (firstUsefulIndex != -1)
|
||||
{
|
||||
ItemView.SelectedIndex = firstUsefulIndex;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -604,6 +686,11 @@ public sealed partial class ListPage : Page,
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsSeparator(ItemView.Items[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ItemView.ContainerFromIndex(i) is FrameworkElement c && c.ActualWidth > 0 && c.ActualHeight > 0)
|
||||
{
|
||||
var p = c.TransformToVisual(ItemView).TransformPoint(new Point(0, 0));
|
||||
@@ -764,6 +851,102 @@ public sealed partial class ListPage : Page,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code stealed from <see cref="Controls.ContextMenu.NavigateUp"/>
|
||||
/// </summary>
|
||||
private void NavigateUp()
|
||||
{
|
||||
var newIndex = ItemView.SelectedIndex;
|
||||
|
||||
if (ItemView.SelectedIndex > 0)
|
||||
{
|
||||
newIndex--;
|
||||
|
||||
while (
|
||||
newIndex >= 0 &&
|
||||
IsSeparator(ItemView.Items[newIndex]) &&
|
||||
newIndex != ItemView.SelectedIndex)
|
||||
{
|
||||
newIndex--;
|
||||
}
|
||||
|
||||
if (newIndex < 0)
|
||||
{
|
||||
newIndex = ItemView.Items.Count - 1;
|
||||
|
||||
while (
|
||||
newIndex >= 0 &&
|
||||
IsSeparator(ItemView.Items[newIndex]) &&
|
||||
newIndex != ItemView.SelectedIndex)
|
||||
{
|
||||
newIndex--;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
newIndex = ItemView.Items.Count - 1;
|
||||
}
|
||||
|
||||
ItemView.SelectedIndex = newIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code stealed from <see cref="Controls.ContextMenu.NavigateDown"/>
|
||||
/// </summary>
|
||||
private void NavigateDown()
|
||||
{
|
||||
var newIndex = ItemView.SelectedIndex;
|
||||
|
||||
if (ItemView.SelectedIndex == ItemView.Items.Count - 1)
|
||||
{
|
||||
newIndex = 0;
|
||||
while (
|
||||
newIndex < ItemView.Items.Count &&
|
||||
IsSeparator(ItemView.Items[newIndex]))
|
||||
{
|
||||
newIndex++;
|
||||
}
|
||||
|
||||
if (newIndex >= ItemView.Items.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
newIndex++;
|
||||
|
||||
while (
|
||||
newIndex < ItemView.Items.Count &&
|
||||
IsSeparator(ItemView.Items[newIndex]) &&
|
||||
newIndex != ItemView.SelectedIndex)
|
||||
{
|
||||
newIndex++;
|
||||
}
|
||||
|
||||
if (newIndex >= ItemView.Items.Count)
|
||||
{
|
||||
newIndex = 0;
|
||||
|
||||
while (
|
||||
newIndex < ItemView.Items.Count &&
|
||||
IsSeparator(ItemView.Items[newIndex]) &&
|
||||
newIndex != ItemView.SelectedIndex)
|
||||
{
|
||||
newIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ItemView.SelectedIndex = newIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Code stealed from <see cref="Controls.ContextMenu.IsSeparator(object)"/>
|
||||
/// </summary>
|
||||
private bool IsSeparator(object? item) => item is ListItemViewModel li && li.IsSectionOrSeparator;
|
||||
|
||||
private enum InputSource
|
||||
{
|
||||
None,
|
||||
|
||||
Reference in New Issue
Block a user