CmdPal: Filters for DynamicListPage? Yes, please. (#40783)

Closes: #40382

## To-do list

- [x] Add support for "single-select" filters to DynamicListPage
- [x] Filters can contain icons
- [x] Filter list can contain separators
- [x] Update Windows Services built-in extension to support filtering by
all, started, stopped, and pending services
- [x] Update SampleExtension dynamic list sample to filter.

## Example of filters in use

```C#
internal sealed partial class ServicesListPage : DynamicListPage
{
    public ServicesListPage()
    {
        Icon = Icons.ServicesIcon;
        Name = "Windows Services";

        var filters = new ServiceFilters();
        filters.PropChanged += Filters_PropChanged;
        Filters = filters;
    }

    private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();

    public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();

    public override IListItem[] GetItems()
    {
       // ServiceHelper.Search knows how to filter based on the CurrentFilterIds provided
        var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterIds).ToArray();

        return items;
    }
}

public partial class ServiceFilters : Filters
{
    public ServiceFilters()
    {
        // This would be a default selection. Not providing this will cause the filter
        // control to display the "Filter" placeholder text.
        CurrentFilterIds = ["all"];
    }

    public override IFilterItem[] GetFilters()
    {
        return [
            new Filter() { Id = "all", Name = "All Services" },
            new Separator(),
            new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon },
            new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon },
            new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon },
        ];
    }
}
```

## Current example of behavior


https://github.com/user-attachments/assets/2e325763-ad3a-4445-bbe2-a840df08d0b3

---------

Co-authored-by: Mike Griese <migrie@microsoft.com>
This commit is contained in:
Michael Jolley 2025-08-21 05:40:09 -05:00 committed by GitHub
parent 1a798e03cd
commit 69dc1d5e18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 646 additions and 33 deletions

View File

@ -189,7 +189,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
} }
else else
{ {
return new SeparatorContextItemViewModel() as IContextItemViewModel; return new SeparatorViewModel() as IContextItemViewModel;
} }
}) })
.ToList(); .ToList();
@ -350,7 +350,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
} }
else else
{ {
return new SeparatorContextItemViewModel() as IContextItemViewModel; return new SeparatorViewModel() as IContextItemViewModel;
} }
}) })
.ToList(); .ToList();

View File

@ -119,7 +119,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC
} }
else else
{ {
return new SeparatorContextItemViewModel(); return new SeparatorViewModel();
} }
}) })
.ToList(); .ToList();
@ -178,7 +178,7 @@ public abstract partial class ContentPageViewModel : PageViewModel, ICommandBarC
} }
else else
{ {
return new SeparatorContextItemViewModel(); return new SeparatorViewModel();
} }
}) })
.ToList(); .ToList();

View File

@ -0,0 +1,57 @@
// 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.Models;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class FilterItemViewModel : ExtensionObjectViewModel, IFilterItemViewModel
{
private ExtensionObject<IFilter> _model;
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public IconInfoViewModel Icon { get; set; } = new(null);
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
public FilterItemViewModel(IFilter filter, WeakReference<IPageContext> context)
: base(context)
{
_model = new(filter);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
var filter = _model.Unsafe;
if (filter == null)
{
return; // throw?
}
Id = filter.Id;
Name = filter.Name;
Icon = new(filter.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
UpdateProperty(nameof(Id));
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Icon));
}
}

View File

