[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:
Guilherme
2025-12-10 23:51:42 -03:00
committed by GitHub
parent a08fc0921f
commit 94ace730c8
15 changed files with 1146 additions and 60 deletions

View File

@@ -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>

View File

@@ -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,