CmdPal: Add a dock (#45824)

Add support for a "dock" window in CmdPal. The dock is a toolbar powered
by the `APPBAR` APIs. This gives you a persistent region to display
commands for quick shortcuts or glanceable widgets.

The dock can be pinned to any side of the screen.
The dock can be independently styled with any of the theming controls
cmdpal already has
The dock has three "regions" to pin to - the "start", the "center", and
the "end".
Elements on the dock are grouped as "bands", which contains a set of
"items". Each "band" is one atomic unit. For example, the Media Player
extension produces 4 items, but one _band_.
The dock has only one size (for now)
The dock will only appear on your primary display (for now)

This PR includes support for pinning arbitrary top-level commands to the
dock - however, we're planning on replacing that with a more universal
ability to pin any command to the dock or top level. (see #45191). This
is at least usable for now.

This is definitely still _even more preview_ than usual PowerToys
features, but it's more than usable. I'd love to get it out there and
start collecting feedback on where to improve next. I'll probably add a
follow-up issue for tracking the remaining bugs & nits.

closes #45201

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
This commit is contained in:
Mike Griese
2026-02-27 07:24:23 -06:00
committed by GitHub
parent 494c14fb88
commit 70bf430d9f
90 changed files with 7148 additions and 193 deletions

View File

@@ -18,9 +18,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
{
private static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
internal static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
private static readonly ObservableCollection<Color> WindowsColorSwatches = [
internal static readonly ObservableCollection<Color> WindowsColorSwatches = [
// row 0
Color.FromArgb(255, 255, 185, 0), // #ffb900

View File

@@ -96,9 +96,10 @@ public partial class CommandBarViewModel : ObservableObject,
SecondaryCommand = SelectedItem.SecondaryCommand;
ShouldShowContextMenu = SelectedItem.MoreCommands
.OfType<CommandContextItemViewModel>()
.Count() > 1;
var hasMoreThanOneContextItem = SelectedItem.MoreCommands.Count() > 1;
var hasMoreThanOneCommand = SelectedItem.MoreCommands.OfType<CommandContextItemViewModel>().Any();
ShouldShowContextMenu = hasMoreThanOneContextItem && hasMoreThanOneCommand;
OnPropertyChanged(nameof(HasSecondaryCommand));
OnPropertyChanged(nameof(SecondaryCommand));

View File

@@ -51,9 +51,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private string _itemTitle = string.Empty;
public string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
protected string ItemTitle => _itemTitle;
public string Subtitle { get; private set; } = string.Empty;
public virtual string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
public virtual string Subtitle { get; private set; } = string.Empty;
private IconInfoViewModel _icon = new(null);
@@ -73,10 +75,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? PrimaryCommand => this;
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null;
public CommandItemViewModel? SecondaryCommand
{
get
{
if (HasMoreCommands)
{
if (MoreCommands[0] is CommandContextItemViewModel command)
{
return command;
}
}
return null;
}
}
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
public bool HasTitle => !string.IsNullOrEmpty(Title);
public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle);
public virtual bool HasText => HasTitle || HasSubtitle;
public DataPackageView? DataPackage { get; private set; }
public List<IContextItemViewModel> AllCommands
@@ -331,11 +353,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
UpdateProperty(nameof(HasText));
break;
case nameof(Title):
_itemTitle = model.Title;
_titleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Subtitle):
@@ -343,6 +367,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
this.Subtitle = modelSubtitle;
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
_subtitleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Icon):
@@ -401,11 +426,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
}
private void UpdateDefaultContextItemIcon()
{
private void UpdateDefaultContextItemIcon() =>
// Command icon takes precedence over our icon on the primary command
_defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
}
private void UpdateTitle(string? title)
{

View File

@@ -22,7 +22,7 @@ public class CommandPalettePageViewModelFactory
{
return page switch
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsNested = nested },
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
_ => null,
};

View File

@@ -6,6 +6,7 @@ using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
@@ -29,6 +30,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
public TopLevelViewModel[] DockBandItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty;
public IExtensionWrapper? Extension { get; }
@@ -57,7 +60,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
// calls are pretty dang safe actually.
_commandProvider = new(provider);
_taskScheduler = mainThread;
TopLevelPageContext = new TopLevelItemPageContext(this, _taskScheduler);
TopLevelPageContext = new(this, _taskScheduler);
// Hook the extension back into us
ExtensionHost = new CommandPaletteHost(provider);
@@ -82,7 +85,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
{
_taskScheduler = mainThread;
_commandProviderCache = commandProviderCache;
TopLevelPageContext = new TopLevelItemPageContext(this, _taskScheduler);
TopLevelPageContext = new(this, _taskScheduler);
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
@@ -146,26 +149,42 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
ICommandItem[] dockBands = []; // do not initialize me to null
var displayInfoInitialized = false;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
loadTopLevelCommandsTask.Start();
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
// On a BG thread here
var fallbacks = model.FallbackCommands();
fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
ICommandItem[] pinnedCommands = [];
if (model is ICommandProvider4 four)
if (model is ICommandProvider3 supportsDockBands)
{
var bands = supportsDockBands.GetDockBands();
if (bands is not null)
{
Logger.LogDebug($"Found {bands.Length} bands on {DisplayName} ({ProviderId}) ");
dockBands = bands;
}
}
ICommandItem[] pinnedCommands = [];
ICommandProvider4? four = null;
if (model is ICommandProvider4 definitelyFour)
{
four = definitelyFour; // stash this away so we don't need to QI again
SupportsPinning = true;
// Load pinned commands from saved settings
@@ -189,7 +208,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
Settings = new(model.Settings, this, _taskScheduler);
// We do need to explicitly initialize commands though
InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider);
var objects = new TopLevelObjects(commands, fallbacks, pinnedCommands, dockBands);
InitializeCommands(objects, serviceProvider, four);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
@@ -220,21 +240,27 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
private void InitializeCommands(
ICommandItem[] commands,
IFallbackCommandItem[] fallbacks,
ICommandItem[] pinnedCommands,
IServiceProvider serviceProvider)
TopLevelObjects objects,
IServiceProvider serviceProvider,
ICommandProvider4? four)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var contextMenuFactory = serviceProvider.GetService<IContextMenuFactory>()!;
var state = serviceProvider.GetService<AppStateModel>()!;
var providerSettings = GetProviderSettings(settings);
var ourContext = GetProviderContext();
var pageContext = new WeakReference<IPageContext>(TopLevelPageContext);
var makeAndAdd = (ICommandItem? i, bool fallback) =>
WeakReference<IPageContext> pageContext = new(this.TopLevelPageContext);
var make = (ICommandItem? i, TopLevelType t) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory);
topLevelViewModel.InitializeProperties();
return topLevelViewModel;
@@ -242,24 +268,103 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
var topLevelList = new List<TopLevelViewModel>();
if (commands is not null)
if (objects.Commands is not null)
{
topLevelList.AddRange(commands.Select(c => makeAndAdd(c, false)));
topLevelList.AddRange(objects.Commands.Select(c => make(c, TopLevelType.Normal)));
}
if (pinnedCommands is not null)
if (objects.PinnedCommands is not null)
{
topLevelList.AddRange(pinnedCommands.Select(c => makeAndAdd(c, false)));
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
}
TopLevelItems = topLevelList.ToArray();
if (fallbacks is not null)
if (objects.Fallbacks is not null)
{
FallbackItems = fallbacks
.Select(c => makeAndAdd(c, true))
FallbackItems = objects.Fallbacks
.Select(c => make(c, TopLevelType.Fallback))
.ToArray();
}
List<TopLevelViewModel> bands = new();
if (objects.DockBands is not null)
{
// Start by adding TopLevelViewModels for all the dock bands which
// are explicitly provided by the provider through the GetDockBands
// API.
foreach (var b in objects.DockBands)
{
var bandVm = make(b, TopLevelType.DockBand);
bands.Add(bandVm);
}
}
var dockSettings = settings.DockSettings;
var allPinnedCommands = dockSettings.AllPinnedCommands;
var pinnedBandsForThisProvider = allPinnedCommands.Where(c => c.ProviderId == ProviderId);
foreach (var (providerId, commandId) in pinnedBandsForThisProvider)
{
Logger.LogDebug($"Looking for pinned dock band command {commandId} for provider {providerId}");
// First, try to lookup the command as one of this provider's
// top-level commands. If it's there, then we can skip a lot of
// work and just clone it as a band.
if (LookupTopLevelCommand(commandId) is TopLevelViewModel topLevelCommand)
{
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} as a top-level command");
var bandModel = topLevelCommand.ToPinnedDockBandItem();
var bandVm = make(bandModel, TopLevelType.DockBand);
bands.Add(bandVm);
continue;
}
// If we didn't find it as a top-level command, then we need to
// try to get it directly from the provider and hope it supports
// being a dock band. This is the fallback for providers that
// don't explicitly support dock bands through GetDockBands, but
// do support pinning commands (ICommandProvider4)
if (four is not null)
{
try
{
var commandItem = four.GetCommandItem(commandId);
if (commandItem is not null)
{
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API");
var bandVm = make(commandItem, TopLevelType.DockBand);
bands.Add(bandVm);
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API. This command won't be shown as a dock band.");
}
}
catch (Exception e)
{
Logger.LogError($"Failed to load pinned dock band command {commandId} for provider {providerId}: {e.Message}");
}
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} as a top-level command, and provider doesn't support ICommandProvider4 API to get it directly. This command won't be shown as a dock band.");
}
}
DockBandItems = bands.ToArray();
}
private TopLevelViewModel? LookupTopLevelCommand(string commandId)
{
foreach (var c in TopLevelItems)
{
if (c.Id == commandId)
{
return c;
}
}
return null;
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
@@ -295,6 +400,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
{
Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider");
}
else if (a is ICommandItem[] commands)
{
Logger.LogDebug($"{ProviderId}: Found an ICommandItem[]");
}
}
}
@@ -306,10 +415,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
if (!providerSettings.PinnedCommandIds.Contains(commandId))
{
providerSettings.PinnedCommandIds.Add(commandId);
SettingsModel.SaveSettings(settings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
}
@@ -320,13 +429,41 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
if (providerSettings.PinnedCommandIds.Remove(commandId))
{
SettingsModel.SaveSettings(settings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var bandSettings = new DockBandSettings
{
CommandId = commandId,
ProviderId = this.ProviderId,
};
settings.DockSettings.StartBands.Add(bandSettings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public ICommandProviderContext GetProviderContext() => this;
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
@@ -342,4 +479,14 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
internal void PinDockBand(TopLevelViewModel bandVm)
{
Logger.LogDebug($"CommandProviderWrapper.PinDockBand: {ProviderId} - {bandVm.Id}");
var bands = this.DockBandItems.ToList();
bands.Add(bandVm);
this.DockBandItems = bands.ToArray();
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
}
}

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.
@@ -18,6 +18,8 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
private readonly IRootPageService _rootPageService;
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { },
@@ -37,11 +39,22 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
_fallbackLogItem,
];
public BuiltInsCommandProvider()
public BuiltInsCommandProvider(IRootPageService rootPageService)
{
Id = "com.microsoft.cmdpal.builtin.core";
DisplayName = Properties.Resources.builtin_display_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
_rootPageService = rootPageService;
}
public override ICommandItem[]? GetDockBands()
{
var rootPage = _rootPageService.GetRootPage();
List<ICommandItem> bandItems = new();
bandItems.Add(new WrappedDockItem(rootPage, Properties.Resources.builtin_command_palette_title));
return bandItems.ToArray();
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);

View File

@@ -65,6 +65,7 @@ public sealed partial class MainListPage : DynamicListPage,
AppStateModel appStateModel,
IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Id = "com.microsoft.cmdpal.home";
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;

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.

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.
@@ -70,6 +70,15 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
StateJson = model.StateJson;
DataJson = model.DataJson;
RenderCard();
UpdateProperty(nameof(Card));
model.PropChanged += Model_PropChanged;
}
private void RenderCard()
{
if (TryBuildCard(TemplateJson, DataJson, out var builtCard, out var renderingError))
{
Card = builtCard;
@@ -93,8 +102,41 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
UpdateProperty(nameof(Card));
return;
}
}
UpdateProperty(nameof(Card));
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this._formModel.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(DataJson):
DataJson = model.DataJson;
RenderCard();
break;
case nameof(TemplateJson):
TemplateJson = model.TemplateJson;
RenderCard();
break;
}
UpdateProperty(propertyName);
}
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveOpenUrlAction))]

