Compare commits

..

4 Commits

Author SHA1 Message Date
Mike Griese
3ab6e182f8 CmdPal: fix the HWND frame on compact mode (#49184)
Admittedly, LLM discovered fix here.

The window frame for compact mode in cmdpal is very very wackadoodle.
User32 is dark and full of terrors, especially when we're trying to both
keep the original resize handles but also make them hidden from view.

TIL about
[`RedrawWindow`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-redrawwindow),
which if you slather that about, helps make sure that the window frame
is correctly repainted by DWM as transparent.

The repro for this before was easy to see with a debug build with the
debugger attached:
* open cmdpal 
* make sure the frame is gone and the cmdpal window is focused
* click on another window
* PRESTO, window frame is back

by adding more RedrawWindow calls, we can make sure that our frame is
correctly erased even when DWM wants to put it back.

Closes: oh no one filed this?
2026-07-07 16:24:38 +00:00
Jiří Polášek
c1ef511697 CmdPal: Add keyboard shortcuts to manually expand compact mode palette (#49177)
## Summary of the Pull Request

This PR adds support for new keyboard shortcuts to expand CmdPal when in
Compact mode. Both of them are "natural"
- Down arrow key
- Tab key

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

- [x] Closes: #49112
<!-- - [ ] 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 11:07:56 -05:00
Jiří Polášek
188f6a58a8 CmdPal: Stop item action being executed when CmdPal is compact and collapsed (#49182)
## Summary of the Pull Request

This PR prevents Command Palette in collapsed compact mode executing
actions on a selected item. Since user is not aware what item is
selected or what action are
2026-07-07 16:04:53 +00:00
Michael Jolley
9449ae3686 Fix dock clock: show seconds, fix stale display, a11y (#48253)
Fixes the Command Palette Dock clock/date extension which showed time ~1
minute late and did not display seconds (unlike the taskbar clock).

The problem was that `timeExtended` was hardcoded `false` in
`NowDockBand.UpdateText()`. Fixed that by passing `true` to
`TimeAndDateHelper.GetStringFormat()`, selecting the `"T"` (long time)
format which includes seconds.

Also, changed from using `PropertyChanged` on `Title` to using
`RaiseItemsChanged` to mimic the behavior used for updating in the
Performance Monitor extension.

Fixes #46192

---------

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Hawk <hawk@example.com>
2026-07-07 13:51:42 +02:00
43 changed files with 688 additions and 723 deletions

View File

@@ -48,6 +48,7 @@
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
<Style.Setters>
<Setter Property="AutomationProperties.LiveSetting" Value="Off" />
<Setter Property="Background" Value="{ThemeResource DockItemBackground}" />
<Setter Property="BorderBrush" Value="{ThemeResource DockItemBorderBrush}" />
<Setter Property="Padding" Value="{StaticResource DockItemPadding}" />

View File

@@ -10,7 +10,6 @@ using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;
@@ -663,6 +662,8 @@ public sealed partial class MainWindow : WindowEx,
var borderColor = showFrame ? DWMWA_COLOR_DEFAULT : DWMWA_COLOR_NONE;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_BORDER_COLOR, &borderColor, sizeof(uint));
}
RedrawWindow(_hwnd);
}
private void InitializeBackdropSupport()
@@ -727,7 +728,7 @@ public sealed partial class MainWindow : WindowEx,
var positionWindowForAnchor = (HWND hwnd) =>
{
PInvoke.GetWindowRect(hwnd, out var bounds);
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER;
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED;
switch (anchorCorner)
{
case AnchorPoint.TopLeft:
@@ -827,17 +828,33 @@ public sealed partial class MainWindow : WindowEx,
// topmost status when we hide the window (because we cloak it instead
// of hiding it).
//
// SWP_FRAMECHANGED is load-bearing for the borderless look on a cold
// start. Asking for SWP_FRAMECHANGED here re-sends WM_NCCALCSIZE and
// forces the NC repaint every time we show, so the frame is gone from
// the very first summon.
// SWP_FRAMECHANGED re-sends WM_NCCALCSIZE so the OS recomputes the
// (zero-width) frame. But on this cloak->show path it doesn't reliably
// *repaint* the non-client area, so the WS_THICKFRAME border that was
// painted earlier survives on the top/left/right edges (the bottom is
// clipped away by the card region). A real resize fixes it because it
// forces that NC repaint - so we do the same explicitly below.
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED);
// Force the non-client frame to actually redraw (RDW_FRAME) and the
// client to repaint over wherever it used to be. Without this the stale
// border lingers until the user resizes the window.
RedrawWindow(hwnd);
// Treat the overall show/hide lifecycle as the authoritative
// visibility transition, not the lower-level cloak/uncloak helpers.
SetIsVisibleToUser(true);
}
private static void RedrawWindow(HWND hwnd)
{
const uint RDW_INVALIDATE = 0x0001;
const uint RDW_UPDATENOW = 0x0100;
const uint RDW_ALLCHILDREN = 0x0080;
const uint RDW_FRAME = 0x0400;
_ = RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_FRAME);
}
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
{
// Leaving a note here, in case we ever need it:
@@ -1302,6 +1319,10 @@ public sealed partial class MainWindow : WindowEx,
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DeleteObject(IntPtr hObject);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool RedrawWindow(IntPtr hWnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, uint flags);
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
{
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
@@ -1668,6 +1689,17 @@ public sealed partial class MainWindow : WindowEx,
case PInvoke.WM_NCCALCSIZE when wParam.Value != 0 && _hwndFrameVisible != true:
return (LRESULT)0;
// On every activation change the OS repaints the non-client frame to
// flip between the active / inactive caption. With WS_THICKFRAME still
// present that paints the OS border back over our borderless window each
// time we lose (or gain) focus - which is the frame that reappears when
// another window steals focus, and the one that shows up shortly after
// startup (the first activation flip). Passing -1 as the update-region
// (lParam) to DefWindowProc keeps the correct activation result while
// telling it to skip the non-client repaint entirely.
case PInvoke.WM_NCACTIVATE when _hwndFrameVisible != true:
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, new LPARAM(-1));
case PInvoke.WM_HOTKEY:
{
var hotkeyIndex = (int)wParam.Value;

View File

@@ -105,6 +105,7 @@ WM_ACTIVATE
WM_ACTIVATEAPP
WA_INACTIVE
WM_DISPLAYCHANGE
WM_NCACTIVATE
WM_SYSCOMMAND
WM_SETTINGCHANGE
WM_WINDOWPOSCHANGING

View File

@@ -121,6 +121,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public bool ExpandedMode { get; set; }
// Item keybindings act on the selected item, which is hidden while collapsed — only honor them when expanded.
private bool ItemActionsAllowed => !_compactMode || ExpandedMode;
public ShellPage()
{
_settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
@@ -949,47 +952,74 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
case VirtualKey.F when modifiers.OnlyAlt: // Alt+F: toggle filter focus
((ShellPage)sender).ToggleFilterFocus();
e.Handled = true;
break;
case VirtualKey.Down when modifiers.None:
case VirtualKey.Tab when modifiers.None:
// In a collapsed compact palette, Down/Tab reveals the top-level items so the
// user can browse and discover them. Only swallow the key when we actually
// expand; otherwise let it fall through to normal list navigation / focus move.
if (((ShellPage)sender).TryExpandCollapsedCompact())
{
e.Handled = true;
}
break;
default:
{
// The CommandBar is responsible for handling all the item keybindings,
// since the bound context item may need to then show another
// context menu
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
// The CommandBar handles item keybindings; skip them while collapsed so a chord can't hit the hidden selection.
if (((ShellPage)sender).ItemActionsAllowed)
{
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
WeakReferenceMessenger.Default.Send(msg);
e.Handled = msg.Handled;
}
break;
}
}
}
private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
private void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
{
var mods = KeyModifiers.GetCurrent();
if (mods.Ctrl && e.Key == VirtualKey.Enter)
if (ItemActionsAllowed && TryHandleItemAction(e))
{
// ctrl+enter
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
e.Handled = true;
return;
}
else if (e.Key == VirtualKey.Enter)
{
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
e.Handled = true;
}
else if (mods.Ctrl && e.Key == VirtualKey.K)
{
// ctrl+k
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
e.Handled = true;
}
else if (e.Key == VirtualKey.Escape)
if (e.Key == VirtualKey.Escape)
{
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
e.Handled = true;
}
}
private static bool TryHandleItemAction(KeyRoutedEventArgs e)
{
var mods = KeyModifiers.GetCurrent();
switch (e.Key)
{
// Ctrl+Enter
case VirtualKey.Enter when mods.OnlyCtrl:
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
break;
// Enter
case VirtualKey.Enter when mods.None:
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
break;
// Ctrl+K
case VirtualKey.K when mods.OnlyCtrl:
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new(null, null, null, ContextMenuFilterLocation.Bottom));
break;
default:
return false;
}
e.Handled = true;
return true;
}
private void ShellPage_OnPointerPressed(object sender, PointerRoutedEventArgs e)
{
try
@@ -1055,6 +1085,23 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
}
/// <summary>
/// Expands a collapsed compact palette on demand (via Down/Tab) so the user can browse the
/// top-level items. Returns <see langword="false"/> and does nothing unless compact mode is
/// on and the palette is currently collapsed, letting the caller keep the key's normal
/// meaning (list navigation / focus traversal) in every other case.
/// </summary>
private bool TryExpandCollapsedCompact()
{
if (!_compactMode || ExpandedMode)
{
return false;
}
HandleExpandCompactOnUiThread(true);
return true;
}
/// <summary>
/// Forces the shell into its compact (collapsed) layout and flushes layout so the host can
/// read the resulting card height. Only has an effect when compact mode is enabled.

