From 94ace730c86e321efce786910a6966450c2093e3 Mon Sep 17 00:00:00 2001
From: Guilherme <57814418+DevLGuilherme@users.noreply.github.com>
Date: Wed, 10 Dec 2025 23:51:42 -0300
Subject: [PATCH] [CmdPal] Add Sections and Separators for List Pages and Grid
Pages (#43952)
## Summary of the Pull Request
This pull request adds sections and separators to ListPages and
GridPages
## PR Checklist
- [x] Closes: #38267
- [ ] **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
## 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
### Window Walker
### Winget
### Search files
### Grid Pages
## Validation Steps Performed
---
.github/actions/spell-check/expect.txt | 2 +
.../ListItemViewModel.cs | 33 +-
.../SeparatorViewModel.cs | 3 +-
.../Controls/WrapPanelCustom/UvBounds.cs | 37 ++
.../Controls/WrapPanelCustom/UvMeasure.cs | 96 ++++
.../Controls/WrapPanelCustom/WrapPanel.cs | 416 ++++++++++++++++++
.../Converters/GridItemTemplateSelector.cs | 15 +
.../Converters/ListItemTemplateSelector.cs | 47 ++
.../ExtViews/ListPage.xaml | 64 ++-
.../ExtViews/ListPage.xaml.cs | 259 +++++++++--
.../SampleListPageWithSections.cs | 114 +++++
.../Pages/SectionsPages/SectionsIndexPage.cs | 40 ++
.../SamplePagesExtension/SamplesListPage.cs | 5 +
.../Section.cs | 39 ++
.../Separator.cs | 36 +-
15 files changed, 1146 insertions(+), 60 deletions(-)
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs
create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs
create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SampleListPageWithSections.cs
create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/SectionsPages/SectionsIndexPage.cs
create mode 100644 src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/Section.cs
diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index f839c5976c..f649476f60 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -1854,6 +1854,8 @@ uitests
UITo
ULONGLONG
ums
+UMax
+UMin
uncompilable
UNCPRIORITY
UNDNAME
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs
index a400374e3c..fb4afc49b8 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs
@@ -24,6 +24,8 @@ public partial class ListItemViewModel : CommandItemViewModel
public string Section { get; private set; } = string.Empty;
+ public bool IsSectionOrSeparator { get; private set; }
+
public DetailsViewModel? Details { get; private set; }
[MemberNotNullWhen(true, nameof(Details))]
@@ -82,14 +84,18 @@ public partial class ListItemViewModel : CommandItemViewModel
}
UpdateTags(li.Tags);
-
Section = li.Section ?? string.Empty;
-
- UpdateProperty(nameof(Section));
+ IsSectionOrSeparator = IsSeparator(li);
+ UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
UpdateAccessibleName();
}
+ private bool IsSeparator(IListItem item)
+ {
+ return item.Command is null;
+ }
+
public override void SlowInitializeProperties()
{
base.SlowInitializeProperties();
@@ -104,8 +110,7 @@ public partial class ListItemViewModel : CommandItemViewModel
{
Details = new(extensionDetails, PageContext);
Details.InitializeProperties();
- UpdateProperty(nameof(Details));
- UpdateProperty(nameof(HasDetails));
+ UpdateProperty(nameof(Details), nameof(HasDetails));
}
AddShowDetailsCommands();
@@ -135,14 +140,18 @@ public partial class ListItemViewModel : CommandItemViewModel
break;
case nameof(model.Section):
Section = model.Section ?? string.Empty;
- UpdateProperty(nameof(Section));
+ IsSectionOrSeparator = IsSeparator(model);
+ UpdateProperty(nameof(Section), nameof(IsSectionOrSeparator));
break;
- case nameof(model.Details):
+ case nameof(model.Command):
+ IsSectionOrSeparator = IsSeparator(model);
+ UpdateProperty(nameof(IsSectionOrSeparator));
+ break;
+ case nameof(Details):
var extensionDetails = model.Details;
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
- UpdateProperty(nameof(Details));
- UpdateProperty(nameof(HasDetails));
+ UpdateProperty(nameof(Details), nameof(HasDetails));
UpdateShowDetailsCommand();
break;
case nameof(model.MoreCommands):
@@ -194,8 +203,7 @@ public partial class ListItemViewModel : CommandItemViewModel
MoreCommands.Add(showDetailsContextItemViewModel);
}
- UpdateProperty(nameof(MoreCommands));
- UpdateProperty(nameof(AllCommands));
+ UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
}
}
@@ -227,8 +235,7 @@ public partial class ListItemViewModel : CommandItemViewModel
showDetailsContextItemViewModel.SlowInitializeProperties();
MoreCommands.Add(showDetailsContextItemViewModel);
- UpdateProperty(nameof(MoreCommands));
- UpdateProperty(nameof(AllCommands));
+ UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
}
}
diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
index a1c4696b35..93fae9beff 100644
--- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
+++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SeparatorViewModel.cs
@@ -3,13 +3,14 @@
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
-using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CommandPalette.Extensions;
+using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public partial class SeparatorViewModel() :
+ CommandItem,
IContextItemViewModel,
IFilterItemViewModel,
ISeparatorContextItem,
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs
new file mode 100644
index 0000000000..2d7567c346
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvBounds.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+internal sealed class UVBounds
+{
+ public double UMin { get; }
+
+ public double UMax { get; }
+
+ public double VMin { get; }
+
+ public double VMax { get; }
+
+ public UVBounds(Orientation orientation, Rect rect)
+ {
+ if (orientation == Orientation.Horizontal)
+ {
+ UMin = rect.Left;
+ UMax = rect.Right;
+ VMin = rect.Top;
+ VMax = rect.Bottom;
+ }
+ else
+ {
+ UMin = rect.Top;
+ UMax = rect.Bottom;
+ VMin = rect.Left;
+ VMax = rect.Right;
+ }
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs
new file mode 100644
index 0000000000..1b75c31564
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/UvMeasure.cs
@@ -0,0 +1,96 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+[DebuggerDisplay("U = {U} V = {V}")]
+internal struct UvMeasure
+{
+ internal double U { get; set; }
+
+ internal double V { get; set; }
+
+ internal static UvMeasure Zero => default(UvMeasure);
+
+ public UvMeasure(Orientation orientation, Size size)
+ : this(orientation, size.Width, size.Height)
+ {
+ }
+
+ public UvMeasure(Orientation orientation, double width, double height)
+ {
+ if (orientation == Orientation.Horizontal)
+ {
+ U = width;
+ V = height;
+ }
+ else
+ {
+ U = height;
+ V = width;
+ }
+ }
+
+ public UvMeasure Add(double u, double v)
+ {
+ UvMeasure result = default(UvMeasure);
+ result.U = U + u;
+ result.V = V + v;
+ return result;
+ }
+
+ public UvMeasure Add(UvMeasure measure)
+ {
+ return Add(measure.U, measure.V);
+ }
+
+ public Size ToSize(Orientation orientation)
+ {
+ if (orientation != Orientation.Horizontal)
+ {
+ return new Size(V, U);
+ }
+
+ return new Size(U, V);
+ }
+
+ public Point GetPoint(Orientation orientation)
+ {
+ return orientation is Orientation.Horizontal ? new Point(U, V) : new Point(V, U);
+ }
+
+ public Size GetSize(Orientation orientation)
+ {
+ return orientation is Orientation.Horizontal ? new Size(U, V) : new Size(V, U);
+ }
+
+ public static bool operator ==(UvMeasure measure1, UvMeasure measure2)
+ {
+ return measure1.U == measure2.U && measure1.V == measure2.V;
+ }
+
+ public static bool operator !=(UvMeasure measure1, UvMeasure measure2)
+ {
+ return !(measure1 == measure2);
+ }
+
+ public override bool Equals(object? obj)
+ {
+ return obj is UvMeasure measure && this == measure;
+ }
+
+ public bool Equals(UvMeasure value)
+ {
+ return this == value;
+ }
+
+ public override int GetHashCode()
+ {
+ return base.GetHashCode();
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs
new file mode 100644
index 0000000000..ea0101bfa3
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/WrapPanelCustom/WrapPanel.cs
@@ -0,0 +1,416 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using CommunityToolkit.WinUI.Controls;
+using Microsoft.CmdPal.Core.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Windows.Foundation;
+
+namespace Microsoft.CmdPal.UI.Controls;
+
+///
+/// Arranges elements by wrapping them to fit the available space.
+/// When is set to Orientation.Horizontal, element are arranged in rows until the available width is reached and then to a new row.
+/// When is set to Orientation.Vertical, element are arranged in columns until the available height is reached.
+///
+public sealed partial class WrapPanel : Panel
+{
+ private struct UvRect
+ {
+ public UvMeasure Position { get; set; }
+
+ public UvMeasure Size { get; set; }
+
+ public Rect ToRect(Orientation orientation)
+ {
+ return orientation switch
+ {
+ Orientation.Vertical => new Rect(Position.V, Position.U, Size.V, Size.U),
+ Orientation.Horizontal => new Rect(Position.U, Position.V, Size.U, Size.V),
+ _ => ThrowArgumentException(),
+ };
+ }
+
+ private static Rect ThrowArgumentException()
+ {
+ throw new ArgumentException("The input orientation is not valid.");
+ }
+ }
+
+ private struct Row
+ {
+ public List ChildrenRects { get; }
+
+ public UvMeasure Size { get; set; }
+
+ public UvRect Rect
+ {
+ get
+ {
+ UvRect result;
+ if (ChildrenRects.Count <= 0)
+ {
+ result = default(UvRect);
+ result.Position = UvMeasure.Zero;
+ result.Size = Size;
+ return result;
+ }
+
+ result = default(UvRect);
+ result.Position = ChildrenRects.First().Position;
+ result.Size = Size;
+ return result;
+ }
+ }
+
+ public Row(List childrenRects, UvMeasure size)
+ {
+ ChildrenRects = childrenRects;
+ Size = size;
+ }
+
+ public void Add(UvMeasure position, UvMeasure size)
+ {
+ ChildrenRects.Add(new UvRect
+ {
+ Position = position,
+ Size = size,
+ });
+
+ Size = new UvMeasure
+ {
+ U = position.U + size.U,
+ V = Math.Max(Size.V, size.V),
+ };
+ }
+ }
+
+ ///
+ /// Gets or sets a uniform Horizontal distance (in pixels) between items when is set to Horizontal,
+ /// or between columns of items when is set to Vertical.
+ ///
+ public double HorizontalSpacing
+ {
+ get { return (double)GetValue(HorizontalSpacingProperty); }
+ set { SetValue(HorizontalSpacingProperty, value); }
+ }
+
+ private bool IsSectionItem(UIElement element) => element is FrameworkElement fe && fe.DataContext is ListItemViewModel item && item.IsSectionOrSeparator;
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty HorizontalSpacingProperty =
+ DependencyProperty.Register(
+ nameof(HorizontalSpacing),
+ typeof(double),
+ typeof(WrapPanel),
+ new PropertyMetadata(0d, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets a uniform Vertical distance (in pixels) between items when is set to Vertical,
+ /// or between rows of items when is set to Horizontal.
+ ///
+ public double VerticalSpacing
+ {
+ get { return (double)GetValue(VerticalSpacingProperty); }
+ set { SetValue(VerticalSpacingProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty VerticalSpacingProperty =
+ DependencyProperty.Register(
+ nameof(VerticalSpacing),
+ typeof(double),
+ typeof(WrapPanel),
+ new PropertyMetadata(0d, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets the orientation of the WrapPanel.
+ /// Horizontal means that child controls will be added horizontally until the width of the panel is reached, then a new row is added to add new child controls.
+ /// Vertical means that children will be added vertically until the height of the panel is reached, then a new column is added.
+ ///
+ public Orientation Orientation
+ {
+ get { return (Orientation)GetValue(OrientationProperty); }
+ set { SetValue(OrientationProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ public static readonly DependencyProperty OrientationProperty =
+ DependencyProperty.Register(
+ nameof(Orientation),
+ typeof(Orientation),
+ typeof(WrapPanel),
+ new PropertyMetadata(Orientation.Horizontal, LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets the distance between the border and its child object.
+ ///
+ ///
+ /// The dimensions of the space between the border and its child as a Thickness value.
+ /// Thickness is a structure that stores dimension values using pixel measures.
+ ///
+ public Thickness Padding
+ {
+ get { return (Thickness)GetValue(PaddingProperty); }
+ set { SetValue(PaddingProperty, value); }
+ }
+
+ ///
+ /// Identifies the Padding dependency property.
+ ///
+ /// The identifier for the dependency property.
+ public static readonly DependencyProperty PaddingProperty =
+ DependencyProperty.Register(
+ nameof(Padding),
+ typeof(Thickness),
+ typeof(WrapPanel),
+ new PropertyMetadata(default(Thickness), LayoutPropertyChanged));
+
+ ///
+ /// Gets or sets a value indicating how to arrange child items
+ ///
+ public StretchChild StretchChild
+ {
+ get { return (StretchChild)GetValue(StretchChildProperty); }
+ set { SetValue(StretchChildProperty, value); }
+ }
+
+ ///
+ /// Identifies the dependency property.
+ ///
+ /// The identifier for the dependency property.
+ public static readonly DependencyProperty StretchChildProperty =
+ DependencyProperty.Register(
+ nameof(StretchChild),
+ typeof(StretchChild),
+ typeof(WrapPanel),
+ new PropertyMetadata(StretchChild.None, LayoutPropertyChanged));
+
+ ///
+ /// Identifies the IsFullLine attached dependency property.
+ /// If true, the child element will occupy the entire width of the panel and force a line break before and after itself.
+ ///
+ public static readonly DependencyProperty IsFullLineProperty =
+ DependencyProperty.RegisterAttached(
+ "IsFullLine",
+ typeof(bool),
+ typeof(WrapPanel),
+ new PropertyMetadata(false, OnIsFullLineChanged));
+
+ public static bool GetIsFullLine(DependencyObject obj)
+ {
+ return (bool)obj.GetValue(IsFullLineProperty);
+ }
+
+ public static void SetIsFullLine(DependencyObject obj, bool value)
+ {
+ obj.SetValue(IsFullLineProperty, value);
+ }
+
+ private static void OnIsFullLineChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (FindVisualParentWrapPanel(d) is WrapPanel wp)
+ {
+ wp.InvalidateMeasure();
+ }
+ }
+
+ private static WrapPanel? FindVisualParentWrapPanel(DependencyObject child)
+ {
+ var parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(child);
+
+ while (parent != null)
+ {
+ if (parent is WrapPanel wrapPanel)
+ {
+ return wrapPanel;
+ }
+
+ parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent);
+ }
+
+ return null;
+ }
+
+ private static void LayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is WrapPanel wp)
+ {
+ wp.InvalidateMeasure();
+ wp.InvalidateArrange();
+ }
+ }
+
+ private readonly List _rows = new List();
+
+ ///
+ protected override Size MeasureOverride(Size availableSize)
+ {
+ var childAvailableSize = new Size(
+ availableSize.Width - Padding.Left - Padding.Right,
+ availableSize.Height - Padding.Top - Padding.Bottom);
+ foreach (var child in Children)
+ {
+ child.Measure(childAvailableSize);
+ }
+
+ var requiredSize = UpdateRows(availableSize);
+ return requiredSize;
+ }
+
+ ///
+ protected override Size ArrangeOverride(Size finalSize)
+ {
+ if ((Orientation == Orientation.Horizontal && finalSize.Width < DesiredSize.Width) ||
+ (Orientation == Orientation.Vertical && finalSize.Height < DesiredSize.Height))
+ {
+ // We haven't received our desired size. We need to refresh the rows.
+ UpdateRows(finalSize);
+ }
+
+ if (_rows.Count > 0)
+ {
+ // Now that we have all the data, we do the actual arrange pass
+ var childIndex = 0;
+ foreach (var row in _rows)
+ {
+ foreach (var rect in row.ChildrenRects)
+ {
+ var child = Children[childIndex++];
+ while (child.Visibility == Visibility.Collapsed)
+ {
+ // Collapsed children are not added into the rows,
+ // we skip them.
+ child = Children[childIndex++];
+ }
+
+ var arrangeRect = new UvRect
+ {
+ Position = rect.Position,
+ Size = new UvMeasure { U = rect.Size.U, V = row.Size.V },
+ };
+
+ var finalRect = arrangeRect.ToRect(Orientation);
+ child.Arrange(finalRect);
+ }
+ }
+ }
+
+ return finalSize;
+ }
+
+ private Size UpdateRows(Size availableSize)
+ {
+ _rows.Clear();
+
+ var paddingStart = new UvMeasure(Orientation, Padding.Left, Padding.Top);
+ var paddingEnd = new UvMeasure(Orientation, Padding.Right, Padding.Bottom);
+
+ if (Children.Count == 0)
+ {
+ return paddingStart.Add(paddingEnd).ToSize(Orientation);
+ }
+
+ var parentMeasure = new UvMeasure(Orientation, availableSize.Width, availableSize.Height);
+ var spacingMeasure = new UvMeasure(Orientation, HorizontalSpacing, VerticalSpacing);
+ var position = new UvMeasure(Orientation, Padding.Left, Padding.Top);
+
+ var currentRow = new Row(new List(), default);
+ var finalMeasure = new UvMeasure(Orientation, width: 0.0, height: 0.0);
+
+ void CommitRow()
+ {
+ // Only adds if the row has a content
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ _rows.Add(currentRow);
+
+ position.V += currentRow.Size.V + spacingMeasure.V;
+ }
+
+ position.U = paddingStart.U;
+
+ currentRow = new Row(new List(), default);
+ }
+
+ void Arrange(UIElement child, bool isLast = false)
+ {
+ if (child.Visibility == Visibility.Collapsed)
+ {
+ return;
+ }
+
+ var isFullLine = IsSectionItem(child);
+ var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize);
+
+ if (isFullLine)
+ {
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ CommitRow();
+ }
+
+ // Forces the width to fill all the available space
+ // (Total width - Padding Left - Padding Right)
+ desiredMeasure.U = parentMeasure.U - paddingStart.U - paddingEnd.U;
+
+ // Adds the Section Header to the row
+ currentRow.Add(position, desiredMeasure);
+
+ // Updates the global measures
+ position.U += desiredMeasure.U + spacingMeasure.U;
+ finalMeasure.U = Math.Max(finalMeasure.U, position.U);
+
+ CommitRow();
+ }
+ else
+ {
+ // Checks if the item can fit in the row
+ if ((desiredMeasure.U + position.U + paddingEnd.U) > parentMeasure.U)
+ {
+ CommitRow();
+ }
+
+ if (isLast)
+ {
+ desiredMeasure.U = parentMeasure.U - position.U;
+ }
+
+ currentRow.Add(position, desiredMeasure);
+
+ position.U += desiredMeasure.U + spacingMeasure.U;
+ finalMeasure.U = Math.Max(finalMeasure.U, position.U);
+ }
+ }
+
+ var lastIndex = Children.Count - 1;
+ for (var i = 0; i < lastIndex; i++)
+ {
+ Arrange(Children[i]);
+ }
+
+ Arrange(Children[lastIndex], StretchChild == StretchChild.Last);
+
+ if (currentRow.ChildrenRects.Count > 0)
+ {
+ _rows.Add(currentRow);
+ }
+
+ if (_rows.Count == 0)
+ {
+ return paddingStart.Add(paddingEnd).ToSize(Orientation);
+ }
+
+ var lastRowRect = _rows.Last().Rect;
+ finalMeasure.V = lastRowRect.Position.V + lastRowRect.Size.V;
+ return finalMeasure.Add(paddingEnd).ToSize(Orientation);
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
index c93470e3e3..f638f3f09e 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs
@@ -18,8 +18,23 @@ internal sealed partial class GridItemTemplateSelector : DataTemplateSelector
public DataTemplate? Gallery { get; set; }
+ public DataTemplate? Section { get; set; }
+
+ public DataTemplate? Separator { get; set; }
+
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject)
{
+ if (item is ListItemViewModel element && element.IsSectionOrSeparator)
+ {
+ if (dependencyObject is UIElement li)
+ {
+ li.IsTabStop = false;
+ li.IsHitTestVisible = false;
+ }
+
+ return string.IsNullOrWhiteSpace(element.Section) ? Separator : Section;
+ }
+
return GridProperties switch
{
SmallGridPropertiesViewModel => Small,
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs
new file mode 100644
index 0000000000..7fb810f5dc
--- /dev/null
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/ListItemTemplateSelector.cs
@@ -0,0 +1,47 @@
+// Copyright (c) Microsoft Corporation
+// The Microsoft Corporation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.CmdPal.Core.ViewModels;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace Microsoft.CmdPal.UI;
+
+public sealed partial class ListItemTemplateSelector : DataTemplateSelector
+{
+ public DataTemplate? ListItem { get; set; }
+
+ public DataTemplate? Separator { get; set; }
+
+ public DataTemplate? Section { get; set; }
+
+ protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container)
+ {
+ DataTemplate? dataTemplate = ListItem;
+
+ if (container is ListViewItem listItem)
+ {
+ if (item is ListItemViewModel element)
+ {
+ if (container is ListViewItem li && element.IsSectionOrSeparator)
+ {
+ li.IsEnabled = false;
+ li.AllowFocusWhenDisabled = false;
+ li.AllowFocusOnInteraction = false;
+ li.IsHitTestVisible = false;
+ dataTemplate = string.IsNullOrWhiteSpace(element.Section) ? Separator : Section;
+ }
+ else
+ {
+ listItem.IsEnabled = true;
+ listItem.AllowFocusWhenDisabled = true;
+ listItem.AllowFocusOnInteraction = true;
+ listItem.IsHitTestVisible = true;
+ }
+ }
+ }
+
+ return dataTemplate;
+ }
+}
diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
index 7cf720198a..859a74eb18 100644
--- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
+++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml
@@ -28,6 +28,8 @@
8