Compare commits

...

18 Commits

Author SHA1 Message Date
Mike Griese
816575f0ea THIS IS THE ONE 2026-03-21 15:03:03 -05:00
Mike Griese
bb0a43c5f1 the taskbar sice is about right 2026-03-21 14:00:42 -05:00
Mike Griese
28b09ab1e1 the right space 2026-03-21 13:26:56 -05:00
Mike Griese
f22d21632e it works when debugged? 2026-03-21 09:36:44 -05:00
Mike Griese
7f4574bb4f it's working so very slow it basically isn't 2026-03-21 06:25:13 -05:00
Mike Griese
d93307dd9b sometimes, its 0 2026-03-20 15:12:42 -05:00
Mike Griese
b8772c6192 genuinely impressed 2026-03-20 15:06:26 -05:00
Mike Griese
22a06fdf51 clanker thinks this works? 2026-03-20 14:56:27 -05:00
Mike Griese
4176025910 need a helper app for testing this 2026-03-20 14:08:03 -05:00
Mike Griese
f489c25d34 make dragdrop easier 2026-03-20 13:09:03 -05:00
Mike Griese
56e9203316 format 2026-03-19 16:10:10 -05:00
Mike Griese
674b22bcd5 transparent 2026-03-19 14:51:26 -05:00
Mike Griese
c9244ec2d1 Revert "toulouse letrec"
This reverts commit 9f47f9f9bb.
2026-03-19 14:00:02 -05:00
Mike Griese
14e443751b Revert "I genuinely don't know if this ever worked"
This reverts commit ba77ec11b7.
2026-03-19 13:59:55 -05:00
Mike Griese
ba77ec11b7 I genuinely don't know if this ever worked 2026-03-19 13:56:45 -05:00
Mike Griese
9f47f9f9bb toulouse letrec 2026-03-18 11:10:27 -05:00
Mike Griese
34d0d26e2e One edit flyout ; SUI 2026-03-12 10:18:17 -05:00
Mike Griese
9cfd0dd7f3 lawd, it did the whole thing 2026-03-12 06:36:19 -05:00
40 changed files with 2924 additions and 29 deletions

View File

@@ -365,6 +365,10 @@
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/UI/">
<Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.Utilities/Microsoft.CmdPal.UI.Utilities.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -14,6 +14,7 @@
"src\\modules\\cmdpal\\Microsoft.CmdPal.Common\\Microsoft.CmdPal.Common.csproj",
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj",
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.Utilities\\Microsoft.CmdPal.UI.Utilities.csproj",
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Common.UnitTests\\Microsoft.CmdPal.Common.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj",

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<RootNamespace>Microsoft.CmdPal.UI.Utilities</RootNamespace>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<ImplicitUsings>enable</ImplicitUsings>
<OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="NativeMethods.txt" />
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"allowMarshaling": false,
"comInterop": {
"preserveSigMethods": ["*"]
}
}

View File

@@ -0,0 +1,14 @@
FindWindow
FindWindowEx
GetWindowRect
GetDpiForWindow
CoCreateInstance
IUIAutomation
IUIAutomationElement
IUIAutomationElementArray
IUIAutomationCondition
UIA_PROPERTY_ID
TreeScope
SafeArrayGetElement
VariantClear
VARIANT

View File

@@ -0,0 +1,278 @@
// 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 Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.System.Com;
using Windows.Win32.System.Variant;
using Windows.Win32.UI.Accessibility;
namespace Microsoft.CmdPal.UI.Utilities;
/// <summary>
/// Measures the taskbar button area and tray area widths using
/// UI Automation with raw COM pointers (no runtime marshalling).
/// AOT-compatible.
/// </summary>
public sealed unsafe class TaskbarMetrics : IDisposable
{
// CUIAutomation CLSID: {FF48DBA4-60EF-4201-AA87-54103EEF594E}
private static readonly Guid CUIAutomationClsid =
new(0xFF48DBA4, 0x60EF, 0x4201, 0xAA, 0x87, 0x54, 0x10, 0x3E, 0xEF, 0x59, 0x4E);
private IUIAutomation* _automation;
private IUIAutomationCondition* _trueCondition;
private bool _disposed;
/// <summary>Width of the taskbar buttons area in physical pixels.</summary>
public int ButtonsWidthInPixels { get; private set; }
/// <summary>Width of the notification/tray area in physical pixels.</summary>
public int TrayWidthInPixels { get; private set; }
/// <summary>Number of buttons found on the taskbar.</summary>
public int ButtonCount { get; private set; }
/// <summary>
/// Re-measures the primary taskbar. Returns true if any value changed.
/// Thread-safe — can be called from any thread.
/// </summary>
public bool Update()
{
ObjectDisposedException.ThrowIf(_disposed, this);
var taskbarHwnd = PInvoke.FindWindow("Shell_TrayWnd", null);
if (taskbarHwnd.IsNull)
{
return false;
}
var newButtons = MeasureButtons(taskbarHwnd, out var newCount);
var newTray = MeasureTray(taskbarHwnd);
// Skip transient error states (e.g. right-click context menu open)
if (newCount == 0 && ButtonCount > 0)
{
return false;
}
if (newButtons == ButtonsWidthInPixels &&
newTray == TrayWidthInPixels &&
newCount == ButtonCount)
{
return false;
}
ButtonsWidthInPixels = newButtons;
TrayWidthInPixels = newTray;
ButtonCount = newCount;
return true;
}
/// <summary>
/// Re-measures the primary taskbar on a background thread.
/// Returns true if any value changed.
/// </summary>
public Task<bool> UpdateAsync() => Task.Run(Update);
private int MeasureButtons(HWND taskbarHwnd, out int buttonCount)
{
buttonCount = 0;
EnsureAutomation();
if (_automation == null)
{
return 0;
}
IUIAutomationElement* root = null;
IUIAutomationElementArray* descendants = null;
try
{
var hr = _automation->ElementFromHandle(taskbarHwnd, &root);
if (hr.Failed || root == null)
{
return 0;
}
// Determine the right boundary: buttons stop where the tray begins.
PInvoke.GetWindowRect(taskbarHwnd, out var taskbarRect);
var trayHwnd = PInvoke.FindWindowEx(taskbarHwnd, HWND.Null, "TrayNotifyWnd", null);
var rightBoundary = taskbarRect.right;
if (!trayHwnd.IsNull)
{
PInvoke.GetWindowRect(trayHwnd, out var trayRect);
rightBoundary = trayRect.left;
}
// Enumerate all UIA descendants of the taskbar.
hr = root->FindAll(TreeScope.TreeScope_Descendants, _trueCondition, &descendants);
int descCount = 0;
if (hr.Succeeded && descendants != null)
{
descendants->get_Length(&descCount);
}
if (descCount == 0)
{
return 0;
}
var maxRight = int.MinValue;
for (var i = 0; i < descCount; i++)
{
IUIAutomationElement* desc = null;
try
{
hr = descendants->GetElement(i, &desc);
if (hr.Failed || desc == null)
{
continue;
}
// Only count buttons (UIA_ButtonControlTypeId = 50000)
VARIANT varType = default;
desc->GetCurrentPropertyValue(UIA_PROPERTY_ID.UIA_ControlTypePropertyId, &varType);
var typeId = 0;
if (varType.Anonymous.Anonymous.vt == VARENUM.VT_I4)
{
typeId = varType.Anonymous.Anonymous.Anonymous.lVal;
}
PInvoke.VariantClear(&varType);
if (typeId != 50000)
{
continue;
}
VARIANT varRect = default;
hr = desc->GetCurrentPropertyValue(
UIA_PROPERTY_ID.UIA_BoundingRectanglePropertyId, &varRect);
if (hr.Succeeded &&
varRect.Anonymous.Anonymous.vt == (VARENUM.VT_R8 | VARENUM.VT_ARRAY))
{
var psa = varRect.Anonymous.Anonymous.Anonymous.parray;
if (psa != null)
{
double x = 0, w = 0;
int idx = 0;
PInvoke.SafeArrayGetElement(psa, &idx, &x);
idx = 2;
PInvoke.SafeArrayGetElement(psa, &idx, &w);
var btnLeft = (int)x;
var btnRight = (int)(x + w);
// Count buttons to the left of the tray area.
// CmdPal's own controls are UserControls, not
// UIA buttons, so the typeId==50000 filter above
// already excludes them.
if (btnLeft < rightBoundary)
{
if (btnRight > maxRight)
{
maxRight = btnRight;
}
buttonCount++;
}
}
}
PInvoke.VariantClear(&varRect);
}
finally
{
if (desc != null)
{
((IUnknown*)desc)->Release();
}
}
}
if (buttonCount == 0)
{
return 0;
}
return maxRight > taskbarRect.left ? maxRight - taskbarRect.left : 0;
}
finally
{
if (descendants != null)
{
((IUnknown*)descendants)->Release();
}
if (root != null)
{
((IUnknown*)root)->Release();
}
}
}
private static int MeasureTray(HWND taskbarHwnd)
{
var tray = PInvoke.FindWindowEx(taskbarHwnd, HWND.Null, "TrayNotifyWnd", null);
if (tray.IsNull)
{
return 0;
}
PInvoke.GetWindowRect(tray, out var rect);
return rect.Width;
}
private void EnsureAutomation()
{
if (_automation != null)
{
return;
}
IUIAutomation* automation;
var hr = PInvoke.CoCreateInstance(
CUIAutomationClsid,
(IUnknown*)null,
CLSCTX.CLSCTX_INPROC_SERVER,
out automation);
if (hr.Failed || automation == null)
{
return;
}
_automation = automation;
IUIAutomationCondition* condition;
hr = _automation->CreateTrueCondition(&condition);
if (hr.Succeeded)
{
_trueCondition = condition;
}
}
public void Dispose()
{
if (!_disposed)
{
if (_trueCondition != null)
{
((IUnknown*)_trueCondition)->Release();
_trueCondition = null;
}
if (_automation != null)
{
((IUnknown*)_automation)->Release();
_automation = null;
}
_disposed = true;
}
}
}

View File

@@ -159,6 +159,12 @@ public partial class DockBandSettingsViewModel : ObservableObject
return DockPinSide.End;
}
var inTaskbar = dockSettings.TaskbarBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inTaskbar)
{
return DockPinSide.Taskbar;
}
return DockPinSide.None;
}
@@ -194,6 +200,7 @@ public partial class DockBandSettingsViewModel : ObservableObject
dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.TaskbarBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
// Add to the selected side
switch (side)
@@ -219,6 +226,13 @@ public partial class DockBandSettingsViewModel : ObservableObject
break;
}
case DockPinSide.Taskbar:
{
var insertIndex = index ?? dockSettings.TaskbarBands.Count;
dockSettings.TaskbarBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.None:
default:
// Do nothing
@@ -241,6 +255,7 @@ public enum DockPinSide
Start,
Center,
End,
Taskbar,
}
public enum ShowLabelsOption

View File