View File

@@ -13,8 +13,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class AvailableResultsListTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class FallbackTimeDateItemTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -5,6 +5,7 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<Nullable>enable</Nullable>
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>

View File

@@ -0,0 +1,160 @@
// 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 Microsoft.CmdPal.Ext.TimeDate;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposable", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed in TestCleanup")]
public class NowDockBandTests
{
private static readonly DateTime FixedTime = new DateTime(2025, 7, 1, 14, 5, 32);
private CultureInfo _originalCulture = null!;
private CultureInfo _originalUiCulture = null!;
private NowDockBand? _band;
[TestInitialize]
public void Setup()
{
_originalCulture = CultureInfo.CurrentCulture;
_originalUiCulture = CultureInfo.CurrentUICulture;
CultureInfo.CurrentCulture = new CultureInfo("en-US", false);
CultureInfo.CurrentUICulture = new CultureInfo("en-US", false);
}
[TestCleanup]
public void Cleanup()
{
_band?.Dispose();
_band = null;
CultureInfo.CurrentCulture = _originalCulture;
CultureInfo.CurrentUICulture = _originalUiCulture;
}
[TestMethod]
public void Constructor_TitleIsSetImmediately()
{
_band = new NowDockBand(clock: () => FixedTime);
Assert.AreEqual("2:05 PM", _band.Title);
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle));
}
[TestMethod]
public void UpdateText_LongTimeFormat_TitleContainsSeconds()
{
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual("2:05:32 PM", _band.Title);
}
[TestMethod]
public void UpdateText_ShortDateFormat_SubtitleIsShortDate()
{
_band = new NowDockBand(clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual("7/1/2025", _band.Subtitle);
}
[TestMethod]
public void UpdateText_FiresOnUpdatedCallback()
{
var callbackFired = false;
_band = new NowDockBand(onUpdated: () => callbackFired = true, clock: () => FixedTime);
callbackFired = false; // reset — constructor already fired it once during synchronous UpdateText()
_band.UpdateText();
Assert.IsTrue(callbackFired);
}
[TestMethod]
public void UpdateText_CallbackFiredAfterAssignments()
{
var titleAtCallback = string.Empty;
_band = new NowDockBand(
onUpdated: () => titleAtCallback = _band?.Title ?? string.Empty,
clock: () => FixedTime);
titleAtCallback = string.Empty; // reset after construction callback
_band.UpdateText();
Assert.IsFalse(string.IsNullOrEmpty(titleAtCallback), "Title should be assigned before callback fires");
}
[TestMethod]
public void UpdateText_CopyCommandsUpdated()
{
_band = new NowDockBand(clock: () => FixedTime);
_band.UpdateText();
Assert.AreEqual(_band.Title, _band.CopyTimeCommand.Text);
Assert.AreEqual(_band.Subtitle, _band.CopyDateCommand.Text);
}
[DataTestMethod]
[DataRow("de-DE")]
[DataRow("fr-FR")]
[DataRow("ar-SA")]
public void UpdateText_CultureSmoke_TitleNonEmpty(string cultureName)
{
// Culture MUST be set before construction — constructor calls UpdateText() synchronously
CultureInfo.CurrentCulture = new CultureInfo(cultureName, false);
CultureInfo.CurrentUICulture = new CultureInfo(cultureName, false);
_band = new NowDockBand(clock: () => FixedTime);
Assert.IsFalse(string.IsNullOrEmpty(_band.Title), $"Title should be non-empty for culture '{cultureName}'");
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle), $"Subtitle should be non-empty for culture '{cultureName}'");
}
[TestMethod]
public void UpdateSettings_EnablingSeconds_TitleIncludesSeconds()
{
_band = new NowDockBand(timeWithSeconds: false, clock: () => FixedTime);
Assert.AreEqual("2:05 PM", _band.Title, "Precondition: seconds hidden by default");
_band.UpdateSettings(timeWithSeconds: true);
Assert.AreEqual("2:05:32 PM", _band.Title, "Title should update live to include seconds");
}
[TestMethod]
public void UpdateSettings_DisablingSeconds_TitleDropsSeconds()
{
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
Assert.AreEqual("2:05:32 PM", _band.Title, "Precondition: seconds shown");
_band.UpdateSettings(timeWithSeconds: false);
Assert.AreEqual("2:05 PM", _band.Title, "Title should update live to drop seconds");
}
[TestMethod]
public void UpdateSettings_NoChange_FiresNoCallback()
{
var callbackCount = 0;
_band = new NowDockBand(timeWithSeconds: false, onUpdated: () => callbackCount++, clock: () => FixedTime);
callbackCount = 0; // reset after construction callback
_band.UpdateSettings(timeWithSeconds: false);
Assert.AreEqual(0, callbackCount, "A no-op settings change should not refresh the band");
}
}

View File