@ -0,0 +1,81 @@
// 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.Mvvm.ComponentModel;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels;
public partial class FiltersViewModel : ExtensionObjectViewModel
{
private readonly ExtensionObject<IFilters> _filtersModel = new(null);
[ObservableProperty]
public partial string CurrentFilterId { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShouldShowFilters))]
public partial IFilterItemViewModel[] Filters { get; set; } = [];
public bool ShouldShowFilters => Filters.Length > 0;
public FiltersViewModel(ExtensionObject<IFilters> filters, WeakReference<IPageContext> context)
: base(context)
{
_filtersModel = filters;
}
public override void InitializeProperties()
{
try
{
if (_filtersModel.Unsafe is not null)
{
var filters = _filtersModel.Unsafe.GetFilters();
Filters = filters.Select<IFilterItem, IFilterItemViewModel>(filter =>
{
var filterItem = filter as IFilter;
if (filterItem != null)
{
var filterVM = new FilterItemViewModel(filterItem!, PageContext);
filterVM.InitializeProperties();
return filterVM;
}
else
{
return new SeparatorViewModel();
}
}).ToArray();
CurrentFilterId = _filtersModel.Unsafe.CurrentFilterId;
return;
}
}
catch (Exception ex)
{
ShowException(ex, _filtersModel.Unsafe?.GetType().Name);
}
Filters = [];
CurrentFilterId = string.Empty;
}
public override void SafeCleanup()
{
base.SafeCleanup();
foreach (var filter in Filters)
{
if (filter is FilterItemViewModel filterVM)
{
filterVM.SafeCleanup();
}
}
Filters = [];
}
}

View File

@ -2,12 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.CmdPal.Core.ViewModels; namespace Microsoft.CmdPal.Core.ViewModels;

View File

@ -0,0 +1,9 @@
// 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.
namespace Microsoft.CmdPal.Core.ViewModels;
public interface IFilterItemViewModel
{
}

View File