@@ -29,6 +29,14 @@ public sealed partial class DockViewModel
public ObservableCollection<DockBandViewModel> EndItems { get; } = new();
public ObservableCollection<DockBandViewModel> TaskbarItems { get; } = new();
/// <summary>
/// Tracks the band currently being dragged. Used for cross-window drag-drop
/// coordination between DockControl and TaskbarBandControl.
/// </summary>
public DockBandViewModel? DraggedBand { get; set; }
public IReadOnlyList<TopLevelViewModel> AllItems => _topLevelCommandManager.GetDockBandsSnapshot();
public DockViewModel(
@@ -69,6 +77,7 @@ public sealed partial class DockViewModel
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
SetupBands(_settings.TaskbarBands, TaskbarItems);
}
private void SetupBands(
@@ -183,6 +192,14 @@ public sealed partial class DockViewModel
}
}
foreach (var band in TaskbarItems)
{
if (band.Id == id)
{
return band;
}
}
return null;
}
@@ -197,7 +214,8 @@ public sealed partial class DockViewModel
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.TaskbarBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
@@ -208,6 +226,7 @@ public sealed partial class DockViewModel
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.TaskbarBands.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
var targetSettings = targetSide switch
@@ -215,6 +234,7 @@ public sealed partial class DockViewModel
DockPinSide.Start => dockSettings.StartBands,
DockPinSide.Center => dockSettings.CenterBands,
DockPinSide.End => dockSettings.EndBands,
DockPinSide.Taskbar => dockSettings.TaskbarBands,
_ => dockSettings.StartBands,
};
var insertIndex = Math.Min(targetIndex, targetSettings.Count);
@@ -232,7 +252,8 @@ public sealed partial class DockViewModel
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.TaskbarBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
@@ -244,9 +265,11 @@ public sealed partial class DockViewModel
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.TaskbarBands.RemoveAll(b => b.CommandId == bandId);
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
TaskbarItems.Remove(band);
// Add to the target side at the specified index
switch (targetSide)
@@ -280,6 +303,16 @@ public sealed partial class DockViewModel
EndItems.Insert(uiIndex, band);
break;
}
case DockPinSide.Taskbar:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.TaskbarBands.Count);
dockSettings.TaskbarBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, TaskbarItems.Count);
TaskbarItems.Insert(uiIndex, band);
break;
}
}
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
@@ -292,7 +325,7 @@ public sealed partial class DockViewModel
public void SaveBandOrder()
{
// Save ShowLabels for all bands
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems).Concat(TaskbarItems))
{
band.SaveShowLabels();
}
@@ -300,6 +333,7 @@ public sealed partial class DockViewModel
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotTaskbarBands = null;
_snapshotBandViewModels = null;
SettingsModel.SaveSettings(_settingsModel);
Logger.LogDebug("Saved band order to settings");
@@ -308,6 +342,7 @@ public sealed partial class DockViewModel
private List<DockBandSettings>? _snapshotStartBands;
private List<DockBandSettings>? _snapshotCenterBands;
private List<DockBandSettings>? _snapshotEndBands;
private List<DockBandSettings>? _snapshotTaskbarBands;
private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels;
/// <summary>
@@ -320,11 +355,12 @@ public sealed partial class DockViewModel
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList();
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList();
_snapshotTaskbarBands = dockSettings.TaskbarBands.Select(b => b.Clone()).ToList();
// Snapshot band ViewModels so we can restore unpinned bands
// Use a dictionary but handle potential duplicates gracefully
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems).Concat(TaskbarItems))
{
_snapshotBandViewModels.TryAdd(band.Id, band);
}
@@ -335,7 +371,7 @@ public sealed partial class DockViewModel
band.SnapshotShowLabels();
}
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands");
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands, {_snapshotTaskbarBands.Count} taskbar bands");
}
/// <summary>
@@ -346,7 +382,8 @@ public sealed partial class DockViewModel
{
if (_snapshotStartBands == null ||
_snapshotCenterBands == null ||
_snapshotEndBands == null || _snapshotBandViewModels == null)
_snapshotEndBands == null ||
_snapshotTaskbarBands == null || _snapshotBandViewModels == null)
{
Logger.LogWarning("No snapshot to restore from");
return;
@@ -364,6 +401,7 @@ public sealed partial class DockViewModel
dockSettings.StartBands.Clear();
dockSettings.CenterBands.Clear();
dockSettings.EndBands.Clear();
dockSettings.TaskbarBands.Clear();
foreach (var bandSnapshot in _snapshotStartBands)
{
@@ -383,12 +421,19 @@ public sealed partial class DockViewModel
dockSettings.EndBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotTaskbarBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.TaskbarBands.Add(bandSettings);
}
// Rebuild UI collections from restored settings using the snapshotted ViewModels
RebuildUICollectionsFromSnapshot();
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotTaskbarBands = null;
_snapshotBandViewModels = null;
Logger.LogDebug("Restored band order from snapshot");
}
@@ -405,6 +450,7 @@ public sealed partial class DockViewModel
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
TaskbarItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
@@ -429,6 +475,14 @@ public sealed partial class DockViewModel
EndItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.TaskbarBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
TaskbarItems.Add(bandVM);
}
}
}
private void RebuildUICollections()
@@ -436,11 +490,12 @@ public sealed partial class DockViewModel
var dockSettings = _settingsModel.DockSettings;
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).Concat(TaskbarItems).ToDictionary(b => b.Id);
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
TaskbarItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
@@ -465,6 +520,14 @@ public sealed partial class DockViewModel
EndItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.TaskbarBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
TaskbarItems.Add(bandVM);
}
}
}
/// <summary>
@@ -489,6 +552,11 @@ public sealed partial class DockViewModel
pinnedBandIds.Add(band.Id);
}
foreach (var band in TaskbarItems)
{
pinnedBandIds.Add(band.Id);
}
// Return all dock bands that are not already pinned
return AllItems.Where(tlc => !pinnedBandIds.Contains(tlc.Id));
}
@@ -530,6 +598,10 @@ public sealed partial class DockViewModel
dockSettings.EndBands.Add(bandSettings);
EndItems.Add(bandVm);
break;
case DockPinSide.Taskbar:
dockSettings.TaskbarBands.Add(bandSettings);
TaskbarItems.Add(bandVm);
break;
}
// Snapshot the new band so it can be removed on discard
@@ -556,11 +628,13 @@ public sealed partial class DockViewModel
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.TaskbarBands.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
TaskbarItems.Remove(band);
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
@@ -582,6 +656,14 @@ public sealed partial class DockViewModel
return vm;
}
public CommandItemViewModel GetContextMenuForTaskbar()
{
var model = new TaskbarContextMenuItem();
var vm = new CommandItemViewModel(new(model), new(_pageContext), contextMenuFactory: null);
vm.SlowInitializeProperties();
return vm;
}
private sealed partial class DockContextMenuItem : CommandItem
{
public DockContextMenuItem()
@@ -589,7 +671,7 @@ public sealed partial class DockViewModel
var editDockCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new EnterDockEditModeMessage());
WeakReferenceMessenger.Default.Send(new EnterEditModeMessage(EditModeOrigin.Dock));
})
{
Name = Properties.Resources.dock_edit_dock_name,
@@ -614,6 +696,38 @@ public sealed partial class DockViewModel
}
}
private sealed partial class TaskbarContextMenuItem : CommandItem
{
public TaskbarContextMenuItem()
{
var editTaskbarCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new EnterEditModeMessage(EditModeOrigin.Taskbar));
})
{
Name = Properties.Resources.taskbar_edit_name,
Icon = Icons.EditIcon,
};
var openSettingsCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Dock"));
})
{
Name = Properties.Resources.dock_settings_name,
Icon = Icons.SettingsIcon,
};
MoreCommands = new CommandContextItem[]
{
new CommandContextItem(editTaskbarCommand),
new CommandContextItem(openSettingsCommand),
};
}
}
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsModel.EnableDock;
@@ -625,6 +739,7 @@ public sealed partial class DockViewModel
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
var taskbarBands = FormatBands(_settings.TaskbarBands);
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record EnterEditModeMessage(EditModeOrigin Origin);
public enum EditModeOrigin
{
Dock,
Taskbar,
}

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
// This file is intentionally left empty.
// EnterEditModeMessage and ExitEditModeMessage in EnterDockEditModeMessage.cs
// replaced the separate dock/taskbar edit mode messages.

View File

@@ -4,4 +4,4 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record EnterDockEditModeMessage();
public record ExitEditModeMessage(bool Save);

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record ShowHideTaskbarMessage(bool ShowTaskbar);

View File

@@ -492,6 +492,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Edit Taskbar.
/// </summary>
public static string taskbar_edit_name {
get {
return ResourceManager.GetString("taskbar_edit_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} items.
/// </summary>

View File

@@ -280,6 +280,10 @@
<value>Edit Dock</value>
<comment>Command name for editing the dock</comment>
</data>
<data name="taskbar_edit_name" xml:space="preserve">
<value>Edit Taskbar</value>
<comment>Command name for editing the taskbar</comment>
</data>
<data name="dock_settings_name" xml:space="preserve">
<value>Settings</value>
<comment>Command name for opening dock settings</comment>

View File

@@ -51,13 +51,16 @@ public class DockSettings
public List<DockBandSettings> EndBands { get; set; } = [];
public List<DockBandSettings> TaskbarBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(TaskbarBands.Select(b => (b.ProviderId, b.CommandId)));
public DockSettings()
{
@@ -87,6 +90,12 @@ public class DockSettings
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
TaskbarBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}
}

View File

@@ -70,6 +70,8 @@ public partial class SettingsModel : ObservableObject
public bool EnableDock { get; set; }
public bool EnableTaskbar { get; set; }
public DockSettings DockSettings { get; set; } = new();
// Theme settings

View File

@@ -239,6 +239,17 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public bool EnableTaskbar
{
get => _settings.EnableTaskbar;
set
{
_settings.EnableTaskbar = value;
Save();
WeakReferenceMessenger.Default.Send(new ShowHideTaskbarMessage(value));
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = new();
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();

View File

@@ -22,7 +22,7 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Dock;
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterEditModeMessage>, IRecipient<ExitEditModeMessage>
{
private DockViewModel _viewModel;
@@ -59,7 +59,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
if (d is DockControl control && e.NewValue is bool isEditMode)
{
control.UpdateEditMode(isEditMode);
control.UpdateEditMode(isEditMode, control._showFlyout);
}
}
@@ -68,7 +68,8 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
_viewModel = viewModel;
InitializeComponent();
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<EnterEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitEditModeMessage>(this);
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -86,16 +87,31 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
ContentGrid.IsCenterVisible = IsEditMode || ViewModel.CenterItems.Count > 0;
}
public void Receive(EnterDockEditModeMessage message)
public void Receive(EnterEditModeMessage message)
{
// Message may arrive from a background thread, dispatch to UI thread
DispatcherQueue.TryEnqueue(() =>
{
EnterEditMode();
EnterEditMode(showFlyout: message.Origin == EditModeOrigin.Dock);
});
}
private void UpdateEditMode(bool isEditMode)
public void Receive(ExitEditModeMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
if (message.Save)
{
ExitEditMode();
}
else
{
DiscardEditMode();
}
});
}
private void UpdateEditMode(bool isEditMode, bool showFlyout = true)
{
// Update center visibility based on edit mode and center items
UpdateCenterVisibility();
@@ -113,7 +129,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
EndListView.CanReorderItems = isEditMode;
EndListView.AllowDrop = isEditMode;
if (isEditMode)
if (isEditMode && showFlyout)
{
EditButtonsTeachingTip.PreferredPlacement = DockSide switch
{
@@ -125,11 +141,16 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
};
}
EditButtonsTeachingTip.IsOpen = isEditMode;
EditButtonsTeachingTip.IsOpen = isEditMode && showFlyout;
}
internal void EnterEditMode()
// Whether this control is showing the save/discard flyout
private bool _showFlyout;
internal void EnterEditMode(bool showFlyout = true)
{
_showFlyout = showFlyout;
// Snapshot current state so we can restore on discard
ViewModel.SnapshotBandOrder();
IsEditMode = true;
@@ -153,12 +174,14 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void DoneEditingButton_Click(object sender, RoutedEventArgs e)
{
ExitEditMode();
// Tell both dock and taskbar to exit edit mode
WeakReferenceMessenger.Default.Send(new ExitEditModeMessage(Save: true));
}
private void DiscardEditingButton_Click(object sender, RoutedEventArgs e)
{
DiscardEditMode();
// Tell both dock and taskbar to discard edit mode
WeakReferenceMessenger.Default.Send(new ExitEditModeMessage(Save: false));
}
internal void UpdateSettings(DockSettings settings)
@@ -339,13 +362,14 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
if (e.Items.Count > 0 && e.Items[0] is DockBandViewModel band)
{
_draggedBand = band;
ViewModel.DraggedBand = band;
e.Data.RequestedOperation = DataPackageOperation.Move;
}
}
private void BandListView_DragOver(object sender, DragEventArgs e)
{
if (_draggedBand != null)
if (_draggedBand != null || ViewModel.DraggedBand != null)
{
e.AcceptedOperation = DataPackageOperation.Move;
}
@@ -385,6 +409,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
}
_draggedBand = null;
ViewModel.DraggedBand = null;
}
private void StartListView_Drop(object sender, DragEventArgs e)
@@ -407,15 +432,19 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void HandleCrossListDrop(DockPinSide targetSide, DragEventArgs e)
{
if (_draggedBand == null)
// Use local _draggedBand for same-window drags, fall back to shared
// ViewModel.DraggedBand for cross-window drags (e.g. from taskbar)
var draggedBand = _draggedBand ?? ViewModel.DraggedBand;
if (draggedBand == null)
{
return;
}
// Check which list the band is currently in
var isInStart = ViewModel.StartItems.Contains(_draggedBand);
var isInCenter = ViewModel.CenterItems.Contains(_draggedBand);
var isInEnd = ViewModel.EndItems.Contains(_draggedBand);
var isInStart = ViewModel.StartItems.Contains(draggedBand);
var isInCenter = ViewModel.CenterItems.Contains(draggedBand);
var isInEnd = ViewModel.EndItems.Contains(draggedBand);
var isInTaskbar = ViewModel.TaskbarItems.Contains(draggedBand);
DockPinSide sourceSide;
if (isInStart)
@@ -426,10 +455,18 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
sourceSide = DockPinSide.Center;
}
else
else if (isInEnd)
{
sourceSide = DockPinSide.End;
}
else if (isInTaskbar)
{
sourceSide = DockPinSide.Taskbar;
}
else
{
return;
}
// Only handle cross-list drops here; same-list reorders are handled in DragItemsCompleted
if (sourceSide != targetSide)
@@ -451,7 +488,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
var dropIndex = GetDropIndex(targetListView, e, targetCollection.Count);
// Move the band to the new side (without saving - save happens on Done)
ViewModel.MoveBandWithoutSaving(_draggedBand, targetSide, dropIndex);
ViewModel.MoveBandWithoutSaving(draggedBand, targetSide, dropIndex);
e.Handled = true;
}
}

