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
25 changed files with 569 additions and 157 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()