Wire it up to the UI, painfully, but it works

This commit is contained in:
Mike Griese
2025-09-12 15:09:17 -05:00
parent 89c9cf1e60
commit e557e052f8
10 changed files with 587 additions and 19 deletions

View File

@@ -0,0 +1,307 @@
// 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 Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Core.ViewModels;
#pragma warning disable SA1402 // File may only contain a single type
#pragma warning disable SA1649 // File name should match first type name
public abstract partial class ParameterRunViewModel : ExtensionObjectViewModel
{
internal InitializedState Initialized { get; set; } = InitializedState.Uninitialized;
protected bool IsInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.Initialized);
public bool IsInErrorState => Initialized.HasFlag(InitializedState.Error);
internal ParameterRunViewModel(WeakReference<IPageContext> context)
: base(context)
{
}
}
public partial class LabelRunViewModel : ParameterRunViewModel
{
private ExtensionObject<ILabelRun> _model;
public string Text { get; set; } = string.Empty;
public LabelRunViewModel(ILabelRun labelRun, WeakReference<IPageContext> context)
: base(context)
{
_model = new(labelRun);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
var labelRun = _model.Unsafe;
if (labelRun == null)
{
return;
}
Text = labelRun.Text;
UpdateProperty(nameof(Text));
Initialized = InitializedState.Initialized;
}
}
public partial class ParameterValueRunViewModel : ParameterRunViewModel
{
private ExtensionObject<IParameterValueRun> _model;
public string PlaceholderText { get; set; } = string.Empty;
public bool NeedsValue { get; set; }
public ParameterValueRunViewModel(IParameterValueRun valueRun, WeakReference<IPageContext> context)
: base(context)
{
_model = new(valueRun);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
var valueRun = _model.Unsafe;
if (valueRun == null)
{
return;
}
PlaceholderText = valueRun.PlaceholderText;
NeedsValue = valueRun.NeedsValue;
UpdateProperty(nameof(PlaceholderText));
UpdateProperty(nameof(NeedsValue));
Initialized = InitializedState.Initialized;
}
}
public partial class StringParameterRunViewModel : ParameterValueRunViewModel
{
private ExtensionObject<IStringParameterRun> _model;
public string Text { get; set; } = string.Empty;
public StringParameterRunViewModel(IStringParameterRun stringRun, WeakReference<IPageContext> context)
: base(stringRun, context)
{
_model = new(stringRun);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
base.InitializeProperties();
var stringRun = _model.Unsafe;
if (stringRun == null)
{
return;
}
Text = stringRun.Text;
UpdateProperty(nameof(Text));
}
}
public partial class CommandParameterRunViewModel : ParameterValueRunViewModel
{
private ExtensionObject<ICommandParameterRun> _model;
public string DisplayText { get; set; } = string.Empty;
public IconInfoViewModel Icon { get; set; } = new(null);
public CommandParameterRunViewModel(ICommandParameterRun commandRun, WeakReference<IPageContext> context)
: base(commandRun, context)
{
_model = new(commandRun);
}
public override void InitializeProperties()
{
if (IsInitialized)
{
return;
}
base.InitializeProperties();
var commandRun = _model.Unsafe;
if (commandRun == null)
{
return;
}
DisplayText = commandRun.DisplayText;
Icon = new(commandRun.Icon);
if (Icon is not null)
{
Icon.InitializeProperties();
}
UpdateProperty(nameof(DisplayText));
UpdateProperty(nameof(Icon));
}
}
public partial class ParametersPageViewModel : PageViewModel, IDisposable
{
private ExtensionObject<IParametersPage> _model;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
{
base.IsInitialized = value;
}
}
public ObservableCollection<ParameterRunViewModel> Items { get; set; } = [];
private readonly Lock _listLock = new();
public event TypedEventHandler<ParametersPageViewModel, object>? ItemsUpdated;
public ParametersPageViewModel(IParametersPage model, TaskScheduler scheduler, AppExtensionHost host)
: base(model, scheduler, host)
{
_model = new(model);
}
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
//// Run on background thread, from InitializeAsync
public override void InitializeProperties()
{
base.InitializeProperties();
var model = _model.Unsafe;
if (model is null)
{
return; // throw?
}
FetchItems();
// model.ItemsChanged += Model_ItemsChanged; // TODO!
}
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
private void FetchItems()
{
// Collect all the items into new viewmodels
Collection<ParameterRunViewModel> newViewModels = [];
try
{
var newItems = _model.Unsafe!.Parameters;
foreach (var item in newItems)
{
ParameterRunViewModel? itemVm = item switch
{
ILabelRun labelRun => new LabelRunViewModel(labelRun, PageContext),
IStringParameterRun stringRun => new StringParameterRunViewModel(stringRun, PageContext),
ICommandParameterRun commandRun => new CommandParameterRunViewModel(commandRun, PageContext),
_ => null,
};
if (itemVm != null)
{
itemVm.InitializeProperties();
newViewModels.Add(itemVm);
}
}
// Update the Items collection on the UI thread
List<ParameterRunViewModel> removedItems = [];
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into
// .Items
}
// If we removed items, we need to clean them up, to remove our event handlers
foreach (var removedItem in removedItems)
{
removedItem.SafeCleanup();
}
}
catch (Exception)
{
// Handle exceptions (e.g., log them)
}
DoOnUiThread(
() =>
{
ItemsUpdated?.Invoke(this, EventArgs.Empty);
OnPropertyChanged(nameof(Items)); // TODO! hack
});
}
public void Dispose()
{
GC.SuppressFinalize(this);
// TODO!
// _cancellationTokenSource?.Cancel();
// _cancellationTokenSource?.Dispose();
// _cancellationTokenSource = null;
// _fetchItemsCancellationTokenSource?.Cancel();
// _fetchItemsCancellationTokenSource?.Dispose();
// _fetchItemsCancellationTokenSource = null;
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// _cancellationTokenSource?.Cancel();
// _fetchItemsCancellationTokenSource?.Cancel();
var model = _model.Unsafe;
if (model is not null)
{
// model.ItemsChanged -= Model_ItemsChanged;
}
lock (_listLock)
{
foreach (var item in Items)
{
item.SafeCleanup();
}
Items.Clear();
}
}
}
#pragma warning restore SA1649 // File name should match first type name
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -23,6 +23,7 @@ public class CommandPalettePageViewModelFactory
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host) { IsNested = nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host),
IParametersPage paramsPage => new ParametersPageViewModel(paramsPage, _scheduler, host),
_ => null,
};
}

