Compare commits

...

13 Commits

Author SHA1 Message Date
Yu Leng (from Dev Box)
f00eed43e8 merge main 2025-04-10 17:05:33 +08:00
Yu Leng (from Dev Box)
c4fdb71fa0 merge main 2025-04-10 16:43:40 +08:00
Seraphima
3d28d8e501 clear groups 2025-03-31 22:36:38 +02:00
Seraphima
32492772b8 lock 2025-03-31 22:36:06 +02:00
Seraphima
6896f59d48 handle selected items from the grouped list 2025-03-31 20:53:38 +02:00
Seraphima
7ce347149f HasGrouping observable 2025-03-31 20:48:50 +02:00
Seraphima
626d43f631 pull 2025-03-31 20:14:53 +02:00
Seraphima
83aff2687d fix exceptions 2025-03-31 20:02:30 +02:00
Seraphima
f8837c4ed0 update groups 2025-03-31 20:01:35 +02:00
Mike Griese
9589d3bd74 Add a sample ; some old styling too 2025-03-31 10:03:40 -05:00
Seraphima
f3b10bfa8e fix xaml 2025-03-31 16:28:49 +02:00
Seraphima
3e7c7d77df set IsSourceGrouped 2025-03-31 12:09:35 +02:00
Seraphima
0badb19936 grouping by Section 2025-03-31 12:09:24 +02:00
8 changed files with 365 additions and 33 deletions

View File

@@ -16,21 +16,31 @@ public partial class SamplesListPage : ListPage
{
Title = "List Page Sample Command",
Subtitle = "Display a list of items",
Section = "Lists",
},
new ListItem(new SampleListPageWithDetails())
{
Title = "List Page With Details",
Subtitle = "A list of items, each with additional details to display",
Section = "Lists",
},
new ListItem(new SampleUpdatingItemsPage())
{
Title = "List page with items that change",
Subtitle = "The items on the list update themselves in real time",
Section = "Lists",
},
new ListItem(new SampleDynamicListPage())
{
Title = "Dynamic List Page Command",
Subtitle = "Changes the list of items in response to the typed query",
Section = "Lists",
},
new ListItem(new FizzBuzzListPage())
{
Title = "Sections sample",
Subtitle = "Changing list of items, with sections",
Section = "Lists",
},
// Content pages
@@ -38,32 +48,38 @@ public partial class SamplesListPage : ListPage
{
Title = "Sample content page",
Subtitle = "Display mixed forms, markdown, and other types of content",
Section = "Content",
},
new ListItem(new SampleTreeContentPage())
{
Title = "Sample nested content",
Subtitle = "Example of nesting a tree of content",
Section = "Content",
},
new ListItem(new SampleCommentsPage())
{
Title = "Sample of nested comments",
Subtitle = "Demo of using nested trees of content to create a comment thread-like experience",
Icon = new IconInfo("\uE90A"), // Comment
Section = "Content",
},
new ListItem(new SampleMarkdownPage())
{
Title = "Markdown Page Sample Command",
Subtitle = "Display a page of rendered markdown",
Section = "Content",
},
new ListItem(new SampleMarkdownManyBodies())
{
Title = "Markdown with multiple blocks",
Subtitle = "A page with multiple blocks of rendered markdown",
Section = "Content",
},
new ListItem(new SampleMarkdownDetails())
{
Title = "Markdown with details",
Subtitle = "A page with markdown and details",
Section = "Content",
},
// Settings helpers
@@ -71,6 +87,7 @@ public partial class SamplesListPage : ListPage
{
Title = "Sample settings page",
Subtitle = "A demo of the settings helpers",
Section = "Settings",
},
// Evil edge cases
@@ -79,6 +96,7 @@ public partial class SamplesListPage : ListPage
{
Title = "Evil samples",
Subtitle = "Samples designed to break the palette in many different evil ways",
Section = "Debugging",
}
];

View File