@ -26,6 +26,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<ListItemViewModel> FilteredItems { get; set; } = []; public partial ObservableCollection<ListItemViewModel> FilteredItems { get; set; } = [];
public FiltersViewModel? Filters { get; set; }
private ObservableCollection<ListItemViewModel> Items { get; set; } = []; private ObservableCollection<ListItemViewModel> Items { get; set; } = [];
private readonly ExtensionObject<IListPage> _model; private readonly ExtensionObject<IListPage> _model;
@ -86,7 +88,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching? // TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(); private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
protected override void OnFilterUpdated(string filter) protected override void OnSearchTextBoxUpdated(string searchTextBox)
{ {
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view //// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements... //// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
@ -104,7 +106,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
{ {
if (_model.Unsafe is IDynamicListPage dynamic) if (_model.Unsafe is IDynamicListPage dynamic)
{ {
dynamic.SearchText = filter; dynamic.SearchText = searchTextBox;
} }
} }
catch (Exception ex) catch (Exception ex)
@ -127,6 +129,26 @@ public partial class ListViewModel : PageViewModel, IDisposable
} }
} }
public void UpdateCurrentFilter(string currentFilterId)
{
// We're getting called on the UI thread.
// Hop off to a BG thread to update the extension.
_ = Task.Run(() =>
{
try
{
if (_model.Unsafe is IListPage listPage)
{
listPage.Filters?.CurrentFilterId = currentFilterId;
}
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
});
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged //// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems() private void FetchItems()
{ {
@ -305,7 +327,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
/// Apply our current filter text to the list of items, and update /// Apply our current filter text to the list of items, and update
/// FilteredItems to match the results. /// FilteredItems to match the results.
/// </summary> /// </summary>
private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, Filter)); private void ApplyFilterUnderLock() => ListHelpers.InPlaceUpdateList(FilteredItems, FilterList(Items, SearchTextBox));
/// <summary> /// <summary>
/// Helper to generate a weighting for a given list item, based on title, /// Helper to generate a weighting for a given list item, based on title,
@ -507,6 +529,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.SlowInitializeProperties(); EmptyContent.SlowInitializeProperties();
Filters = new(new(model.Filters), PageContext);
Filters.InitializeProperties();
UpdateProperty(nameof(Filters));
FetchItems(); FetchItems();
model.ItemsChanged += Model_ItemsChanged; model.ItemsChanged += Model_ItemsChanged;
} }
@ -578,6 +604,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
EmptyContent = new(new(model.EmptyContent), PageContext); EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent.SlowInitializeProperties(); EmptyContent.SlowInitializeProperties();
break; break;
case nameof(Filters):
Filters = new(new(model.Filters), PageContext);
Filters.InitializeProperties();
break;
case nameof(IsLoading): case nameof(IsLoading):
UpdateEmptyContent(); UpdateEmptyContent();
break; break;
@ -641,6 +671,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
FilteredItems.Clear(); FilteredItems.Clear();
} }
Filters?.SafeCleanup();
var model = _model.Unsafe; var model = _model.Unsafe;
if (model is not null) if (model is not null)
{ {

View File

@ -32,7 +32,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
// This is set from the SearchBar // This is set from the SearchBar
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSuggestion))] [NotifyPropertyChangedFor(nameof(ShowSuggestion))]
public partial string Filter { get; set; } = string.Empty; public partial string SearchTextBox { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
public virtual partial string PlaceholderText { get; private set; } = "Type here to search..."; public virtual partial string PlaceholderText { get; private set; } = "Type here to search...";
@ -41,7 +41,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
[NotifyPropertyChangedFor(nameof(ShowSuggestion))] [NotifyPropertyChangedFor(nameof(ShowSuggestion))]
public virtual partial string TextToSuggest { get; protected set; } = string.Empty; public virtual partial string TextToSuggest { get; protected set; } = string.Empty;
public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != Filter; public bool ShowSuggestion => !string.IsNullOrEmpty(TextToSuggest) && TextToSuggest != SearchTextBox;
[ObservableProperty] [ObservableProperty]
public partial AppExtensionHost ExtensionHost { get; private set; } public partial AppExtensionHost ExtensionHost { get; private set; }
@ -167,9 +167,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
} }
} }
partial void OnFilterChanged(string oldValue, string newValue) => OnFilterUpdated(newValue); partial void OnSearchTextBoxChanged(string oldValue, string newValue) => OnSearchTextBoxUpdated(newValue);
protected virtual void OnFilterUpdated(string filter) protected virtual void OnSearchTextBoxUpdated(string searchTextBox)
{ {
// The base page has no notion of data, so we do nothing here... // The base page has no notion of data, so we do nothing here...
// subclasses should override. // subclasses should override.

View File

@ -9,6 +9,10 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Core.ViewModels; namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public partial class SeparatorContextItemViewModel() : IContextItemViewModel, ISeparatorContextItem public partial class SeparatorViewModel() :
IContextItemViewModel,
IFilterItemViewModel,
ISeparatorContextItem,
ISeparatorFilterItem
{ {
} }

View File

@ -108,7 +108,7 @@
</DataTemplate> </DataTemplate>
<!-- Template for context item separators --> <!-- Template for context item separators -->
<DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorContextItemViewModel"> <DataTemplate x:Key="SeparatorContextMenuViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
<Rectangle <Rectangle
Height="1" Height="1"
Margin="-16,-12,-12,-12" Margin="-16,-12,-12,-12"

View File

@ -270,7 +270,7 @@ public sealed partial class ContextMenu : UserControl,
private bool IsSeparator(object item) private bool IsSeparator(object item)
{ {
return item is SeparatorContextItemViewModel; return item is SeparatorViewModel;
} }
private void UpdateUiForStackChange() private void UpdateUiForStackChange()

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.FiltersDropDown"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Background="Transparent"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<converters:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<cmdpalUI:FilterTemplateSelector
x:Key="FilterTemplateSelector"
Default="{StaticResource FilterItemViewModelTemplate}"
Separator="{StaticResource SeparatorViewModelTemplate}" />
<Style
x:Name="ComboBoxStyle"
BasedOn="{StaticResource DefaultComboBoxStyle}"
TargetType="ComboBox">
<Style.Setters>
<Setter Property="Visibility" Value="Collapsed" />
<Setter Property="Margin" Value="0,0,12,0" />
<Setter Property="Padding" Value="16,4" />
</Style.Setters>
</Style>
<!-- Template for the filter items -->
<DataTemplate x:Key="FilterItemViewModelTemplate" x:DataType="coreViewModels:FilterItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
Width="16"
Margin="4,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
SourceKey="{x:Bind Icon}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Bind Name}" />
</Grid>
</DataTemplate>
<!-- Template for separators -->
<DataTemplate x:Key="SeparatorViewModelTemplate" x:DataType="coreViewModels:SeparatorViewModel">
<Rectangle
Height="1"
Margin="-16,-12,-12,-12"
Fill="{ThemeResource MenuFlyoutSeparatorThemeBrush}" />
</DataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<ComboBox
Name="FiltersComboBox"
x:Uid="FiltersComboBox"
VerticalAlignment="Center"
ItemTemplateSelector="{StaticResource FilterTemplateSelector}"
ItemsSource="{x:Bind ViewModel.Filters, Mode=OneWay}"
PlaceholderText="Filters"
PreviewKeyDown="FiltersComboBox_PreviewKeyDown"
SelectionChanged="FiltersComboBox_SelectionChanged"
Style="{StaticResource ComboBoxStyle}"
Visibility="{x:Bind ViewModel.ShouldShowFilters, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<ComboBox.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultComboBoxItemStyle}" TargetType="ComboBoxItem">
<Setter Property="MinHeight" Value="0" />
<Setter Property="Padding" Value="12,8" />
</Style>
</ComboBox.ItemContainerStyle>
<ComboBox.ItemContainerTransitions>
<TransitionCollection />
</ComboBox.ItemContainerTransitions>
</ComboBox>
</UserControl>

View File

@ -0,0 +1,189 @@
// 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.CmdPal.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Windows.System;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class FiltersDropDown : UserControl,
ICurrentPageAware
{
public PageViewModel? CurrentPageViewModel
{
get => (PageViewModel?)GetValue(CurrentPageViewModelProperty);
set => SetValue(CurrentPageViewModelProperty, value);
}
public static readonly DependencyProperty CurrentPageViewModelProperty =
DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(FiltersDropDown), new PropertyMetadata(null, OnCurrentPageViewModelChanged));
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var @this = (FiltersDropDown)d;
if (@this != null
&& e.OldValue is PageViewModel old)
{
old.PropertyChanged -= @this.Page_PropertyChanged;
}
// If this new page does not implement ListViewModel or if
// it doesn't contain Filters, we need to clear any filters
// that may have been set.
if (@this != null)
{
if (e.NewValue is ListViewModel listViewModel)
{
@this.ViewModel = listViewModel.Filters;
}
else
{
@this.ViewModel = null;
}
}
if (@this != null
&& e.NewValue is PageViewModel page)
{
page.PropertyChanged += @this.Page_PropertyChanged;
}
}
public FiltersViewModel? ViewModel
{
get => (FiltersViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(FiltersViewModel), typeof(FiltersDropDown), new PropertyMetadata(null));
public FiltersDropDown()
{
this.InitializeComponent();
}
// Used to handle the case when a ListPage's `Filters` may have changed
private void Page_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var property = e.PropertyName;
if (CurrentPageViewModel is ListViewModel list)
{
if (property == nameof(ListViewModel.Filters))
{
ViewModel = list.Filters;
}
}
}
private void FiltersComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (CurrentPageViewModel is ListViewModel listViewModel &&
FiltersComboBox.SelectedItem is FilterItemViewModel filterItem)
{
listViewModel.UpdateCurrentFilter(filterItem.Id);
}
}
private void FiltersComboBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Up)
{
NavigateUp();
e.Handled = true;
}
else if (e.Key == VirtualKey.Down)
{
NavigateDown();
e.Handled = true;
}
}
private void NavigateUp()
{
var newIndex = FiltersComboBox.SelectedIndex;
if (FiltersComboBox.SelectedIndex > 0)
{
newIndex--;
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex--;
}
if (newIndex < 0)
{
newIndex = FiltersComboBox.Items.Count - 1;
while (
newIndex >= 0 &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex--;
}
}
}
else
{
newIndex = FiltersComboBox.Items.Count - 1;
}
FiltersComboBox.SelectedIndex = newIndex;
}
private void NavigateDown()
{
var newIndex = FiltersComboBox.SelectedIndex;
if (FiltersComboBox.SelectedIndex == FiltersComboBox.Items.Count - 1)
{
newIndex = 0;
}
else
{
newIndex++;
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex++;
}
if (newIndex >= FiltersComboBox.Items.Count)
{
newIndex = 0;
while (
newIndex < FiltersComboBox.Items.Count &&
IsSeparator(FiltersComboBox.Items[newIndex]) &&
newIndex != FiltersComboBox.SelectedIndex)
{
newIndex++;
}
}
}
FiltersComboBox.SelectedIndex = newIndex;
}
private bool IsSeparator(object item)
{
return item is SeparatorViewModel;
}
}