View File

@@ -146,6 +146,7 @@
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.WebSearch\Microsoft.CmdPal.Ext.WebSearch.csproj" />
<ProjectReference Include="..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.UI.ViewModels\Microsoft.CmdPal.UI.ViewModels.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.UI.Utilities\Microsoft.CmdPal.UI.Utilities.csproj" />
<ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />

View File

@@ -112,3 +112,10 @@ AttachThreadInput
GetWindowPlacement
WINDOWPLACEMENT
WM_DPICHANGED
FindWindow
FindWindowEx
SetParent
CreateRectRgn
SetWindowRgn
WM_DESTROY

View File

@@ -14,6 +14,8 @@ using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.Settings;
using Microsoft.CmdPal.UI.Taskbar;
using Microsoft.CmdPal.UI.Utilities;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
@@ -25,7 +27,6 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using Windows.UI.Core;
using WinUIEx;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using VirtualKey = Windows.System.VirtualKey;
@@ -50,6 +51,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
IRecipient<ShowToastMessage>,
IRecipient<NavigateToPageMessage>,
IRecipient<ShowHideDockMessage>,
IRecipient<ShowHideTaskbarMessage>,
INotifyPropertyChanged,
IDisposable
{
@@ -68,6 +70,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private SettingsWindow? _settingsWindow;
private DockWindow? _dockWindow;
private TaskbarWindow? _taskbarWindow;
private CancellationTokenSource? _focusAfterLoadedCts;
private WeakReference<Page>? _lastNavigatedPageRef;
@@ -101,6 +104,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<NavigateToPageMessage>(this);
WeakReferenceMessenger.Default.Register<ShowHideDockMessage>(this);
WeakReferenceMessenger.Default.Register<ShowHideTaskbarMessage>(this);
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
@@ -116,6 +120,34 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_dockWindow = new DockWindow();
_dockWindow.Show();
}
if (App.Current.Services.GetService<SettingsModel>()!.EnableTaskbar)
{
_ = CreateAndShowTaskbarAsync();
}
}
private async Task CreateAndShowTaskbarAsync()
{
// Capture dispatcher before crossing to a background thread.
var dispatcher = DispatcherQueue;
// Run the expensive first UIA measurement on a background thread.
var metrics = await Task.Run(() =>
{
var m = new TaskbarMetrics();
m.Update();
return m;
});
// Window creation + Show MUST happen on the UI thread.
// WinUI 3 has no SynchronizationContext, so we can't rely on
// await resuming on the UI thread. Explicitly dispatch.
dispatcher.TryEnqueue(() =>
{
_taskbarWindow = new TaskbarWindow(metrics);
_taskbarWindow.Show();
});
}
/// <summary>
@@ -505,6 +537,32 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
});
}
public void Receive(ShowHideTaskbarMessage message)
{
if (message.ShowTaskbar)
{
if (_taskbarWindow is null)
{
_ = CreateAndShowTaskbarAsync();
}
else
{
DispatcherQueue.TryEnqueue(() => _taskbarWindow.Show());
}
}
else
{
DispatcherQueue.TryEnqueue(() =>
{
if (_taskbarWindow is not null)
{
_taskbarWindow.Close();
_taskbarWindow = null;
}
});
}
}
private void ToggleFilterFocus()
{
if (!FiltersDropDown.IsFilterVisible)
@@ -794,5 +852,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_focusAfterLoadedCts = null;
_dockWindow?.Dispose();
_taskbarWindow?.Dispose();
}
}

View File

@@ -48,6 +48,11 @@
<ToggleSwitch IsOn="{x:Bind ViewModel.EnableDock, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- Enable Taskbar -->
<controls:SettingsCard x:Uid="Settings_GeneralPage_EnableTaskbar_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE737;}">
<ToggleSwitch IsOn="{x:Bind ViewModel.EnableTaskbar, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- Appearance Section -->
<TextBlock x:Uid="DockAppearanceSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

View File

@@ -434,6 +434,12 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_GeneralPage_EnableDock_SettingsCard.Description" xml:space="preserve">
<value>Enable a toolbar with quick access to commands</value>
</data>
<data name="Settings_GeneralPage_EnableTaskbar_SettingsCard.Header" xml:space="preserve">
<value>Enable Taskbar</value>
</data>
<data name="Settings_GeneralPage_EnableTaskbar_SettingsCard.Description" xml:space="preserve">
<value>Embed command bands directly in the Windows taskbar</value>
</data>
<data name="BackButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Back</value>
</data>

View File