View File

@@ -4,7 +4,9 @@
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:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:coreVm="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -13,21 +15,77 @@
<UserControl.Resources>
<ResourceDictionary>
<cmdpalUi:PlaceholderTextConverter x:Key="PlaceholderTextConverter" />
<DataTemplate x:Key="LabelRunTemplate" x:DataType="coreVm:LabelRunViewModel">
<TextBlock
MinHeight="32"
VerticalAlignment="Center"
Text="{x:Bind Text, Mode=OneWay}" />
</DataTemplate>
<DataTemplate x:Key="ButtonParamTemplate" x:DataType="coreVm:CommandParameterRunViewModel">
<Button
MinHeight="32"
VerticalAlignment="Center"
VerticalContentAlignment="Stretch"
Content="{x:Bind DisplayText, Mode=OneWay}" />
</DataTemplate>
<DataTemplate x:Key="StringParamTemplate" x:DataType="coreVm:StringParameterRunViewModel">
<TextBox
MinHeight="32"
VerticalAlignment="Center"
VerticalContentAlignment="Stretch"
BorderBrush="{ThemeResource AccentTextFillColorPrimaryBrush}"
BorderThickness="1"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
PlaceholderText="{x:Bind PlaceholderText, Mode=OneWay}"
Style="{StaticResource SearchTextBoxStyle}"
Text="{x:Bind Text, Mode=TwoWay}" />
</DataTemplate>
<cpcontrols:ParameterRunTemplateSelector
x:Key="ParameterRunTemplateSelector"
ButtonParamTemplate="{StaticResource ButtonParamTemplate}"
LabelRunTemplate="{StaticResource LabelRunTemplate}"
StringParamTemplate="{StaticResource StringParamTemplate}" />
</ResourceDictionary>
</UserControl.Resources>
<!-- Search box -->
<TextBox
x:Name="FilterBox"
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
PreviewKeyDown="FilterBox_PreviewKeyDown"
PreviewKeyUp="FilterBox_PreviewKeyUp"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterBox_TextChanged" />
<!-- Disabled Description="{x:Bind CurrentPageViewModel.TextToSuggest, Mode=OneWay}" for now, needs more work -->
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:String"
Value="{x:Bind PageType, Mode=OneWay}">
<controls:Case Value="List">
<!-- Search box -->
<TextBox
x:Name="FilterBox"
MinHeight="32"
VerticalAlignment="Stretch"
VerticalContentAlignment="Stretch"
AutomationProperties.AutomationId="MainSearchBox"
KeyDown="FilterBox_KeyDown"
PlaceholderText="{x:Bind CurrentPageViewModel.PlaceholderText, Converter={StaticResource PlaceholderTextConverter}, Mode=OneWay}"
PreviewKeyDown="FilterBox_PreviewKeyDown"
PreviewKeyUp="FilterBox_PreviewKeyUp"
Style="{StaticResource SearchTextBoxStyle}"
TextChanged="FilterBox_TextChanged" />
<!-- Disabled Description="{x:Bind CurrentPageViewModel.TextToSuggest, Mode=OneWay}" for now, needs more work -->
</controls:Case>
<controls:Case Value="Parameters">
<ItemsControl
x:Name="ParametersBar"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
ItemTemplateSelector="{StaticResource ParameterRunTemplateSelector}"
ItemsSource="{x:Bind Parameters, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</controls:Case>
</controls:SwitchPresenter>
</UserControl>

