Compare commits

...

4 Commits

Author SHA1 Message Date
Jiří Polášek
a32c75928b CmdPal: Avoid unnecessary handler runs on settings changes (part 1) (#49171)
## Summary of the Pull Request

This PR stops Command Palette subscribers from re-running their full
hot-reload path on every SettingsChanged event. Previously any settings
edit re-registered hotkeys, rebuilt the backdrop, and tore
down/recreated dock windows even when nothing the subscriber consumed
had changed.

- `MainWindow`: added `MainWindowSettingsComparer
`(IEqualityComparer<SettingsModel>); skips HotReloadSettings unless a
consumed setting changed.
- `DockWindowManager`: OnSettingsChanged bails unless
EnableDock/DockSettings changed; monitor-topology path still always
syncs.
- `DockWindow `/ `DockViewModel`: guard reloads with _settings ==
args.DockSettings.
- `DockSettings `/ `DockMonitorConfig`: list backing fields now use a
new EquatableList<T> wrapper, giving record equality content-based (not
reference-based) list comparison so guards hold after a reload rebuilds
the lists.
- Tests: EquatableListTests and DockSettingsEqualityTests cover the
wrapper and structural DockSettings equality.
- Public API and persisted JSON contract unchanged (EquatableList<T> is
backing-field only; properties still expose ImmutableList<T>).

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #49168
- [ ] <!-- - [ ] Closes: #yyy (add separate lines for additional
resolved issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-07-07 00:43:09 +00:00
Jiří Polášek
c7e53c6ad9 CmdPal: Fix issues related to deferred loading of pages in Compact mode (#49165)
## Summary of the Pull Request

This PR fixes a navigation issue in Compact mode. When Compact mode is
enabled, the frame might not always load the page right away, so the
attached behavior does not trigger.

- Search box visibility update
- Search box visibility is now reliably re-evaluated when navigating to
the home page. Until now, it was handled in the `Loaded` event handler,
but since that is deferred in Compact mode, it was not evaluated in time
and blocked the app.

- A11Y announcements after navigation
- Navigation announcements are now more consistent and reliable. As in
the previous point, the announcement was previously triggered by the
`Loaded` event.
  - Adds a note mentioning FocusState.Keyboard to Narrator annoucements.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #49116 
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-07-06 17:41:22 -05:00
Jiří Polášek
52df5882a3 CmdPal: For each single-metric band, add a matching band in softdisabled state (#49162)
## Summary of the Pull Request

This PR updates Performance Monitor safe mode to add a placeholder page
for each Dock band that is available in normal mode. This way, when
Performance Monitor is soft-disabled, the user still sees the Dock band
and is informed that it is disabled.

This PR does not fix the missing battery Dock band, since that should be
moved to a separate extension soon(TM).

## Pictures? Pictures!

<img width="1022" height="134" alt="image"
src="https://github.com/user-attachments/assets/12bd33e6-041f-4f36-ad22-ed8287f19a3e"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Related to: #49159 
- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-07-06 17:40:15 -05:00
Michael Jolley
0790674ddf CmdPal: Subscribe DetailsViewModel to PropChanged for live pane updates (#48070)
## Summary

Fixes #47745 — the details pane now updates live when an extension
modifies its `IDetails` properties (Title, Body, HeroImage, Metadata)
after initial display.

## Changes

### `DetailsViewModel.cs`
- Subscribe to `INotifyPropChanged` at construction (runtime check,
since `IDetails` doesn't require it)
- `Model_PropChanged` → `FetchProperty` with switch on Title, Body,
HeroImage, Metadata
- `RebuildMetadata` rebuilds the metadata list from the model
- `UnsafeCleanup` unsubscribes from PropChanged

### `ListItemViewModel.cs`
- Call `Details?.SafeCleanup()` before replacing the DetailsViewModel in
`FetchProperty("Details")`, ensuring the old subscription is cleaned up.

### `DetailsViewModelTests.cs` (new)
- 6 unit tests covering PropChanged subscription, property updates,
metadata rebuild, cleanup/unsubscribe, and non-observable IDetails
handling.

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-06 21:18:43 +00:00
21 changed files with 888 additions and 55 deletions

View File

@@ -7,9 +7,11 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class DetailsViewModel(IDetails _details, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
public partial class DetailsViewModel : ExtensionObjectViewModel
{
private readonly ExtensionObject<IDetails> _detailsModel = new(_details);
private readonly ExtensionObject<IDetails> _detailsModel;
private INotifyPropChanged? _observableDetails;
private bool _isSubscribed;
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
@@ -25,6 +27,81 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
// where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator}
public List<DetailsElementViewModel> Metadata { get; private set; } = [];
public DetailsViewModel(IDetails details, WeakReference<IPageContext> context)
: base(context)
{
_detailsModel = new(details);
}
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
private void FetchProperty(string propertyName)
{
var model = _detailsModel.Unsafe;
if (model is null)
{
return;
}
switch (propertyName)
{
case nameof(IDetails.Title):
Title = model.Title ?? string.Empty;
UpdateProperty(nameof(Title));
break;
case nameof(IDetails.Body):
Body = model.Body ?? string.Empty;
UpdateProperty(nameof(Body));
break;
case nameof(IDetails.HeroImage):
HeroImage = new(model.HeroImage);
HeroImage.InitializeProperties();
UpdateProperty(nameof(HeroImage));
break;
case nameof(IDetails.Metadata):
RebuildMetadata(model);
UpdateProperty(nameof(Metadata));
break;
}
}
private void RebuildMetadata(IDetails model)
{
var newMetadata = new List<DetailsElementViewModel>();
var meta = model.Metadata;
if (meta is not null)
{
foreach (var element in meta)
{
DetailsElementViewModel? vm = element.Data switch
{
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
_ => null,
};
if (vm is not null)
{
vm.InitializeProperties();
newMetadata.Add(vm);
}
}
}
Metadata = newMetadata;
}
public override void InitializeProperties()
{
var model = _detailsModel.Unsafe;
@@ -33,6 +110,14 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
return;
}
// Subscribe to PropChanged if the model supports it (only subscribe once)
if (!_isSubscribed && model is INotifyPropChanged observable)
{
observable.PropChanged += Model_PropChanged;
_observableDetails = observable;
_isSubscribed = true;
}
Title = model.Title ?? string.Empty;
Body = model.Body ?? string.Empty;
HeroImage = new(model.HeroImage);
@@ -57,25 +142,18 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
UpdateProperty(nameof(Size));
var meta = model.Metadata;
if (meta is not null)
RebuildMetadata(model);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
if (_isSubscribed && _observableDetails is not null)
{
foreach (var element in meta)
{
DetailsElementViewModel? vm = element.Data switch
{
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
_ => null,
};
if (vm is not null)
{
vm.InitializeProperties();
Metadata.Add(vm);
}
}
_observableDetails.PropChanged -= Model_PropChanged;
_observableDetails = null;
_isSubscribed = false;
}
}
}

View File

@@ -83,6 +83,11 @@ public sealed partial class DockViewModel : IDisposable
return;
}
if (_settings == settings)
{
return;
}
_settings = settings;
SetupBands();
}

View File

@@ -158,11 +158,13 @@ public partial class ListItemViewModel : CommandItemViewModel
UpdateProperty(nameof(Type), nameof(IsInteractive));
break;
case nameof(Details):
var existingReference = Details;
var extensionDetails = model.Details;
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
Details?.InitializeProperties();
UpdateProperty(nameof(Details), nameof(HasDetails));
UpdateShowDetailsCommand();
existingReference?.SafeCleanup();
break;
case nameof(model.MoreCommands):
AddShowDetailsCommands();

View File

@@ -48,7 +48,10 @@ public record DockSettings
public string? BackgroundImagePath { get; init; }
// </Theme settings>
private ImmutableList<DockBandSettings>? _startBands = ImmutableList.Create(
// Band lists use EquatableList backing fields so the compiler-synthesized record equality
// compares them by content, not by reference. See EquatableList<T> for why this matters.
private readonly EquatableList<DockBandSettings> _startBands = new(ImmutableList.Create(
new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
@@ -59,23 +62,23 @@ public record DockSettings
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowTitles = false,
});
}));
public ImmutableList<DockBandSettings> StartBands
{
get => _startBands ?? ImmutableList<DockBandSettings>.Empty;
init => _startBands = value;
get => _startBands.List;
init => _startBands = new(value);
}
private ImmutableList<DockBandSettings>? _centerBands = ImmutableList<DockBandSettings>.Empty;
private readonly EquatableList<DockBandSettings> _centerBands = new(ImmutableList<DockBandSettings>.Empty);
public ImmutableList<DockBandSettings> CenterBands
{
get => _centerBands ?? ImmutableList<DockBandSettings>.Empty;
init => _centerBands = value;
get => _centerBands.List;
init => _centerBands = new(value);
}
private ImmutableList<DockBandSettings>? _endBands = ImmutableList.Create(
private readonly EquatableList<DockBandSettings> _endBands = new(ImmutableList.Create(
new DockBandSettings
{
ProviderId = "PerformanceMonitor",
@@ -85,12 +88,12 @@ public record DockSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}));
public ImmutableList<DockBandSettings> EndBands
{
get => _endBands ?? ImmutableList<DockBandSettings>.Empty;
init => _endBands = value;
get => _endBands.List;
init => _endBands = new(value);
}
public bool ShowLabels { get; init; } = true;
@@ -99,12 +102,12 @@ public record DockSettings
/// Gets the per-monitor dock configurations. Each entry overrides global
/// settings for a specific display. Empty by default (all monitors use global).
/// </summary>
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
private readonly EquatableList<DockMonitorConfig> _monitorConfigs = new(ImmutableList<DockMonitorConfig>.Empty);
public ImmutableList<DockMonitorConfig> MonitorConfigs
{
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
get => _monitorConfigs.List;
init => _monitorConfigs = new(value);
}
/// <summary>
@@ -192,20 +195,41 @@ public sealed record DockMonitorConfig
/// </summary>
public bool IsCustomized { get; init; }
// Nullable EquatableList backing fields give the synthesized record equality structural
// comparison of the per-monitor bands while preserving null ("inherit global") vs. an
// explicit (possibly empty) list. See EquatableList<T>.
private readonly EquatableList<DockBandSettings>? _startBands;
/// <summary>
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? StartBands { get; init; }
public ImmutableList<DockBandSettings>? StartBands
{
get => _startBands?.List;
init => _startBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
private readonly EquatableList<DockBandSettings>? _centerBands;
/// <summary>
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
public ImmutableList<DockBandSettings>? CenterBands
{
get => _centerBands?.List;
init => _centerBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
private readonly EquatableList<DockBandSettings>? _endBands;
/// <summary>
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? EndBands { get; init; }
public ImmutableList<DockBandSettings>? EndBands
{
get => _endBands?.List;
init => _endBands = value is null ? null : new EquatableList<DockBandSettings>(value);
}
/// <summary>
/// Gets the UTC timestamp when this monitor was last seen connected. Used for

View File

@@ -0,0 +1,70 @@
// 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.Immutable;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// A thin wrapper around <see cref="ImmutableList{T}"/> that provides <em>structural</em>
/// (element-by-element) equality. <see cref="ImmutableList{T}"/> itself only implements
/// reference equality, which means a record holding one compares unequal to an otherwise
/// identical record whenever the list was rebuilt into a fresh instance (e.g. after loading
/// settings from disk).
/// </summary>
/// <remarks>
/// Used as the <em>backing field</em> type for record list properties (the public properties
/// still expose <see cref="ImmutableList{T}"/>). Because the compiler-synthesized record
/// equality compares backing fields, swapping the field type here makes that synthesized
/// equality structural — with no hand-written <c>Equals</c> to keep in sync as new properties
/// are added.
/// </remarks>
internal readonly struct EquatableList<T> : IEquatable<EquatableList<T>>
{
private readonly ImmutableList<T>? _list;
public EquatableList(ImmutableList<T>? list) => _list = list;
public ImmutableList<T> List => _list ?? ImmutableList<T>.Empty;
public bool Equals(EquatableList<T> other)
{
var a = List;
var b = other.List;
if (ReferenceEquals(a, b))
{
return true;
}
if (a.Count != b.Count)
{
return false;
}
var comparer = EqualityComparer<T>.Default;
for (var i = 0; i < a.Count; i++)
{
if (!comparer.Equals(a[i], b[i]))
{
return false;
}
}
return true;
}
public override bool Equals(object? obj) => obj is EquatableList<T> other && Equals(other);
public override int GetHashCode()
{
var hash = default(HashCode);
foreach (var item in List)
{
hash.Add(item);
}
return hash.ToHashCode();
}
}

View File

@@ -11,6 +11,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public record SettingsModel
{
// LOAD BEARNING: Some SettingsChanged subscribers react selectively (e.g.
// MainWindow.MainWindowSettingsComparer, DockWindowManager.OnSettingsChanged). If a new
// setting needs a live reaction, add it there too - otherwise it won't take effect.
///////////////////////////////////////////////////////////////////////////
// SETTINGS HERE
public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space

View File

@@ -55,6 +55,18 @@ public partial class ShellViewModel : ObservableObject,
oldValue.PropertyChanged -= CurrentPage_PropertyChanged;
value.PropertyChanged += CurrentPage_PropertyChanged;
// Re-evaluate search-box visibility for the page we're switching to.
// CurrentPage_PropertyChanged only reacts to a *change* of HasSearchBox, so
// switching to a page whose HasSearchBox already holds its final value (e.g.
// navigating back to a list from a ContentPage) would otherwise never restore
// the search box. Only force it visible here; hiding it on content pages is
// deliberately deferred (see ShellPage.FocusAfterLoaded) so focus doesn't jump
// around for screen readers.
if (value.HasSearchBox)
{
IsSearchBoxVisible = true;
}
if (oldValue is IDisposable disposable)
{
try

View File

@@ -498,6 +498,12 @@ public sealed partial class SearchBar : UserControl,
}
}
/// <summary>
/// Moves focus to the inner search text box using <see cref="FocusState.Keyboard"/>, which
/// (unlike a programmatic <c>Focus</c> on the control) lets the screen reader announce the
/// box and its placeholder. Used for summon and post-navigation focus alike so both paths
/// are announced consistently.
/// </summary>
internal void FocusActiveControl()
{
this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>

View File

@@ -219,6 +219,11 @@ public sealed partial class DockWindow : WindowEx,
return;
}
if (_settings == args.DockSettings)
{
return;
}
_settings = args.DockSettings;
RefreshSideOverride();
DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread);

View File

@@ -26,6 +26,9 @@ public sealed partial class DockWindowManager : IDisposable
private bool _disposed;
private int _syncing;
private bool? _lastSyncedEnableDock;
private DockSettings? _lastSyncedDockSettings;
public DockWindowManager(
IMonitorService monitorService,
ISettingsService settingsService,
@@ -217,6 +220,14 @@ public sealed partial class DockWindowManager : IDisposable
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
if (args.EnableDock == _lastSyncedEnableDock && args.DockSettings == _lastSyncedDockSettings)
{
return;
}
_lastSyncedEnableDock = args.EnableDock;
_lastSyncedDockSettings = args.DockSettings;
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)

View File

@@ -2,6 +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.
using System.Collections.Immutable;
using System.Diagnostics;
using System.Runtime.InteropServices;
using CmdPalKeyboardService;
@@ -81,6 +82,11 @@ public sealed partial class MainWindow : WindowEx,
private bool _suppressDpiChange;
private bool _themeServiceInitialized;
// The snapshot of settings last consumed by HotReloadSettings. Used to skip redundant
// hot-reloads when a SettingsChanged notification touches settings this window doesn't
// care about (theme, provider config, aliases, and so on).
private SettingsModel? _lastAppliedSettings;
// Session tracking for telemetry
private Stopwatch? _sessionStopwatch;
private int _sessionCommandsExecuted;
@@ -239,6 +245,14 @@ public sealed partial class MainWindow : WindowEx,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
{
// Only rebuild window state when a setting HotReloadSettings actually consumes has
// changed. Most SettingsChanged notifications (theme, provider config, aliases, ...)
// don't affect the host window, so hot-reloading on every one is wasteful.
if (MainWindowSettingsComparer.Instance.Equals(_lastAppliedSettings, args))
{
return;
}
DispatcherQueue.TryEnqueue(HotReloadSettings);
}
@@ -464,7 +478,11 @@ public sealed partial class MainWindow : WindowEx,
private void HotReloadSettings()
{
// NOTE: SettingsChangedHandler skips this method when nothing relevant changed, using
// MainWindowSettingsComparer. When you start consuming a new setting below, add it to
// that comparer too — otherwise changes to it won't trigger a hot-reload.
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
_lastAppliedSettings = settings;
SetupHotkey(settings);
App.Current.Services.GetService<TrayIconService>()!.SetupTrayIcon(settings.ShowSystemTrayIcon);
@@ -489,6 +507,87 @@ public sealed partial class MainWindow : WindowEx,
private static bool ShouldShowHwndFrame(SettingsModel settings) =>
!BuildInfo.IsCiBuild && settings.ShowHwndFrame;
/// <summary>
/// Compares two <see cref="SettingsModel"/> instances by only the settings that
/// <see cref="HotReloadSettings"/> (and the methods it calls) actually consume. Any change
/// to a setting outside this set is invisible to the host window, so treating such models
/// as equal lets <see cref="SettingsChangedHandler"/> skip a redundant hot-reload.
/// </summary>
/// <remarks>
/// Keep this in sync with the settings read by <see cref="HotReloadSettings"/>,
/// <see cref="SetupHotkey"/>, <see cref="ShouldShowHwndFrame"/>, and
/// <see cref="HandleExpandCompactOnUiThread"/>.
/// </remarks>
private sealed class MainWindowSettingsComparer : IEqualityComparer<SettingsModel>
{
public static MainWindowSettingsComparer Instance { get; } = new();
public bool Equals(SettingsModel? x, SettingsModel? y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x is null || y is null)
{
return false;
}
return x.UseLowLevelGlobalHotkey == y.UseLowLevelGlobalHotkey
&& x.ShowSystemTrayIcon == y.ShowSystemTrayIcon
&& x.IgnoreShortcutWhenFullscreen == y.IgnoreShortcutWhenFullscreen
&& x.IgnoreShortcutWhenBusy == y.IgnoreShortcutWhenBusy
&& x.AllowBreakthroughShortcut == y.AllowBreakthroughShortcut
&& x.AutoGoHomeInterval == y.AutoGoHomeInterval
&& x.ShowHwndFrame == y.ShowHwndFrame
&& x.CompactMode == y.CompactMode
&& x.Hotkey == y.Hotkey // HotkeySettings is a record (value equality)
&& CommandHotkeysEqual(x.CommandHotkeys, y.CommandHotkeys);
}
// TopLevelHotkey is a record (value equality); compare element-wise. ImmutableList
// itself only implements reference equality, so we can't rely on ==.
private static bool CommandHotkeysEqual(ImmutableList<TopLevelHotkey> x, ImmutableList<TopLevelHotkey> y)
{
if (ReferenceEquals(x, y))
{
return true;
}
if (x.Count != y.Count)
{
return false;
}
for (var i = 0; i < x.Count; i++)
{
if (x[i] != y[i])
{
return false;
}
}
return true;
}
public int GetHashCode(SettingsModel obj)
{
var hash = default(HashCode);
hash.Add(obj.UseLowLevelGlobalHotkey);
hash.Add(obj.ShowSystemTrayIcon);
hash.Add(obj.IgnoreShortcutWhenFullscreen);
hash.Add(obj.IgnoreShortcutWhenBusy);
hash.Add(obj.AllowBreakthroughShortcut);
hash.Add(obj.AutoGoHomeInterval);
hash.Add(obj.ShowHwndFrame);
hash.Add(obj.CompactMode);
hash.Add(obj.Hotkey);
hash.Add(obj.CommandHotkeys.Count);
return hash.ToHashCode();
}
}
/// <summary>
/// Configures the HWND for the borderless / transparent main-window mode and (when
/// the internal debug toggle is enabled) overlays the OS-drawn chrome so the HWND's

View File

@@ -584,9 +584,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
ViewModel.GoHome(withAnimation, focusSearch);
}
if (focusSearch)
// Only move focus when the palette is actually on screen. FocusActiveControl uses keyboard
// focus so the screen reader announces the box; doing that while the window is hidden
// would announce it prematurely.
if (focusSearch && HostWindow?.IsVisibleToUser == true)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
}
@@ -601,10 +604,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
GoBack(withAnimation, focusSearch: false);
}
// focus search box, even if we were already home
if (focusSearch)
// focus search box, even if we were already home (but only when the palette is on
// screen - see GoBack; keyboard focus while hidden announces prematurely).
if (focusSearch && HostWindow?.IsVisibleToUser == true)
{
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
}
@@ -813,7 +817,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
return;
}
SearchBox.Focus(FocusState.Programmatic);
SearchBox.FocusActiveControl();
SearchBox.SelectSearch();
}
else