@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class QueryTests : CommandPaletteUnitTestBase
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -166,7 +166,7 @@ public class QueryTests : CommandPaletteUnitTestBase
Assert.IsTrue(results.Length > 0, $"Query '{query}' should return at least one result");
var firstItem = results.FirstOrDefault();
Assert.IsTrue(firstItem.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
Assert.IsTrue(firstItem!.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
}
[DataTestMethod]
@@ -201,7 +201,7 @@ public class QueryTests : CommandPaletteUnitTestBase
// Assert
Assert.IsNotNull(results);
var firstResult = results.FirstOrDefault();
Assert.IsTrue(firstResult.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
Assert.IsTrue(firstResult!.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
}
[DataTestMethod]
@@ -218,7 +218,7 @@ public class QueryTests : CommandPaletteUnitTestBase
// Assert
Assert.IsNotNull(resultsList);
var firstResult = resultsList.FirstOrDefault();
Assert.IsTrue(firstResult.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
Assert.IsTrue(firstResult!.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
}
[TestMethod]

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class ResultHelperTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -41,6 +41,8 @@ public class ResultHelperTests
{
Label = "Test Label",
Value = "Test Value",
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -55,10 +57,10 @@ public class ResultHelperTests
[TestMethod]
public void ResultHelper_CreateListItem_HandlesNullInput()
{
AvailableResult availableResult = null;
AvailableResult? availableResult = null;
// Act & Assert
Assert.ThrowsException<System.NullReferenceException>(() => availableResult.ToListItem());
Assert.ThrowsException<System.NullReferenceException>(() => availableResult!.ToListItem());
}
[TestMethod]
@@ -69,6 +71,8 @@ public class ResultHelperTests
{
Label = string.Empty,
Value = string.Empty,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -88,6 +92,7 @@ public class ResultHelperTests
{
Label = "Test Label",
Value = "Test Value",
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Date,
};
@@ -110,6 +115,8 @@ public class ResultHelperTests
{
Label = longText,
Value = longText,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act
@@ -130,6 +137,8 @@ public class ResultHelperTests
{
Label = specialText,
Value = specialText,
AlternativeSearchTag = string.Empty,
IconType = ResultIconType.Time,
};
// Act

View File

@@ -13,6 +13,7 @@ public class Settings : ISettingsInterface
private readonly int firstDayOfWeek;
private readonly bool enableFallbackItems;
private readonly bool timeWithSecond;
private readonly bool dockClockWithSecond;
private readonly bool dateWithWeekday;
private readonly List<string> customFormats;
@@ -21,13 +22,15 @@ public class Settings : ISettingsInterface
int firstDayOfWeek = -1,
bool enableFallbackItems = true,
bool timeWithSecond = false,
bool dockClockWithSecond = false,
bool dateWithWeekday = false,
List<string> customFormats = null)
List<string>? customFormats = null)
{
this.firstWeekOfYear = firstWeekOfYear;
this.firstDayOfWeek = firstDayOfWeek;
this.enableFallbackItems = enableFallbackItems;
this.timeWithSecond = timeWithSecond;
this.dockClockWithSecond = dockClockWithSecond;
this.dateWithWeekday = dateWithWeekday;
this.customFormats = customFormats ?? new List<string>();
}
@@ -40,6 +43,8 @@ public class Settings : ISettingsInterface
public bool TimeWithSecond => timeWithSecond;
public bool DockClockWithSecond => dockClockWithSecond;
public bool DateWithWeekday => dateWithWeekday;
public List<string> CustomFormats => customFormats;

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class StringParserTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class TimeAndDateHelperTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()

View File

@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
[TestClass]
public class TimeDateCalculatorTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -69,7 +69,7 @@ public class TimeDateCalculatorTests
var settings = new SettingsManager();
// Act
var results = TimeDateCalculator.ExecuteSearch(settings, null);
var results = TimeDateCalculator.ExecuteSearch(settings, null!);
// Assert
Assert.IsNotNull(results);

View File

@@ -11,8 +11,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
[TestClass]
public class TimeDateCommandsProviderTests
{
private CultureInfo originalCulture;
private CultureInfo originalUiCulture;
private CultureInfo originalCulture = null!;
private CultureInfo originalUiCulture = null!;
[TestInitialize]
public void Setup()
@@ -90,5 +90,27 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
// Assert
Assert.IsFalse(string.IsNullOrEmpty(displayName));
}
[TestMethod]
public void GetDockBands_ReturnsNonEmptyArray()
{
var provider = new TimeDateCommandsProvider();
var bands = provider.GetDockBands();
Assert.IsTrue(bands.Length > 0, "GetDockBands should return at least one item");
Assert.IsNotNull(bands[0], "First dock band should not be null");
}
[TestMethod]
public void GetDockBands_NotificationCenterBandDoesNotSetDockIcon()
{
var provider = new TimeDateCommandsProvider();
var bands = provider.GetDockBands();
Assert.IsTrue(bands.Length > 1, "Expected notification center band to be present");
Assert.IsNull(bands[1].Icon, "Notification center band should not set a dock icon");
}
}
}

View File

@@ -29,16 +29,16 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
_validOptions = new(StringComparer.OrdinalIgnoreCase)
{
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture)!,
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture),
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture)!,
};
}
@@ -53,7 +53,7 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
}
var availableResults = AvailableResultsList.GetList(false, _settingsManager, timestamp: _timestamp);
ListItem result = null;
ListItem? result = null;
var maxScore = 0;
foreach (var f in availableResults)

View File

@@ -10,22 +10,22 @@ internal sealed class AvailableResult
/// <summary>
/// Gets or sets the time/date value
/// </summary>
internal string Value { get; set; }
internal required string Value { get; set; }
/// <summary>
/// Gets or sets the text used for the subtitle and as search term
/// </summary>
internal string Label { get; set; }
internal required string Label { get; set; }
/// <summary>
/// Gets or sets an alternative search tag that will be evaluated if label doesn't match. For example we like to show the era on searches for 'year' too.
/// </summary>
internal string AlternativeSearchTag { get; set; }
internal required string AlternativeSearchTag { get; set; }
/// <summary>
/// Gets or sets a value indicating the type of result
/// </summary>
internal ResultIconType IconType { get; set; }
internal required ResultIconType IconType { get; set; }
/// <summary>
/// Gets or sets a value to show additional error details
@@ -37,7 +37,7 @@ internal sealed class AvailableResult
/// </summary>
/// <param name="theme">Theme</param>
/// <returns>Path</returns>
public IconInfo GetIconInfo()
public IconInfo? GetIconInfo()
{
return IconType switch
{

View File

@@ -20,6 +20,8 @@ public interface ISettingsInterface
public bool TimeWithSecond { get; }
public bool DockClockWithSecond { get; }
public bool DateWithWeekday { get; }
public List<string> CustomFormats { get; }

View File

@@ -17,7 +17,7 @@ internal static class ResultHelper
/// <param name="stringId">Id of the string. (Example: `MyString` for `MyString` and `MyStringNow`)</param>
/// <param name="stringIdNow">Optional string id for now case</param>
/// <returns>The string from the resource file, or <see cref="string.Empty"/> otherwise.</returns>
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string stringIdNow = default)
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string? stringIdNow = default)
{
return !isSystemTimeDate
? Resources.ResourceManager.GetString(stringId, CultureInfo.CurrentUICulture) ?? string.Empty

View File

@@ -87,6 +87,12 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Resources.Microsoft_plugin_timedate_SettingTimeWithSeconds_Description,
false); // TODO -- double check default value
private readonly ToggleSetting _dockClockWithSeconds = new(
Namespaced(nameof(DockClockWithSecond)),
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds,
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description,
false);
private readonly ToggleSetting _dateWithWeekday = new(
Namespaced(nameof(DateWithWeekday)),
Resources.Microsoft_plugin_timedate_SettingDateWithWeekday,
@@ -143,9 +149,11 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool TimeWithSecond => _timeWithSeconds.Value;
public bool DockClockWithSecond => _dockClockWithSeconds.Value;
public bool DateWithWeekday => _dateWithWeekday.Value;
public List<string> CustomFormats => _customFormats.Value.Split(TEXTBOXNEWLINE).ToList();
public List<string> CustomFormats => (_customFormats.Value ?? string.Empty).Split(TEXTBOXNEWLINE).ToList();
internal static string SettingsJsonPath()
{
@@ -161,6 +169,7 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Settings.Add(_enableFallbackItems);
Settings.Add(_timeWithSeconds);
Settings.Add(_dockClockWithSeconds);
Settings.Add(_dateWithWeekday);
Settings.Add(_firstWeekOfYear);
Settings.Add(_firstDayOfWeek);

View File

@@ -5,6 +5,7 @@
<PropertyGroup>
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate</RootNamespace>
<Nullable>enable</Nullable>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>

View File

@@ -0,0 +1,134 @@
// 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 Microsoft.CmdPal.Ext.TimeDate.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.TimeDate;
internal sealed partial class NowDockBand : ListItem, IDisposable
{
private static readonly TimeSpan PerSecondUpdateInterval = TimeSpan.FromSeconds(1);
private static readonly TimeSpan PerMinuteUpdateInterval = TimeSpan.FromMinutes(1);
private readonly System.Timers.Timer _timer;
private readonly Action? _onUpdated;
private readonly Func<DateTime> _clock;
private bool _timeWithSeconds;
private CopyTextCommand _copyTimeCommand;
private CopyTextCommand _copyDateCommand;
internal CopyTextCommand CopyTimeCommand => _copyTimeCommand;
internal CopyTextCommand CopyDateCommand => _copyDateCommand;
internal NowDockBand(bool timeWithSeconds = false, Action? onUpdated = null, Func<DateTime>? clock = null)
{
_timeWithSeconds = timeWithSeconds;
_onUpdated = onUpdated;
_clock = clock ?? (() => DateTime.Now);
Command = new OpenUrlCommand("ms-actioncenter:")
{
Id = "com.microsoft.cmdpal.timedate.dockBand",
Name = Resources.timedate_show_notification_center_command_name,
Result = CommandResult.Dismiss(),
};
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
MoreCommands =
[
new CommandContextItem(_copyTimeCommand),
new CommandContextItem(_copyDateCommand),
];
UpdateText();
_timer = new System.Timers.Timer() { AutoReset = true };
ConfigureTimer();
}
// Reads the current "show seconds" preference and, if it changed, reconfigures
// the timer cadence and refreshes the displayed text. Safe to call at any time
// (e.g. from a settings-changed handler) so the dock clock stays in sync without
// requiring the app to restart.
internal void UpdateSettings(bool timeWithSeconds)
{
if (_timeWithSeconds == timeWithSeconds)
{
return;
}
_timeWithSeconds = timeWithSeconds;
ConfigureTimer();
UpdateText();
}
private void ConfigureTimer()
{
_timer.Stop();
_timer.Elapsed -= Timer_Elapsed;
_timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
if (_timeWithSeconds)
{
_timer.Interval = PerSecondUpdateInterval.TotalMilliseconds;
_timer.Elapsed += Timer_Elapsed;
}
else
{
// Align the first tick to the next minute boundary so the clock flips
// exactly when the system clock does, then fall back to a per-minute cadence.
var now = _clock();
_timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
_timer.Elapsed += Timer_ElapsedFirstMinuteTick;
}
_timer.Start();
}
private void Timer_ElapsedFirstMinuteTick(object? sender, System.Timers.ElapsedEventArgs e)
{
if (sender is System.Timers.Timer timer)
{
timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds;
timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
timer.Elapsed += Timer_Elapsed;
}
Timer_Elapsed(sender, e);
}
private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
UpdateText();
}
internal void UpdateText()
{
var now = _clock();
var timeString = now.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, _timeWithSeconds, false),
CultureInfo.CurrentCulture);
var dateString = now.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, false, false),
CultureInfo.CurrentCulture);
Title = timeString;
Subtitle = dateString;
_copyTimeCommand.Text = timeString;
_copyDateCommand.Text = dateString;
_onUpdated?.Invoke(); // Must remain last — ViewModel reads Title/Subtitle via GetItems() on callback
}
public void Dispose()
{
_timer.Stop();
_timer.Dispose();
}
}