View File

@@ -59,11 +59,8 @@ public partial class ContextMenuViewModel : ObservableObject,
{
if (SelectedItem is not null)
{
if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands)
{
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
}
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
}
}

View File

@@ -40,4 +40,12 @@ public partial class DefaultContextMenuFactory : IContextMenuFactory
return results;
}
public void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems)
{
// do nothing
}
}

View File

@@ -0,0 +1,251 @@
// 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.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockBandSettingsViewModel : ObservableObject
{
private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural);
private readonly SettingsModel _settingsModel;
private readonly DockBandSettings _dockSettingsModel;
private readonly TopLevelViewModel _adapter;
private readonly DockBandViewModel? _bandViewModel;
public string Title => _adapter.Title;
public string Description
{
get
{
List<string> parts = [_adapter.ExtensionName];
// Add the number of items in the band
var itemCount = NumItemsInBand();
if (itemCount > 0)
{
var itemsString = itemCount == 1 ?
Properties.Resources.dock_item_count_singular :
string.Format(CultureInfo.CurrentCulture, PluralItemsFormatString, itemCount);
parts.Add(itemsString);
}
return string.Join(" - ", parts);
}
}
public string ProviderId => _adapter.CommandProviderId;
public IconInfoViewModel Icon => _adapter.IconViewModel;
private ShowLabelsOption _showLabels;
public ShowLabelsOption ShowLabels
{
get => _showLabels;
set
{
if (value != _showLabels)
{
_showLabels = value;
_dockSettingsModel.ShowLabels = value switch
{
ShowLabelsOption.Default => null,
ShowLabelsOption.ShowLabels => true,
ShowLabelsOption.HideLabels => false,
_ => null,
};
Save();
}
}
}
private ShowLabelsOption FetchShowLabels()
{
if (_dockSettingsModel.ShowLabels == null)
{
return ShowLabelsOption.Default;
}
return _dockSettingsModel.ShowLabels.Value ? ShowLabelsOption.ShowLabels : ShowLabelsOption.HideLabels;
}
// used to map to ComboBox selection
public int ShowLabelsIndex
{
get => (int)ShowLabels;
set => ShowLabels = (ShowLabelsOption)value;
}
private DockPinSide PinSide
{
get => _pinSide;
set
{
if (value != _pinSide)
{
UpdatePinSide(value);
}
}
}
private DockPinSide _pinSide;
public int PinSideIndex
{
get => (int)PinSide;
set => PinSide = (DockPinSide)value;
}
/// <summary>
/// Gets or sets a value indicating whether the band is pinned to the dock.
/// When enabled, pins to Center. When disabled, removes from all sides.
/// </summary>
public bool IsPinned
{
get => PinSide != DockPinSide.None;
set
{
if (value && PinSide == DockPinSide.None)
{
// Pin to Center by default when enabling
PinSide = DockPinSide.Center;
}
else if (!value && PinSide != DockPinSide.None)
{
// Remove from dock when disabling
PinSide = DockPinSide.None;
}
}
}
public DockBandSettingsViewModel(
DockBandSettings dockSettingsModel,
TopLevelViewModel topLevelAdapter,
DockBandViewModel? bandViewModel,
SettingsModel settingsModel)
{
_dockSettingsModel = dockSettingsModel;
_adapter = topLevelAdapter;
_bandViewModel = bandViewModel;
_settingsModel = settingsModel;
_pinSide = FetchPinSide();
_showLabels = FetchShowLabels();
}
private DockPinSide FetchPinSide()
{
var dockSettings = _settingsModel.DockSettings;
var inStart = dockSettings.StartBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inStart)
{
return DockPinSide.Start;
}
var inCenter = dockSettings.CenterBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inCenter)
{
return DockPinSide.Center;
}
var inEnd = dockSettings.EndBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inEnd)
{
return DockPinSide.End;
}
return DockPinSide.None;
}
private int NumItemsInBand()
{
var bandVm = _bandViewModel;
if (bandVm is null)
{
return 0;
}
return bandVm.Items.Count;
}
private void Save()
{
SettingsModel.SaveSettings(_settingsModel);
}
private void UpdatePinSide(DockPinSide value)
{
OnPinSideChanged(value);
OnPropertyChanged(nameof(PinSideIndex));
OnPropertyChanged(nameof(PinSide));
OnPropertyChanged(nameof(IsPinned));
}
public void SetBandPosition(DockPinSide side, int? index)
{
var dockSettings = _settingsModel.DockSettings;
// Remove from all sides first
dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
// Add to the selected side
switch (side)
{
case DockPinSide.Start:
{
var insertIndex = index ?? dockSettings.StartBands.Count;
dockSettings.StartBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.Center:
{
var insertIndex = index ?? dockSettings.CenterBands.Count;
dockSettings.CenterBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.End:
{
var insertIndex = index ?? dockSettings.EndBands.Count;
dockSettings.EndBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.None:
default:
// Do nothing
break;
}
Save();
}
private void OnPinSideChanged(DockPinSide value)
{
SetBandPosition(value, null);
_pinSide = value;
}
}
public enum DockPinSide
{
None,
Start,
Center,
End,
}
public enum ShowLabelsOption
{
Default,
ShowLabels,
HideLabels,
}

View File

@@ -0,0 +1,300 @@
// 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.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class DockBandViewModel : ExtensionObjectViewModel
{
private readonly CommandItemViewModel _rootItem;
private readonly DockBandSettings _bandSettings;
private readonly DockSettings _dockSettings;
private readonly Action _saveSettings;
private readonly IContextMenuFactory _contextMenuFactory;
public ObservableCollection<DockItemViewModel> Items { get; } = new();
private bool _showTitles = true;
private bool _showSubtitles = true;
private bool? _showTitlesSnapshot;
private bool? _showSubtitlesSnapshot;
public string Id => _rootItem.Command.Id;
/// <summary>
/// Gets or sets a value indicating whether titles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowTitles
{
get => _showTitles;
set
{
if (_showTitles != value)
{
_showTitles = value;
foreach (var item in Items)
{
item.ShowTitle = value;
}
UpdateProperty(nameof(ShowTitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether subtitles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowSubtitles
{
get => _showSubtitles;
set
{
if (_showSubtitles != value)
{
_showSubtitles = value;
foreach (var item in Items)
{
item.ShowSubtitle = value;
}
UpdateProperty(nameof(ShowSubtitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether labels (both titles and subtitles) are shown.
/// Provided for backward compatibility - setting this sets both ShowTitles and ShowSubtitles.
/// </summary>
public bool ShowLabels
{
get => _showTitles && _showSubtitles;
set
{
ShowTitles = value;
ShowSubtitles = value;
}
}
/// <summary>
/// Takes a snapshot of the current label settings before editing.
/// </summary>
internal void SnapshotShowLabels()
{
_showTitlesSnapshot = _showTitles;
_showSubtitlesSnapshot = _showSubtitles;
}
/// <summary>
/// Saves the current label settings to settings.
/// </summary>
internal void SaveShowLabels()
{
_bandSettings.ShowTitles = _showTitles;
_bandSettings.ShowSubtitles = _showSubtitles;
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
/// <summary>
/// Restores the label settings from the snapshot.
/// </summary>
internal void RestoreShowLabels()
{
if (_showTitlesSnapshot.HasValue)
{
ShowTitles = _showTitlesSnapshot.Value;
_showTitlesSnapshot = null;
}
if (_showSubtitlesSnapshot.HasValue)
{
ShowSubtitles = _showSubtitlesSnapshot.Value;
_showSubtitlesSnapshot = null;
}
}
internal DockBandViewModel(
CommandItemViewModel commandItemViewModel,
WeakReference<IPageContext> errorContext,
DockBandSettings settings,
DockSettings dockSettings,
Action saveSettings,
IContextMenuFactory contextMenuFactory)
: base(errorContext)
{
_rootItem = commandItemViewModel;
_bandSettings = settings;
_dockSettings = dockSettings;
_saveSettings = saveSettings;
_contextMenuFactory = contextMenuFactory;
_showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels);
_showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels);
}
private void InitializeFromList(IListPage list)
{
var items = list.GetItems();
var newViewModels = new List<DockItemViewModel>();
foreach (var item in items)
{
var newItemVm = new DockItemViewModel(new(item), this.PageContext, _showTitles, _showSubtitles, _contextMenuFactory);
newItemVm.SlowInitializeProperties();
newViewModels.Add(newItemVm);
}
List<DockItemViewModel> removed = new();
DoOnUiThread(() =>
{
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removed);
});
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
public override void InitializeProperties()
{
var command = _rootItem.Command;
var list = command.Model.Unsafe as IListPage;
if (list is not null)
{
InitializeFromList(list);
list.ItemsChanged += HandleItemsChanged;
}
else
{
var dockItem = new DockItemViewModel(_rootItem, _showTitles, _showSubtitles, _contextMenuFactory);
dockItem.SlowInitializeProperties();
DoOnUiThread(() =>
{
Items.Add(dockItem);
});
}
}
private void HandleItemsChanged(object sender, IItemsChangedEventArgs args)
{
if (_rootItem.Command.Model.Unsafe is IListPage p)
{
InitializeFromList(p);
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
var command = _rootItem.Command;
if (command.Model.Unsafe is IListPage list)
{
list.ItemsChanged -= HandleItemsChanged;
}
foreach (var item in Items)
{
item.SafeCleanup();
}
}
}
public partial class DockItemViewModel : CommandItemViewModel
{
private bool _showTitle = true;
private bool _showSubtitle = true;
public bool ShowTitle
{
get => _showTitle;
internal set
{
if (_showTitle != value)
{
_showTitle = value;
UpdateProperty(nameof(ShowTitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(HasText));
UpdateProperty(nameof(Title));
}
}
}
public bool ShowSubtitle
{
get => _showSubtitle;
internal set
{
if (_showSubtitle != value)
{
_showSubtitle = value;
UpdateProperty(nameof(ShowSubtitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(Subtitle));
}
}
}
/// <summary>
/// Gets a value indicating whether labels are shown (either titles or subtitles).
/// Setting this sets both ShowTitle and ShowSubtitle.
/// </summary>
public bool ShowLabel
{
get => _showTitle || _showSubtitle;
internal set
{
ShowTitle = value;
ShowSubtitle = value;
}
}
public override string Title => _showTitle ? ItemTitle : string.Empty;
public override string Subtitle => _showSubtitle ? base.Subtitle : string.Empty;
public override bool HasText => (_showTitle && !string.IsNullOrEmpty(ItemTitle)) || (_showSubtitle && !string.IsNullOrEmpty(base.Subtitle));
/// <summary>
/// Gets the tooltip for the dock item, which includes the title and
/// subtitle. If it doesn't have one part, it just returns the other.
/// </summary>
/// <remarks>
/// Trickery: in the case one is empty, we can just concatenate, and it will
/// always only be the one that's non-empty
/// </remarks>
public string Tooltip =>
!string.IsNullOrEmpty(ItemTitle) && !string.IsNullOrEmpty(base.Subtitle) ?
$"{ItemTitle}\n{base.Subtitle}" :
ItemTitle + base.Subtitle;
public DockItemViewModel(CommandItemViewModel root, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: this(root.Model, root.PageContext, showTitle, showSubtitle, contextMenuFactory)
{
}
public DockItemViewModel(ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: base(item, errorContext, contextMenuFactory)
{
_showTitle = showTitle;
_showSubtitle = showSubtitle;
}
}
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -0,0 +1,633 @@
// 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.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly SettingsModel _settingsModel;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private DockSettings _settings;
public TaskScheduler Scheduler { get; }
public ObservableCollection<DockBandViewModel> StartItems { get; } = new();
public ObservableCollection<DockBandViewModel> CenterItems { get; } = new();
public ObservableCollection<DockBandViewModel> EndItems { get; } = new();
public ObservableCollection<TopLevelViewModel> AllItems => _topLevelCommandManager.DockBands;
public DockViewModel(
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
SettingsModel settings,
TaskScheduler scheduler)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsModel = settings;
_settings = settings.DockSettings;
Scheduler = scheduler;
_pageContext = new(this);
_topLevelCommandManager.DockBands.CollectionChanged += DockBands_CollectionChanged;
}
private void DockBands_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
Logger.LogDebug("Starting DockBands_CollectionChanged");
SetupBands();
Logger.LogDebug("Ended DockBands_CollectionChanged");
}
public void UpdateSettings(DockSettings settings)
{
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
}
private void SetupBands(
List<DockBandSettings> bands,
ObservableCollection<DockBandViewModel> target)
{
List<DockBandViewModel> newBands = new();
foreach (var band in bands)
{
var commandId = band.CommandId;
var topLevelCommand = _topLevelCommandManager.LookupDockBand(commandId);
if (topLevelCommand is null)
{
Logger.LogWarning($"Failed to find band {commandId}");
}
if (topLevelCommand is not null)
{
// note: CreateBandItem doesn't actually initialize the band, it
// just creates the VM. Callers need to make sure to call
// InitializeProperties() on a BG thread elsewhere
var bandVm = CreateBandItem(band, topLevelCommand.ItemViewModel);
newBands.Add(bandVm);
}
}
var beforeCount = target.Count;
var afterCount = newBands.Count;
DoOnUiThread(() =>
{
List<DockBandViewModel> removed = new();
ListHelpers.InPlaceUpdateList(target, newBands, out removed);
var isStartBand = target == StartItems;
var label = isStartBand ? "Start bands:" : "End bands:";
Logger.LogDebug($"{label} ({beforeCount}) -> ({afterCount}), Removed {removed?.Count ?? 0} items");
// then, back to a BG thread:
Task.Run(() =>
{
if (removed is not null)
{
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
});
});
// Initialize properties on BG thread
Task.Run(() =>
{
foreach (var band in newBands)
{
band.SafeInitializePropertiesSynchronous();
}
});
}
/// <summary>
/// Instantiate a new band view model for this CommandItem, given the
/// settings. The DockBandViewModel will _not_ be initialized - callers
/// will need to make sure to initialize it somewhere else (off the UI
/// thread)
/// </summary>
private DockBandViewModel CreateBandItem(
DockBandSettings bandSettings,
CommandItemViewModel commandItem)
{
DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, SaveSettings, _contextMenuFactory);
// the band is NOT initialized here!
return band;
}
private void SaveSettings()
{
SettingsModel.SaveSettings(_settingsModel);
}
public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc)
{
var id = tlc.Id;
return FindBandById(id);
}
public DockBandViewModel? FindBandById(string id)
{
foreach (var band in StartItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in CenterItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in EndItems)
{
if (band.Id == id)
{
return band;
}
}
return null;
}
/// <summary>
/// Syncs the band position in settings after a same-list reorder.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
var targetSettings = targetSide switch
{
DockPinSide.Start => dockSettings.StartBands,
DockPinSide.Center => dockSettings.CenterBands,
DockPinSide.End => dockSettings.EndBands,
_ => dockSettings.StartBands,
};
var insertIndex = Math.Min(targetIndex, targetSettings.Count);
targetSettings.Insert(insertIndex, bandSettings);
}
/// <summary>
/// Moves a dock band to a new position (cross-list drop).
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
Logger.LogWarning($"Could not find band settings for band {bandId}");
return;
}
// Remove from all sides (settings and UI)
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
// Add to the target side at the specified index
switch (targetSide)
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count);
dockSettings.StartBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
break;
}
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count);
dockSettings.CenterBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
break;
}
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count);
dockSettings.EndBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
break;
}
}
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
/// <summary>
/// Saves the current band order and label settings to settings.
/// Call this when exiting edit mode.
/// </summary>
public void SaveBandOrder()
{
// Save ShowLabels for all bands
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
band.SaveShowLabels();
}
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
SettingsModel.SaveSettings(_settingsModel);
Logger.LogDebug("Saved band order to settings");
}
private List<DockBandSettings>? _snapshotStartBands;
private List<DockBandSettings>? _snapshotCenterBands;
private List<DockBandSettings>? _snapshotEndBands;
private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels;
/// <summary>
/// Takes a snapshot of the current band order and label settings before editing.
/// Call this when entering edit mode.
/// </summary>
public void SnapshotBandOrder()
{
var dockSettings = _settingsModel.DockSettings;
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList();
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList();
// Snapshot band ViewModels so we can restore unpinned bands
// Use a dictionary but handle potential duplicates gracefully
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
_snapshotBandViewModels.TryAdd(band.Id, band);
}
// Snapshot ShowLabels for all bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.SnapshotShowLabels();
}
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands");
}
/// <summary>
/// Restores the band order and label settings from the snapshot taken when entering edit mode.
/// Call this when discarding edit mode changes.
/// </summary>
public void RestoreBandOrder()
{
if (_snapshotStartBands == null ||
_snapshotCenterBands == null ||
_snapshotEndBands == null || _snapshotBandViewModels == null)
{
Logger.LogWarning("No snapshot to restore from");
return;
}
// Restore ShowLabels for all snapshotted bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.RestoreShowLabels();
}
var dockSettings = _settingsModel.DockSettings;
// Restore settings from snapshot
dockSettings.StartBands.Clear();
dockSettings.CenterBands.Clear();
dockSettings.EndBands.Clear();
foreach (var bandSnapshot in _snapshotStartBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.StartBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotCenterBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.CenterBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotEndBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.EndBands.Add(bandSettings);
}
// Rebuild UI collections from restored settings using the snapshotted ViewModels
RebuildUICollectionsFromSnapshot();
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
Logger.LogDebug("Restored band order from snapshot");
}
private void RebuildUICollectionsFromSnapshot()
{
if (_snapshotBandViewModels == null)
{
return;
}
var dockSettings = _settingsModel.DockSettings;
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
private void RebuildUICollections()
{
var dockSettings = _settingsModel.DockSettings;
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
/// <summary>
/// Gets the list of dock bands that are not currently pinned to any section.
/// </summary>
public IEnumerable<TopLevelViewModel> GetAvailableBandsToAdd()
{
// Get IDs of all bands currently in the dock
var pinnedBandIds = new HashSet<string>();
foreach (var band in StartItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in CenterItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in EndItems)
{
pinnedBandIds.Add(band.Id);
}
// Return all dock bands that are not already pinned
return AllItems.Where(tlc => !pinnedBandIds.Contains(tlc.Id));
}
/// <summary>
/// Adds a band to the specified dock section.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide)
{
var bandId = topLevel.Id;
// Check if already in the dock
if (FindBandById(bandId) != null)
{
Logger.LogWarning($"Band {bandId} is already in the dock");
return;
}
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null };
var dockSettings = _settingsModel.DockSettings;
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
// Add to the appropriate section
switch (targetSide)
{
case DockPinSide.Start:
dockSettings.StartBands.Add(bandSettings);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
dockSettings.CenterBands.Add(bandSettings);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
dockSettings.EndBands.Add(bandSettings);
EndItems.Add(bandVm);
break;
}
// Snapshot the new band so it can be removed on discard
bandVm.SnapshotShowLabels();
Task.Run(() =>
{
bandVm.SafeInitializePropertiesSynchronous();
});
Logger.LogDebug($"Added band {bandId} to {targetSide} (not saved yet)");
}
/// <summary>
/// Unpins a band from the dock, removing it from whichever section it's in.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
// Remove from settings
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
private void DoOnUiThread(Action action)
{
Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
Scheduler);
}
public CommandItemViewModel GetContextMenuForDock()
{
var model = new DockContextMenuItem();
var vm = new CommandItemViewModel(new(model), new(_pageContext), contextMenuFactory: null);
vm.SlowInitializeProperties();
return vm;
}
private sealed partial class DockContextMenuItem : CommandItem
{
public DockContextMenuItem()
{
var editDockCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new EnterDockEditModeMessage());
})
{
Name = Properties.Resources.dock_edit_dock_name,
Icon = Icons.EditIcon,
};
var openSettingsCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Dock"));
})
{
Name = Properties.Resources.dock_settings_name,
Icon = Icons.SettingsIcon,
};
MoreCommands = new CommandContextItem[]
{
new CommandContextItem(editDockCommand),
new CommandContextItem(openSettingsCommand),
};
}
}
/// <summary>
/// Provides an empty page context, for the dock's own context menu. We're
/// building the context menu for the dock using literally our own cmdpal
/// types, but that means we need a page context for the VM we will
/// generate.
/// </summary>
private sealed partial class DockPageContext(DockViewModel dockViewModel) : IPageContext
{
public TaskScheduler Scheduler => dockViewModel.Scheduler;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint)
{
var extensionText = extensionHint ?? "<unknown>";
Logger.LogError($"Error in dock context {extensionText}", ex);
}
}
}

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 CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockWindowViewModel : ObservableObject, IDisposable
{
private readonly IThemeService _themeService;
private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!;
[ObservableProperty]
public partial ImageSource? BackgroundImageSource { get; private set; }
[ObservableProperty]
public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
[ObservableProperty]
public partial double BackgroundImageOpacity { get; private set; }
[ObservableProperty]
public partial Color BackgroundImageTint { get; private set; }
[ObservableProperty]
public partial double BackgroundImageTintIntensity { get; private set; }
[ObservableProperty]
public partial int BackgroundImageBlurAmount { get; private set; }
[ObservableProperty]
public partial double BackgroundImageBrightness { get; private set; }
[ObservableProperty]
public partial bool ShowBackgroundImage { get; private set; }
[ObservableProperty]
public partial bool ShowColorizationOverlay { get; private set; }
[ObservableProperty]
public partial Color ColorizationColor { get; private set; }
[ObservableProperty]
public partial double ColorizationOpacity { get; private set; }
public DockWindowViewModel(IThemeService themeService)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeService_ThemeChanged;
UpdateFromThemeSnapshot();
}
private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_uiDispatcherQueue.TryEnqueue(UpdateFromThemeSnapshot);
}
private void UpdateFromThemeSnapshot()
{
var snapshot = _themeService.CurrentDockTheme;
BackgroundImageSource = snapshot.BackgroundImageSource;
BackgroundImageStretch = snapshot.BackgroundImageStretch;
BackgroundImageOpacity = snapshot.BackgroundImageOpacity;
BackgroundImageBrightness = snapshot.BackgroundBrightness;
BackgroundImageTint = snapshot.Tint;
BackgroundImageTintIntensity = snapshot.TintIntensity;
BackgroundImageBlurAmount = snapshot.BlurAmount;
ShowBackgroundImage = BackgroundImageSource != null;
// Colorization overlay for transparent backdrop
ShowColorizationOverlay = snapshot.Backdrop == DockBackdrop.Transparent && snapshot.TintIntensity > 0;
ColorizationColor = snapshot.Tint;
ColorizationOpacity = snapshot.TintIntensity;
}
public void Dispose()
{
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,341 @@
// 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;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
using Windows.UI.ViewManagement;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// View model for dock appearance settings, controlling theme, backdrop, colorization,
/// and background image settings for the dock.
/// </summary>
public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, IDisposable
{
private readonly SettingsModel _settings;
private readonly DockSettings _dockSettings;
private readonly UISettings _uiSettings;
private readonly IThemeService _themeService;
private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
private ElementTheme? _elementThemeOverride;
private Color _currentSystemAccentColor;
public ObservableCollection<Color> Swatches => AppearanceSettingsViewModel.WindowsColorSwatches;
public int ThemeIndex
{
get => (int)_dockSettings.Theme;
set => Theme = (UserTheme)value;
}
public UserTheme Theme
{
get => _dockSettings.Theme;
set
{
if (_dockSettings.Theme != value)
{
_dockSettings.Theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex));
Save();
}
}
}
public int BackdropIndex
{
get => (int)_dockSettings.Backdrop;
set => Backdrop = (DockBackdrop)value;
}
public DockBackdrop Backdrop
{
get => _dockSettings.Backdrop;
set
{
if (_dockSettings.Backdrop != value)
{
_dockSettings.Backdrop = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackdropIndex));
Save();
}
}
}
public ColorizationMode ColorizationMode
{
get => _dockSettings.ColorizationMode;
set
{
if (_dockSettings.ColorizationMode != value)
{
_dockSettings.ColorizationMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible));
OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
OnPropertyChanged(nameof(IsBackgroundControlsVisible));
OnPropertyChanged(nameof(IsNoBackgroundVisible));
OnPropertyChanged(nameof(IsAccentColorControlsVisible));
if (value == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save();
}
}
}
public int ColorizationModeIndex
{
get => (int)_dockSettings.ColorizationMode;
set => ColorizationMode = (ColorizationMode)value;
}
public Color ThemeColor
{
get => _dockSettings.CustomThemeColor;
set
{
if (_dockSettings.CustomThemeColor != value)
{
_dockSettings.CustomThemeColor = value;
OnPropertyChanged();
if (ColorIntensity == 0)
{
ColorIntensity = 100;
}
Save();
}
}
}
public int ColorIntensity
{
get => _dockSettings.CustomThemeColorIntensity;
set
{
_dockSettings.CustomThemeColorIntensity = value;
OnPropertyChanged();
Save();
}
}
public string BackgroundImagePath
{
get => _dockSettings.BackgroundImagePath ?? string.Empty;
set
{
if (_dockSettings.BackgroundImagePath != value)
{
_dockSettings.BackgroundImagePath = value;
OnPropertyChanged();
if (BackgroundImageOpacity == 0)
{
BackgroundImageOpacity = 100;
}
Save();
}
}
}
public int BackgroundImageOpacity
{
get => _dockSettings.BackgroundImageOpacity;
set
{
if (_dockSettings.BackgroundImageOpacity != value)
{
_dockSettings.BackgroundImageOpacity = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBrightness
{
get => _dockSettings.BackgroundImageBrightness;
set
{
if (_dockSettings.BackgroundImageBrightness != value)
{
_dockSettings.BackgroundImageBrightness = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBlurAmount
{
get => _dockSettings.BackgroundImageBlurAmount;
set
{
if (_dockSettings.BackgroundImageBlurAmount != value)
{
_dockSettings.BackgroundImageBlurAmount = value;
OnPropertyChanged();
Save();
}
}
}
public BackgroundImageFit BackgroundImageFit
{
get => _dockSettings.BackgroundImageFit;
set
{
if (_dockSettings.BackgroundImageFit != value)
{
_dockSettings.BackgroundImageFit = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save();
}
}
}
public int BackgroundImageFitIndex
{
get => BackgroundImageFit switch
{
BackgroundImageFit.Fill => 1,
_ => 0,
};
set => BackgroundImageFit = value switch
{
1 => BackgroundImageFit.Fill,
_ => BackgroundImageFit.UniformToFill,
};
}
[ObservableProperty]
public partial bool IsColorizationDetailsExpanded { get; set; }
public bool IsCustomTintVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
public bool IsCustomTintIntensityVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
public bool IsBackgroundControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.Image;
public bool IsNoBackgroundVisible => _dockSettings.ColorizationMode is ColorizationMode.None;
public bool IsAccentColorControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.WindowsAccentColor;
public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
public Color EffectiveThemeColor => ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
// Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
public ImageSource? EffectiveBackgroundImageSource =>
ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
_settings = settings;
_dockSettings = settings.DockSettings;
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
UpdateAccentColor(_uiSettings);
Reapply();
IsColorizationDetailsExpanded = _dockSettings.ColorizationMode != ColorizationMode.None;
}
private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
private void UpdateAccentColor(UISettings sender)
{
_currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
if (ColorizationMode == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
}
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Save()
{
SettingsModel.SaveSettings(_settings);
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Reapply()
{
OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
OnPropertyChanged(nameof(EffectiveThemeColor));
OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
// LOAD BEARING:
// We need to cycle through the EffectiveTheme property to force reload of resources.
_elementThemeOverride = ElementTheme.Light;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = ElementTheme.Dark;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = null;
OnPropertyChanged(nameof(EffectiveTheme));
}
[RelayCommand]
private void ResetBackgroundImageProperties()
{
BackgroundImageBrightness = 0;
BackgroundImageBlurAmount = 0;
BackgroundImageFit = BackgroundImageFit.UniformToFill;
BackgroundImageOpacity = 100;
ColorIntensity = 0;
}
public void Dispose()
{
_uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
}
}

View File

@@ -59,7 +59,7 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatc
LogIfDefaultScheduler();
}
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
{
ArgumentNullException.ThrowIfNull(contextRef);

View File

@@ -9,4 +9,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public interface IContextMenuFactory
{
List<IContextItemViewModel> UnsafeBuildAndInitMoreCommands(IContextItem[] items, CommandItemViewModel commandItem);
void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems);
}

View File

@@ -2,7 +2,7 @@
// 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;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class ItemsUpdatedEventArgs : EventArgs
{

View File

@@ -8,7 +8,6 @@ using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -371,7 +370,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
UpdateEmptyContent();
}
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(!IsNested));
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
_isLoading.Clear();
});
}

View File

@@ -12,6 +12,7 @@ public partial class LoadingPageViewModel : PageViewModel
: base(model, scheduler, host, CommandProviderContext.Empty)
{
ModelIsLoading = true;
HasBackButton = false;
IsInitialized = false;
}
}