View File

@@ -2,6 +2,8 @@
// 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 System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.ViewModels;
@@ -19,6 +21,7 @@ using VirtualKey = Windows.System.VirtualKey;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class SearchBar : UserControl,
INotifyPropertyChanged,
IRecipient<GoHomeMessage>,
IRecipient<FocusSearchBoxMessage>,
IRecipient<UpdateSuggestionMessage>,
@@ -46,6 +49,8 @@ public sealed partial class SearchBar : UserControl,
public static readonly DependencyProperty CurrentPageViewModelProperty =
DependencyProperty.Register(nameof(CurrentPageViewModel), typeof(PageViewModel), typeof(SearchBar), new PropertyMetadata(null, OnCurrentPageViewModelChanged));
public event PropertyChangedEventHandler? PropertyChanged;
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
//// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work...
@@ -67,8 +72,20 @@ public sealed partial class SearchBar : UserControl,
page.PropertyChanged += @this.Page_PropertyChanged;
}
@this?.PropertyChanged?.Invoke(@this, new(nameof(PageType)));
}
public string PageType => CurrentPageViewModel switch
{
ListViewModel => "List",
ContentPageViewModel => "Content",
ParametersPageViewModel => "Parameters",
_ => string.Empty,
};
public ObservableCollection<ParameterRunViewModel>? Parameters => CurrentPageViewModel is ParametersPageViewModel ppvm ? ppvm.Items : null;
public SearchBar()
{
this.InitializeComponent();
@@ -355,6 +372,13 @@ public sealed partial class SearchBar : UserControl,
SelectSearch();
}
}
else if (CurrentPageViewModel is ParametersPageViewModel parametersPage)
{
if (property == nameof(ParametersPageViewModel.Items))
{
this.PropertyChanged?.Invoke(this, new(nameof(Parameters)));
}
}
}
public void Receive(GoHomeMessage message) => ClearSearch();
@@ -453,3 +477,30 @@ public sealed partial class SearchBar : UserControl,
}));
}
}
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class ParameterRunTemplateSelector : DataTemplateSelector
#pragma warning restore SA1402 // File may only contain a single type
{
public DataTemplate? LabelRunTemplate { get; set; }
public DataTemplate? StringParamTemplate { get; set; }
public DataTemplate? ButtonParamTemplate { get; set; }
protected override DataTemplate? SelectTemplateCore(object item)
{
return item switch
{
LabelRunViewModel => LabelRunTemplate,
StringParameterRunViewModel => StringParamTemplate,
CommandParameterRunViewModel => ButtonParamTemplate,
_ => base.SelectTemplateCore(item),
};
}
protected override DataTemplate? SelectTemplateCore(object item, DependencyObject container)
{
return SelectTemplateCore(item);
}
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.ParametersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Interactions="using:Microsoft.Xaml.Interactions.Core"
xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:cmdPalControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:labToolkit="using:CommunityToolkit.Labs.WinUI.MarkdownTextBlock"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:markdownTextBlockRns="using:CommunityToolkit.WinUI.Controls.MarkdownTextBlockRns"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary />
</Page.Resources>
<Grid>
<TextBlock Text="Parameters Page" />
</Grid>
</Page>

View File

@@ -0,0 +1,85 @@
// 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.Messaging;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ParametersPage : Page
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
public ParametersPageViewModel? ViewModel
{
get => (ParametersPageViewModel?)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
// Using a DependencyProperty as the backing store for ViewModel. This enables animation, styling, binding, etc...
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(nameof(ViewModel), typeof(ParametersPageViewModel), typeof(ParametersPage), new PropertyMetadata(null));
public ParametersPage()
{
this.InitializeComponent();
this.Unloaded += OnUnloaded;
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// Unhook from everything to ensure nothing can reach us
// between this point and our complete and utter destruction.
WeakReferenceMessenger.Default.UnregisterAll(this);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
if (e.Parameter is ParametersPageViewModel vm)
{
ViewModel = vm;
}
// if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSelectedListItemMessage>(this))
// {
// WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
// }
// if (!WeakReferenceMessenger.Default.IsRegistered<ActivateSecondaryCommandMessage>(this))
// {
// WeakReferenceMessenger.Default.Register<ActivateSecondaryCommandMessage>(this);
// }
base.OnNavigatedTo(e);
}
protected override void OnNavigatingFrom(NavigatingCancelEventArgs e)
{
base.OnNavigatingFrom(e);
// WeakReferenceMessenger.Default.Unregister<ActivateSelectedListItemMessage>(this);
// WeakReferenceMessenger.Default.Unregister<ActivateSecondaryCommandMessage>(this);
// Clean-up event listeners
ViewModel = null;
}
// // this comes in on Enter keypresses in the SearchBox
// public void Receive(ActivateSelectedListItemMessage message)
// {
// ViewModel?.InvokePrimaryCommandCommand?.Execute(ViewModel);
// }
// // this comes in on Ctrl+Enter keypresses in the SearchBox
// public void Receive(ActivateSecondaryCommandMessage message)
// {
// ViewModel?.InvokeSecondaryCommandCommand?.Execute(ViewModel);
// }
}

View File

@@ -127,6 +127,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{
ListViewModel => typeof(ListPage),
ContentPageViewModel => typeof(ContentPage),
ParametersPageViewModel => typeof(ParametersPage),
_ => throw new NotSupportedException(),
},
message.Page,

View File

@@ -194,11 +194,6 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// Some emojis (e.g. 2⃣) would be rendered as emoji glyphs otherwise.
family = L"Segoe UI Emoji, Segoe UI";
}
else if (!fontFamily.empty())
{
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ fontFamily });
}
else
{
family = L"Segoe UI";

View File

@@ -13,6 +13,40 @@ namespace SamplePagesExtension.Pages;
#pragma warning disable SA1649 // File name should match first type name
#nullable enable
public sealed partial class SimpleParameterTest : ParametersPage
{
private readonly StringParameterRun _stringParameter;
private readonly InvokableCommand _command;
public SimpleParameterTest()
{
Name = "Open";
_stringParameter = new StringParameterRun()
{
PlaceholderText = "Type something",
};
_command = new AnonymousCommand(() =>
{
var input = _stringParameter.Text;
var toast = new ToastStatusMessage(new StatusMessage() { Message = $"You entered: {input}" });
toast.Show();
})
{
Name = "Submit",
Icon = new IconInfo("\uE724"), // Send
};
}
public override IParameterRun[] Parameters => new IParameterRun[]
{
new LabelRun("Enter a value:"),
_stringParameter,
};
public override IListItem Command => new ListItem(_command);
}
public sealed partial class CreateNoteParametersPage : ParametersPage
{
private readonly SelectFolderPage _selectFolderPage = new();

View File

@@ -89,6 +89,13 @@ public partial class SamplesListPage : ListPage
Subtitle = "A demo of the settings helpers",
},
// Parameter pages
new ListItem(new SimpleParameterTest())
{
Title = "Sample parameters page",
Subtitle = "A demo of a command that takes simple parameters",
},
// Evil edge cases
// Anything weird that might break the palette - put that in here.
new ListItem(new EvilSamplesPage())