mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-05 09:59:28 +02:00
Compare commits
18 Commits
dependabot
...
dev/migrie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816575f0ea | ||
|
|
bb0a43c5f1 | ||
|
|
28b09ab1e1 | ||
|
|
f22d21632e | ||
|
|
7f4574bb4f | ||
|
|
d93307dd9b | ||
|
|
b8772c6192 | ||
|
|
22a06fdf51 | ||
|
|
4176025910 | ||
|
|
f489c25d34 | ||
|
|
56e9203316 | ||
|
|
674b22bcd5 | ||
|
|
c9244ec2d1 | ||
|
|
14e443751b | ||
|
|
ba77ec11b7 | ||
|
|
9f47f9f9bb | ||
|
|
34d0d26e2e | ||
|
|
9cfd0dd7f3 |
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://aka.ms/CsWin32.schema.json",
|
||||
"allowMarshaling": false,
|
||||
"comInterop": {
|
||||
"preserveSigMethods": ["*"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
FindWindow
|
||||
FindWindowEx
|
||||
GetWindowRect
|
||||
GetDpiForWindow
|
||||
CoCreateInstance
|
||||
IUIAutomation
|
||||
IUIAutomationElement
|
||||
IUIAutomationElementArray
|
||||
IUIAutomationCondition
|
||||
UIA_PROPERTY_ID
|
||||
TreeScope
|
||||
SafeArrayGetElement
|
||||
VariantClear
|
||||
VARIANT
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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.
|
||||
@@ -4,4 +4,4 @@
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
|
||||
public record EnterDockEditModeMessage();
|
||||
public record ExitEditModeMessage(bool Save);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -112,3 +112,10 @@ AttachThreadInput
|
||||
GetWindowPlacement
|
||||
WINDOWPLACEMENT
|
||||
WM_DPICHANGED
|
||||
|
||||
FindWindow
|
||||
FindWindowEx
|
||||
SetParent
|
||||
CreateRectRgn
|
||||
SetWindowRgn
|
||||
WM_DESTROY
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=}">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.EnableTaskbar, Mode=TwoWay}" />
|
||||
</controls:SettingsCard>
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<TextBlock x:Uid="DockAppearanceSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
x:Name="MoreButton"
|
||||
Padding="4"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="Collapsed">
|
||||
<FontIcon FontSize="12" Glyph="" />
|
||||
<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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/modules/cmdpal/tools/TaskbarMonitor/AppJsonContext.cs
Normal file
13
src/modules/cmdpal/tools/TaskbarMonitor/AppJsonContext.cs
Normal 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
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate from repo-level MSBuild settings so this tool builds standalone with dotnet build -->
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
<Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
180
src/modules/cmdpal/tools/TaskbarMonitor/HwndTest.cs
Normal file
180
src/modules/cmdpal/tools/TaskbarMonitor/HwndTest.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://aka.ms/CsWin32.schema.json",
|
||||
"allowMarshaling": false,
|
||||
"comInterop": {
|
||||
"preserveSigMethods": [ "*" ]
|
||||
}
|
||||
}
|
||||
52
src/modules/cmdpal/tools/TaskbarMonitor/NativeMethods.txt
Normal file
52
src/modules/cmdpal/tools/TaskbarMonitor/NativeMethods.txt
Normal 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
|
||||
176
src/modules/cmdpal/tools/TaskbarMonitor/Program.cs
Normal file
176
src/modules/cmdpal/tools/TaskbarMonitor/Program.cs
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
388
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarPoller.cs
Normal file
388
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarPoller.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarSnapshot.cs
Normal file
45
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarSnapshot.cs
Normal 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; }
|
||||
}
|
||||
134
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarView.cs
Normal file
134
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarView.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
97
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarWatcher.cs
Normal file
97
src/modules/cmdpal/tools/TaskbarMonitor/TaskbarWatcher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user