View File

@@ -0,0 +1,7 @@
// 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.UI.ViewModels.Messages;
public record EnterDockEditModeMessage();

View File

@@ -4,4 +4,4 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken);
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken, bool TransientPage = false);

View File

@@ -18,6 +18,8 @@ public record PerformCommandMessage
public bool WithAnimation { get; set; } = true;
public bool TransientPage { get; set; }
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;

View File

@@ -0,0 +1,7 @@
// 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.UI.ViewModels.Messages;
public record PinToDockMessage(string ProviderId, string CommandId, bool Pin);

View File

@@ -0,0 +1,7 @@
// 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.UI.ViewModels.Messages;
public record ShowHideDockMessage(bool ShowDock);

View File

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record UnpinCommandItemMessage(string ProviderId, string CommandId)
{
}
public record UnpinCommandItemMessage(string ProviderId, string CommandId);

View File

@@ -0,0 +1,7 @@
// 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.ViewModels.Messages;
public sealed record WindowHiddenMessage();

View File

@@ -4,5 +4,11 @@
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: PageViewModel(null, scheduler, extensionHost, CommandProviderContext.Empty);
internal sealed partial class NullPageViewModel : PageViewModel
{
internal NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: base(null, scheduler, extensionHost, CommandProviderContext.Empty)
{
HasBackButton = false;
}
}