View File

@ -62,7 +62,7 @@ public sealed partial class SearchBar : UserControl,
{ {
// TODO: In some cases we probably want commands to clear a filter // TODO: In some cases we probably want commands to clear a filter
// somewhere in the process, so we need to figure out when that is. // somewhere in the process, so we need to figure out when that is.
@this.FilterBox.Text = page.Filter; @this.FilterBox.Text = page.SearchTextBox;
@this.FilterBox.Select(@this.FilterBox.Text.Length, 0); @this.FilterBox.Select(@this.FilterBox.Text.Length, 0);
page.PropertyChanged += @this.Page_PropertyChanged; page.PropertyChanged += @this.Page_PropertyChanged;
@ -87,7 +87,7 @@ public sealed partial class SearchBar : UserControl,
if (CurrentPageViewModel is not null) if (CurrentPageViewModel is not null)
{ {
CurrentPageViewModel.Filter = string.Empty; CurrentPageViewModel.SearchTextBox = string.Empty;
} }
})); }));
} }
@ -145,7 +145,7 @@ public sealed partial class SearchBar : UserControl,
// hack TODO GH #245 // hack TODO GH #245
if (CurrentPageViewModel is not null) if (CurrentPageViewModel is not null)
{ {
CurrentPageViewModel.Filter = FilterBox.Text; CurrentPageViewModel.SearchTextBox = FilterBox.Text;
} }
} }
@ -156,7 +156,7 @@ public sealed partial class SearchBar : UserControl,
// hack TODO GH #245 // hack TODO GH #245
if (CurrentPageViewModel is not null) if (CurrentPageViewModel is not null)
{ {
CurrentPageViewModel.Filter = FilterBox.Text; CurrentPageViewModel.SearchTextBox = FilterBox.Text;
} }
} }
} }
@ -320,7 +320,7 @@ public sealed partial class SearchBar : UserControl,
// Actually plumb Filtering to the view model // Actually plumb Filtering to the view model
if (CurrentPageViewModel is not null) if (CurrentPageViewModel is not null)
{ {
CurrentPageViewModel.Filter = FilterBox.Text; CurrentPageViewModel.SearchTextBox = FilterBox.Text;
} }
} }