@@ -0,0 +1,16 @@
// 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.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListGroup : ObservableObject
{
public string Key { get; set; } = string.Empty;
[ObservableProperty]
public partial ObservableCollection<ListItemViewModel> Items { get; set; } = [];
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// 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.
@@ -27,6 +27,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
private ObservableCollection<ListItemViewModel> Items { get; set; } = [];
[ObservableProperty]
public partial bool HasGrouping { get; private set; } = false;
[ObservableProperty]
public partial ObservableCollection<ListGroup> Groups { get; set; } = [];
private readonly ExtensionObject<IListPage> _model;
private readonly Lock _listLock = new();
@@ -248,6 +254,53 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
}
public void UpdateGroupsIfNeeded()
{
HasGrouping = FilteredItems.Any(i => !string.IsNullOrEmpty(i.Section));
if (HasGrouping)
{
lock (_listLock)
{
if (FilteredItems.Count == 0)
{
Groups.Clear();
return;
}
// get current groups
var groups = FilteredItems.GroupBy(item => item.Section).Select(group => group);
// Remove any groups that no longer exist
foreach (var group in Groups)
{
if (!groups.Any(g => g.Key == group.Key))
{
Groups.Remove(group);
}
}
// Update lists for each existing group
foreach (var group in groups)
{
var existingGroup = Groups.FirstOrDefault(groupItem => groupItem.Key == group.Key);
if (existingGroup == null)
{
// Add a new group if it doesn't exist
Groups.Add(new ListGroup { Key = group.Key, Items = new ObservableCollection<ListItemViewModel>(group) });
existingGroup = Groups.FirstOrDefault(groupItem => groupItem.Key == group.Key);
}
if (existingGroup != null)
{
// Update the existing group
ListHelpers.InPlaceUpdateList(existingGroup.Items, FilteredItems.Where(item => item.Section == group.Key));
}
}
}
}
}
/// <summary>
/// Apply our current filter text to the list of items, and update
/// FilteredItems to match the results.
@@ -487,6 +540,18 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
FilteredItems.Clear();
foreach (ListGroup group in Groups)
{
foreach (ListItemViewModel item in group.Items)
{
item.SafeCleanup();
}
group.Items.Clear();
}
Groups.Clear();
}
IListPage? model = _model.Unsafe;

View File

@@ -11,6 +11,7 @@
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="Styles/Button.xaml" />
<ResourceDictionary Source="Styles/Colors.xaml" />
<ResourceDictionary Source="Styles/Generic.xaml" />
<ResourceDictionary Source="Styles/TextBox.xaml" />
<ResourceDictionary Source="Styles/Settings.xaml" />
<ResourceDictionary Source="Controls/Tag.xaml" />

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.ListPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -17,10 +17,16 @@
<Page.Resources>
<!-- TODO: Figure out what we want to do here for filtering/grouping and where -->
<!-- https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.data.collectionviewsource -->
<!--<CollectionViewSource
x:Name="ItemsCVS"
<CollectionViewSource
x:Name="GroupedItemsCVS"
IsSourceGrouped="True"
Source="{x:Bind ViewModel.Items, Mode=OneWay}" />-->
ItemsPath="Items"
Source="{x:Bind ViewModel.Groups, Mode=OneWay}" />
<CollectionViewSource
x:Name="UngroupedItemsCVS"
IsSourceGrouped="False"
Source="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" />
<converters:StringVisibilityConverter
x:Key="StringVisibilityConverter"
@@ -107,32 +113,74 @@
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}">
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
DoubleTapped="ItemsList_DoubleTapped"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="ItemsList_ItemClick"
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
SelectionChanged="ItemsList_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
<!--<ListView.GroupStyle>
<GroupStyle HidesIfEmpty="True">
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock
Margin="0,16,0,0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{Binding Key, Mode=OneWay}" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListView.GroupStyle>-->
</ListView>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.HasGrouping, Mode=OneWay}">
<controls:Case Value="True">
<ListView
x:Name="GroupedItemsList"
Padding="0,2,0,0"
DoubleTapped="GroupedItemsList_DoubleTapped"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="ItemsList_ItemClick"
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
ItemsSource="{Binding ElementName=GroupedItemsCVS, Path=View}"
SelectionChanged="GroupedItemsList_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
<ListView.GroupStyle>
<!--<GroupStyle HeaderContainerStyle="{StaticResource CustomHeaderContainerStyle}" HidesIfEmpty="True">
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock
Padding="20,12,0,8"
AutomationProperties.AccessibilityView="Raw"
FontSize="14"
FontWeight="SemiBold"
Text="{x:Bind Key}"
Visibility="{x:Bind Key, Converter={StaticResource StringNotEmptyToVisibilityConverter}, Mode=OneWay}" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>-->
<GroupStyle HeaderContainerStyle="{StaticResource CustomHeaderContainerStyle}" HidesIfEmpty="True">
<GroupStyle.HeaderTemplate>
<DataTemplate>
<TextBlock
Margin="0,16,0,0"
Padding="20,8,0,4"
AutomationProperties.AccessibilityView="Raw"
FontSize="14"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{Binding Key, Mode=OneWay}"
Visibility="{Binding Key, Converter={StaticResource StringVisibilityConverter}}" />
</DataTemplate>
</GroupStyle.HeaderTemplate>
</GroupStyle>
</ListView.GroupStyle>
</ListView>
</controls:Case>
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
DoubleTapped="ItemsList_DoubleTapped"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="ItemsList_ItemClick"
ItemTemplate="{StaticResource ListItemViewModelTemplate}"
ItemsSource="{Binding ElementName=UngroupedItemsCVS, Path=View}"
SelectionChanged="ItemsList_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
</controls:SwitchPresenter>
</controls:Case>
<controls:Case Value="True">
<StackPanel