View File

@@ -26,8 +26,25 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
[ObservableProperty]
public partial string ErrorMessage { get; protected set; } = string.Empty;
/// <summary>
/// Explicitly: is this page, the VM for the root page. This is used
/// slightly differently than being "nested". When we open CmdPal as a
/// transient window, we want that page to not have a back button, but that
/// page is _not_ the root page.
///
/// Later in ListViewModel, we will have logic that checks if it is the root
/// page, and modify how selection is handled when the list changes.
/// </summary>
[ObservableProperty]
public partial bool IsNested { get; set; } = true;
public partial bool IsRootPage { get; set; } = true;
/// <summary>
/// This is used to determine whether to show the back button on this page.
/// When a nested page is opened for the transient "dock flyout" window,
/// then we don't want to show the back button.
/// </summary>
[ObservableProperty]
public partial bool HasBackButton { get; set; } = true;
// This is set from the SearchBar
[ObservableProperty]

View File

@@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette.
/// </summary>
public static string builtin_command_palette_title {
get {
return ResourceManager.GetString("builtin_command_palette_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create another.
/// </summary>
@@ -294,6 +303,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Built-in.
/// </summary>
public static string builtin_extension_name_fallback {
get {
return ResourceManager.GetString("builtin_extension_name_fallback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} commands.
/// </summary>
@@ -465,6 +483,42 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Edit dock.
/// </summary>
public static string dock_edit_dock_name {
get {
return ResourceManager.GetString("dock_edit_dock_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} items.
/// </summary>
public static string dock_item_count_plural {
get {
return ResourceManager.GetString("dock_item_count_plural", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to 1 item.
/// </summary>
public static string dock_item_count_singular {
get {
return ResourceManager.GetString("dock_item_count_singular", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dock settings.
/// </summary>
public static string dock_settings_name {
get {
return ResourceManager.GetString("dock_settings_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fallbacks.
/// </summary>
@@ -475,6 +529,14 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
public static string PinnedItemSuffix {
get {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
/// Looks up a localized string similar to Results.
/// </summary>
public static string results {

View File

@@ -254,16 +254,44 @@
<data name="builtin_settings_extension_n_extensions_installed" xml:space="preserve">
<value>{0} extensions installed</value>
</data>
<data name="builtin_extension_name_fallback" xml:space="preserve">
<value>Built-in</value>
<comment>Fallback name for built-in extensions</comment>
</data>
<data name="dock_item_count_singular" xml:space="preserve">
<value>1 item</value>
<comment>Singular form for item count in dock band</comment>
</data>
<data name="dock_item_count_plural" xml:space="preserve">
<value>{0} items</value>
<comment>Plural form for item count in dock band</comment>
</data>
<data name="builtin_command_palette_title" xml:space="preserve">
<value>Open Command Palette</value>
<comment>Title for the command to open the command palette</comment>
</data>
<data name="builtin_settings_appearance_pick_background_image_title" xml:space="preserve">
<value>Pick background image</value>
</data>
<data name="fallbacks" xml:space="preserve">
<value>Fallbacks</value>
</data>
<data name="dock_edit_dock_name" xml:space="preserve">
<value>Edit dock</value>
<comment>Command name for editing the dock</comment>
</data>
<data name="dock_settings_name" xml:space="preserve">
<value>Dock settings</value>
<comment>Command name for opening dock settings</comment>
</data>
<data name="ShowDetailsCommand" xml:space="preserve">
<value>Show details</value>
<comment>Name for the command that shows details of an item</comment>
</data>
<data name="PinnedItemSuffix" xml:space="preserve">
<value>Pinned</value>
<comment>Suffix shown for pinned items in the dock</comment>
</data>
<data name="results" xml:space="preserve">
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>

View File

@@ -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.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Represents a snapshot of dock theme-related visual settings, including accent color, theme preference,
/// backdrop, and background image configuration, for use in rendering the Dock UI.
/// </summary>
public sealed class DockThemeSnapshot
{
/// <summary>
/// Gets the accent tint color used by the Dock visuals.
/// </summary>
public required Color Tint { get; init; }
/// <summary>
/// Gets the intensity of the accent tint color (0-1 range).
/// </summary>
public required float TintIntensity { get; init; }
/// <summary>
/// Gets the configured application theme preference for the Dock.
/// </summary>
public required ElementTheme Theme { get; init; }
/// <summary>
/// Gets the backdrop type for the Dock.
/// </summary>
public required DockBackdrop Backdrop { get; init; }
/// <summary>
/// Gets the image source to render as the background, if any.
/// </summary>
/// <remarks>
/// Returns <see langword="null"/> when no background image is configured.
/// </remarks>
public required ImageSource? BackgroundImageSource { get; init; }
/// <summary>
/// Gets the stretch mode used to lay out the background image.
/// </summary>
public required Stretch BackgroundImageStretch { get; init; }
/// <summary>
/// Gets the opacity applied to the background image.
/// </summary>
/// <value>
/// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque.
/// </value>
public required double BackgroundImageOpacity { get; init; }
/// <summary>
/// Gets the effective acrylic backdrop parameters based on current settings and theme.
/// </summary>
public required BackdropParameters BackdropParameters { get; init; }
/// <summary>
/// Gets the blur amount for the background image.
/// </summary>
public required int BlurAmount { get; init; }
/// <summary>
/// Gets the brightness adjustment for the background (0-1 range).
/// </summary>
public required float BackgroundBrightness { get; init; }
}

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 Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
@@ -36,4 +38,9 @@ public interface IThemeService
/// Gets the current theme settings.
/// </summary>
ThemeSnapshot Current { get; }
/// <summary>
/// Gets the current dock theme settings.
/// </summary>
DockThemeSnapshot CurrentDockTheme { get; }
}

View File

@@ -0,0 +1,172 @@
// 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.Text.Json.Serialization;
using Microsoft.UI;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
#pragma warning disable SA1402 // File may only contain a single type
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// </summary>
public class DockSettings
{
public DockSide Side { get; set; } = DockSide.Top;
public DockSize DockSize { get; set; } = DockSize.Small;
public DockSize DockIconsSize { get; set; } = DockSize.Small;
// <Theme settings>
public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic;
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
public Color CustomThemeColor { get; set; } = Colors.Transparent;
public int CustomThemeColorIntensity { get; set; } = 100;
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageBlurAmount { get; set; }
public int BackgroundImageBrightness { get; set; }
public BackgroundImageFit BackgroundImageFit { get; set; }
public string? BackgroundImagePath { get; set; }
// </Theme settings>
// public List<string> PinnedCommands { get; set; } = [];
public List<DockBandSettings> StartBands { get; set; } = [];
public List<DockBandSettings> CenterBands { get; set; } = [];
public List<DockBandSettings> EndBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public DockSettings()
{
// Initialize with default values
// PinnedCommands = [
// "com.microsoft.cmdpal.winget"
// ];
StartBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
CommandId = "com.microsoft.cmdpal.home",
});
StartBands.Add(new DockBandSettings
{
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowLabels = false,
});
EndBands.Add(new DockBandSettings
{
ProviderId = "PerformanceMonitor",
CommandId = "com.microsoft.cmdpal.performanceWidget",
});
EndBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}
}
/// <summary>
/// Settings for a specific dock band. These are per-band settings stored
/// within the overall <see cref="DockSettings"/>.
/// </summary>
public class DockBandSettings
{
public required string ProviderId { get; set; }
public required string CommandId { get; set; }
/// <summary>
/// Gets or sets whether titles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowTitles { get; set; }
/// <summary>
/// Gets or sets whether subtitles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowSubtitles { get; set; }
/// <summary>
/// Gets or sets a value for backward compatibility. Maps to ShowTitles.
/// </summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool? ShowLabels
{
get => ShowTitles;
set => ShowTitles = value;
}
/// <summary>
/// Resolves the effective value of <see cref="ShowTitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowTitles(bool defaultValue) => ShowTitles ?? defaultValue;
/// <summary>
/// Resolves the effective value of <see cref="ShowSubtitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue;
public DockBandSettings Clone()
{
return new()
{
ProviderId = this.ProviderId,
CommandId = this.CommandId,
ShowTitles = this.ShowTitles,
ShowSubtitles = this.ShowSubtitles,
};
}
}
public enum DockSide
{
Left = 0,
Top = 1,
Right = 2,
Bottom = 3,
}
public enum DockSize
{
Small,
Medium,
Large,
}
public enum DockBackdrop
{
Transparent,
Acrylic,
}
#pragma warning restore SA1402 // File may only contain a single type

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.
@@ -68,6 +68,11 @@ public partial class SettingsModel : ObservableObject
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
public bool EnableDock { get; set; }
public DockSettings DockSettings { get; set; } = new();
// Theme settings
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
@@ -92,6 +97,8 @@ public partial class SettingsModel : ObservableObject
public int BackdropOpacity { get; set; } = 100;
// </Theme settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -230,7 +237,7 @@ public partial class SettingsModel : ObservableObject
return false;
}
public static void SaveSettings(SettingsModel model)
public static void SaveSettings(SettingsModel model, bool hotReload = true)
{
if (string.IsNullOrEmpty(FilePath))
{
@@ -265,7 +272,10 @@ public partial class SettingsModel : ObservableObject
// TODO: Instead of just raising the event here, we should
// have a file change watcher on the settings file, and
// reload the settings then
model.SettingsChanged?.Invoke(model, null);
if (hotReload)
{
model.SettingsChanged?.Invoke(model, null);
}
}
else
{
@@ -311,6 +321,7 @@ public partial class SettingsModel : ObservableObject
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(Color))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(SettingsModel))]
[JsonSerializable(typeof(WindowPosition))]

View File

@@ -4,6 +4,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -32,6 +34,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public AppearanceSettingsViewModel Appearance { get; }
public DockAppearanceSettingsViewModel DockAppearance { get; }
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -183,6 +187,58 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public DockSide Dock_Side
{
get => _settings.DockSettings.Side;
set
{
_settings.DockSettings.Side = value;
Save();
}
}
public DockSize Dock_DockSize
{
get => _settings.DockSettings.DockSize;
set
{
_settings.DockSettings.DockSize = value;
Save();
}
}
public DockBackdrop Dock_Backdrop
{
get => _settings.DockSettings.Backdrop;
set
{
_settings.DockSettings.Backdrop = value;
Save();
}
}
public bool Dock_ShowLabels
{
get => _settings.DockSettings.ShowLabels;
set
{
_settings.DockSettings.ShowLabels = value;
Save();
}
}
public bool EnableDock
{
get => _settings.EnableDock;
set
{
_settings.EnableDock = value;
Save();
WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value));
WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = new();
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
@@ -195,6 +251,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_topLevelCommandManager = topLevelCommandManager;
Appearance = new AppearanceSettingsViewModel(themeService, _settings);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settings);
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;

View File

@@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -16,7 +17,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ShellViewModel : ObservableObject,
IDisposable,
IRecipient<PerformCommandMessage>,
IRecipient<HandleCommandResultMessage>
IRecipient<HandleCommandResultMessage>,
IRecipient<WindowHiddenMessage>
{
private readonly IRootPageService _rootPageService;
private readonly IAppHostService _appHostService;
@@ -79,8 +81,9 @@ public partial class ShellViewModel : ObservableObject,
private IPage? _rootPage;
private bool _isNested;
private bool _currentlyTransient;
public bool IsNested => _isNested;
public bool IsNested => _isNested && !_currentlyTransient;
public PageViewModel NullPage { get; private set; }
@@ -101,6 +104,7 @@ public partial class ShellViewModel : ObservableObject,
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
WeakReferenceMessenger.Default.Register<WindowHiddenMessage>(this);
}
[RelayCommand]
@@ -260,7 +264,7 @@ public partial class ShellViewModel : ObservableObject,
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var providerContext = _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
_rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host);
_rootPageService.OnPerformCommand(message.Context, CurrentPage.IsRootPage, host);
try
{
@@ -270,6 +274,7 @@ public partial class ShellViewModel : ObservableObject,
var isMainPage = command == _rootPage;
_isNested = !isMainPage;
_currentlyTransient = message.TransientPage;
// Telemetry: Track extension page navigation for session metrics
if (host is not null)
@@ -289,6 +294,9 @@ public partial class ShellViewModel : ObservableObject,
throw new NotSupportedException();
}
pageViewModel.IsRootPage = isMainPage;
pageViewModel.HasBackButton = IsNested;
// Clear command bar, ViewModel initialization can already set new commands if it wants to
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
@@ -308,7 +316,8 @@ public partial class ShellViewModel : ObservableObject,
_scheduler);
// While we're loading in the background, immediately move to the next page.
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage);
WeakReferenceMessenger.Default.Send(msg);
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
@@ -479,6 +488,19 @@ public partial class ShellViewModel : ObservableObject,
UnsafeHandleCommandResult(message.Result.Unsafe);
}
public void Receive(WindowHiddenMessage message)
{
// If the window was hidden while we had a transient page, we need to reset that state.
if (_currentlyTransient)
{
_currentlyTransient = false;
// navigate back to the main page without animation
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
}
}
private void OnUIThread(Action action)
{
_ = Task.Factory.StartNew(

View File

@@ -23,6 +23,7 @@ public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<PinCommandItemMessage>,
IRecipient<UnpinCommandItemMessage>,
IRecipient<PinToDockMessage>,
IDisposable
{
private readonly IServiceProvider _serviceProvider;
@@ -32,6 +33,11 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
private readonly Lock _commandProvidersLock = new();
// watch out: if you add code that locks CommandProviders, be sure to always
// lock CommandProviders before locking DockBands, or you will cause a
// deadlock.
private readonly Lock _dockBandsLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
@@ -42,11 +48,14 @@ public partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
}
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
@@ -82,12 +91,26 @@ public partial class TopLevelCommandManager : ObservableObject,
_builtInCommands.Add(wrapper);
}
var commands = await LoadTopLevelCommandsFromProvider(wrapper);
var objects = await LoadTopLevelCommandsFromProvider(wrapper);
lock (TopLevelCommands)
{
foreach (var c in commands)
if (objects.Commands is IEnumerable<TopLevelViewModel> commands)
{
TopLevelCommands.Add(c);
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (objects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
}
}
}
@@ -100,7 +123,7 @@ public partial class TopLevelCommandManager : ObservableObject,
}
// May be called from a background thread
private async Task<IEnumerable<TopLevelViewModel>> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
private async Task<TopLevelObjectSets> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{
await commandProvider.LoadTopLevelCommands(_serviceProvider);
@@ -108,6 +131,7 @@ public partial class TopLevelCommandManager : ObservableObject,
() =>
{
List<TopLevelViewModel> commands = [];
List<TopLevelViewModel> bands = [];
foreach (var item in commandProvider.TopLevelItems)
{
commands.Add(item);
@@ -121,7 +145,15 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
return commands;
foreach (var item in commandProvider.DockBandItems)
{
bands.Add(item);
}
var commandsCount = commands.Count;
var bandsCount = bands.Count;
Logger.LogDebug($"{commandProvider.ProviderId}: Loaded {commandsCount} commands, {bandsCount} bands");
return new TopLevelObjectSets(commands, bands);
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -160,6 +192,8 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
List<TopLevelViewModel> newBands = [.. sender.DockBandItems];
// modify the TopLevelCommands under shared lock; event if we clone it, we don't want
// TopLevelCommands to get modified while we're working on it. Otherwise, we might
// out clone would be stale at the end of this method.
@@ -178,6 +212,16 @@ public partial class TopLevelCommandManager : ObservableObject,
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
}
lock (_dockBandsLock)
{
// same idea for DockBands
List<TopLevelViewModel> dockClone = [.. DockBands];
var dockStartIndex = FindIndexForFirstProviderItem(dockClone, sender.ProviderId);
dockClone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
dockClone.InsertRange(dockStartIndex, newBands);
ListHelpers.InPlaceUpdateList(DockBands, dockClone);
}
return;
static int FindIndexForFirstProviderItem(List<TopLevelViewModel> topLevelItems, string providerId)
@@ -224,6 +268,11 @@ public partial class TopLevelCommandManager : ObservableObject,
TopLevelCommands.Clear();
}
lock (_dockBandsLock)
{
DockBands.Clear();
}
await LoadBuiltinsAsync();
_ = Task.Run(LoadExtensionsAsync);
}
@@ -298,13 +347,31 @@ public partial class TopLevelCommandManager : ObservableObject,
var commandSets = (await Task.WhenAll(loadTasks)).Where(results => results is not null).Select(r => r!).ToList();
lock (TopLevelCommands)
foreach (var providerObjects in commandSets)
{
foreach (var commands in commandSets)
var commandsCount = providerObjects.Commands?.Count() ?? 0;
var bandsCount = providerObjects.DockBands?.Count() ?? 0;
Logger.LogDebug($"(some provider) Loaded {commandsCount} commands and {bandsCount} bands");
lock (TopLevelCommands)
{
foreach (var c in commands)
if (providerObjects.Commands is IEnumerable<TopLevelViewModel> commands)
{
TopLevelCommands.Add(c);
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (providerObjects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
}
}
}
@@ -328,7 +395,9 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
private async Task<IEnumerable<TopLevelViewModel>?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
private record TopLevelObjectSets(IEnumerable<TopLevelViewModel>? Commands, IEnumerable<TopLevelViewModel>? DockBands);
private async Task<TopLevelObjectSets?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
{
try
{
@@ -354,6 +423,7 @@ public partial class TopLevelCommandManager : ObservableObject,
{
// Then find all the top-level commands that belonged to that extension
List<TopLevelViewModel> commandsToRemove = [];
List<TopLevelViewModel> bandsToRemove = [];
lock (TopLevelCommands)
{
foreach (var extension in extensions)
@@ -366,6 +436,15 @@ public partial class TopLevelCommandManager : ObservableObject,
commandsToRemove.Add(command);
}
}
foreach (var band in DockBands)
{
var host = band.ExtensionHost;
if (host?.Extension == extension)
{
bandsToRemove.Add(band);
}
}
}
}
@@ -385,6 +464,17 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
}
lock (_dockBandsLock)
{
if (bandsToRemove.Count != 0)
{
foreach (var deleted in bandsToRemove)
{
DockBands.Remove(deleted);
}
}
}
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -408,6 +498,22 @@ public partial class TopLevelCommandManager : ObservableObject,
return null;
}
public TopLevelViewModel? LookupDockBand(string id)
{
lock (_dockBandsLock)
{
foreach (var command in DockBands)
{
if (command.Id == id)
{
return command;
}
}
}
return null;
}
public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false);
@@ -423,6 +529,21 @@ public partial class TopLevelCommandManager : ObservableObject,
wrapper?.UnpinCommand(message.CommandId, _serviceProvider);
}
public void Receive(PinToDockMessage message)
{
if (LookupProvider(message.ProviderId) is CommandProviderWrapper wrapper)
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider);
}
else
{
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider);
}
}
}
public CommandProviderWrapper? LookupProvider(string providerId)
{
lock (_commandProvidersLock)
@@ -441,6 +562,53 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
internal void PinDockBand(TopLevelViewModel bandVm)
{
lock (_dockBandsLock)
{
foreach (var existing in DockBands)
{
if (existing.Id == bandVm.Id)
{
// already pinned
Logger.LogDebug($"Dock band '{bandVm.Id}' is already pinned.");
return;
}
}
Logger.LogDebug($"Attempting to pin dock band '{bandVm.Id}' from provider '{bandVm.CommandProviderId}'.");
var providerId = bandVm.CommandProviderId;
var foundProvider = false;
// WATCH OUT: This locks CommandProviders. If you add code that
// locks CommandProviders first, before locking DockBands, you will
// cause a deadlock.
foreach (var provider in CommandProviders)
{
if (provider.Id == providerId)
{
Logger.LogDebug($"Found provider '{providerId}' to pin dock band '{bandVm.Id}'.");
provider.PinDockBand(bandVm);
foundProvider = true;
break;
}
}
if (!foundProvider)
{
Logger.LogWarning($"Could not find provider '{providerId}' to pin dock band '{bandVm.Id}'.");
}
else
{
// Add the band to DockBands if not already present
if (!DockBands.Any(b => b.Id == bandVm.Id))
{
DockBands.Add(bandVm);
}
}
}
}
public void Dispose()
{
_reloadCommandsGate.Dispose();

View File

@@ -25,6 +25,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private readonly ProviderSettings _providerSettings;
private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
private readonly IContextMenuFactory _contextMenuFactory;
public ICommandProviderContext ProviderContext { get; private set; }
@@ -52,39 +53,28 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
public CommandPaletteHost ExtensionHost { get; private set; }
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
public string CommandProviderId => ProviderContext.ProviderId;
public IconInfoViewModel IconViewModel => _commandItemViewModel.Icon;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
public string Subtitle => _commandItemViewModel.Subtitle;
public IIconInfo Icon => _commandItemViewModel.Icon;
public IIconInfo Icon => (IIconInfo)IconViewModel;
public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon;
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands
.Select(item =>
{
if (item is ISeparatorContextItem)
{
return item as IContextItem;
}
else if (item is CommandContextItemViewModel commandItem)
{
return commandItem.Model.Unsafe;
}
else
{
return null;
}
}).ToArray();
IContextItem?[] ICommandItem.MoreCommands => BuildContextMenu();
////// IListItem
ITag[] IListItem.Tags => Tags.ToArray();
@@ -183,17 +173,46 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
}
}
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
// Dock properties
public bool IsDockBand { get; private set; }
public DockBandSettings? DockBandSettings
{
get
{
if (!IsDockBand)
{
return null;
}
var bandSettings = _settings.DockSettings.StartBands
.Concat(_settings.DockSettings.CenterBands)
.Concat(_settings.DockSettings.EndBands)
.FirstOrDefault(band => band.CommandId == this.Id);
if (bandSettings is null)
{
return new DockBandSettings()
{
ProviderId = this.CommandProviderId,
CommandId = this.Id,
ShowLabels = true,
};
}
return bandSettings;
}
}
public TopLevelViewModel(
CommandItemViewModel item,
bool isFallback,
TopLevelType topLevelType,
CommandPaletteHost extensionHost,
ICommandProviderContext commandProviderContext,
SettingsModel settings,
ProviderSettings providerSettings,
IServiceProvider serviceProvider,
ICommandItem? commandItem)
ICommandItem? commandItem,
IContextMenuFactory? contextMenuFactory)
{
_serviceProvider = serviceProvider;
_settings = settings;
@@ -201,22 +220,26 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
ProviderContext = commandProviderContext;
_commandItemViewModel = item;
IsFallback = isFallback;
_contextMenuFactory = contextMenuFactory ?? DefaultContextMenuFactory.Instance;
IsFallback = topLevelType == TopLevelType.Fallback;
IsDockBand = topLevelType == TopLevelType.DockBand;
ExtensionHost = extensionHost;
if (isFallback && commandItem is FallbackCommandItem fallback)
if (IsFallback && commandItem is FallbackCommandItem fallback)
{
_fallbackId = fallback.Id;
}
item.PropertyChangedBackground += Item_PropertyChanged;
// UpdateAlias();
// UpdateHotkey();
// UpdateTags();
}
internal void InitializeProperties()
{
// Init first, so that we get the ID & titles,
// then generate the ID,
// then slow init for the context menu
ItemViewModel.InitializeProperties();
GenerateId();
ItemViewModel.SlowInitializeProperties();
if (IsFallback)
@@ -278,7 +301,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
return;
}
_initialIcon = _commandItemViewModel.Icon;
_initialIcon = (IIconInfo?)_commandItemViewModel.Icon;
if (raiseNotification)
{
@@ -452,4 +475,43 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
return ToString();
}
/// <summary>
/// Helper to convert our context menu viewmodels back into the API
/// interfaces that ICommandItem expects.
/// </summary>
private IContextItem?[] BuildContextMenu()
{
List<IContextItem?> contextItems = new();
foreach (var item in _commandItemViewModel.MoreCommands)
{
if (item is ISeparatorContextItem)
{
contextItems.Add(item as IContextItem);
}
else if (item is CommandContextItemViewModel commandItem)
{
contextItems.Add(commandItem.Model.Unsafe);
}
}
_contextMenuFactory.AddMoreCommandsToTopLevel(this, this.ProviderContext, contextItems);
return contextItems.ToArray();
}
internal ICommandItem ToPinnedDockBandItem()
{
var item = new PinnedDockItem(item: this, id: Id);
return item;
}
}
public enum TopLevelType
{
Normal,
Fallback,
DockBand,
}