View File

@@ -834,6 +834,24 @@ namespace Microsoft.CmdPal.Ext.TimeDate {
}
}
/// <summary>
/// Looks up a localized string similar to Show seconds on the dock clock.
/// </summary>
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds {
get {
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to This setting applies to the clock shown on the Command Palette dock..
/// </summary>
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description {
get {
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Select for more details..
/// </summary>

View File

@@ -267,6 +267,12 @@
<data name="Microsoft_plugin_timedate_SettingTimeWithSeconds_Description" xml:space="preserve">
<value>This setting applies to the 'Time' and 'Now' result.</value>
</data>
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds" xml:space="preserve">
<value>Show seconds on the dock clock</value>
</data>
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description" xml:space="preserve">
<value>This setting applies to the clock shown on the Command Palette dock.</value>
</data>
<data name="Microsoft_plugin_timedate_SubTitleNote" xml:space="preserve">
<value>Select or press Ctrl+C to copy</value>
<comment>'Ctrl+C' is a shortcut</comment>

View File

@@ -20,8 +20,11 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
private static readonly TimeDateExtensionPage _timeDateExtensionPage = new(_settingsManager);
private readonly FallbackTimeDateItem _fallbackTimeDateItem = new(_settingsManager);
private readonly ListItem _bandItem;
private readonly ListItem _notificationCenterBandItem;
private readonly WrappedDockItem _bandItem;
private readonly WrappedDockItem _notificationCenterBandItem;
// Keep a reference to the band so we can dispose it when the provider is disposed.
private NowDockBand? _nowDockBand;
public TimeDateCommandsProvider()
{
@@ -37,8 +40,40 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
Icon = _timeDateExtensionPage.Icon;
Settings = _settingsManager.Settings;
_bandItem = new NowDockBand();
_notificationCenterBandItem = new NotificationCenterDockBand();
WrappedDockItem? wrappedBand = null;
// During NowDockBand construction, UpdateText() runs synchronously.
// At that point wrappedBand is still null so the callback is a no-op.
// On subsequent timer ticks, wrappedBand is non-null and SetItems fires
// RaiseItemsChanged - the framework marshals to the UI thread in
// DockBandViewModel.InitializeFromList via DoOnUiThread.
_nowDockBand = new NowDockBand(_settingsManager.DockClockWithSecond, onUpdated: () =>
{
if (wrappedBand is not null)
{
wrappedBand.Items = [_nowDockBand!];
}
});
// Re-read the dock clock preference whenever settings change so the band updates
// live (no app restart required). The band ignores no-op changes internally.
_settingsManager.Settings.SettingsChanged += OnSettingsChanged;
wrappedBand = new WrappedDockItem(
[_nowDockBand],
"com.microsoft.cmdpal.timedate.dockBand",
Resources.Microsoft_plugin_timedate_dock_band_title)
{
Icon = Icons.TimeDateExtIcon,
};
_bandItem = wrappedBand;
var notificationCenterBand = new NotificationCenterDockBand();
_notificationCenterBandItem = new WrappedDockItem(
[notificationCenterBand],
"com.microsoft.cmdpal.timedate.notificationCenterBand",
Resources.timedate_notification_center_band_title);
}
private string GetTranslatedPluginDescription()
@@ -56,97 +91,26 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
public override ICommandItem[] GetDockBands()
{
var clockBand = new WrappedDockItem(
[_bandItem],
"com.microsoft.cmdpal.timedate.dockBand",
Resources.Microsoft_plugin_timedate_dock_band_title)
{
Icon = Icons.TimeDateExtIcon,
};
return [_bandItem, _notificationCenterBandItem];
}
var notificationBand = new WrappedDockItem(
[_notificationCenterBandItem],
"com.microsoft.cmdpal.timedate.notificationCenterBand",
Resources.timedate_notification_center_band_title)
{
Icon = Icons.NotificationCenterIcon,
};
private void OnSettingsChanged(object sender, Settings args)
{
_nowDockBand?.UpdateSettings(_settingsManager.DockClockWithSecond);
}
return new ICommandItem[] { clockBand, notificationBand };
public override void Dispose()
{
_settingsManager.Settings.SettingsChanged -= OnSettingsChanged;
_nowDockBand?.Dispose();
_nowDockBand = null;
GC.SuppressFinalize(this);
base.Dispose();
}
}
#pragma warning disable SA1402 // File may only contain a single type
internal sealed partial class NowDockBand : ListItem
{
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(60);
private CopyTextCommand _copyTimeCommand;
private CopyTextCommand _copyDateCommand;
public NowDockBand()
{
// Open Notification Center on click
Command = new OpenUrlCommand("ms-actioncenter:") { Id = "com.microsoft.cmdpal.timedate.dockBand", Name = Resources.timedate_show_notification_center_command_name, Result = CommandResult.Dismiss(), Icon = null };
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
MoreCommands = [
new CommandContextItem(_copyTimeCommand),
new CommandContextItem(_copyDateCommand)
];
UpdateText();
// Create a timer to update the time every minute
System.Timers.Timer timer = new(UpdateInterval.TotalMilliseconds);
// but we want it to tick on the minute, so calculate the initial delay
var now = DateTime.Now;
timer.Interval = UpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
// then after the first tick, set it to 60 seconds
timer.Elapsed += Timer_ElapsedFirst;
timer.Start();
}
private void Timer_ElapsedFirst(object sender, System.Timers.ElapsedEventArgs e)
{
// After the first tick, set the interval to 60 seconds
if (sender is System.Timers.Timer timer)
{
timer.Interval = UpdateInterval.TotalMilliseconds;
timer.Elapsed -= Timer_ElapsedFirst;
timer.Elapsed += Timer_Elapsed;
// Still call the callback, so that we update the clock
Timer_Elapsed(sender, e);
}
}
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
{
UpdateText();
}
private void UpdateText()
{
var timeExtended = false; // timeLongFormat ?? settings.TimeWithSecond;
var dateExtended = false; // dateLongFormat ?? settings.DateWithWeekday;
var dateTimeNow = DateTime.Now;
var timeString = dateTimeNow.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, timeExtended, dateExtended),
CultureInfo.CurrentCulture);
var dateString = dateTimeNow.ToString(
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, timeExtended, dateExtended),
CultureInfo.CurrentCulture);
Title = timeString;
Subtitle = dateString;
_copyDateCommand.Text = dateString;
_copyTimeCommand.Text = timeString;
}
}
internal sealed partial class NotificationCenterDockBand : ListItem
{
public NotificationCenterDockBand()

View File

@@ -1,48 +0,0 @@
// 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;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace PowerDisplay.UnitTests;
[TestClass]
public class LightSwitchProfileIdTests
{
[TestMethod]
public void LightSwitchProperties_ProfileIds_RoundTripThroughJson_DefaultZero()
{
var props = new LightSwitchProperties();
Assert.AreEqual(0, props.LightModeProfileId.Value);
Assert.AreEqual(0, props.DarkModeProfileId.Value);
props.LightModeProfileId.Value = 3;
props.DarkModeProfileId.Value = 5;
var json = JsonSerializer.Serialize(props);
var back = JsonSerializer.Deserialize<LightSwitchProperties>(json);
Assert.IsNotNull(back);
Assert.AreEqual(3, back!.LightModeProfileId.Value);
Assert.AreEqual(5, back.DarkModeProfileId.Value);
}
[TestMethod]
public void LightSwitchSettings_Clone_PreservesProfileIds()
{
var settings = new LightSwitchSettings();
settings.Properties.DarkModeProfileId.Value = 7;
settings.Properties.LightModeProfileId.Value = 3;
settings.Properties.DarkModeProfile.Value = "Night";
settings.Properties.LightModeProfile.Value = "Day";
var clone = (LightSwitchSettings)settings.Clone();
Assert.AreEqual(7, clone.Properties.DarkModeProfileId.Value);
Assert.AreEqual(3, clone.Properties.LightModeProfileId.Value);
Assert.AreEqual("Night", clone.Properties.DarkModeProfile.Value);
Assert.AreEqual("Day", clone.Properties.LightModeProfile.Value);
}
}

View File

@@ -1,134 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerDisplay.Models;
namespace PowerDisplay.UnitTests;
[TestClass]
public class PowerDisplayProfilesTests
{
private static PowerDisplayProfile MakeProfile(string name, int id = 0)
{
var p = new PowerDisplayProfile(name, new List<ProfileMonitorSetting>
{
new ProfileMonitorSetting("MON1", 50, null, null, null),
});
p.Id = id;
return p;
}
[TestMethod]
public void IdAndNextId_RoundTripThroughJson_AndDefaultToZero()
{
var profiles = new PowerDisplayProfiles();
var p = MakeProfile("Gaming", id: 7);
profiles.Profiles.Add(p);
profiles.NextId = 8;
var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles);
var back = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles);
Assert.IsNotNull(back);
Assert.AreEqual(8, back!.NextId);
Assert.AreEqual(7, back.Profiles[0].Id);
Assert.AreEqual(0, new PowerDisplayProfile().Id);
Assert.AreEqual(0, new PowerDisplayProfiles().NextId);
}
[TestMethod]
public void GetById_ReturnsMatch_OrNullForZeroAndMissing()
{
var profiles = new PowerDisplayProfiles();
var a = MakeProfile("A", id: 1);
var b = MakeProfile("B", id: 2);
profiles.Profiles.Add(a);
profiles.Profiles.Add(b);
Assert.AreSame(b, profiles.GetById(2));
Assert.IsNull(profiles.GetById(0));
Assert.IsNull(profiles.GetById(99));
}
[TestMethod]
public void RemoveProfileById_RemovesWhenPresent()
{
var profiles = new PowerDisplayProfiles();
profiles.Profiles.Add(MakeProfile("A", id: 1));
profiles.Profiles.Add(MakeProfile("B", id: 2));
Assert.IsTrue(profiles.RemoveProfile(2));
Assert.AreEqual(1, profiles.Profiles.Count);
Assert.IsFalse(profiles.RemoveProfile(2));
}
[TestMethod]
public void SetProfile_AssignsIncreasingIds_AndAllowsDuplicateNames()
{
var profiles = new PowerDisplayProfiles { NextId = 1 };
var a = MakeProfile("Same");
var b = MakeProfile("Same");
profiles.SetProfile(a);
profiles.SetProfile(b);
Assert.AreEqual(1, a.Id);
Assert.AreEqual(2, b.Id);
Assert.AreEqual(3, profiles.NextId);
Assert.AreEqual(2, profiles.Profiles.Count); // both kept: duplicate names allowed
}
[TestMethod]
public void SetProfile_WithExplicitId_ReplacesSameIdAndHealsNextId()
{
var profiles = new PowerDisplayProfiles { NextId = 1 };
var original = MakeProfile("A", id: 5);
profiles.SetProfile(original); // explicit id 5
var replacement = MakeProfile("A-edited", id: 5);
profiles.SetProfile(replacement);
Assert.AreEqual(1, profiles.Profiles.Count);
Assert.AreSame(replacement, profiles.GetById(5));
Assert.IsTrue(profiles.NextId > 5); // healed past the explicit id
}
[TestMethod]
public void EnsureIds_BackfillsInOrder_SetsNextId_AndIsIdempotent()
{
var profiles = new PowerDisplayProfiles(); // NextId defaults to 0 (legacy file)
profiles.Profiles.Add(MakeProfile("A")); // Id 0
profiles.Profiles.Add(MakeProfile("B")); // Id 0
Assert.IsTrue(profiles.EnsureIds());
Assert.AreEqual(1, profiles.Profiles[0].Id);
Assert.AreEqual(2, profiles.Profiles[1].Id);
Assert.AreEqual(3, profiles.NextId);
Assert.IsFalse(profiles.EnsureIds()); // second run: no change
}
[TestMethod]
public void EnsureIds_SelfHealsNextId_AndPreservesExistingIds()
{
var profiles = new PowerDisplayProfiles { NextId = 2 }; // corrupt: <= existing max
profiles.Profiles.Add(MakeProfile("A", id: 5));
profiles.Profiles.Add(MakeProfile("B")); // Id 0
Assert.IsTrue(profiles.EnsureIds());
Assert.AreEqual(5, profiles.Profiles[0].Id); // preserved
Assert.AreEqual(6, profiles.Profiles[1].Id); // assigned above max
Assert.AreEqual(7, profiles.NextId);
}
[TestMethod]
public void DisplayName_CombinesNameAndId()
{
var p = MakeProfile("Gaming", id: 4);
Assert.AreEqual("Gaming (#4)", p.DisplayName);
}
}