View File

@@ -0,0 +1,137 @@
// 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;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class DetailsViewModelTests
{
private sealed class TestPageContext : IPageContext
{
public TaskScheduler Scheduler => TaskScheduler.Default;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint = null)
{
throw new AssertFailedException($"Unexpected exception from view model: {ex}");
}
}
private static WeakReference<IPageContext> CreatePageContext()
{
var ctx = new TestPageContext();
return new WeakReference<IPageContext>(ctx);
}
[TestMethod]
public void InitializeProperties_SetsBodyAndTitle()
{
var details = new Details { Title = "Hello", Body = "World" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
Assert.AreEqual("Hello", vm.Title);
Assert.AreEqual("World", vm.Body);
}
[TestMethod]
public void PropChanged_Body_UpdatesViewModelProperty()
{
var details = new Details { Title = "Initial", Body = "Initial body" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
// Act — toolkit Details raises PropChanged synchronously on set
details.Body = "Updated body";
// The property value is set synchronously in FetchProperty;
// ApplyPendingUpdates flushes the PropertyChanged notification queue.
vm.ApplyPendingUpdates();
Assert.AreEqual("Updated body", vm.Body);
}
[TestMethod]
public void PropChanged_Title_UpdatesViewModelProperty()
{
var details = new Details { Title = "Original", Body = "Text" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
details.Title = "New Title";
vm.ApplyPendingUpdates();
Assert.AreEqual("New Title", vm.Title);
}
[TestMethod]
public void PropChanged_Metadata_RebuildsList()
{
var details = new Details
{
Title = "T",
Body = "B",
Metadata = [],
};
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
Assert.AreEqual(0, vm.Metadata.Count);
// Act — update metadata with a link element
details.Metadata = [new DetailsElement { Key = "link", Data = new DetailsLink("http://example.com", "Example") }];
vm.ApplyPendingUpdates();
Assert.AreEqual(1, vm.Metadata.Count);
}
[TestMethod]
public void Cleanup_UnsubscribesFromPropChanged()
{
var details = new Details { Title = "T", Body = "Original" };
var vm = new DetailsViewModel(details, CreatePageContext());
vm.InitializeProperties();
// Act — cleanup unsubscribes, then change should not propagate
vm.SafeCleanup();
details.Body = "After cleanup";
Assert.AreEqual("Original", vm.Body);
}
[TestMethod]
public void NonObservableDetails_DoesNotThrow()
{
// IDetails that does NOT implement INotifyPropChanged
var details = new NonObservableDetails();
var vm = new DetailsViewModel(details, CreatePageContext());
// Should not throw — just doesn't subscribe to anything
vm.InitializeProperties();
Assert.AreEqual("Static Title", vm.Title);
Assert.AreEqual("Static Body", vm.Body);
}
/// <summary>
/// A minimal IDetails that does NOT implement INotifyPropChanged.
/// </summary>
private sealed partial class NonObservableDetails : IDetails
{
public IIconInfo HeroImage => new IconInfo(string.Empty);
public string Title => "Static Title";
public string Body => "Static Body";
public IDetailsElement[] Metadata => [];
}
}

View File

@@ -0,0 +1,98 @@
// 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.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Verifies that <see cref="DockSettings"/> (and its nested <see cref="DockMonitorConfig"/>)
/// compare by <em>content</em> rather than by list reference. Dock consumers guard their
/// (expensive) reloads with <c>_settings == args.DockSettings</c>, so two settings that differ
/// only by having freshly-rebuilt band lists — e.g. after loading from disk — must compare
/// equal, otherwise the reload fires needlessly.
/// </summary>
[TestClass]
public class DockSettingsEqualityTests
{
private static DockBandSettings Band(string providerId, string commandId, bool? showTitles = null) =>
new() { ProviderId = providerId, CommandId = commandId, ShowTitles = showTitles };
[TestMethod]
public void Equal_WhenBandListsHaveSameContentButDifferentInstances()
{
var a = new DockSettings
{
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
};
var b = new DockSettings
{
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
};
// Distinct ImmutableList instances — would be reference-unequal without EquatableList.
Assert.AreNotSame(a.StartBands, b.StartBands);
Assert.AreEqual(a, b);
Assert.IsTrue(a == b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void NotEqual_WhenABandDiffers()
{
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "home")) };
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "settings")) };
Assert.AreNotEqual(a, b);
Assert.IsTrue(a != b);
}
[TestMethod]
public void NotEqual_WhenBandOrderDiffers()
{
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "a"), Band("p", "b")) };
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "b"), Band("p", "a")) };
Assert.AreNotEqual(a, b);
}
[TestMethod]
public void Equal_WhenMonitorConfigsHaveSameContentIncludingPerMonitorBands()
{
var a = new DockSettings
{
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(Band("p", "home")),
}),
};
var b = new DockSettings
{
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(Band("p", "home")),
}),
};
Assert.AreEqual(a, b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void NotEqual_WhenPerMonitorBandsIsNullVersusEmpty()
{
// null means "inherit global bands"; an explicit empty list does not. The two must
// stay distinguishable so a monitor can't silently switch between the two meanings.
var inherit = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = null };
var explicitEmpty = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = ImmutableList<DockBandSettings>.Empty };
Assert.AreNotEqual(inherit, explicitEmpty);
}
}