View File

@ -28,7 +28,7 @@ internal sealed partial class ContextItemTemplateSelector : DataTemplateSelector
{ {
li.IsEnabled = true; li.IsEnabled = true;
if (item is SeparatorContextItemViewModel) if (item is SeparatorViewModel)
{ {
li.IsEnabled = false; li.IsEnabled = false;
li.AllowFocusWhenDisabled = false; li.AllowFocusWhenDisabled = false;

View File

@ -0,0 +1,36 @@
// 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;
internal sealed partial class FilterTemplateSelector : DataTemplateSelector
{
public DataTemplate? Default { get; set; }
public DataTemplate? Separator { get; set; }
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject)
{
DataTemplate? dataTemplate = Default;
if (dependencyObject is ComboBoxItem comboBoxItem)
{
comboBoxItem.IsEnabled = true;
if (item is SeparatorViewModel)
{
comboBoxItem.IsEnabled = false;
comboBoxItem.AllowFocusWhenDisabled = false;
comboBoxItem.AllowFocusOnInteraction = false;
dataTemplate = Separator;
}
}
return dataTemplate;
}
}

View File

@ -176,6 +176,7 @@
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" /> <ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- Back button --> <!-- Back button -->
@ -320,6 +321,18 @@
</TransitionCollection> </TransitionCollection>
</Grid.Transitions> </Grid.Transitions>
</Grid> </Grid>
<!-- Filter: wrapped in a grid to enable RepositionThemeTransitions -->
<Grid Grid.Column="2" HorizontalAlignment="Right">
<cpcontrols:FiltersDropDown
x:Name="FiltersDropDown"
HorizontalAlignment="Right"
CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
<Grid.Transitions>
<TransitionCollection>
<RepositionThemeTransition />
</TransitionCollection>
</Grid.Transitions>
</Grid>
</Grid> </Grid>
<ProgressBar <ProgressBar

View File

@ -131,7 +131,7 @@ internal sealed partial class AppListItem : ListItem
var newCommands = new List<IContextItem>(); var newCommands = new List<IContextItem>();
newCommands.AddRange(commands); newCommands.AddRange(commands);
newCommands.Add(new SeparatorContextItem()); newCommands.Add(new Separator());
// 0x50 = P // 0x50 = P
// Full key chord would be Ctrl+P // Full key chord would be Ctrl+P

View File

@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WindowsServices.Helpers;
public static class ServiceHelper public static class ServiceHelper
{ {
public static IEnumerable<ListItem> Search(string search) public static IEnumerable<ListItem> Search(string search, string filterId)
{ {
var services = ServiceController.GetServices().OrderBy(s => s.DisplayName); var services = ServiceController.GetServices().OrderBy(s => s.DisplayName);
IEnumerable<ServiceController> serviceList = []; IEnumerable<ServiceController> serviceList = [];
@ -44,6 +44,21 @@ public static class ServiceHelper
serviceList = servicesStartsWith.Concat(servicesContains); serviceList = servicesStartsWith.Concat(servicesContains);
} }
switch (filterId)
{
case "running":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Running);
break;
case "stopped":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Stopped);
break;
case "paused":
serviceList = serviceList.Where(w => w.Status == ServiceControllerStatus.Paused);
break;
case "all":
break;
}
var result = serviceList.Select(s => var result = serviceList.Select(s =>
{ {
var serviceResult = ServiceResult.CreateServiceController(s); var serviceResult = ServiceResult.CreateServiceController(s);

View File

@ -0,0 +1,26 @@
// 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.Ext.WindowsServices;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ServiceFilters : Filters
{
public ServiceFilters()
{
CurrentFilterId = "all";
}
public override IFilterItem[] GetFilters()
{
return [
new Filter() { Id = "all", Name = "All Services" },
new Separator(),
new Filter() { Id = "running", Name = "Running", Icon = Icons.GreenCircleIcon },
new Filter() { Id = "stopped", Name = "Stopped", Icon = Icons.RedCircleIcon },
new Filter() { Id = "paused", Name = "Paused", Icon = Icons.PauseIcon },
];
}
}

View File

@ -16,13 +16,19 @@ internal sealed partial class ServicesListPage : DynamicListPage
{ {
Icon = Icons.ServicesIcon; Icon = Icons.ServicesIcon;
Name = "Windows Services"; Name = "Windows Services";
var filters = new ServiceFilters();
filters.PropChanged += Filters_PropChanged;
Filters = filters;
} }
private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0); public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(0);
public override IListItem[] GetItems() public override IListItem[] GetItems()
{ {
var items = ServiceHelper.Search(SearchText).ToArray(); var items = ServiceHelper.Search(SearchText, Filters.CurrentFilterId).ToArray();
return items; return items;
} }

View File

@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license. // The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using System;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices.WindowsRuntime; using System.Runtime.InteropServices.WindowsRuntime;
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
@ -16,9 +17,14 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
Icon = new IconInfo(string.Empty); Icon = new IconInfo(string.Empty);
Name = "Dynamic List"; Name = "Dynamic List";
IsLoading = true; IsLoading = true;
var filters = new SampleFilters();
filters.PropChanged += Filters_PropChanged;
Filters = filters;
} }
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged(newSearch.Length); private void Filters_PropChanged(object sender, IPropChangedEventArgs args) => RaiseItemsChanged();
public override void UpdateSearchText(string oldSearch, string newSearch) => RaiseItemsChanged();
public override IListItem[] GetItems() public override IListItem[] GetItems()
{ {
@ -28,6 +34,23 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }]; items = [new ListItem(new NoOpCommand()) { Title = "Start typing in the search box" }];
} }
if (!string.IsNullOrEmpty(Filters.CurrentFilterId))
{
switch (Filters.CurrentFilterId)
{
case "mod2":
items = items.Where((item, index) => (index + 1) % 2 == 0).ToArray();
break;
case "mod3":
items = items.Where((item, index) => (index + 1) % 3 == 0).ToArray();
break;
case "all":
default:
// No filtering
break;
}
}
if (items.Length > 0) if (items.Length > 0)
{ {
items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box"; items[0].Subtitle = "Notice how the number of items changes for this page when you type in the filter box";
@ -36,3 +59,18 @@ internal sealed partial class SampleDynamicListPage : DynamicListPage
return items; return items;
} }
} }
#pragma warning disable SA1402 // File may only contain a single type
public partial class SampleFilters : Filters
#pragma warning restore SA1402 // File may only contain a single type
{
public override IFilterItem[] GetFilters()
{
return
[
new Filter() { Id = "all", Name = "All" },
new Filter() { Id = "mod2", Name = "Every 2nd", Icon = new IconInfo("2") },
new Filter() { Id = "mod3", Name = "Every 3rd", Icon = new IconInfo("3") },
];
}
}

