From 4d464bc8010bc5d12321e6af74ee87d608d5d1ee Mon Sep 17 00:00:00 2001 From: Mike Griese Date: Thu, 23 Jan 2025 16:06:55 -0600 Subject: [PATCH] Add support for hiding messages too (#355) Bits of status messages that were omitted from #281. This lets extensions hide messages (and exposes the helper in the helper lib). It also adds support for displaying progress as a progress bar underneath the text of the status message. I'll need An Adult to help with the XAML, to re-template the InfoBar to allow a progress wheel in the icon instead, but for now? good enough. I'm doing this to unblock the next PR, which should add some rudimentary winget support. --- .../Pages/SampleListPage.cs | 12 +- .../CommandItemViewModel.cs | 6 + .../CommandPaletteHost.cs | 86 +++++++++++--- .../ProgressViewModel.cs | 73 ++++++++++++ .../StatusMessageViewModel.cs | 35 +++++- .../cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml | 13 ++- .../ExtensionHost.cs | 26 +++-- .../Pages/SendMessageCommand.cs | 105 ++++++++++++++++++ 8 files changed, 325 insertions(+), 31 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs diff --git a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs index 2743154d005d..ee69c851c7fd 100644 --- a/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/Exts/SamplePagesExtension/Pages/SampleListPage.cs @@ -30,7 +30,17 @@ public override IListItem[] GetItems() } ], }, - new ListItem(new SendMessageCommand()) { Title = "I send messages" }, + new ListItem(new SendMessageCommand()) + { + Title = "I send lots of messages", + Subtitle = "Status messages can be used to provide feedback to the user in-app", + }, + new SendSingleMessageItem(), + new ListItem(new IndeterminateProgressMessageCommand()) + { + Title = "Do a thing with a spinner", + Subtitle = "Messages can have progress spinners, to indicate something is happening in the background", + }, ]; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 8d3416c77558..0459b600e32b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -162,6 +162,12 @@ protected virtual void FetchProperty(string propertyName) switch (propertyName) { + case nameof(Command): + this.Command = new(model.Command); + Name = model.Command?.Name ?? string.Empty; + UpdateProperty(nameof(Name)); + + break; case nameof(Name): this.Name = model.Command?.Name ?? string.Empty; break; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs index 02be57a5a13e..ce86d7ced7d6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandPaletteHost.cs @@ -18,19 +18,15 @@ public sealed partial class CommandPaletteHost : IExtensionHost private static readonly GlobalLogPageContext _globalLogPageContext = new(); - private ulong _hostHwnd; - - public ulong HostingHwnd => _hostHwnd; + public ulong HostingHwnd { get; private set; } public string LanguageOverride => string.Empty; - public static ObservableCollection LogMessages { get; } = new(); - - public ObservableCollection StatusMessages { get; } = new(); + public static ObservableCollection LogMessages { get; } = []; - private readonly IExtensionWrapper? _source; + public ObservableCollection StatusMessages { get; } = []; - public IExtensionWrapper? Extension => _source; + public IExtensionWrapper? Extension { get; } private CommandPaletteHost() { @@ -38,11 +34,16 @@ private CommandPaletteHost() public CommandPaletteHost(IExtensionWrapper source) { - _source = source; + Extension = source; } - public IAsyncAction ShowStatus(IStatusMessage message) + public IAsyncAction ShowStatus(IStatusMessage? message) { + if (message == null) + { + return Task.CompletedTask.AsAsyncAction(); + } + Debug.WriteLine(message.Message); _ = Task.Run(() => @@ -53,13 +54,27 @@ public IAsyncAction ShowStatus(IStatusMessage message) return Task.CompletedTask.AsAsyncAction(); } - public IAsyncAction HideStatus(IStatusMessage message) + public IAsyncAction HideStatus(IStatusMessage? message) { + if (message == null) + { + return Task.CompletedTask.AsAsyncAction(); + } + + _ = Task.Run(() => + { + ProcessHideStatusMessage(message); + }); return Task.CompletedTask.AsAsyncAction(); } - public IAsyncAction LogMessage(ILogMessage message) + public IAsyncAction LogMessage(ILogMessage? message) { + if (message == null) + { + return Task.CompletedTask.AsAsyncAction(); + } + Debug.WriteLine(message.Message); _ = Task.Run(() => @@ -77,9 +92,9 @@ public void ProcessLogMessage(ILogMessage message) var vm = new LogMessageViewModel(message, _globalLogPageContext); vm.SafeInitializePropertiesSynchronous(); - if (_source != null) + if (Extension != null) { - vm.ExtensionPfn = _source.PackageFamilyName; + vm.ExtensionPfn = Extension.PackageFamilyName; } Task.Factory.StartNew( @@ -94,12 +109,28 @@ public void ProcessLogMessage(ILogMessage message) public void ProcessStatusMessage(IStatusMessage message) { + // If this message is already in the list of messages, just bring it to the top + var oldVm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); + if (oldVm != null) + { + Task.Factory.StartNew( + () => + { + StatusMessages.Remove(oldVm); + StatusMessages.Add(oldVm); + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); + return; + } + var vm = new StatusMessageViewModel(message, _globalLogPageContext); vm.SafeInitializePropertiesSynchronous(); - if (_source != null) + if (Extension != null) { - vm.ExtensionPfn = _source.PackageFamilyName; + vm.ExtensionPfn = Extension.PackageFamilyName; } Task.Factory.StartNew( @@ -112,8 +143,27 @@ public void ProcessStatusMessage(IStatusMessage message) _globalLogPageContext.Scheduler); } - public void SetHostHwnd(ulong hostHwnd) + public void ProcessHideStatusMessage(IStatusMessage message) { - _hostHwnd = hostHwnd; + Task.Factory.StartNew( + () => + { + try + { + var vm = StatusMessages.Where(messageVM => messageVM.Model.Unsafe == message).FirstOrDefault(); + if (vm != null) + { + StatusMessages.Remove(vm); + } + } + catch + { + } + }, + CancellationToken.None, + TaskCreationOptions.None, + _globalLogPageContext.Scheduler); } + + public void SetHostHwnd(ulong hostHwnd) => HostingHwnd = hostHwnd; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs new file mode 100644 index 000000000000..cf0a31da681f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProgressViewModel.cs @@ -0,0 +1,73 @@ +// 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.Extensions; +using Microsoft.CmdPal.UI.ViewModels.Models; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ProgressViewModel : ExtensionObjectViewModel +{ + public ExtensionObject Model { get; } + + public bool IsIndeterminate { get; private set; } + + public uint ProgressPercent { get; private set; } + + public double ProgressValue => ProgressPercent / 100.0; + + public ProgressViewModel(IProgressState progress, IPageContext context) + : base(context) + { + Model = new(progress); + } + + public override void InitializeProperties() + { + var model = Model.Unsafe; + if (model == null) + { + return; // throw? + } + + IsIndeterminate = model.IsIndeterminate; + ProgressPercent = model.ProgressPercent; + + model.PropChanged += Model_PropChanged; + } + + private void Model_PropChanged(object sender, PropChangedEventArgs args) + { + try + { + FetchProperty(args.PropertyName); + } + catch (Exception ex) + { + PageContext.ShowException(ex); + } + } + + protected virtual void FetchProperty(string propertyName) + { + var model = this.Model.Unsafe; + if (model == null) + { + return; // throw? + } + + switch (propertyName) + { + case nameof(IsIndeterminate): + this.IsIndeterminate = model.IsIndeterminate; + break; + case nameof(ProgressPercent): + this.ProgressPercent = model.ProgressPercent; + UpdateProperty(nameof(ProgressValue)); + break; + } + + UpdateProperty(propertyName); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs index 700681f2b7b4..838f51be05e9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/StatusMessageViewModel.cs @@ -9,7 +9,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class StatusMessageViewModel : ExtensionObjectViewModel { - private readonly ExtensionObject _model; + public ExtensionObject Model { get; } public string Message { get; private set; } = string.Empty; @@ -17,15 +17,21 @@ public partial class StatusMessageViewModel : ExtensionObjectViewModel public string ExtensionPfn { get; set; } = string.Empty; + public ProgressViewModel? Progress { get; private set; } + + public bool HasProgress => Progress != null; + + // public bool IsIndeterminate => Progress != null && Progress.IsIndeterminate; + // public double ProgressValue => (Progress?.ProgressPercent ?? 0) / 100.0; public StatusMessageViewModel(IStatusMessage message, IPageContext context) : base(context) { - _model = new(message); + Model = new(message); } public override void InitializeProperties() { - var model = _model.Unsafe; + var model = Model.Unsafe; if (model == null) { return; // throw? @@ -33,6 +39,13 @@ public override void InitializeProperties() Message = model.Message; State = model.State; + var modelProgress = model.Progress; + if (modelProgress != null) + { + Progress = new(modelProgress, this.PageContext); + Progress.InitializeProperties(); + UpdateProperty(nameof(HasProgress)); + } model.PropChanged += Model_PropChanged; } @@ -51,7 +64,7 @@ private void Model_PropChanged(object sender, PropChangedEventArgs args) protected virtual void FetchProperty(string propertyName) { - var model = this._model.Unsafe; + var model = this.Model.Unsafe; if (model == null) { return; // throw? @@ -65,6 +78,20 @@ protected virtual void FetchProperty(string propertyName) case nameof(State): this.State = model.State; break; + case nameof(Progress): + var modelProgress = model.Progress; + if (modelProgress != null) + { + Progress = new(modelProgress, this.PageContext); + Progress.InitializeProperties(); + } + else + { + Progress = null; + } + + UpdateProperty(nameof(HasProgress)); + break; } UpdateProperty(propertyName); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml index af07ba1d204a..60afda81a399 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ShellPage.xaml @@ -240,7 +240,18 @@ CornerRadius="{ThemeResource ControlCornerRadius}" IsOpen="{x:Bind ViewModel.CurrentPage.HasStatusMessage, Mode=OneWay}" Message="{x:Bind ViewModel.CurrentPage.MostRecentStatusMessage.Message, Mode=OneWay}" - Severity="{x:Bind ViewModel.CurrentPage.MostRecentStatusMessage.State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}" /> + Severity="{x:Bind ViewModel.CurrentPage.MostRecentStatusMessage.State, Mode=OneWay, Converter={StaticResource MessageStateToSeverityConverter}}"> + + + + + + diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs index 4bd85a75c76d..7a1323c3c91f 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CmdPal.Extensions.Helpers/ExtensionHost.cs @@ -6,14 +6,9 @@ namespace Microsoft.CmdPal.Extensions.Helpers; public class ExtensionHost { - private static IExtensionHost? _host; + public static IExtensionHost? Host { get; private set; } - public static IExtensionHost? Host => _host; - - public static void Initialize(IExtensionHost host) - { - _host = host; - } + public static void Initialize(IExtensionHost host) => Host = host; /// /// Fire-and-forget a log message to the Command Palette host app. Since @@ -61,4 +56,21 @@ public static void ShowStatus(IStatusMessage message) }); } } + + public static void HideStatus(IStatusMessage message) + { + if (Host != null) + { + _ = Task.Run(async () => + { + try + { + await Host.HideStatus(message); + } + catch (Exception) + { + } + }); + } + } } diff --git a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs index 6b741ea84760..b0dc8fc05fa5 100644 --- a/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs +++ b/src/modules/cmdpal/exts/SamplePagesExtension/Pages/SendMessageCommand.cs @@ -2,8 +2,11 @@ // 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.Threading; +using System.Threading.Tasks; using Microsoft.CmdPal.Extensions; using Microsoft.CmdPal.Extensions.Helpers; +using Windows.Foundation; namespace SamplePagesExtension; @@ -27,3 +30,105 @@ public override ICommandResult Invoke() return CommandResult.KeepOpen(); } } + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code sometimes makes more sense in a single file")] +internal sealed partial class SendSingleMessageItem : ListItem +{ + private readonly SingleMessageCommand _command; + + public SendSingleMessageItem() + : base(new SingleMessageCommand()) + { + Title = "I send a single message"; + Title = "This demonstrates both showing and hiding a single message"; + + _command = (SingleMessageCommand)Command; + _command.UpdateListItem += (sender, args) => + { + Title = _command.Shown ? "Hide message" : "I send a single message"; + }; + } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code sometimes makes more sense in a single file")] +internal sealed partial class SingleMessageCommand : InvokableCommand +{ + public event TypedEventHandler UpdateListItem; + + private readonly StatusMessage _myMessage = new() { Message = "I am a status message" }; + + public bool Shown { get; private set; } + + public override ICommandResult Invoke() + { + if (Shown) + { + ExtensionHost.HideStatus(_myMessage); + } + else + { + ExtensionHost.ShowStatus(_myMessage); + } + + Shown = !Shown; + Name = Shown ? "Hide" : "Show"; + UpdateListItem?.Invoke(this, null); + return CommandResult.KeepOpen(); + } +} + +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Sample code sometimes makes more sense in a single file")] +internal sealed partial class IndeterminateProgressMessageCommand : InvokableCommand +{ + private readonly StatusMessage _myMessage = new() + { + Message = "Doing the thing...", + Progress = new ProgressState() { IsIndeterminate = true }, + }; + + private enum State + { + NotStarted, + Started, + Done, + } + + private State _state; + + public IndeterminateProgressMessageCommand() + { + this.Name = "Do the thing"; + } + + public override ICommandResult Invoke() + { + if (_state == State.NotStarted) + { + ExtensionHost.ShowStatus(_myMessage); + _ = Task.Run(() => + { + Thread.Sleep(3000); + + _state = State.Done; + _myMessage.State = MessageState.Success; + _myMessage.Message = "Did the thing!"; + _myMessage.Progress = null; + + Thread.Sleep(3000); + ExtensionHost.HideStatus(_myMessage); + _state = State.NotStarted; + + _myMessage.State = MessageState.Info; + _myMessage.Message = "Doing the thing..."; + _myMessage.Progress = new ProgressState() { IsIndeterminate = true }; + }); + _state = State.Started; + } + else if (_state == State.Started) + { + ExtensionHost.ShowStatus(_myMessage); + } + + return CommandResult.KeepOpen(); + } +}