View File

@@ -0,0 +1,105 @@
// 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.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
/// <summary>
/// Tests for <see cref="EquatableList{T}"/> — the value-equality wrapper used as the backing
/// field for record list properties so record equality compares list contents, not references.
/// </summary>
[TestClass]
public class EquatableListTests
{
private static EquatableList<string> Of(params string[] items) =>
new(ImmutableList.Create(items));
[TestMethod]
public void Equal_WhenSameContentDifferentInstances()
{
var a = Of("x", "y", "z");
var b = Of("x", "y", "z");
Assert.AreNotSame(a.List, b.List);
Assert.IsTrue(a.Equals(b));
Assert.AreEqual(a, b);
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
}
[TestMethod]
public void Equal_WhenSameUnderlyingReference()
{
var shared = ImmutableList.Create("a", "b");
var a = new EquatableList<string>(shared);
var b = new EquatableList<string>(shared);
Assert.IsTrue(a.Equals(b));
}
[TestMethod]
public void NotEqual_WhenAnElementDiffers()
{
Assert.AreNotEqual(Of("a", "b"), Of("a", "c"));
}
[TestMethod]
public void NotEqual_WhenOrderDiffers()
{
Assert.AreNotEqual(Of("a", "b"), Of("b", "a"));
}
[TestMethod]
public void NotEqual_WhenCountsDiffer()
{
Assert.AreNotEqual(Of("a"), Of("a", "b"));
}
[TestMethod]
public void EmptyLists_AreEqual()
{
var fromEmpty = new EquatableList<string>(ImmutableList<string>.Empty);
var fromNull = new EquatableList<string>(null);
Assert.IsTrue(fromEmpty.Equals(fromNull));
Assert.AreEqual(fromEmpty.GetHashCode(), fromNull.GetHashCode());
}
[TestMethod]
public void Default_ExposesEmptyListAndEqualsEmpty()
{
EquatableList<string> defaulted = default;
// A default(struct) has a null inner list; List must still surface an empty list
// (never null) and compare equal to an explicitly-empty instance.
Assert.IsNotNull(defaulted.List);
Assert.AreEqual(0, defaulted.List.Count);
Assert.IsTrue(defaulted.Equals(new EquatableList<string>(ImmutableList<string>.Empty)));
}
[TestMethod]
public void List_IsNeverNull()
{
Assert.IsNotNull(new EquatableList<string>(null).List);
}
[TestMethod]
public void EqualsObject_ReturnsFalse_ForOtherTypes()
{
object other = "not an equatable list";
Assert.IsFalse(Of("a").Equals(other));
Assert.IsFalse(Of("a").Equals(null!));
}
[TestMethod]
public void EqualsObject_ReturnsTrue_ForBoxedEqualValue()
{
object boxed = Of("a", "b");
Assert.IsTrue(Of("a", "b").Equals(boxed));
}
}