View File

@@ -16,9 +16,6 @@ namespace PowerDisplay.Models
[JsonPropertyName("name")]
public string Name { get; set; }
[JsonPropertyName("id")]
public int Id { get; set; }
[JsonPropertyName("monitorSettings")]
public List<ProfileMonitorSetting> MonitorSettings { get; set; }
@@ -59,12 +56,5 @@ namespace PowerDisplay.Models
{
LastModified = DateTime.UtcNow;
}
/// <summary>
/// Gets a human-readable label that disambiguates duplicate names, e.g. "Gaming (#4)".
/// Not serialized; UI display only.
/// </summary>
[JsonIgnore]
public string DisplayName => $"{Name} (#{Id})";
}
}

View File

@@ -19,9 +19,6 @@ namespace PowerDisplay.Models
[JsonPropertyName("profiles")]
public List<PowerDisplayProfile> Profiles { get; set; }
[JsonPropertyName("nextId")]
public int NextId { get; set; }
[JsonPropertyName("lastUpdated")]
public DateTime LastUpdated { get; set; }
@@ -40,17 +37,7 @@ namespace PowerDisplay.Models
}
/// <summary>
/// Gets the profile by its stable id, or null when id is not positive or no profile has it.
/// </summary>
public PowerDisplayProfile? GetById(int id)
{
return id <= 0 ? null : Profiles.FirstOrDefault(p => p.Id == id);
}
/// <summary>
/// Adds or updates a profile, keyed by its stable id. When the incoming profile has no id
/// (Id == 0) a new one is assigned from the monotonic NextId counter. Names are not required
/// to be unique.
/// Adds or updates a profile
/// </summary>
public void SetProfile(PowerDisplayProfile profile)
{
@@ -59,27 +46,10 @@ namespace PowerDisplay.Models
throw new ArgumentException("Profile is invalid");
}
if (profile.Id == 0)
var existing = GetProfile(profile.Name);
if (existing != null)
{
if (NextId < 1)
{
NextId = 1;
}
profile.Id = NextId++;
}
else
{
var existing = GetById(profile.Id);
if (existing != null)
{
Profiles.Remove(existing);
}
if (NextId <= profile.Id)
{
NextId = profile.Id + 1;
}
Profiles.Remove(existing);
}
profile.Touch();
@@ -104,66 +74,23 @@ namespace PowerDisplay.Models
}
/// <summary>
/// Removes a profile by its stable id.
/// Checks if a profile name is valid and available
/// </summary>
public bool RemoveProfile(int id)
public bool IsNameAvailable(string name, string? excludeName = null)
{
var profile = GetById(id);
if (profile != null)
if (string.IsNullOrWhiteSpace(name))
{
Profiles.Remove(profile);
LastUpdated = DateTime.UtcNow;
return true;
return false;
}
return false;
}
/// <summary>
/// One-shot upgrade: assigns a stable id to every profile still missing one (Id == 0), in
/// list order, and advances NextId past the highest id in use (self-healing a corrupt or
/// legacy counter). Returns true when anything changed. Idempotent on subsequent calls.
/// </summary>
public bool EnsureIds()
{
var changed = false;
var maxId = 0;
foreach (var p in Profiles)
// Check if name is already used (excluding the profile being renamed)
var existing = GetProfile(name);
if (existing != null && (excludeName == null || !existing.Name.Equals(excludeName, StringComparison.OrdinalIgnoreCase)))
{
if (p is not null && p.Id > maxId)
{
maxId = p.Id;
}
return false;
}
var next = NextId;
if (next < 1)
{
next = 1;
}
if (next <= maxId)
{
next = maxId + 1;
}
foreach (var p in Profiles)
{
if (p is not null && p.Id == 0)
{
p.Id = next++;
changed = true;
}
}
if (NextId != next)
{
NextId = next;
changed = true;
}
return changed;
return true;
}
}
}