@@ -0,0 +1,278 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Taskbar.TaskbarBandControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:dock="using:Microsoft.CmdPal.UI.Dock"
xmlns:dockVm="using:Microsoft.CmdPal.UI.ViewModels.Dock"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Microsoft.CmdPal.UI.ViewModels"
SizeChanged="OnSizeChanged"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<StackLayout
x:Key="HorizontalItemsLayout"
Orientation="Horizontal"
Spacing="4" />
<DataTemplate x:Key="TaskbarBandTemplate" x:DataType="dockVm:DockBandViewModel">
<ItemsRepeater
ItemsSource="{x:Bind Items, Mode=OneWay}"
Layout="{StaticResource HorizontalItemsLayout}"
TabFocusNavigation="Local">
<ItemsRepeater.Transitions>
<TransitionCollection />
</ItemsRepeater.Transitions>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="dockVm:DockItemViewModel">
<dock:DockItemControl
Title="{x:Bind Title, Mode=OneWay}"
RightTapped="BandItem_RightTapped"
Subtitle="{x:Bind Subtitle, Mode=OneWay}"
Tag="{x:Bind}"
Tapped="BandItem_Tapped"
ToolTip="{x:Bind Tooltip, Mode=OneWay}">
<dock:DockItemControl.Icon>
<cpcontrols:IconBox
x:Name="IconBorder"
Width="16"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested16}" />
</dock:DockItemControl.Icon>
</dock:DockItemControl>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</DataTemplate>
<Style x:Key="TaskbarBandListViewItemStyle" TargetType="ListViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="IsTabStop" Value="False" />
</Style>
<Style
x:Name="ContextMenuFlyoutStyle"
BasedOn="{StaticResource DefaultFlyoutPresenterStyle}"
TargetType="FlyoutPresenter">
<Style.Setters>
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="{ThemeResource DesktopAcrylicTransparentBrush}" />
<Setter Property="BorderBrush" Value="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Style.Setters>
</Style>
<Flyout
x:Name="ContextMenuFlyout"
FlyoutPresenterStyle="{StaticResource ContextMenuFlyoutStyle}"
Opened="ContextMenuFlyout_Opened"
ShouldConstrainToRootBounds="False"
SystemBackdrop="{ThemeResource AcrylicBackgroundFillColorDefaultBackdrop}">
<cpcontrols:ContextMenu x:Name="ContextControl" />
</Flyout>
<!-- Edit mode context menu for taskbar bands -->
<MenuFlyout x:Name="EditModeContextMenu" ShouldConstrainToRootBounds="False">
<MenuFlyoutSubItem x:Name="LabelsSubMenu" x:Uid="Dock_EditMode_Labels">
<MenuFlyoutSubItem.Icon>
<FontIcon Glyph="&#xE8EC;" />
</MenuFlyoutSubItem.Icon>
<ToggleMenuFlyoutItem
x:Name="ShowTitlesMenuItem"
x:Uid="Dock_EditMode_ShowTitles"
Click="ShowTitlesMenuItem_Click" />
<ToggleMenuFlyoutItem
x:Name="ShowSubtitlesMenuItem"
x:Uid="Dock_EditMode_ShowSubtitles"
Click="ShowSubtitlesMenuItem_Click" />
</MenuFlyoutSubItem>
<MenuFlyoutSeparator />
<MenuFlyoutItem
x:Name="UnpinBandMenuItem"
x:Uid="Dock_EditMode_Unpin"
Click="UnpinBandMenuItem_Click">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE77A;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
<!-- Add band flyout -->
<Flyout
x:Name="AddBandFlyout"
Placement="Top"
ShouldConstrainToRootBounds="False">
<StackPanel Width="320">
<TextBlock
x:Uid="Dock_Bands_Header"
Margin="8,8,8,12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<TextBlock
x:Name="NoAvailableBandsText"
x:Uid="Dock_AddBand_NoCommandsAvailable"
Margin="8,0,0,0"
Visibility="Collapsed" />
<ListView
x:Name="AddBandListView"
MaxHeight="300"
Margin="-12,0,-12,0"
HorizontalAlignment="Stretch"
IsItemClickEnabled="True"
ItemClick="AddBandListView_ItemClick"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="vm:TopLevelViewModel">
<Grid Padding="4" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
Width="20"
Height="20"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind IconViewModel, Mode=OneWay}"
SourceRequested="{x:Bind help:IconCacheProvider.SourceRequested20}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis" />
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Rectangle
Height="1"
Margin="-16,24,-16,0"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<Grid Margin="8,24,0,0" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
Margin="0,4,0,0"
VerticalAlignment="Top"
AutomationProperties.AccessibilityView="Raw"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE946;" />
<TextBlock
x:Uid="Dock_Pin_Instruction"
Grid.Column="1"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
TextWrapping="Wrap" />
</Grid>
</StackPanel>
</Flyout>
</ResourceDictionary>
</UserControl.Resources>
<StackPanel
Background="Transparent"
Orientation="Horizontal"
RightTapped="RootPanel_RightTapped">
<ListView
x:Name="BandsListView"
MinWidth="48"
MinHeight="32"
VerticalAlignment="Center"
Background="Transparent"
DragEnter="BandsListView_DragEnter"
DragItemsCompleted="BandsListView_DragItemsCompleted"
DragItemsStarting="BandsListView_DragItemsStarting"
DragLeave="BandsListView_DragLeave"
DragOver="BandsListView_DragOver"
Drop="BandsListView_Drop"
IsItemClickEnabled="False"
ItemContainerStyle="{StaticResource TaskbarBandListViewItemStyle}"
ItemTemplate="{StaticResource TaskbarBandTemplate}"
SelectionMode="None">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
<Button
x:Name="AddBandButton"
MinHeight="30"
Click="AddBandButton_Click"
Style="{StaticResource SubtleButtonStyle}"
Visibility="Collapsed">
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
<Button
x:Name="MoreButton"
Padding="4"
VerticalAlignment="Center"
Visibility="Collapsed">
<FontIcon FontSize="12" Glyph="&#xE712;" />
<Button.Flyout>
<Flyout Placement="Top" ShouldConstrainToRootBounds="False">
<ListView
x:Name="OverflowListView"
MinWidth="200"
MaxWidth="380"
MaxHeight="400"
HorizontalAlignment="Left"
VerticalAlignment="Top"
IsItemClickEnabled="False"
ItemContainerStyle="{StaticResource TaskbarBandListViewItemStyle}"
ItemTemplate="{StaticResource TaskbarBandTemplate}"
SelectionMode="None">
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="8" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
</ListView>
</Flyout>
</Button.Flyout>
</Button>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
PreferredPlacement="Top"
ShouldConstrainToRootBounds="False"
Style="{StaticResource TeachingTipWithoutCloseButtonStyle}"
Target="{x:Bind BandsListView}">
<TeachingTip.Content>
<StackPanel
HorizontalAlignment="Stretch"
Orientation="Vertical"
Spacing="4">
<Button
x:Uid="Dock_EditMode_Save"
HorizontalAlignment="Stretch"
Click="DoneEditingButton_Click"
Style="{StaticResource AccentButtonStyle}" />
<Button
x:Uid="Dock_EditMode_Discard"
HorizontalAlignment="Stretch"
Click="DiscardEditingButton_Click" />
</StackPanel>
</TeachingTip.Content>
</TeachingTip>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,446 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Windows.ApplicationModel.DataTransfer;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Taskbar;
public sealed partial class TaskbarBandControl : UserControl,
IRecipient<CloseContextMenuMessage>,
IRecipient<EnterEditModeMessage>,
IRecipient<ExitEditModeMessage>
{
private DockViewModel _viewModel;
private bool _isEditMode;
private bool _showFlyout;
private DockBandViewModel? _editModeContextBand;
private DockBandViewModel? _draggedBand;
internal DockViewModel ViewModel => _viewModel;
internal TaskbarBandControl(DockViewModel viewModel)
{
_viewModel = viewModel;
InitializeComponent();
BandsListView.ItemsSource = _viewModel.TaskbarItems;
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitEditModeMessage>(this);
UpdateEditMode(false);
}
public void SetMaxAvailableWidth(double availableSpace)
{
if (availableSpace <= 0)
{
MoreButton.Visibility = Visibility.Collapsed;
BandsListView.Visibility = Visibility.Collapsed;
return;
}
BandsListView.Visibility = Visibility.Visible;
// Measure how much space the bands need
double neededSpace = 0;
var items = _viewModel.TaskbarItems;
var visibleBands = new List<DockBandViewModel>();
var overflowBands = new List<DockBandViewModel>();
foreach (var band in items)
{
if (BandsListView.ContainerFromItem(band) is FrameworkElement container)
{
container.Measure(new Size(availableSpace, ActualHeight));
var needed = container.DesiredSize.Width;
neededSpace += needed;
}
}
if (neededSpace <= availableSpace)
{
// Everything fits
MoreButton.Visibility = Visibility.Collapsed;
OverflowListView.ItemsSource = null;
}
else
{
// Some items need to overflow
MoreButton.Visibility = Visibility.Visible;
double moreButtonWidth = 40; // approximate
double takenSpace = moreButtonWidth;
foreach (var band in items)
{
if (BandsListView.ContainerFromItem(band) is FrameworkElement container)
{
var needed = container.DesiredSize.Width;
if (takenSpace + needed > availableSpace)
{
overflowBands.Add(band);
}
else
{
visibleBands.Add(band);
takenSpace += needed;
}
}
}
OverflowListView.ItemsSource = overflowBands;
}
}
internal void EnterEditMode(bool showFlyout = true)
{
_showFlyout = showFlyout;
_viewModel.SnapshotBandOrder();
_isEditMode = true;
UpdateEditMode(true, showFlyout);
}
internal void ExitEditMode()
{
_isEditMode = false;
UpdateEditMode(false);
_viewModel.SaveBandOrder();
}
internal void DiscardEditMode()
{
_isEditMode = false;
UpdateEditMode(false);
_viewModel.RestoreBandOrder();
}
private void UpdateEditMode(bool isEditMode, bool showFlyout = true)
{
BandsListView.CanDragItems = isEditMode;
BandsListView.CanReorderItems = isEditMode;
BandsListView.AllowDrop = isEditMode;
AddBandButton.Visibility = isEditMode ? Visibility.Visible : Visibility.Collapsed;
EditButtonsTeachingTip.IsOpen = isEditMode && showFlyout;
}
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
// Parent (TaskbarWindow) will manage available width via SetMaxAvailableWidth
}
private void BandItem_Tapped(object sender, Microsoft.UI.Xaml.Input.TappedRoutedEventArgs e)
{
if (_isEditMode)
{
return;
}
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
{
var borderPos = dockItem.TransformToVisual(null).TransformPoint(new Point(0, 0));
var borderCenter = new Point(
borderPos.X + (dockItem.ActualWidth / 2),
borderPos.Y + (dockItem.ActualHeight / 2));
InvokeItem(item, borderCenter);
e.Handled = true;
}
}
private void BandItem_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
{
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
{
if (_isEditMode)
{
_editModeContextBand = band;
ShowTitlesMenuItem.IsChecked = _editModeContextBand.ShowTitles;
ShowSubtitlesMenuItem.IsChecked = _editModeContextBand.ShowSubtitles;
EditModeContextMenu.ShowAt(
dockItem,
new FlyoutShowOptions()
{
ShowMode = FlyoutShowMode.Standard,
Placement = FlyoutPlacementMode.TopEdgeAlignedRight,
});
e.Handled = true;
return;
}
if (item.HasMoreCommands)
{
ContextControl.ViewModel.SelectedItem = item;
ContextControl.ShowFilterBox = true;
ContextMenuFlyout.ShowAt(
dockItem,
new FlyoutShowOptions()
{
ShowMode = FlyoutShowMode.Standard,
Placement = FlyoutPlacementMode.TopEdgeAlignedRight,
});
e.Handled = true;
}
}
}
private void ShowTitlesMenuItem_Click(object sender, RoutedEventArgs e)
{
if (_editModeContextBand != null)
{
_editModeContextBand.ShowTitles = ShowTitlesMenuItem.IsChecked;
}
}
private void ShowSubtitlesMenuItem_Click(object sender, RoutedEventArgs e)
{
if (_editModeContextBand != null)
{
_editModeContextBand.ShowSubtitles = ShowSubtitlesMenuItem.IsChecked;
}
}
private void UnpinBandMenuItem_Click(object sender, RoutedEventArgs e)
{
if (_editModeContextBand != null)
{
_viewModel.UnpinBand(_editModeContextBand);
_editModeContextBand = null;
}
}
private void InvokeItem(DockItemViewModel item, Point pos)
{
var command = item.Command;
try
{
PerformCommandMessage m = new(command.Model);
m.WithAnimation = false;
m.TransientPage = true;
WeakReferenceMessenger.Default.Send(m);
var isPage = command.Model.Unsafe is not IInvokableCommand;
if (isPage)
{
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos));
}
}
catch (COMException e)
{
Logger.LogError("Error invoking taskbar command", e);
}
}
private void ContextMenuFlyout_Opened(object sender, object e)
{
ContextControl.FocusSearchBox();
}
public void Receive(CloseContextMenuMessage message)
{
if (ContextMenuFlyout.IsOpen)
{
ContextMenuFlyout.Hide();
}
}
public void Receive(EnterEditModeMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
EnterEditMode(showFlyout: message.Origin == EditModeOrigin.Taskbar);
});
}
public void Receive(ExitEditModeMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
if (message.Save)
{
ExitEditMode();
}
else
{
DiscardEditMode();
}
});
}
private void RootPanel_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
{
if (_isEditMode)
{
return;
}
var pos = e.GetPosition(null);
var item = _viewModel.GetContextMenuForTaskbar();
if (item.HasMoreCommands)
{
ContextControl.ViewModel.SelectedItem = item;
ContextControl.ShowFilterBox = false;
ContextMenuFlyout.ShowAt(
(FrameworkElement)sender,
new FlyoutShowOptions()
{
ShowMode = FlyoutShowMode.Standard,
Placement = FlyoutPlacementMode.TopEdgeAlignedRight,
Position = e.GetPosition((UIElement)sender),
});
e.Handled = true;
}
}
private void DoneEditingButton_Click(object sender, RoutedEventArgs e)
{
// Tell both dock and taskbar to exit edit mode
WeakReferenceMessenger.Default.Send(new ExitEditModeMessage(Save: true));
}
private void DiscardEditingButton_Click(object sender, RoutedEventArgs e)
{
// Tell both dock and taskbar to discard edit mode
WeakReferenceMessenger.Default.Send(new ExitEditModeMessage(Save: false));
}
private void AddBandButton_Click(object sender, RoutedEventArgs e)
{
var availableBands = _viewModel.GetAvailableBandsToAdd().ToList();
AddBandListView.ItemsSource = availableBands;
var hasAvailableBands = availableBands.Count > 0;
NoAvailableBandsText.Visibility = hasAvailableBands ? Visibility.Collapsed : Visibility.Visible;
AddBandListView.Visibility = hasAvailableBands ? Visibility.Visible : Visibility.Collapsed;
AddBandFlyout.ShowAt((Button)sender);
}
private void AddBandListView_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is TopLevelViewModel topLevel)
{
_viewModel.AddBandToSection(topLevel, DockPinSide.Taskbar);
AddBandFlyout.Hide();
}
}
// Drag and drop handlers
private void BandsListView_DragItemsStarting(object sender, DragItemsStartingEventArgs e)
{
if (e.Items.Count > 0 && e.Items[0] is DockBandViewModel band)
{
_draggedBand = band;
_viewModel.DraggedBand = band;
e.Data.RequestedOperation = DataPackageOperation.Move;
}
}
private void BandsListView_DragOver(object sender, DragEventArgs e)
{
// Accept drops from this window (_draggedBand) or from the dock
// window (shared via _viewModel.DraggedBand)
if (_draggedBand != null || _viewModel.DraggedBand != null)
{
e.AcceptedOperation = DataPackageOperation.Move;
}
}
private void BandsListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args)
{
if (args.DropResult == DataPackageOperation.Move && _draggedBand != null)
{
var newIndex = _viewModel.TaskbarItems.IndexOf(_draggedBand);
if (newIndex >= 0)
{
_viewModel.SyncBandPosition(_draggedBand, DockPinSide.Taskbar, newIndex);
}
}
_draggedBand = null;
_viewModel.DraggedBand = null;
}
private void BandsListView_Drop(object sender, DragEventArgs e)
{
// Use local _draggedBand for same-window drags, fall back to shared
// _viewModel.DraggedBand for cross-window drags (e.g. from dock)
var draggedBand = _draggedBand ?? _viewModel.DraggedBand;
if (draggedBand == null)
{
return;
}
// Only handle cross-section drops; same-list reorders are handled in DragItemsCompleted
if (!_viewModel.TaskbarItems.Contains(draggedBand))
{
var dropIndex = GetDropIndex(BandsListView, e, _viewModel.TaskbarItems.Count);
_viewModel.MoveBandWithoutSaving(draggedBand, DockPinSide.Taskbar, dropIndex);
e.Handled = true;
}
ResetListViewState(sender);
}
private int GetDropIndex(ListView listView, DragEventArgs e, int itemCount)
{
var position = e.GetPosition(listView);
for (var i = 0; i < itemCount; i++)
{
if (listView.ContainerFromIndex(i) is ListViewItem container)
{
var itemBounds = container.TransformToVisual(listView).TransformBounds(
new Rect(0, 0, container.ActualWidth, container.ActualHeight));
// Horizontal layout: check X position
if (position.X < itemBounds.X + (itemBounds.Width / 2))
{
return i;
}
}
}
return itemCount;
}
private void BandsListView_DragEnter(object sender, DragEventArgs e)
{
if (sender is ListView view)
{
view.Background = Application.Current.Resources["ControlAltFillColorQuarternaryBrush"] as Microsoft.UI.Xaml.Media.SolidColorBrush;
e.DragUIOverride.IsGlyphVisible = false;
e.DragUIOverride.IsCaptionVisible = false;
}
}
private void BandsListView_DragLeave(object sender, DragEventArgs e)
{
ResetListViewState(sender);
}
private void ResetListViewState(object sender)
{
if (sender is ListView view)
{
view.Background = new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Transparent);
}
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="Microsoft.CmdPal.UI.Taskbar.TaskbarWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.CmdPal.UI.Taskbar"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:winuiex="using:WinUIEx"
Title="CmdPal Taskbar"
IsAlwaysOnTop="True"
mc:Ignorable="d">
<Window.SystemBackdrop>
<winuiex:TransparentTintBackdrop />
</Window.SystemBackdrop>
<Grid
x:Name="Root"
Height="44"
HorizontalAlignment="Stretch"
VerticalAlignment="Top"
Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="WindowsLogo" Width="60" />
<ColumnDefinition x:Name="TaskbarButtons" Width="0" />
<ColumnDefinition x:Name="Mid" Width="*" />
<ColumnDefinition x:Name="ContentColumn" Width="Auto" />
<ColumnDefinition x:Name="TrayIcons" Width="400" />
</Grid.ColumnDefinitions>
<!-- Debug: color each column so we can see layout -->
<Border Grid.Column="0" Background="#40FF0000" />
<Border Grid.Column="1" Background="#400000FF" />
<Border Grid.Column="2" Background="#4000FF00" />
<Border Grid.Column="3" Background="#40FF00FF" />
<Border Grid.Column="4" Background="#40FFFF00" />
<ContentControl
x:Name="MainContent"
Grid.Column="3"
HorizontalAlignment="Right"
SizeChanged="MainContent_SizeChanged" />
</Grid>
</winuiex:WindowEx>