View File

@ -81,7 +81,7 @@ internal sealed partial class SampleListPage : ListPage
Title = "I'm a second command", Title = "I'm a second command",
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1),
}, },
new SeparatorContextItem(), new Separator(),
new CommandContextItem( new CommandContextItem(
new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3
{ {

View File

@ -0,0 +1,23 @@
// 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.
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public abstract partial class Filters : BaseObservable, IFilters
{
public string CurrentFilterId
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(CurrentFilterId));
}
}
= string.Empty;
// This method should be overridden in derived classes to provide the actual filters.
public abstract IFilterItem[] GetFilters();
}

View File

@ -4,6 +4,6 @@
namespace Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class SeparatorContextItem : ISeparatorContextItem public partial class Separator : ISeparatorContextItem, ISeparatorFilterItem
{ {
} }

View File

@ -122,7 +122,7 @@ namespace Microsoft.CommandPalette.Extensions
interface ISeparatorFilterItem requires IFilterItem {} interface ISeparatorFilterItem requires IFilterItem {}
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFilter requires IFilterItem { interface IFilter requires INotifyPropChanged, IFilterItem {
String Id { get; }; String Id { get; };
String Name { get; }; String Name { get; };
IIconInfo Icon { get; }; IIconInfo Icon { get; };
@ -131,7 +131,7 @@ namespace Microsoft.CommandPalette.Extensions
[contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)] [contract(Microsoft.CommandPalette.Extensions.ExtensionsContract, 1)]
interface IFilters { interface IFilters {
String CurrentFilterId { get; set; }; String CurrentFilterId { get; set; };
IFilterItem[] Filters(); IFilterItem[] GetFilters();
} }
struct Color struct Color