View File

@@ -121,24 +121,6 @@ namespace PowerDisplay.Models
}
}
/// <summary>
/// Removes a profile by its stable id and persists to disk atomically.
/// </summary>
public static bool RemoveProfileById(int id)
{
lock (_lock)
{
var profiles = LoadProfilesCore();
bool removed = profiles.RemoveProfile(id);
if (removed)
{
SaveProfilesCore(profiles);
}
return removed;
}
}
/// <summary>
/// Gets a profile by name.
/// </summary>

View File

@@ -377,12 +377,12 @@ namespace PowerDisplay
}
else if (messageType == Constants.PowerDisplayApplyProfileMessage())
{
// Apply profile by id
if (messageParts.Length > 1 && _mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null
&& int.TryParse(messageParts[1].Trim(), out var profileId))
// Apply profile by name
if (messageParts.Length > 1 && _mainWindow is MainWindow mainWindow && mainWindow.ViewModel != null)
{
Logger.LogInfo($"[NamedPipe] Applying profile id: {profileId}");
await mainWindow.ViewModel.ApplyProfileByIdAsync(profileId);
var profileName = messageParts[1].Trim();
Logger.LogInfo($"[NamedPipe] Applying profile: {profileName}");
await mainWindow.ViewModel.ApplyProfileByNameAsync(profileName);
}
}
else if (messageType == Constants.PowerDisplayTerminateAppMessage())

View File

@@ -1,94 +0,0 @@
// 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.PowerToys.Settings.UI.Library;
using PowerDisplay.Models;
namespace PowerDisplay.Services
{
/// <summary>
/// Pure resolution + migration helpers for the LightSwitch theme->profile mapping. The mapping
/// stores both a canonical profile id and a display/fallback name; resolution prefers the id.
/// </summary>
public static class LightSwitchProfileResolver
{
/// <summary>None sentinel that older/manual settings may hold in the name field.</summary>
public const string NoneSentinel = "(None)";
/// <summary>
/// Returns the effective profile id for a stored (id, name) pair, or null for "none":
/// a positive id that still exists wins; otherwise a non-empty, non-"(None)" name resolves
/// by first match; otherwise null. A stale/deleted id does NOT fall back to the name.
/// </summary>
public static int? Resolve(int storedId, string? storedName, PowerDisplayProfiles profiles)
{
if (profiles is null)
{
return null;
}
if (storedId >= 1)
{
return profiles.GetById(storedId) is not null ? storedId : (int?)null;
}
if (!string.IsNullOrEmpty(storedName) && storedName != NoneSentinel)
{
var byName = profiles.GetProfile(storedName);
if (byName is not null && byName.Id >= 1)
{
return byName.Id;
}
}
return null;
}
/// <summary>
/// One-shot upgrade of a LightSwitch mapping from name-only to id+name. For each of light and
/// dark: when the id is unset (0) and the stored name resolves to a profile, store its id and
/// re-sync the name; when the name does not resolve, clear to "none" (id 0, empty name).
/// Returns true when anything changed. Idempotent.
/// </summary>
public static bool MigrateNamesToIds(LightSwitchProperties props, PowerDisplayProfiles profiles)
{
if (props is null || profiles is null)
{
return false;
}
var changed = false;
changed |= MigrateOne(profiles, props.LightModeProfileId, props.LightModeProfile);
changed |= MigrateOne(profiles, props.DarkModeProfileId, props.DarkModeProfile);
return changed;
}
private static bool MigrateOne(PowerDisplayProfiles profiles, IntProperty idProp, StringProperty nameProp)
{
if (idProp.Value >= 1)
{
return false; // already migrated
}
var name = nameProp.Value;
if (string.IsNullOrEmpty(name) || name == NoneSentinel)
{
return false; // already "none"
}
var match = profiles.GetProfile(name);
if (match is not null && match.Id >= 1)
{
idProp.Value = match.Id;
nameProp.Value = match.Name;
}
else
{
nameProp.Value = string.Empty; // unknown/deleted -> none
}
return true;
}
}
}

View File