View File

@@ -19,6 +19,16 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
public const string ProviderLoadGuardBlockId = ProviderIdValue + ".ProviderLoad";
public const string PageIdValue = "com.microsoft.cmdpal.performanceWidget";
private static readonly PerformanceMetricKind?[] BandMetrics =
[
null,
PerformanceMetricKind.Cpu,
PerformanceMetricKind.Memory,
PerformanceMetricKind.Network,
PerformanceMetricKind.Gpu,
PerformanceMetricKind.Battery,
];
internal static ProviderCrashSentinel CrashSentinel { get; } = new(ProviderIdValue);
private readonly Lock _stateLock = new();
@@ -109,15 +119,26 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
DisposeActivePages();
var page = new PerformanceMonitorDisabledPage(this);
var band = new PerformanceMonitorDisabledPage(this);
_bands =
[
new CommandItem(band)
// Mirror the compact form of the real bands, and keep texts short
var disabledValue = Resources.GetResource("Performance_Monitor_Disabled_Band_Title");
var bands = new List<ICommandItem>(BandMetrics.Length);
foreach (var metric in BandMetrics)
{
var icon = GetBandIcon(metric);
var item = new ListItem(page)
{
Title = Resources.GetResource("Performance_Monitor_Disabled_Band_Title"),
Subtitle = DisplayName,
},
];
Title = disabledValue,
Subtitle = GetBandSubtitle(metric),
Icon = icon,
};
bands.Add(new WrappedDockItem([item], PerformanceWidgetsPage.GetBandId(metric), GetBandDisplayTitle(metric))
{
Icon = icon,
});
}
_bands = bands.ToArray();
_commands =
[
new CommandItem(page)
@@ -129,6 +150,45 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
_softDisabled = true;
}
private string GetBandDisplayTitle(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Title"),
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Title"),
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Title"),
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Title"),
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Title"),
_ => DisplayName,
};
}
private string GetBandSubtitle(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Subtitle"),
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Subtitle"),
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Subtitle"),
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Subtitle"),
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Subtitle"),
_ => string.Empty,
};
}
private static IconInfo GetBandIcon(PerformanceMetricKind? metric)
{
return metric switch
{
PerformanceMetricKind.Cpu => Icons.CpuIcon,
PerformanceMetricKind.Memory => Icons.MemoryIcon,
PerformanceMetricKind.Network => Icons.NetworkIcon,
PerformanceMetricKind.Gpu => Icons.GpuIcon,
PerformanceMetricKind.Battery => Icons.BatteryIcon,
_ => Icons.PerformanceMonitorIcon,
};
}
private void SetEnabledState()
{
DisposeActivePages();

View File

@@ -12,9 +12,9 @@ internal sealed partial class PerformanceMonitorDisabledPage : ContentPage
{
private readonly MarkdownContent _content;
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider)
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider, string? id = null)
{
Id = PerformanceMonitorCommandsProvider.PageIdValue;
Id = id ?? PerformanceMonitorCommandsProvider.PageIdValue;
Name = Resources.GetResource("Performance_Monitor_Disabled_Title");
Title = Resources.GetResource("Performance_Monitor_Title");
Icon = Icons.PerformanceMonitorIcon;

View File

@@ -93,7 +93,7 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
{
_isBandPage = isBandPage;
_singleMetric = singleMetric;
_id = singleMetric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(singleMetric.Value)}";
_id = GetBandId(singleMetric);
if (IncludesMetric(PerformanceMetricKind.Cpu))
{
@@ -287,6 +287,11 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
_batteryPage?.Dispose();
}
internal static string GetBandId(PerformanceMetricKind? metric)
{
return metric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(metric.Value)}";
}
private bool IncludesMetric(PerformanceMetricKind metric)
{
return _singleMetric is null || _singleMetric == metric;

View File

@@ -199,7 +199,7 @@
<value>Temporarily disabled after repeated startup crashes</value>
</data>
<data name="Performance_Monitor_Disabled_Band_Title" xml:space="preserve">
<value>Perf disabled</value>
<value>Disabled</value>
</data>
<data name="Performance_Monitor_Disabled_Body" xml:space="preserve">
<value>Performance monitor was temporarily disabled after repeated crashes while initializing or reading performance counters.

View File

@@ -0,0 +1,103 @@
// 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;
using System.Globalization;
using System.Timers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
#nullable enable
namespace SamplePagesExtension;
internal sealed partial class SampleLiveDetailsPage : ListPage, IDisposable
{
private readonly Details _clockDetails = new()
{
Title = "Current Time",
Body = "Loading...",
};
private readonly Details _counterDetails = new()
{
Title = "Count: 0",
Body = "Elapsed: 0 seconds",
};
private readonly Details _staticDetails = new()
{
Title = "Static Details",
Body = "This item does not update. Select the items above to see live updates in the details pane.",
};
private readonly ListItem[] _items;
private Timer? _timer;
private int _counter;
private bool _disposed;
public SampleLiveDetailsPage()
{
Icon = new IconInfo("\uE916"); // Refresh
Name = Title = "Live Updating Details";
ShowDetails = true;
_items = [
new ListItem(new NoOpCommand())
{
Title = "Live Clock",
Subtitle = "Details pane shows current time, updating every second",
Details = _clockDetails,
},
new ListItem(new NoOpCommand())
{
Title = "Counter",
Subtitle = "Details pane increments a counter every second",
Details = _counterDetails,
},
new ListItem(new NoOpCommand())
{
Title = "Static Item",
Subtitle = "This item's details do not change",
Details = _staticDetails,
},
];
}
public override IListItem[] GetItems()
{
if (_timer is null)
{
_timer = new Timer(1000);
_timer.Elapsed += Timer_Elapsed;
_timer.AutoReset = true;
_timer.Enabled = true;
}
return _items;
}
private void Timer_Elapsed(object? sender, ElapsedEventArgs e)
{
_counter++;
// Updating Details properties fires INotifyPropChanged automatically
// (Details extends BaseObservable). DetailsViewModel picks up the change
// live without requiring the user to reselect the item.
_clockDetails.Body = DateTime.Now.ToString("HH:mm:ss", CultureInfo.CurrentCulture);
_counterDetails.Title = $"Count: {_counter}";
_counterDetails.Body = $"Elapsed: {_counter} second{(_counter == 1 ? string.Empty : "s")}";
}
public void Dispose()
{
if (!_disposed)
{
_timer?.Dispose();
_timer = null;
_disposed = true;
}
}
}

View File

@@ -29,6 +29,11 @@ public partial class SamplesListPage : ListPage
Title = "List Page With Details",
Subtitle = "A list of items, each with additional details to display",
},
new ListItem(new SampleLiveDetailsPage())
{
Title = "Live Updating Details",
Subtitle = "Details pane updates in real time without reselecting",
},
new ListItem(new SectionsIndexPage())
{
Title = "List Pages With Sections",