View File

@@ -37,6 +37,7 @@ public sealed partial class ListPage : Page,
this.InitializeComponent();
this.NavigationCacheMode = NavigationCacheMode.Disabled;
this.ItemsList.Loaded += ItemsList_Loaded;
this.GroupedItemsList.Loaded += GroupedItemsList_Loaded;
}
protected override void OnNavigatedTo(NavigationEventArgs e)
@@ -121,6 +122,18 @@ public sealed partial class ListPage : Page,
}
}
private void GroupedItemsList_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e)
{
if (GroupedItemsList.SelectedItem is ListItemViewModel vm)
{
var settings = App.Current.Services.GetService<SettingsModel>()!;
if (!settings.SingleClickActivates)
{
ViewModel?.InvokeItemCommand.Execute(vm);
}
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void ItemsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
@@ -148,6 +161,24 @@ public sealed partial class ListPage : Page,
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS is too aggressive at pruning methods bound in XAML")]
private void GroupedItemsList_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (GroupedItemsList.SelectedItem is ListItemViewModel item)
{
var vm = ViewModel;
_ = Task.Run(() =>
{
vm?.UpdateSelectedItemCommand.Execute(item);
});
}
if (GroupedItemsList.SelectedItem != null)
{
GroupedItemsList.ScrollIntoView(GroupedItemsList.SelectedItem);
}
}
private void ItemsList_Loaded(object sender, RoutedEventArgs e)
{
// Find the ScrollViewer in the ListView
@@ -159,6 +190,17 @@ public sealed partial class ListPage : Page,
}
}
private void GroupedItemsList_Loaded(object sender, RoutedEventArgs e)
{
// Find the ScrollViewer in the ListView
var listViewScrollViewer = FindScrollViewer(this.GroupedItemsList);
if (listViewScrollViewer != null)
{
listViewScrollViewer.ViewChanged += ListViewScrollViewer_ViewChanged;
}
}
private void ListViewScrollViewer_ViewChanged(object? sender, ScrollViewerViewChangedEventArgs e)
{
var scrollView = sender as ScrollViewer;
@@ -187,6 +229,10 @@ public sealed partial class ListPage : Page,
{
ItemsList.SelectedIndex++;
}
else if (GroupedItemsList.SelectedIndex < GroupedItemsList.Items.Count - 1)
{
GroupedItemsList.SelectedIndex++;
}
else
{
ItemsList.SelectedIndex = 0;
@@ -199,9 +245,10 @@ public sealed partial class ListPage : Page,
{
ItemsList.SelectedIndex--;
}
else
if (GroupedItemsList.SelectedIndex > 0)
{
ItemsList.SelectedIndex = ItemsList.Items.Count - 1;
GroupedItemsList.SelectedIndex--;
}
}
@@ -215,6 +262,10 @@ public sealed partial class ListPage : Page,
{
ViewModel?.InvokeItemCommand.Execute(item);
}
else if (GroupedItemsList.SelectedItem is ListItemViewModel groupedItem)
{
ViewModel?.InvokeItemCommand.Execute(groupedItem);
}
}
public void Receive(ActivateSecondaryCommandMessage message)
@@ -227,6 +278,10 @@ public sealed partial class ListPage : Page,
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(item);
}
else if (GroupedItemsList.SelectedItem is ListItemViewModel groupedItem)
{
ViewModel?.InvokeSecondaryCommandCommand.Execute(groupedItem);
}
}
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
@@ -255,6 +310,11 @@ public sealed partial class ListPage : Page,
// GetItems or a change in the filter.
private void Page_ItemsUpdated(ListViewModel sender, object args)
{
if (ViewModel != null)
{
ViewModel?.UpdateGroupsIfNeeded();
}
// If for some reason, we don't have a selected item, fix that.
//
// It's important to do this here, because once there's no selection
@@ -266,6 +326,11 @@ public sealed partial class ListPage : Page,
{
ItemsList.SelectedIndex = 0;
}
if (GroupedItemsList.SelectedItem == null)
{
GroupedItemsList.SelectedIndex = 0;
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)

View File

@@ -0,0 +1,29 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Using custom style to remove header divider from list view headers -->
<Style x:Key="CustomHeaderContainerStyle" TargetType="ListViewHeaderItem">
<Setter Property="FontFamily" Value="{ThemeResource ContentControlThemeFontFamily}" />
<Setter Property="FontSize" Value="{ThemeResource ListViewHeaderItemThemeFontSize}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Margin" Value="0,0,0,0" />
<Setter Property="Padding" Value="12,8,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Left" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="UseSystemFocusVisuals" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewHeaderItem">
<ContentPresenter
x:Name="ContentPresenter"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
Background="{TemplateBinding Background}"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
ContentTransitions="{TemplateBinding ContentTransitions}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,90 @@
// 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.Collections.Generic;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace SamplePagesExtension;
internal sealed partial class FizzBuzzListPage : ListPage
{
public override string Title => "FizzBuzz Page";
public override IconInfo Icon => new("\uE94C"); // % symbol
public override string Name => "Open";
private readonly List<ListItem> _items;
internal FizzBuzzListPage()
{
var addNewItem = new ListItem(new AnonymousCommand(() =>
{
var c = _items.Count;
var f = c % 3 == 0;
var b = c % 5 == 0;
var s = string.Empty;
if (f)
{
s += "Fizz";
}
if (b)
{
s += "Buzz";
}
_items.Add(new ListItem(new NoOpCommand())
{
Title = $"{c}",
Icon = IconFromIndex(_items.Count),
Section = s,
});
RaiseItemsChanged();
})
{ Result = CommandResult.KeepOpen() })
{
Title = "Add item",
Subtitle = "Each item will be sorted into sections. Add at least three",
Icon = new IconInfo("\uED0E"),
};
_items = [addNewItem];
}
public override IListItem[] GetItems()
{
return _items.ToArray();
}
private IconInfo IconFromIndex(int index)
{
return _icons[index % _icons.Length];
}
private readonly IconInfo[] _icons =
[
new IconInfo("\ue700"),
new IconInfo("\ue701"),
new IconInfo("\ue702"),
new IconInfo("\ue703"),
new IconInfo("\ue704"),
new IconInfo("\ue705"),
new IconInfo("\ue706"),
new IconInfo("\ue707"),
new IconInfo("\ue708"),
new IconInfo("\ue709"),
new IconInfo("\ue70a"),
new IconInfo("\ue70b"),
new IconInfo("\ue70c"),
new IconInfo("\ue70d"),
new IconInfo("\ue70e"),
new IconInfo("\ue70f"),
new IconInfo("\ue710"),
new IconInfo("\ue711"),
new IconInfo("\ue712"),
new IconInfo("\ue713"),
];
}