@@ -5,7 +5,6 @@
using System;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using PowerDisplay.Common.Services;
using Settings.UI.Library;
namespace PowerDisplay.Services
@@ -19,11 +18,11 @@ namespace PowerDisplay.Services
private const string LogPrefix = "[LightSwitch]";
/// <summary>
/// Get the profile id to apply for the given theme.
/// Get the profile name to apply for the given theme.
/// </summary>
/// <param name="isLightMode">Whether the theme changed to light mode.</param>
/// <returns>The profile id to apply, or null if no profile is configured.</returns>
public static int? GetProfileForTheme(bool isLightMode)
/// <returns>The profile name to apply, or null if no profile is configured.</returns>
public static string? GetProfileForTheme(bool isLightMode)
{
try
{
@@ -37,37 +36,36 @@ namespace PowerDisplay.Services
return null;
}
bool enabled;
int storedId;
string? storedName;
string? profileName;
if (isLightMode)
{
enabled = settings.Properties.EnableLightModeProfile.Value;
storedId = settings.Properties.LightModeProfileId.Value;
storedName = settings.Properties.LightModeProfile.Value;
if (!settings.Properties.EnableLightModeProfile.Value)
{
Logger.LogInfo($"{LogPrefix} Light mode profile is disabled");
return null;
}
profileName = settings.Properties.LightModeProfile.Value;
}
else
{
enabled = settings.Properties.EnableDarkModeProfile.Value;
storedId = settings.Properties.DarkModeProfileId.Value;
storedName = settings.Properties.DarkModeProfile.Value;
if (!settings.Properties.EnableDarkModeProfile.Value)
{
Logger.LogInfo($"{LogPrefix} Dark mode profile is disabled");
return null;
}
profileName = settings.Properties.DarkModeProfile.Value;
}
if (!enabled)
if (string.IsNullOrEmpty(profileName) || profileName == "(None)")
{
Logger.LogInfo($"{LogPrefix} {(isLightMode ? "Light" : "Dark")} mode profile is disabled");
Logger.LogInfo($"{LogPrefix} No profile configured for {(isLightMode ? "light" : "dark")} mode");
return null;
}
var id = LightSwitchProfileResolver.Resolve(storedId, storedName, ProfileService.LoadProfiles());
if (id is null)
{
Logger.LogInfo($"{LogPrefix} No profile resolved for {(isLightMode ? "light" : "dark")} mode");
return null;
}
Logger.LogInfo($"{LogPrefix} Profile id to apply: {id.Value}");
return id;
Logger.LogInfo($"{LogPrefix} Profile to apply: {profileName}");
return profileName;
}
catch (Exception ex)
{

View File

@@ -154,31 +154,33 @@ public partial class MainViewModel
}
/// <summary>
/// Apply profile by id (called via Named Pipe from Settings UI). Preserves GUI behavior;
/// only the lookup key changed from name to the stable id.
/// Apply profile by name (called via Named Pipe from Settings UI)
/// This is the new direct method that receives the profile name via IPC.
/// </summary>
/// <param name="profileId">The stable id of the profile to apply.</param>
public async Task ApplyProfileByIdAsync(int profileId)
/// <param name="profileName">The name of the profile to apply.</param>
public async Task ApplyProfileByNameAsync(string profileName)
{
try
{
Logger.LogInfo($"[Profile] Applying profile by id: {profileId}");
Logger.LogInfo($"[Profile] Applying profile by name: {profileName}");
// Load profiles and find the requested one
var profilesData = ProfileService.LoadProfiles();
var profile = profilesData.GetById(profileId);
var profile = profilesData.GetProfile(profileName);
if (profile == null || !profile.IsValid())
{
Logger.LogWarning($"[Profile] Profile id {profileId} not found or invalid");
Logger.LogWarning($"[Profile] Profile '{profileName}' not found or invalid");
return;
}
// Apply the profile settings to monitors
await ApplyProfileAsync(profile.MonitorSettings);
Logger.LogInfo($"[Profile] Successfully applied profile id: {profileId}");
Logger.LogInfo($"[Profile] Successfully applied profile: {profileName}");
}
catch (Exception ex)
{
Logger.LogError($"[Profile] Failed to apply profile id {profileId}: {ex.Message}");
Logger.LogError($"[Profile] Failed to apply profile '{profileName}': {ex.Message}");
}
}
@@ -189,9 +191,9 @@ public partial class MainViewModel
/// <param name="isLightMode">Whether the theme changed to light mode.</param>
public void ApplyLightSwitchProfile(bool isLightMode)
{
var profileId = LightSwitchService.GetProfileForTheme(isLightMode);
var profileName = LightSwitchService.GetProfileForTheme(isLightMode);
if (profileId is null)
if (string.IsNullOrEmpty(profileName))
{
return;
}
@@ -200,15 +202,15 @@ public partial class MainViewModel
{
try
{
Logger.LogInfo($"[LightSwitch Integration] Applying profile id: {profileId.Value}");
Logger.LogInfo($"[LightSwitch Integration] Applying profile: {profileName}");
// Load and apply the profile
var profilesData = ProfileService.LoadProfiles();
var profile = profilesData.GetById(profileId.Value);
var profile = profilesData.GetProfile(profileName);
if (profile == null || !profile.IsValid())
{
Logger.LogWarning($"[LightSwitch Integration] Profile id {profileId.Value} not found or invalid");
Logger.LogWarning($"[LightSwitch Integration] Profile '{profileName}' not found or invalid");
return;
}
@@ -258,12 +260,6 @@ public partial class MainViewModel
/// <summary>
/// Apply profile settings to monitors. Profiles are per-monitor snapshots, so applying one
/// turns off linked brightness before writing individual monitor values.
/// <para>
/// This method is the GUI code path. It is preserved exactly as-is: settings are dispatched
/// in parallel via <c>Task.WhenAll</c> and no per-setting outcome is captured. All existing
/// callers (<see cref="ApplyProfileByIdAsync"/>, <see cref="ApplyProfileAndCompleteAsync"/>)
/// continue to call this overload.
/// </para>
/// </summary>
private async Task ApplyProfileAsync(List<ProfileMonitorSetting> monitorSettings)
{
@@ -580,17 +576,6 @@ public partial class MainViewModel
/// </summary>
private void MigrateLegacyMonitorIdsInSideFiles()
{
// Assign stable ids to any pre-id profiles. Independent of monitor discovery, so it runs
// before the "no monitors" early-return below.
try
{
BackfillProfileIds();
}
catch (Exception ex)
{
Logger.LogError($"[LegacyMigration] Failed to back-fill profile ids: {ex.Message}");
}
var discovered = Monitors
.Where(m => !string.IsNullOrEmpty(m.Id))
.Select(m => (m.Id, m.MonitorNumber))
@@ -671,36 +656,6 @@ public partial class MainViewModel
}
}
private static void BackfillProfileIds()
{
var profiles = ProfileService.LoadProfiles();
if (profiles is null)
{
return;
}
if (profiles.EnsureIds())
{
ProfileService.SaveProfiles(profiles);
Logger.LogInfo("[LegacyMigration] profiles.json updated with stable profile ids.");
}
try
{
var lightSwitch = SettingsUtils.Default.GetSettingsOrDefault<LightSwitchSettings>(LightSwitchSettings.ModuleName);
if (lightSwitch?.Properties != null
&& LightSwitchProfileResolver.MigrateNamesToIds(lightSwitch.Properties, profiles))
{
SettingsUtils.Default.SaveSettings(lightSwitch.ToJsonString(), LightSwitchSettings.ModuleName);
Logger.LogInfo("[LegacyMigration] LightSwitch profile references migrated to ids.");
}
}
catch (Exception ex)
{
Logger.LogError($"[LegacyMigration] Failed to migrate LightSwitch profile references: {ex.Message}");
}
}
/// <summary>
/// Signal Settings UI that the monitor list has been refreshed
/// </summary>

View File

@@ -21,8 +21,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
public const bool DefaultEnableLightModeProfile = false;
public const string DefaultDarkModeProfile = "";
public const string DefaultLightModeProfile = "";
public const int DefaultDarkModeProfileId = 0;
public const int DefaultLightModeProfileId = 0;
public static readonly HotkeySettings DefaultToggleThemeHotkey = new HotkeySettings(true, true, false, true, 0x44); // Ctrl+Win+Shift+D
public LightSwitchProperties()
@@ -41,8 +39,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
EnableLightModeProfile = new BoolProperty(DefaultEnableLightModeProfile);
DarkModeProfile = new StringProperty(DefaultDarkModeProfile);
LightModeProfile = new StringProperty(DefaultLightModeProfile);
DarkModeProfileId = new IntProperty(DefaultDarkModeProfileId);
LightModeProfileId = new IntProperty(DefaultLightModeProfileId);
}
[JsonPropertyName("changeSystem")]
@@ -86,11 +82,5 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("lightModeProfile")]
public StringProperty LightModeProfile { get; set; }
[JsonPropertyName("darkModeProfileId")]
public IntProperty DarkModeProfileId { get; set; }
[JsonPropertyName("lightModeProfileId")]
public IntProperty LightModeProfileId { get; set; }
}
}

View File

@@ -64,8 +64,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
EnableLightModeProfile = new BoolProperty(Properties.EnableLightModeProfile.Value),
DarkModeProfile = new StringProperty(Properties.DarkModeProfile.Value),
LightModeProfile = new StringProperty(Properties.LightModeProfile.Value),
DarkModeProfileId = new IntProperty(Properties.DarkModeProfileId.Value),
LightModeProfileId = new IntProperty(Properties.LightModeProfileId.Value),
},
};
}

View File

@@ -245,7 +245,7 @@
</tkcontrols:SettingsCard.Header>
<ComboBox
MinWidth="200"
DisplayMemberPath="DisplayName"
DisplayMemberPath="Name"
IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableProfiles, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedDarkModeProfile, Mode=TwoWay}" />
@@ -259,7 +259,7 @@
</tkcontrols:SettingsCard.Header>
<ComboBox
MinWidth="200"
DisplayMemberPath="DisplayName"
DisplayMemberPath="Name"
IsEnabled="{x:Bind ViewModel.IsPowerDisplayEnabled, Mode=OneWay}"
ItemsSource="{x:Bind ViewModel.AvailableProfiles, Mode=OneWay}"
SelectedItem="{x:Bind ViewModel.SelectedLightModeProfile, Mode=TwoWay}" />

View File

@@ -160,7 +160,7 @@
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}">
<tkcontrols:SettingsExpander.ItemTemplate>
<DataTemplate x:DataType="pdmodels:PowerDisplayProfile">
<tkcontrols:SettingsCard Header="{x:Bind DisplayName}">
<tkcontrols:SettingsCard Header="{x:Bind Name}">
<StackPanel Orientation="Horizontal" Spacing="{StaticResource PowerDisplayActionButtonSpacing}">
<Button
x:Uid="PowerDisplay_Profile_ApplyButton"

View File

@@ -112,7 +112,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
if (result == ContentDialogResult.Primary && dialog.ResultProfile != null)
{
ViewModel.UpdateProfile(dialog.ResultProfile);
ViewModel.UpdateProfile(profile.Name, dialog.ResultProfile);
}
}
}
@@ -137,7 +137,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
if (result == ContentDialogResult.Primary)
{
ViewModel.DeleteProfile(profile.Id);
ViewModel.DeleteProfile(profile.Name);
}
}
}

View File