View File

@@ -0,0 +1,348 @@
// 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.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using ManagedCommon;
using Microsoft.CmdPal.UI.Utilities;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.WindowsAndMessaging;
using WinRT.Interop;
using WinUIEx;
namespace Microsoft.CmdPal.UI.Taskbar;
public sealed partial class TaskbarWindow : WindowEx,
IRecipient<QuitMessage>,
IDisposable
{
private readonly uint wMTASKBARRESTART;
private readonly HWND _hwnd;
private readonly TaskbarMetrics _taskbarMetrics;
private readonly TaskbarBandControl _bandsControl;
private readonly WNDPROC? _originalWndProc;
private readonly WNDPROC? _customWndProc;
private readonly DispatcherQueueTimer _updateLayoutDebouncer;
private double _lastContentSpace;
private int _clipVersion;
private bool _disposed;
internal TaskbarWindow(TaskbarMetrics metrics)
{
var serviceProvider = App.Current.Services;
var viewModel = serviceProvider.GetRequiredService<DockViewModel>();
_bandsControl = new TaskbarBandControl(viewModel);
InitializeComponent();
MainContent.Content = _bandsControl;
wMTASKBARRESTART = PInvoke.RegisterWindowMessage("TaskbarCreated");
_hwnd = new HWND(WindowNative.GetWindowHandle(this));
_updateLayoutDebouncer = DispatcherQueue.CreateTimer();
MainContent.SizeChanged += MainContent_SizeChanged;
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
_taskbarMetrics = metrics;
// LOAD BEARING: The delegate must be stored in a member field.
// A local variable would be collected, leaving a dangling function pointer.
_customWndProc = CustomWndProc;
var procPointer = Marshal.GetFunctionPointerForDelegate(_customWndProc);
_originalWndProc = Marshal.GetDelegateForFunctionPointer<WNDPROC>(
PInvoke.SetWindowLongPtr(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_WNDPROC, procPointer));
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
if (AppWindow.Presenter is OverlappedPresenter overlappedPresenter)
{
overlappedPresenter.SetBorderAndTitleBar(false, false);
overlappedPresenter.IsResizable = false;
}
MoveToTaskbar();
}
private void MainContent_SizeChanged(object sender, SizeChangedEventArgs e)
{
ClipWindow().ConfigureAwait(false);
}
private LRESULT CustomWndProc(HWND hwnd, uint uMsg, WPARAM wParam, LPARAM lParam)
{
if (uMsg == PInvoke.WM_DISPLAYCHANGE)
{
DispatcherQueue.TryEnqueue(() => MoveToTaskbar());
}
else if (uMsg == PInvoke.WM_SETTINGCHANGE)
{
if (wParam == (uint)SYSTEM_PARAMETERS_INFO_ACTION.SPI_SETWORKAREA)
{
DispatcherQueue.TryEnqueue(TriggerDebouncedLayoutUpdate);
}
}
else if (uMsg == PInvoke.WM_DESTROY)
{
return (LRESULT)0;
}
else if (uMsg == wMTASKBARRESTART)
{
Logger.LogDebug("TaskbarWindow: WM_TASKBAR_RESTART");
DispatcherQueue.TryEnqueue(() => this.Close());
}
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
private async Task UpdateLayoutForDPI()
{
MoveToTaskbar();
await Task.Delay(200);
MainContent.Padding = new Thickness(1);
await Task.Delay(10);
MainContent.Padding = new Thickness(0);
}
private void TriggerDebouncedLayoutUpdate()
{
_updateLayoutDebouncer.Debounce(
async () => await UpdateLayoutForDPI(),
interval: TimeSpan.FromMilliseconds(200),
immediate: false);
}
private void MoveToTaskbar()
{
if (AppWindow is null)
{
Logger.LogDebug("TaskbarWindow: AppWindow was null");
return;
}
var taskbarWindow = PInvoke.FindWindow("Shell_TrayWnd", null);
var reBarWindow = PInvoke.FindWindowEx(taskbarWindow, HWND.Null, "ReBarWindow32", null);
var oldStyle = (WINDOW_STYLE)PInvoke.GetWindowLong(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE);
var newStyle = (oldStyle & ~WINDOW_STYLE.WS_POPUP) | WINDOW_STYLE.WS_CHILD;
_ = PInvoke.SetWindowLong(_hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE, (int)newStyle);
PInvoke.SetParent(_hwnd, taskbarWindow);
PInvoke.GetWindowRect(taskbarWindow, out var taskbarRect);
PInvoke.GetWindowRect(reBarWindow, out var reBarRect);
RECT newWindowRect = default;
newWindowRect.left = taskbarRect.left;
newWindowRect.top = reBarRect.top - taskbarRect.top;
newWindowRect.right = newWindowRect.left + (taskbarRect.right - taskbarRect.left);
newWindowRect.bottom = newWindowRect.top + (reBarRect.bottom - reBarRect.top);
// Don't clear the window region — leave the previous clip in
// place until ClipWindow applies the updated one. Clearing it
// would flash the window across the full taskbar width.
PInvoke.SetWindowPos(
_hwnd,
HWND.Null,
newWindowRect.left,
newWindowRect.top,
newWindowRect.Width,
newWindowRect.Height,
SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE);
// Apply an immediate clip using pre-loaded metrics so the window
// never flashes across the full taskbar width. The async ClipWindow
// call will refine this once XAML layout has settled.
if (_taskbarMetrics.ButtonsWidthInPixels > 0 || _taskbarMetrics.TrayWidthInPixels > 0)
{
var clipLeft = _taskbarMetrics.ButtonsWidthInPixels;
var clipRight = newWindowRect.Width - _taskbarMetrics.TrayWidthInPixels;
if (clipRight > clipLeft)
{
var hrgn = PInvoke.CreateRectRgn(clipLeft, 0, clipRight, newWindowRect.Height);
_ = PInvoke.SetWindowRgn(_hwnd, hrgn, true);
}
}
ClipWindow().ConfigureAwait(false);
}
private async Task<bool> UpdateTaskbarButtonsAsync()
{
// Capture dispatcher before crossing to a background thread.
var dispatcher = DispatcherQueue;
// Run UIA enumeration on a background thread.
var changed = await _taskbarMetrics.UpdateAsync();
// On the very first call _lastContentSpace is 0 and the grid
// columns haven't been set yet. Always apply layout in that case,
// even if the metrics didn't change (they were pre-loaded).
if (!changed && _lastContentSpace != 0)
{
dispatcher.TryEnqueue(() => _bandsControl.SetMaxAvailableWidth(_lastContentSpace));
return false;
}
// Capture values from the background result.
var buttonsPixels = _taskbarMetrics.ButtonsWidthInPixels;
var trayPixels = _taskbarMetrics.TrayWidthInPixels;
var tcs = new TaskCompletionSource<bool>();
dispatcher.TryEnqueue(() =>
{
try
{
var scaleFactor = PInvoke.GetDpiForWindow(_hwnd) / 96.0f;
var buttonsInDips = buttonsPixels / scaleFactor;
TaskbarButtons.Width = new GridLength(Math.Max(0, buttonsInDips - WindowsLogo.Width.Value));
var trayInDips = trayPixels / scaleFactor;
TrayIcons.Width = new GridLength(trayInDips);
var available = this.Bounds.Width;
var forContent = available - buttonsInDips - trayInDips;
if (_lastContentSpace == forContent)
{
_bandsControl.SetMaxAvailableWidth(forContent);
tcs.TrySetResult(false);
return;
}
if (forContent > 0)
{
ContentColumn.MaxWidth = Root.ActualWidth == 0 ? double.MaxValue : forContent;
ContentColumn.Width = GridLength.Auto;
_bandsControl.SetMaxAvailableWidth(forContent);
}
else
{
ContentColumn.MaxWidth = 0;
ContentColumn.Width = new GridLength(0);
_bandsControl.SetMaxAvailableWidth(0);
}
_lastContentSpace = forContent;
tcs.TrySetResult(true);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return await tcs.Task;
}
private async Task ClipWindow(bool onlyIfButtonsChanged = false)
{
// Increment version so that any older in-flight ClipWindow calls
// will see a stale version and skip applying their clip region.
var myVersion = Interlocked.Increment(ref _clipVersion);
var dispatcher = DispatcherQueue;
var taskbarChanged = await UpdateTaskbarButtonsAsync();
if (onlyIfButtonsChanged && !taskbarChanged)
{
return;
}
// Wait for layout to settle.
await Task.Delay(100);
// If another ClipWindow was started while we were waiting, bail.
if (Volatile.Read(ref _clipVersion) != myVersion)
{
return;
}
var tcs = new TaskCompletionSource();
dispatcher.TryEnqueue(() =>
{
// Check again on the UI thread.
if (Volatile.Read(ref _clipVersion) != myVersion)
{
tcs.TrySetResult();
return;
}
try
{
var scaleFactor = PInvoke.GetDpiForWindow(_hwnd) / 96.0f;
FrameworkElement clipToElement = MainContent;
if (clipToElement.ActualWidth <= 0 || clipToElement.ActualHeight <= 0)
{
tcs.TrySetResult();
return;
}
var position = clipToElement.TransformToVisual(this.Content).TransformPoint(default);
RECT scaledBounds = new()
{
left = (int)(position.X * scaleFactor),
top = (int)(position.Y * scaleFactor),
right = (int)((position.X + clipToElement.ActualWidth) * scaleFactor),
bottom = (int)((position.Y + clipToElement.ActualHeight) * scaleFactor),
};
if (scaledBounds.right <= scaledBounds.left || scaledBounds.bottom <= scaledBounds.top)
{
tcs.TrySetResult();
return;
}
var hrgn = PInvoke.CreateRectRgn(
scaledBounds.left,
scaledBounds.top,
scaledBounds.right,
scaledBounds.bottom);
_ = PInvoke.SetWindowRgn(_hwnd, hrgn, true);
}
catch
{
// Window may have been destroyed
}
tcs.TrySetResult();
});
await tcs.Task;
}
public void Receive(QuitMessage message)
{
_updateLayoutDebouncer?.Stop();
DispatcherQueue.TryEnqueue(() => Close());
}
public void Dispose()
{
if (!_disposed)
{
_updateLayoutDebouncer?.Stop();
_taskbarMetrics.Dispose();
WeakReferenceMessenger.Default.UnregisterAll(this);
_disposed = true;
}
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace TaskbarMonitor;
[JsonSerializable(typeof(List<TaskbarSnapshot>))]
[JsonSerializable(typeof(TaskbarSnapshot))]
internal partial class AppJsonContext : JsonSerializerContext
{
}

View File

@@ -0,0 +1,3 @@
<Project>
<!-- Isolate from repo-level MSBuild settings so this tool builds standalone with dotnet build -->
</Project>

View File

@@ -0,0 +1,2 @@
<Project>
</Project>

View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,180 @@
// 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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.WindowsAndMessaging;
namespace TaskbarMonitor;
/// <summary>
/// Creates a small test window, parents it to the taskbar, and prints
/// diagnostic information about the parenting/positioning/clipping.
/// Used with --hwnd flag to debug the parenting approach.
/// </summary>
internal static unsafe class HwndTest
{
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
private static LRESULT WndProc(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam)
{
if (msg == PInvoke.WM_DESTROY)
{
PInvoke.PostQuitMessage(0);
return (LRESULT)0;
}
return PInvoke.DefWindowProc(hwnd, msg, wParam, lParam);
}
public static void Run()
{
var log = Console.Out;
// Step 1: Find the taskbar hierarchy
var shellTray = PInvoke.FindWindow("Shell_TrayWnd", null);
log.WriteLine($"Shell_TrayWnd = 0x{(nint)shellTray.Value:X}");
if (shellTray.IsNull)
{
log.WriteLine("FATAL: Shell_TrayWnd not found");
return;
}
var rebar = PInvoke.FindWindowEx(shellTray, HWND.Null, "ReBarWindow32", null);
log.WriteLine($" ReBarWindow32 = 0x{(nint)rebar.Value:X}");
PInvoke.GetWindowRect(shellTray, out var taskbarRect);
log.WriteLine($"Taskbar rect = ({taskbarRect.left},{taskbarRect.top},{taskbarRect.right},{taskbarRect.bottom}) {taskbarRect.Width}x{taskbarRect.Height}");
PInvoke.GetWindowRect(rebar, out var rebarRect);
log.WriteLine($"ReBar rect = ({rebarRect.left},{rebarRect.top},{rebarRect.right},{rebarRect.bottom}) {rebarRect.Width}x{rebarRect.Height}");
var tray = PInvoke.FindWindowEx(shellTray, HWND.Null, "TrayNotifyWnd", null);
PInvoke.GetWindowRect(tray, out var trayRect);
log.WriteLine($"TrayNotifyWnd rect = ({trayRect.left},{trayRect.top},{trayRect.right},{trayRect.bottom}) {trayRect.Width}x{trayRect.Height}");
var dpi = PInvoke.GetDpiForWindow(shellTray);
var scale = dpi / 96.0;
log.WriteLine($"DPI={dpi} scale={scale:F2}x");
// Get metrics
using var poller = new TaskbarPoller();
var snapshots = poller.PollAll();
var primary = snapshots.FirstOrDefault(s => s.IsPrimary);
if (primary == null)
{
log.WriteLine("FATAL: no primary taskbar found");
return;
}
log.WriteLine($"Metrics: buttons={primary.ButtonsWidth}px tray={primary.TrayWidth}px count={primary.ButtonCount}");
// Step 2: Create a test window — WS_CHILD from the start
var hInstance = PInvoke.GetModuleHandle((PCWSTR)null);
fixed (char* className = "TaskbarMonitor_TestWnd")
{
var wc = new WNDCLASSEXW
{
cbSize = (uint)sizeof(WNDCLASSEXW),
lpfnWndProc = &WndProc,
hInstance = (HINSTANCE)hInstance.Value,
lpszClassName = className,
hbrBackground = PInvoke.CreateSolidBrush(new COLORREF(0x000000FF)), // Red (BGR)
};
PInvoke.RegisterClassEx(in wc);
// Create as WS_CHILD of Shell_TrayWnd from the start
var hwnd = PInvoke.CreateWindowEx(
WINDOW_EX_STYLE.WS_EX_TOOLWINDOW | WINDOW_EX_STYLE.WS_EX_TOPMOST,
className,
(PCWSTR)null,
WINDOW_STYLE.WS_CHILD | WINDOW_STYLE.WS_VISIBLE,
0, 0, taskbarRect.Width, taskbarRect.Height,
shellTray,
HMENU.Null,
wc.hInstance,
null);
log.WriteLine($"\nCreated HWND = 0x{(nint)hwnd.Value:X}");
// Position exactly like CmdPal does
var x = taskbarRect.left;
var y = rebarRect.top - taskbarRect.top;
var w = taskbarRect.Width;
var h = rebarRect.bottom - rebarRect.top;
PInvoke.SetWindowRgn(hwnd, HRGN.Null, true);
// HWND_TOPMOST = (HWND)-1
var hwndTopmost = new HWND((void*)-1);
PInvoke.SetWindowPos(
hwnd,
hwndTopmost,
x, y, w, h,
SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE);
log.WriteLine($"Positioned: x={x} y={y} w={w} h={h}");
DumpState(log, hwnd, "After initial position");
// Step 3: Clip to the content area (between buttons and tray)
var clipLeft = primary.ButtonsWidth;
var clipRight = w - primary.TrayWidth;
log.WriteLine($"\nClip: left={clipLeft} right={clipRight} (content area = {clipRight - clipLeft}px)");
var hrgn = PInvoke.CreateRectRgn(clipLeft, 0, clipRight, h);
PInvoke.SetWindowRgn(hwnd, hrgn, true);
DumpState(log, hwnd, "After initial clip");
// Step 4: Monitor window state over time. Skip re-measurement
// of buttons (that uses Shell_TrayWnd scope which breaks once
// we're parented). Just monitor visibility/rect/style.
log.WriteLine("\n=== MONITORING (10 seconds) ===");
log.WriteLine("Watching window state every 500ms...\n");
for (var tick = 0; tick < 20; tick++)
{
Thread.Sleep(500);
var curParent = PInvoke.GetParent(hwnd);
var visible = PInvoke.IsWindowVisible(hwnd);
PInvoke.GetWindowRect(hwnd, out var curRect);
var style = (WINDOW_STYLE)PInvoke.GetWindowLong(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE);
log.Write($" t={(tick + 1) * 500}ms: ");
log.Write($"vis={visible} ");
log.Write($"parent=0x{(nint)curParent.Value:X} ");
log.Write($"rect=({curRect.left},{curRect.top},{curRect.right},{curRect.bottom}) ");
log.Write($"CHILD={((style & WINDOW_STYLE.WS_CHILD) != 0)} ");
log.Write($"VIS={((style & WINDOW_STYLE.WS_VISIBLE) != 0)}");
log.WriteLine();
}
log.WriteLine("\nPress Enter to exit...");
Console.ReadLine();
PInvoke.DestroyWindow(hwnd);
}
}
private static void DumpState(TextWriter log, HWND hwnd, string label)
{
PInvoke.GetWindowRect(hwnd, out var rect);
var parent = PInvoke.GetParent(hwnd);
var visible = PInvoke.IsWindowVisible(hwnd);
var style = (WINDOW_STYLE)PInvoke.GetWindowLong(hwnd, WINDOW_LONG_PTR_INDEX.GWL_STYLE);
var exStyle = (WINDOW_EX_STYLE)PInvoke.GetWindowLong(hwnd, WINDOW_LONG_PTR_INDEX.GWL_EXSTYLE);
log.WriteLine($" [{label}]");
log.WriteLine($" rect=({rect.left},{rect.top},{rect.right},{rect.bottom}) {rect.Width}x{rect.Height}");
log.WriteLine($" parent=0x{(nint)parent.Value:X} visible={visible}");
log.WriteLine($" WS_CHILD={((style & WINDOW_STYLE.WS_CHILD) != 0)} WS_POPUP={((style & WINDOW_STYLE.WS_POPUP) != 0)} WS_VISIBLE={((style & WINDOW_STYLE.WS_VISIBLE) != 0)}");
log.WriteLine($" exStyle=0x{(uint)exStyle:X8}");
}
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"allowMarshaling": false,
"comInterop": {
"preserveSigMethods": [ "*" ]
}
}

View File

@@ -0,0 +1,52 @@
FindWindow
FindWindowEx
GetWindowRect
GetDpiForWindow
SetProcessDpiAwarenessContext
CoCreateInstance
IUIAutomation
IUIAutomationElement
IUIAutomationElementArray
IUIAutomationCondition
UIA_PROPERTY_ID
TreeScope
SafeArrayGetElement
VariantClear
VARIANT
SetWinEventHook
UnhookWinEvent
EVENT_OBJECT_REORDER
EVENT_OBJECT_CREATE
EVENT_OBJECT_DESTROY
EVENT_OBJECT_NAMECHANGE
WINEVENT_OUTOFCONTEXT
GetMessage
TranslateMessage
DispatchMessage
PostThreadMessage
WM_QUIT
WM_NULL
GetCurrentThreadId
SetParent
GetParent
GetWindowLong
SetWindowLong
SetWindowPos
CreateWindowEx
RegisterClassEx
WNDCLASSEXW
GetModuleHandle
DefWindowProc
DestroyWindow
CreateRectRgn
SetWindowRgn
WINDOW_STYLE
SET_WINDOW_POS_FLAGS
WINDOW_LONG_PTR_INDEX
WINDOW_EX_STYLE
GetStockObject
WM_DESTROY
PostQuitMessage
IsWindowVisible
GetClassName
CreateSolidBrush

View File

@@ -0,0 +1,176 @@
// 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 TaskbarMonitor;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;
TaskbarPoller.SetDpiAwareness();
Console.OutputEncoding = System.Text.Encoding.UTF8;
var jsonMode = args.Contains("--json");
var debugMode = args.Contains("--debug");
var hwndMode = args.Contains("--hwnd");
var inputSiteMode = args.Contains("--inputsite");
using var poller = new TaskbarPoller();
if (hwndMode)
{
HwndTest.Run();
return;
}
if (inputSiteMode)
{
unsafe
{
var shellTray = PInvoke.FindWindow("Shell_TrayWnd", null);
Console.WriteLine($"Shell_TrayWnd = 0x{(nint)shellTray.Value:X}");
// Check various HWND children
var inputSite = PInvoke.FindWindowEx(shellTray, HWND.Null, "Windows.UI.Input.InputSite.WindowClass", null);
Console.WriteLine($"InputSite (direct child) = 0x{(nint)inputSite.Value:X}");
var rebar = PInvoke.FindWindowEx(shellTray, HWND.Null, "ReBarWindow32", null);
Console.WriteLine($"ReBarWindow32 = 0x{(nint)rebar.Value:X}");
var taskSw = PInvoke.FindWindowEx(rebar, HWND.Null, "MSTaskSwWClass", null);
Console.WriteLine($"MSTaskSwWClass = 0x{(nint)taskSw.Value:X}");
var taskList = PInvoke.FindWindowEx(taskSw, HWND.Null, "MSTaskListWClass", null);
Console.WriteLine($"MSTaskListWClass = 0x{(nint)taskList.Value:X}");
// Check for InputSite deeper in hierarchy
var isInRebar = PInvoke.FindWindowEx(rebar, HWND.Null, "Windows.UI.Input.InputSite.WindowClass", null);
Console.WriteLine($"InputSite (in ReBar) = 0x{(nint)isInRebar.Value:X}");
var isInTaskSw = PInvoke.FindWindowEx(taskSw, HWND.Null, "Windows.UI.Input.InputSite.WindowClass", null);
Console.WriteLine($"InputSite (in MSTaskSwWClass) = 0x{(nint)isInTaskSw.Value:X}");
var isInTaskList = PInvoke.FindWindowEx(taskList, HWND.Null, "Windows.UI.Input.InputSite.WindowClass", null);
Console.WriteLine($"InputSite (in MSTaskListWClass) = 0x{(nint)isInTaskList.Value:X}");
// List ALL direct children of Shell_TrayWnd
Console.WriteLine($"\nAll HWND children of Shell_TrayWnd:");
var child = HWND.Null;
Span<char> buf = stackalloc char[256];
while (true)
{
child = PInvoke.FindWindowEx(shellTray, child, null as string, null);
if (child.IsNull)
{
break;
}
PInvoke.GetClassName(child, buf);
var cn = new string(buf.TrimEnd('\0'));
PInvoke.GetWindowRect(child, out var cr);
Console.WriteLine($" 0x{(nint)child.Value:X} class={cn} rect=({cr.left},{cr.top},{cr.right},{cr.bottom}) {cr.Width}x{cr.Height}");
}
}
// Use TaskbarMetrics
var metrics = new Microsoft.CmdPal.UI.Utilities.TaskbarMetrics();
metrics.Update();
Console.WriteLine($"\nTaskbarMetrics (InputSite scope):");
Console.WriteLine($" ButtonsWidthInPixels = {metrics.ButtonsWidthInPixels}");
Console.WriteLine($" TrayWidthInPixels = {metrics.TrayWidthInPixels}");
Console.WriteLine($" ButtonCount = {metrics.ButtonCount}");
var snapshots2 = poller.PollAll();
var primary = snapshots2.FirstOrDefault(s => s.IsPrimary);
Console.WriteLine($"\nTaskbarPoller (Shell_TrayWnd scope):");
Console.WriteLine($" ButtonsWidth = {primary?.ButtonsWidth}");
Console.WriteLine($" TrayWidth = {primary?.TrayWidth}");
Console.WriteLine($" ButtonCount = {primary?.ButtonCount}");
metrics.Dispose();
return;
}
if (jsonMode || debugMode)
{
var snapshots = poller.PollAll(debugMode ? Console.Error : null);
if (jsonMode)
{
Console.WriteLine(JsonSerializer.Serialize(snapshots, AppJsonContext.Default.ListTaskbarSnapshot));
}
else
{
foreach (var s in snapshots)
{
Console.WriteLine(s);
}
}
return;
}
// Event-driven mode: use SetWinEventHook to detect taskbar changes,
// then re-poll only when something changes.
List<TaskbarSnapshot>? previous = null;
var dirty = true; // poll once on startup
using var watcher = new TaskbarWatcher();
var threadId = PInvoke.GetCurrentThreadId();
watcher.Changed += () =>
{
dirty = true;
// Wake the message pump so it processes the change
PInvoke.PostThreadMessage(threadId, PInvoke.WM_NULL, default, default);
};
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
TaskbarView.LeaveAlternateScreen();
Environment.Exit(0);
};
TaskbarView.EnterAlternateScreen();
// Initial render
var snapshot = poller.PollAll();
TaskbarView.Render(snapshot, previous);
previous = snapshot;
dirty = false;
try
{
// Win32 message pump — required for SetWinEventHook with
// WINEVENT_OUTOFCONTEXT. GetMessage blocks until a message arrives,
// so we burn zero CPU while idle.
MSG msg;
while (PInvoke.GetMessage(out msg, HWND.Null, 0, 0))
{
PInvoke.TranslateMessage(in msg);
PInvoke.DispatchMessage(in msg);
if (dirty)
{
dirty = false;
snapshot = poller.PollAll();
// When the user right-clicks the taskbar, UIA momentarily
// reports 0 children. Skip these transient error snapshots
// and keep showing the previous valid data.
if (snapshot.Any(s => s.IsBottom && s.ButtonCount == 0))
{
continue;
}
TaskbarView.Render(snapshot, previous);
previous = snapshot;
}
}
}
finally
{
TaskbarView.LeaveAlternateScreen();
}

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0-windows10.0.26100.0</TargetFramework>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<TargetPlatformMinVersion>10.0.19041.0</TargetPlatformMinVersion>
<SupportedOSPlatformVersion>10.0.19041.0</SupportedOSPlatformVersion>
<RootNamespace>TaskbarMonitor</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsAotCompatible>true</IsAotCompatible>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CA1416</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.183">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Microsoft.CmdPal.UI.Utilities\Microsoft.CmdPal.UI.Utilities.csproj" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="NativeMethods.txt" />
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,388 @@
// 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.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.System.Com;
using Windows.Win32.System.Variant;
using Windows.Win32.UI.Accessibility;
namespace TaskbarMonitor;
/// <summary>
/// Polls the Windows taskbar using P/Invoke and UI Automation
/// (preserveSig / no-marshalling) for AOT compatibility.
/// Call <see cref="SetDpiAwareness"/> once at process start.
/// </summary>
public sealed unsafe class TaskbarPoller : IDisposable
{
// CUIAutomation CLSID: {FF48DBA4-60EF-4201-AA87-54103EEF594E}
private static readonly Guid CLSID_CUIAutomation =
new(0xFF48DBA4, 0x60EF, 0x4201, 0xAA, 0x87, 0x54, 0x10, 0x3E, 0xEF, 0x59, 0x4E);
private IUIAutomation* _automation;
private IUIAutomationCondition* _trueCondition;
private bool _disposed;
public static void SetDpiAwareness()
{
PInvoke.SetProcessDpiAwarenessContext(
new Windows.Win32.UI.HiDpi.DPI_AWARENESS_CONTEXT((void*)-4));
}
public List<TaskbarSnapshot> PollAll(TextWriter? log = null)
{
ObjectDisposedException.ThrowIf(_disposed, this);
EnsureAutomation(log);
var results = new List<TaskbarSnapshot>();
var primary = PInvoke.FindWindow("Shell_TrayWnd", null);
log?.WriteLine($"[poll] Shell_TrayWnd = 0x{(nint)primary.Value:X}");
if (!primary.IsNull)
{
results.Add(SnapshotTaskbar(primary, isPrimary: true, log));
}
var secondary = HWND.Null;
while (true)
{
secondary = PInvoke.FindWindowEx(
HWND.Null, secondary, "Shell_SecondaryTrayWnd", null);
if (secondary.IsNull)
{
break;
}
log?.WriteLine($"[poll] Shell_SecondaryTrayWnd = 0x{(nint)secondary.Value:X}");
results.Add(SnapshotTaskbar(secondary, isPrimary: false, log));
}
return results;
}
private TaskbarSnapshot SnapshotTaskbar(HWND taskbarHwnd, bool isPrimary, TextWriter? log)
{
PInvoke.GetWindowRect(taskbarHwnd, out var taskbarRect);
var dpi = PInvoke.GetDpiForWindow(taskbarHwnd);
var isBottom = taskbarRect.Width > taskbarRect.Height;
log?.WriteLine($"[snap] rect=({taskbarRect.left},{taskbarRect.top},{taskbarRect.right},{taskbarRect.bottom}) dpi={dpi} isBottom={isBottom}");
int buttonsWidth = 0;
int trayWidth = 0;
int buttonCount = 0;
if (isBottom)
{
buttonsWidth = MeasureTaskbarButtons(taskbarHwnd, out buttonCount, log);
trayWidth = MeasureTray(taskbarHwnd);
log?.WriteLine($"[snap] buttonsWidth={buttonsWidth} buttonCount={buttonCount} trayWidth={trayWidth}");
}
return new TaskbarSnapshot
{
IsPrimary = isPrimary,
TaskbarWidth = taskbarRect.Width,
TaskbarHeight = taskbarRect.Height,
IsBottom = isBottom,
ButtonsWidth = buttonsWidth,
TrayWidth = trayWidth,
Dpi = dpi,
ButtonCount = buttonCount,
};
}
private int MeasureTaskbarButtons(HWND taskbarHwnd, out int buttonCount, TextWriter? log)
{
buttonCount = 0;
if (_automation == null)
{
log?.WriteLine("[buttons] _automation is null");
return 0;
}
IUIAutomationElement* root = null;
IUIAutomationElementArray* children = null;
IUIAutomationElementArray* descendants = null;
try
{
var hr = _automation->ElementFromHandle(taskbarHwnd, &root);
log?.WriteLine($"[buttons] ElementFromHandle(taskbar) hr=0x{hr.Value:X8} root={(nint)root:X}");
if (hr.Failed || root == null)
{
return 0;
}
// Step 1: Find the MSTaskSwWClass or MSTaskListWClass child to
// determine the clip region for the running-app buttons area.
hr = root->FindAll(TreeScope.TreeScope_Children, _trueCondition, &children);
int childCount = 0;
if (hr.Succeeded && children != null)
{
children->get_Length(&childCount);
}
double clipX = 0, clipY = 0, clipW = 0, clipH = 0;
var foundClip = false;
for (var i = 0; i < childCount; i++)
{
IUIAutomationElement* child = null;
try
{
hr = children->GetElement(i, &child);
if (hr.Failed || child == null)
{
continue;
}
VARIANT varClass = default;
child->GetCurrentPropertyValue(UIA_PROPERTY_ID.UIA_ClassNamePropertyId, &varClass);
var isTaskArea = false;
if (varClass.Anonymous.Anonymous.vt == VARENUM.VT_BSTR &&
varClass.Anonymous.Anonymous.Anonymous.bstrVal.Value != null)
{
var className = new string(varClass.Anonymous.Anonymous.Anonymous.bstrVal.Value);
log?.WriteLine($"[buttons] child[{i}] class={className}");
isTaskArea = className is "MSTaskSwWClass" or "MSTaskListWClass";
}
PInvoke.VariantClear(&varClass);
if (isTaskArea && !foundClip)
{
VARIANT varRect = default;
child->GetCurrentPropertyValue(UIA_PROPERTY_ID.UIA_BoundingRectanglePropertyId, &varRect);
if (varRect.Anonymous.Anonymous.vt == (VARENUM.VT_R8 | VARENUM.VT_ARRAY))
{
var psa = varRect.Anonymous.Anonymous.Anonymous.parray;
if (psa != null)
{
int idx = 0;
PInvoke.SafeArrayGetElement(psa, &idx, &clipX);
idx = 1; PInvoke.SafeArrayGetElement(psa, &idx, &clipY);
idx = 2; PInvoke.SafeArrayGetElement(psa, &idx, &clipW);
idx = 3; PInvoke.SafeArrayGetElement(psa, &idx, &clipH);
foundClip = true;
log?.WriteLine($"[buttons] clip region: x={clipX} y={clipY} w={clipW} h={clipH}");
}
}
PInvoke.VariantClear(&varRect);
}
}
finally
{
if (child != null)
{
((IUnknown*)child)->Release();
}
}
}
if (!foundClip)
{
log?.WriteLine("[buttons] No task area child found");
return 0;
}
// Step 2: Enumerate ALL descendants of the entire taskbar.
// The buttons live inside the XAML Islands window, not as
// direct UIA children of MSTaskSwWClass. Filter by position:
// buttons start at clipLeft and go until the tray area.
hr = root->FindAll(TreeScope.TreeScope_Descendants, _trueCondition, &descendants);
int descCount = 0;
if (hr.Succeeded && descendants != null)
{
descendants->get_Length(&descCount);
}
log?.WriteLine($"[buttons] Descendants count={descCount}");
// The right boundary is the tray area's left edge (if present),
// otherwise the taskbar's right edge.
PInvoke.GetWindowRect(taskbarHwnd, out var taskbarRect);
var trayHwnd = PInvoke.FindWindowEx(taskbarHwnd, HWND.Null, "TrayNotifyWnd", null);
int rightBoundary = taskbarRect.right;
if (!trayHwnd.IsNull)
{
PInvoke.GetWindowRect(trayHwnd, out var trayRect);
rightBoundary = trayRect.left;
}
var clipLeft = (int)clipX;
log?.WriteLine($"[buttons] clipLeft={clipLeft} rightBoundary={rightBoundary}");
var maxRight = int.MinValue;
for (var i = 0; i < descCount; i++)
{
IUIAutomationElement* desc = null;
try
{
hr = descendants->GetElement(i, &desc);
if (hr.Failed || desc == null)
{
continue;
}
// Only count buttons (ControlType = 50000)
VARIANT varType = default;
desc->GetCurrentPropertyValue(UIA_PROPERTY_ID.UIA_ControlTypePropertyId, &varType);
var typeId = 0;
if (varType.Anonymous.Anonymous.vt == VARENUM.VT_I4)
{
typeId = varType.Anonymous.Anonymous.Anonymous.lVal;
}
PInvoke.VariantClear(&varType);
if (typeId != 50000)
{
continue;
}
VARIANT varRect = default;
hr = desc->GetCurrentPropertyValue(
UIA_PROPERTY_ID.UIA_BoundingRectanglePropertyId, &varRect);
if (hr.Succeeded &&
varRect.Anonymous.Anonymous.vt == (VARENUM.VT_R8 | VARENUM.VT_ARRAY))
{
var psa = varRect.Anonymous.Anonymous.Anonymous.parray;
if (psa != null)
{
double x = 0, y = 0, w = 0, h = 0;
int idx = 0;
PInvoke.SafeArrayGetElement(psa, &idx, &x);
idx = 1; PInvoke.SafeArrayGetElement(psa, &idx, &y);
idx = 2; PInvoke.SafeArrayGetElement(psa, &idx, &w);
idx = 3; PInvoke.SafeArrayGetElement(psa, &idx, &h);
var btnLeft = (int)x;
var btnRight = (int)(x + w);
// Count buttons to the left of the tray area
if (btnLeft < rightBoundary)
{
if (buttonCount < 3)
{
log?.WriteLine($"[buttons] btn[{buttonCount}] x={x} y={y} w={w} h={h} right={btnRight}");
}
if (btnRight > maxRight)
{
maxRight = btnRight;
}
buttonCount++;
}
}
}
PInvoke.VariantClear(&varRect);
}
finally
{
if (desc != null)
{
((IUnknown*)desc)->Release();
}
}
}
log?.WriteLine($"[buttons] maxRight={maxRight} taskbar.left={taskbarRect.left} buttonCount={buttonCount}");
if (buttonCount == 0)
{
return 0;
}
return maxRight > taskbarRect.left ? maxRight - taskbarRect.left : 0;
}
finally
{
if (descendants != null)
{
((IUnknown*)descendants)->Release();
}
if (children != null)
{
((IUnknown*)children)->Release();
}
if (root != null)
{
((IUnknown*)root)->Release();
}
}
}
private static int MeasureTray(HWND taskbarHwnd)
{
var tray = PInvoke.FindWindowEx(taskbarHwnd, HWND.Null, "TrayNotifyWnd", null);
if (tray.IsNull)
{
return 0;
}
PInvoke.GetWindowRect(tray, out var rect);
return rect.Width;
}
private void EnsureAutomation(TextWriter? log = null)
{
if (_automation != null)
{
return;
}
IUIAutomation* automation;
var hr = PInvoke.CoCreateInstance(
CLSID_CUIAutomation,
(IUnknown*)null,
CLSCTX.CLSCTX_INPROC_SERVER,
out automation);
log?.WriteLine($"[uia] CoCreateInstance hr=0x{hr.Value:X8} ptr={(nint)automation:X}");
if (hr.Failed || automation == null)
{
return;
}
_automation = automation;
IUIAutomationCondition* condition;
hr = _automation->CreateTrueCondition(&condition);
log?.WriteLine($"[uia] CreateTrueCondition hr=0x{hr.Value:X8} ptr={(nint)condition:X}");
if (hr.Succeeded)
{
_trueCondition = condition;
}
}
public void Dispose()
{
if (!_disposed)
{
if (_trueCondition != null)
{
((IUnknown*)_trueCondition)->Release();
_trueCondition = null;
}
if (_automation != null)
{
((IUnknown*)_automation)->Release();
_automation = null;
}
_disposed = true;
}
}
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace TaskbarMonitor;
/// <summary>
/// Immutable snapshot of taskbar metrics at a point in time.
/// </summary>
public sealed record TaskbarSnapshot
{
/// <summary>Whether this is the primary taskbar (Shell_TrayWnd) or a secondary one.</summary>
public bool IsPrimary { get; init; }
public int TaskbarWidth { get; init; }
public int TaskbarHeight { get; init; }
public bool IsBottom { get; init; }
/// <summary>
/// Total width occupied by taskbar buttons (pixels). Only meaningful when <see cref="IsBottom"/>.
/// </summary>
public int ButtonsWidth { get; init; }
/// <summary>
/// Width of the notification/tray area (pixels). Only meaningful when <see cref="IsBottom"/>.
/// </summary>
public int TrayWidth { get; init; }
/// <summary>
/// DPI for the monitor this taskbar lives on.
/// </summary>
public uint Dpi { get; init; }
/// <summary>
/// DPI scale factor (Dpi / 96.0).
/// </summary>
public double ScaleFactor => Dpi / 96.0;
/// <summary>
/// Number of top-level child windows found in the button list.
/// </summary>
public int ButtonCount { get; init; }
}

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.Text;
namespace TaskbarMonitor;
/// <summary>
/// Renders <see cref="TaskbarSnapshot"/> data to the alternate screen buffer.
/// </summary>
public static class TaskbarView
{
private const string Esc = "\x1b";
public static void EnterAlternateScreen()
{
Console.Write($"{Esc}[?1049h"); // enter alt buffer
Console.Write($"{Esc}[?25l"); // hide cursor
Console.CursorVisible = false;
}
public static void LeaveAlternateScreen()
{
Console.Write($"{Esc}[?25h"); // show cursor
Console.Write($"{Esc}[?1049l"); // leave alt buffer
}
public static void Render(List<TaskbarSnapshot> snapshots, List<TaskbarSnapshot>? previous)
{
if (previous != null && snapshots.SequenceEqual(previous))
{
return;
}
var sb = new StringBuilder(1024);
sb.Append($"{Esc}[H"); // home
sb.Append($"{Esc}[2J"); // clear
sb.Append($"{Esc}[1m┌──────────────────────────────────────────┐{Esc}[0m\n");
sb.Append($"{Esc}[1m│ Taskbar Monitor │{Esc}[0m\n");
sb.Append($"{Esc}[1m└──────────────────────────────────────────┘{Esc}[0m\n");
sb.AppendLine();
if (snapshots.Count == 0)
{
sb.Append($" {Esc}[33mNo taskbar found.{Esc}[0m\n");
}
else
{
for (var i = 0; i < snapshots.Count; i++)
{
RenderOne(sb, snapshots[i], i);
}
}
if (previous != null && !snapshots.SequenceEqual(previous))
{
sb.Append($" {Esc}[33m● Changed{Esc}[0m\n");
}
else
{
sb.Append($" {Esc}[32m● Up to date{Esc}[0m\n");
}
sb.AppendLine();
sb.Append($" {Esc}[2mEvent-driven (SetWinEventHook). Press Ctrl+C to exit.{Esc}[0m\n");
Console.Write(sb);
}
private static void RenderOne(StringBuilder sb, TaskbarSnapshot s, int index)
{
var label = s.IsPrimary ? "Primary" : $"Secondary #{index}";
sb.Append($" {Esc}[1m── {label} ──{Esc}[0m\n");
sb.AppendLine();
var scale = s.ScaleFactor;
sb.Append($" {Esc}[36mSize:{Esc}[0m {s.TaskbarWidth} × {s.TaskbarHeight} px");
sb.Append($" ({s.TaskbarWidth / scale:F0} × {s.TaskbarHeight / scale:F0} DIPs)\n");
sb.Append($" {Esc}[36mDPI:{Esc}[0m {s.Dpi} ({scale:F2}x)\n");
sb.Append($" {Esc}[36mPosition:{Esc}[0m ");
if (s.IsBottom)
{
sb.Append($"{Esc}[32mBottom{Esc}[0m ✓\n");
}
else
{
sb.Append($"{Esc}[33mNot bottom{Esc}[0m (side/top metrics skipped)\n");
sb.AppendLine();
return;
}
sb.AppendLine();
sb.Append($" {Esc}[36mButtons:{Esc}[0m {s.ButtonsWidth} px");
sb.Append($" ({s.ButtonsWidth / scale:F0} DIPs)");
sb.Append($" [{s.ButtonCount} children]\n");
sb.Append($" {Esc}[36mTray:{Esc}[0m {s.TrayWidth} px");
sb.Append($" ({s.TrayWidth / scale:F0} DIPs)\n");
sb.AppendLine();
// Visual bar
var barWidth = Math.Min(60, Console.WindowWidth - 4);
if (barWidth > 10 && s.TaskbarWidth > 0)
{
var btnCols = (int)Math.Round((double)s.ButtonsWidth / s.TaskbarWidth * barWidth);
var trayCols = (int)Math.Round((double)s.TrayWidth / s.TaskbarWidth * barWidth);
var midCols = Math.Max(0, barWidth - btnCols - trayCols);
sb.Append(" ");
sb.Append($"{Esc}[44m{new string('█', btnCols)}{Esc}[0m");
sb.Append($"{Esc}[100m{new string('░', midCols)}{Esc}[0m");
sb.Append($"{Esc}[45m{new string('█', trayCols)}{Esc}[0m");
sb.AppendLine();
sb.Append($" {Esc}[34mbuttons{Esc}[0m");
var pad = barWidth - 7 - 4;
if (pad > 0)
{
sb.Append(new string(' ', pad));
}
sb.Append($"{Esc}[35mtray{Esc}[0m");
sb.AppendLine();
}
sb.AppendLine();
}
}

View File

@@ -0,0 +1,97 @@
// 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.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.UI.Accessibility;
namespace TaskbarMonitor;
/// <summary>
/// Watches for taskbar changes using SetWinEventHook (EVENT_OBJECT_REORDER,
/// EVENT_OBJECT_CREATE, EVENT_OBJECT_DESTROY, EVENT_OBJECT_NAMECHANGE).
/// Fires <see cref="Changed"/> when a change is detected.
/// <para>
/// Must be created and disposed on the thread that runs the message pump.
/// </para>
/// </summary>
public sealed unsafe class TaskbarWatcher : IDisposable
{
private readonly List<HWINEVENTHOOK> _hooks = new();
private bool _disposed;
/// <summary>Raised when any taskbar-related accessibility event fires.</summary>
public event Action? Changed;
// Must be stored in a field to prevent GC from collecting the pointer target.
// (The static method reference is fine, but the delegate instance is not.)
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
private static void WinEventProc(
HWINEVENTHOOK hWinEventHook,
uint @event,
HWND hwnd,
int idObject,
int idChild,
uint idEventThread,
uint dwmsEventTime)
{
_instance?.Changed?.Invoke();
}
// Static instance reference so the static callback can reach our event.
// Only one TaskbarWatcher is expected per process.
private static TaskbarWatcher? _instance;
public TaskbarWatcher()
{
_instance = this;
// Hook events that indicate taskbar button changes:
// EVENT_OBJECT_REORDER (0x8004) — children reordered
// EVENT_OBJECT_CREATE (0x8000) — new element created
// EVENT_OBJECT_DESTROY (0x8001) — element removed
// EVENT_OBJECT_NAMECHANGE (0x800C) — element renamed (e.g. window title change)
uint[] events =
[
PInvoke.EVENT_OBJECT_REORDER,
PInvoke.EVENT_OBJECT_CREATE,
PInvoke.EVENT_OBJECT_DESTROY,
PInvoke.EVENT_OBJECT_NAMECHANGE,
];
foreach (var evt in events)
{
var hook = PInvoke.SetWinEventHook(
evt,
evt,
HMODULE.Null,
&WinEventProc,
0, // all processes
0, // all threads
PInvoke.WINEVENT_OUTOFCONTEXT);
if (!hook.IsNull)
{
_hooks.Add(hook);
}
}
}
public void Dispose()
{
if (!_disposed)
{
foreach (var hook in _hooks)
{
PInvoke.UnhookWinEvent(hook);
}
_hooks.Clear();
_instance = null;
_disposed = true;
}
}
}