@@ -62,9 +62,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
// Set profile name
ViewModel.ProfileName = profile.Name;
// Carry the edited profile's stable id so saving replaces it in place (a rename keeps the id).
ViewModel.EditingId = profile.Id;
// Pre-fill monitor settings from existing profile
foreach (var monitorSetting in profile.MonitorSettings)
{

View File

@@ -650,14 +650,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
_selectedDarkModeProfile = value;
// Sync both the canonical id and the display/fallback name into settings.
var newId = value?.Id ?? 0;
var newName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.DarkModeProfileId.Value != newId
|| ModuleSettings.Properties.DarkModeProfile.Value != newName)
// Sync with the string property stored in settings
var newProfileName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.DarkModeProfile.Value != newProfileName)
{
ModuleSettings.Properties.DarkModeProfileId.Value = newId;
ModuleSettings.Properties.DarkModeProfile.Value = newName;
ModuleSettings.Properties.DarkModeProfile.Value = newProfileName;
SaveSettings();
}
@@ -675,14 +672,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
_selectedLightModeProfile = value;
// Sync both the canonical id and the display/fallback name into settings.
var newId = value?.Id ?? 0;
var newName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.LightModeProfileId.Value != newId
|| ModuleSettings.Properties.LightModeProfile.Value != newName)
// Sync with the string property stored in settings
var newProfileName = value?.Name ?? string.Empty;
if (ModuleSettings.Properties.LightModeProfile.Value != newProfileName)
{
ModuleSettings.Properties.LightModeProfileId.Value = newId;
ModuleSettings.Properties.LightModeProfile.Value = newName;
ModuleSettings.Properties.LightModeProfile.Value = newProfileName;
SaveSettings();
}
@@ -702,7 +696,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleSettings.Properties.DarkModeProfile.Value = value;
// Sync with the object property
SelectByStoredReference(isDarkMode: true);
UpdateSelectedProfileFromName(value, isDarkMode: true);
NotifyPropertyChanged();
}
@@ -719,7 +713,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleSettings.Properties.LightModeProfile.Value = value;
// Sync with the object property
SelectByStoredReference(isDarkMode: false);
UpdateSelectedProfileFromName(value, isDarkMode: false);
NotifyPropertyChanged();
}
@@ -741,9 +735,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
Logger.LogInfo($"Loaded {profilesData.Profiles.Count} PowerDisplay profiles");
// Sync selected profiles from settings (id-first, name fallback).
SelectByStoredReference(isDarkMode: true);
SelectByStoredReference(isDarkMode: false);
// Sync selected profiles from settings
UpdateSelectedProfileFromName(ModuleSettings.Properties.DarkModeProfile.Value, isDarkMode: true);
UpdateSelectedProfileFromName(ModuleSettings.Properties.LightModeProfile.Value, isDarkMode: false);
}
catch (Exception ex)
{
@@ -753,43 +747,49 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
/// <summary>
/// Selects the profile object for the given theme from settings, preferring the stable id and
/// falling back to the stored name only when no id is set. A stored id that no longer matches
/// any profile clears the selection (it does NOT fall back to the name), mirroring the
/// app-side <c>LightSwitchProfileResolver.Resolve</c>.
/// Helper method to sync the selected profile object from the profile name stored in settings.
/// If the configured profile no longer exists, clears the selection and updates settings.
/// </summary>
private void SelectByStoredReference(bool isDarkMode)
private void UpdateSelectedProfileFromName(string profileName, bool isDarkMode)
{
var storedId = isDarkMode
? ModuleSettings.Properties.DarkModeProfileId.Value
: ModuleSettings.Properties.LightModeProfileId.Value;
var storedName = isDarkMode
? ModuleSettings.Properties.DarkModeProfile.Value
: ModuleSettings.Properties.LightModeProfile.Value;
PowerDisplayProfile? matchingProfile = null;
PowerDisplayProfile? match = null;
if (storedId >= 1)
if (!string.IsNullOrEmpty(profileName))
{
match = AvailableProfiles.FirstOrDefault(p => p.Id == storedId);
}
else if (!string.IsNullOrEmpty(storedName) && storedName != "(None)")
{
match = AvailableProfiles.FirstOrDefault(p => p.Name.Equals(storedName, StringComparison.OrdinalIgnoreCase));
matchingProfile = AvailableProfiles.FirstOrDefault(p =>
p.Name.Equals(profileName, StringComparison.OrdinalIgnoreCase));
// If the configured profile no longer exists, clear it from settings
if (matchingProfile == null)
{
Logger.LogWarning($"Configured {(isDarkMode ? "dark" : "light")} mode profile '{profileName}' no longer exists, clearing selection");
if (isDarkMode)
{
ModuleSettings.Properties.DarkModeProfile.Value = string.Empty;
}
else
{
ModuleSettings.Properties.LightModeProfile.Value = string.Empty;
}
SaveSettings();
}
}
if (isDarkMode)
{
if (_selectedDarkModeProfile != match)
if (_selectedDarkModeProfile != matchingProfile)
{
_selectedDarkModeProfile = match;
_selectedDarkModeProfile = matchingProfile;
NotifyPropertyChanged(nameof(SelectedDarkModeProfile));
}
}
else
{
if (_selectedLightModeProfile != match)
if (_selectedLightModeProfile != matchingProfile)
{
_selectedLightModeProfile = match;
_selectedLightModeProfile = matchingProfile;
NotifyPropertyChanged(nameof(SelectedLightModeProfile));
}
}

View File

@@ -803,10 +803,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return;
}
Logger.LogInfo($"Applying profile: {profile.DisplayName}");
Logger.LogInfo($"Applying profile: {profile.Name}");
// Send custom action to trigger profile application
// The profile id is passed via Named Pipe IPC to PowerDisplay.exe
// The profile name is passed via Named Pipe IPC to PowerDisplay.exe
var actionMessage = new PowerDisplayActionMessage
{
Action = new PowerDisplayActionMessage.ActionData
@@ -814,14 +814,14 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
PowerDisplay = new PowerDisplayActionMessage.PowerDisplayAction
{
ActionName = "ApplyProfile",
Value = profile.Id.ToString(System.Globalization.CultureInfo.InvariantCulture),
Value = profile.Name,
},
},
};
SendConfigMSG(JsonSerializer.Serialize(actionMessage, SettingsSerializationContext.Default.PowerDisplayActionMessage));
Logger.LogInfo($"Profile '{profile.DisplayName}' apply request sent via IPC");
Logger.LogInfo($"Profile '{profile.Name}' apply request sent via IPC");
}
catch (Exception ex)
{
@@ -861,22 +861,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
/// <summary>
/// Update an existing profile. The profile's stable id is the key, so this replaces the
/// matching entry in place (a rename keeps the same id).
/// Update an existing profile
/// </summary>
public void UpdateProfile(PowerDisplayProfile updatedProfile)
public void UpdateProfile(string oldName, PowerDisplayProfile newProfile)
{
try
{
if (updatedProfile == null || !updatedProfile.IsValid())
if (newProfile == null || !newProfile.IsValid())
{
Logger.LogWarning("Invalid profile");
return;
}
Logger.LogInfo($"Updating profile: {updatedProfile.DisplayName}");
Logger.LogInfo($"Updating profile: {oldName} -> {newProfile.Name}");
ProfileHelper.AddOrUpdateProfile(updatedProfile);
ProfileHelper.RenameAndUpdateProfile(oldName, newProfile);
// Reload profile list
LoadProfiles();
@@ -884,7 +883,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile '{updatedProfile.DisplayName}' updated successfully");
Logger.LogInfo($"Profile updated to '{newProfile.Name}' successfully");
}
catch (Exception ex)
{
@@ -893,20 +892,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
/// <summary>
/// Delete a profile by its stable id.
/// Delete a profile
/// </summary>
public void DeleteProfile(int id)
public void DeleteProfile(string profileName)
{
try
{
if (id < 1)
if (string.IsNullOrEmpty(profileName))
{
return;
}
Logger.LogInfo($"Deleting profile id: {id}");
Logger.LogInfo($"Deleting profile: {profileName}");
ProfileHelper.RemoveProfileById(id);
ProfileHelper.RemoveProfile(profileName);
// Reload profile list
LoadProfiles();
@@ -914,7 +913,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// Signal PowerDisplay to reload profiles
SignalSettingsUpdated();
Logger.LogInfo($"Profile id {id} deleted successfully");
Logger.LogInfo($"Profile '{profileName}' deleted successfully");
}
catch (Exception ex)
{

View File

@@ -70,13 +70,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
/// <summary>
/// Gets or sets the stable id of the profile being edited. 0 for a brand-new profile
/// (the store assigns a fresh id on save); the existing id when editing, so the update
/// replaces the same profile in place.
/// </summary>
public int EditingId { get; set; }
public ObservableCollection<MonitorSelectionItem> Monitors
{
get => _monitors;
@@ -110,7 +103,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
m.IncludeVolume && m.SupportsVolume ? (int?)m.Volume : null))
.ToList();
return new PowerDisplayProfile(_profileName, settings) { Id = EditingId };
return new PowerDisplayProfile(_profileName, settings);
}
public event PropertyChangedEventHandler? PropertyChanged;