CmdPal Dock: Multi-monitor support (#46915)

This pull request introduces per-monitor dock customization support and
refactors how dock band settings are managed to enable independent
layouts on different monitors. The changes add a new
`DockMonitorConfigViewModel` for monitor-specific configuration, update
`DockViewModel` to handle per-monitor band lists and settings, and
refactor band movement and ordering logic to respect per-monitor
overrides.

**Per-monitor dock customization:**

* Added `DockMonitorConfigViewModel` to encapsulate the configuration
and state for each monitor, exposing properties for binding and
persisting changes using `ISettingsService`.
* Updated `DockViewModel` to track an optional `MonitorDeviceId`,
enabling docks to be associated with a specific monitor and to expose
per-monitor settings and methods.
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L17-R33)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L41-R56)

**Band management refactor for per-monitor settings:**

* Refactored band retrieval and update logic in `DockViewModel` to use
new helper methods (`GetActiveBands`, `WithActiveBands`) that select and
modify either global or per-monitor band lists as appropriate.
* Updated band movement and ordering methods (`SyncBandPosition`,
`MoveBandWithoutSaving`, `SaveBandOrder`) to operate on the correct band
lists for each monitor, ensuring that changes apply to the intended
scope (global or per-monitor).
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L205-R399)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L250-R413)
[[3]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L263-R424)
[[4]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L280-R437)
[[5]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L290-R447)
[[6]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L300-R465)
[[7]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L329-R491)

**Resource management:**

* Implemented `IDisposable` on `DockViewModel` to clean up event
handlers and prevent resource leaks.
[[1]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06L17-R33)
[[2]](diffhunk://#diff-b661a9311de64dd1123860e858f1f4963f05ccee06b5bd218916635495b2ff06R85-R235)

Closes #46939

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Mike Griese <migrie@microsoft.com>
This commit is contained in:
Michael Jolley
2026-05-13 17:37:58 -05:00
committed by GitHub
parent 34cebb8285
commit 966c1db76a
30 changed files with 2946 additions and 178 deletions

View File

@@ -496,16 +496,37 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null)
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
var settings = settingsService.Settings;
var dockSettings = settings.DockSettings;
// Prevent duplicate pins — check all sections
if (dockSettings.StartBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.CenterBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
dockSettings.EndBands.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId))
// Prevent duplicate pins — check the target destination's bands.
// When pinning to a specific monitor, check that monitor's resolved bands
// (which include forked-from-global bands). Otherwise, check global bands.
DockMonitorConfig? targetConfig = null;
if (monitorDeviceId is not null)
{
foreach (var cfg in dockSettings.MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
targetConfig = cfg;
break;
}
}
}
var resolvedStart = targetConfig?.ResolveStartBands(dockSettings.StartBands) ?? dockSettings.StartBands;
var resolvedCenter = targetConfig?.ResolveCenterBands(dockSettings.CenterBands) ?? dockSettings.CenterBands;
var resolvedEnd = targetConfig?.ResolveEndBands(dockSettings.EndBands) ?? dockSettings.EndBands;
var alreadyPinned = resolvedStart.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedCenter.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId) ||
resolvedEnd.Any(b => b.CommandId == commandId && b.ProviderId == this.ProviderId);
if (alreadyPinned)
{
Logger.LogDebug($"Dock band '{commandId}' from provider '{this.ProviderId}' is already pinned; skipping.");
return;
@@ -519,6 +540,21 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
ShowSubtitles = showSubtitles,
};
if (monitorDeviceId is not null)
{
PinDockBandToMonitor(settingsService, bandSettings, side, monitorDeviceId);
}
else
{
PinDockBandGlobal(settingsService, bandSettings, side);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
{
settingsService.UpdateSettings(
s =>
{
@@ -534,9 +570,59 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
};
},
hotReload: false);
}
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
private static void PinDockBandToMonitor(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side, string monitorDeviceId)
{
settingsService.UpdateSettings(
s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
// Find or create the monitor config
DockMonitorConfig? target = null;
var targetIndex = -1;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, monitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
target = configs[i];
targetIndex = i;
break;
}
}
if (target is null)
{
// Monitor not yet in config; create and fork from global
target = new DockMonitorConfig { MonitorDeviceId = monitorDeviceId, Enabled = true };
target = target.ForkFromGlobal(dockSettings);
configs = configs.Add(target);
targetIndex = configs.Count - 1;
}
else if (!target.IsCustomized)
{
// Fork from global on first per-monitor customization
target = target.ForkFromGlobal(dockSettings);
}
// Add band to the appropriate section
target = side switch
{
Dock.DockPinSide.Center => target with { CenterBands = (target.CenterBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
Dock.DockPinSide.End => target with { EndBands = (target.EndBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
_ => target with { StartBands = (target.StartBands ?? System.Collections.Immutable.ImmutableList<DockBandSettings>.Empty).Add(bandSettings) },
};
configs = configs.SetItem(targetIndex, target);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs },
};
},
hotReload: false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)

View File

@@ -105,7 +105,18 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
/// </summary>
internal void SaveShowLabels()
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
// Only write to settings if the label values actually changed from
// the snapshot. When multiple non-customized monitors share global
// bands, an unconditional save would overwrite changes made by
// another monitor's ViewModel (last-save-wins clobber).
var changed = _showTitlesSnapshot is null
|| _showTitles != _showTitlesSnapshot
|| _showSubtitles != _showSubtitlesSnapshot;
if (changed)
{
ReplaceBandInSettings(_bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles });
}
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
@@ -135,15 +146,52 @@ public sealed partial class DockBandViewModel : ExtensionObjectViewModel
s =>
{
var dockSettings = s.DockSettings;
return s with
// Update in global bands
var updatedDock = dockSettings with
{
DockSettings = dockSettings with
{
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
},
StartBands = ReplaceBandInList(dockSettings.StartBands, commandId, newSettings),
CenterBands = ReplaceBandInList(dockSettings.CenterBands, commandId, newSettings),
EndBands = ReplaceBandInList(dockSettings.EndBands, commandId, newSettings),
};
// Also update in per-monitor bands for customized monitors
var configs = updatedDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
var configsChanged = false;
for (var i = 0; i < configs.Count; i++)
{
var config = configs[i];
if (!config.IsCustomized)
{
continue;
}
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
var newStart = ReplaceBandInList(start, commandId, newSettings);
var newCenter = ReplaceBandInList(center, commandId, newSettings);
var newEnd = ReplaceBandInList(end, commandId, newSettings);
if (newStart != start || newCenter != center || newEnd != end)
{
configs = configs.SetItem(i, config with
{
StartBands = newStart,
CenterBands = newCenter,
EndBands = newEnd,
});
configsChanged = true;
}
}
if (configsChanged)
{
updatedDock = updatedDock with { MonitorConfigs = configs };
}
return s with { DockSettings = updatedDock };
},
false);
_bandSettings = newSettings;

View File

@@ -0,0 +1,197 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
/// <summary>
/// ViewModel wrapping a <see cref="DockMonitorConfig"/> paired with its
/// <see cref="MonitorInfo"/>. Exposes bindable properties for the monitor
/// config UI and persists changes through <see cref="ISettingsService"/>.
/// </summary>
public partial class DockMonitorConfigViewModel : ObservableObject
{
private static readonly CompositeFormat ResolutionFormat = CompositeFormat.Parse("{0} \u00D7 {1}");
private readonly MonitorInfo _monitorInfo;
private readonly ISettingsService _settingsService;
private readonly string _monitorDeviceId;
public DockMonitorConfigViewModel(
DockMonitorConfig config,
MonitorInfo monitorInfo,
ISettingsService settingsService)
{
_monitorInfo = monitorInfo;
_settingsService = settingsService;
_monitorDeviceId = config.MonitorDeviceId;
}
/// <summary>Gets the human-readable display name from the monitor hardware.</summary>
public string DisplayName => _monitorInfo.DisplayName;
/// <summary>Gets the stable device identifier for this monitor.</summary>
public string DeviceId => _monitorInfo.DeviceId;
/// <summary>Gets a value indicating whether this is the primary monitor.</summary>
public bool IsPrimary => _monitorInfo.IsPrimary;
/// <summary>Gets the monitor resolution formatted as "W × H".</summary>
public string Resolution => string.Format(
CultureInfo.CurrentCulture,
ResolutionFormat,
_monitorInfo.Bounds.Width,
_monitorInfo.Bounds.Height);
/// <summary>
/// Gets or sets a value indicating whether the dock is enabled on this monitor.
/// </summary>
public bool IsEnabled
{
get => GetConfig()?.Enabled ?? true;
set
{
UpdateConfig(c => c with { Enabled = value });
OnPropertyChanged();
}
}
/// <summary>
/// Gets or sets the side-override index for ComboBox binding.
/// 0 = "Use default" (inherit), 1 = Left, 2 = Top, 3 = Right, 4 = Bottom.
/// </summary>
public int SideOverrideIndex
{
get => GetConfig()?.Side switch
{
null => 0,
DockSide.Left => 1,
DockSide.Top => 2,
DockSide.Right => 3,
DockSide.Bottom => 4,
_ => 0,
};
set
{
var newSide = value switch
{
1 => (DockSide?)DockSide.Left,
2 => (DockSide?)DockSide.Top,
3 => (DockSide?)DockSide.Right,
4 => (DockSide?)DockSide.Bottom,
_ => null,
};
UpdateConfig(c => c with { Side = newSide });
OnPropertyChanged();
OnPropertyChanged(nameof(HasSideOverride));
}
}
/// <summary>Gets a value indicating whether this monitor has a per-monitor side override.</summary>
public bool HasSideOverride => GetConfig()?.Side is not null;
/// <summary>
/// Gets or sets a value indicating whether this monitor uses custom band pinning.
/// When toggled ON, forks band lists from global settings.
/// When toggled OFF, clears per-monitor bands.
/// </summary>
public bool IsCustomized
{
get => GetConfig()?.IsCustomized ?? false;
set
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var config = configs[index];
DockMonitorConfig updated;
if (value)
{
updated = config.ForkFromGlobal(dockSettings);
}
else
{
updated = config with
{
IsCustomized = false,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
}
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
OnPropertyChanged();
}
}
private DockMonitorConfig? GetConfig()
{
var configs = _settingsService.Settings.DockSettings.MonitorConfigs;
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return configs[i];
}
}
return null;
}
private void UpdateConfig(Func<DockMonitorConfig, DockMonitorConfig> transform)
{
_settingsService.UpdateSettings(s =>
{
var dockSettings = s.DockSettings;
var configs = dockSettings.MonitorConfigs;
var index = FindConfigIndex(configs);
if (index < 0)
{
return s;
}
var updated = transform(configs[index]);
return s with
{
DockSettings = dockSettings with { MonitorConfigs = configs.SetItem(index, updated) },
};
});
}
private int FindConfigIndex(ImmutableList<DockMonitorConfig> configs)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return i;
}
}
return -1;
}
}

View File

@@ -14,15 +14,23 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
public sealed partial class DockViewModel : IDisposable
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly ISettingsService _settingsService;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private readonly string? _monitorDeviceId;
private DockSettings _settings;
private bool _isEditing;
private bool _disposed;
/// <summary>
/// Gets the monitor device identifier this dock is associated with, or <c>null</c>
/// for the default (single-monitor) dock.
/// </summary>
public string? MonitorDeviceId => _monitorDeviceId;
public TaskScheduler Scheduler { get; }
@@ -38,12 +46,14 @@ public sealed partial class DockViewModel
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
TaskScheduler scheduler,
ISettingsService settingsService)
ISettingsService settingsService,
string? monitorDeviceId = null)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsService = settingsService;
_settings = _settingsService.Settings.DockSettings;
_monitorDeviceId = monitorDeviceId;
Scheduler = scheduler;
_pageContext = new(this);
@@ -72,17 +82,168 @@ public sealed partial class DockViewModel
public void UpdateSettings(DockSettings settings)
{
if (_isEditing)
{
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
return;
}
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
/// <summary>
/// Initializes bands from current settings. Call after the UI scheduler is ready
/// (i.e., after the DockWindow is shown) to ensure proper dispatcher access.
/// </summary>
public void InitializeBands() => SetupBands();
/// <summary>
/// Gets the active band lists for this dock instance. Returns per-monitor bands
/// when the associated monitor is customized; otherwise falls back to global bands.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBands()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(_settings.StartBands),
config.ResolveCenterBands(_settings.CenterBands),
config.ResolveEndBands(_settings.EndBands));
}
}
return (_settings.StartBands, _settings.CenterBands, _settings.EndBands);
}
/// <summary>
/// Returns an updated <see cref="DockSettings"/> with the given bands placed in the
/// correct location — per-monitor config when customized, or global otherwise.
/// </summary>
private DockSettings WithActiveBands(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
return _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, updatedConfig),
};
}
}
return _settings with
{
StartBands = start,
CenterBands = center,
EndBands = end,
};
}
/// <summary>
/// Ensures the monitor associated with this dock has its own independent band lists.
/// If the monitor is not yet customized, forks bands from global settings.
/// Returns <c>true</c> if the fork was performed, <c>false</c> if already customized or no monitor.
/// </summary>
public bool EnsureMonitorForked()
{
if (_monitorDeviceId is null)
{
return false;
}
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is null || config.IsCustomized)
{
return false;
}
var forked = config.ForkFromGlobal(_settings);
_settings = _settings with
{
MonitorConfigs = ReplaceMonitorConfig(_settings.MonitorConfigs, forked),
};
SaveSettings();
return true;
}
/// <summary>
/// Gets the effective dock side for this instance, considering per-monitor overrides.
/// </summary>
public DockSide GetEffectiveSide()
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(_settings, _monitorDeviceId);
if (config is not null)
{
return config.ResolveSide(_settings.Side);
}
}
return _settings.Side;
}
private static DockMonitorConfig? FindMonitorConfig(DockSettings settings, string deviceId)
{
var configs = settings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (string.Equals(config.MonitorDeviceId, deviceId, System.StringComparison.OrdinalIgnoreCase))
{
return config;
}
}
return null;
}
private static ImmutableList<DockMonitorConfig> ReplaceMonitorConfig(
ImmutableList<DockMonitorConfig> configs,
DockMonitorConfig updated)
{
for (var i = 0; i < configs.Count; i++)
{
if (string.Equals(configs[i].MonitorDeviceId, updated.MonitorDeviceId, System.StringComparison.OrdinalIgnoreCase))
{
return configs.SetItem(i, updated);
}
}
return configs.Add(updated);
}
public void Dispose()
{
if (!_disposed)
{
_topLevelCommandManager.DockBands.CollectionChanged -= DockBands_CollectionChanged;
_disposed = true;
}
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
var (start, center, end) = GetActiveBands();
SetupBands(start, StartItems);
SetupBands(center, CenterItems);
SetupBands(end, EndItems);
}
private void SetupBands(
@@ -207,42 +368,46 @@ public sealed partial class DockViewModel
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
// Remove from all active band lists
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
// Add to target list at the correct index
var targetList = targetSide switch
{
DockPinSide.Start => newDock.StartBands,
DockPinSide.Center => newDock.CenterBands,
DockPinSide.End => newDock.EndBands,
_ => newDock.StartBands,
DockPinSide.Start => newStart,
DockPinSide.Center => newCenter,
DockPinSide.End => newEnd,
_ => newStart,
};
var insertIndex = Math.Min(targetIndex, targetList.Count);
newDock = targetSide switch
switch (targetSide)
{
DockPinSide.Start => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.Center => newDock with { CenterBands = targetList.Insert(insertIndex, bandSettings) },
DockPinSide.End => newDock with { EndBands = targetList.Insert(insertIndex, bandSettings) },
_ => newDock with { StartBands = targetList.Insert(insertIndex, bandSettings) },
};
_settings = newDock;
case DockPinSide.Start:
newStart = newStart.Insert(insertIndex, bandSettings);
break;
case DockPinSide.Center:
newCenter = newCenter.Insert(insertIndex, bandSettings);
break;
case DockPinSide.End:
default:
newEnd = newEnd.Insert(insertIndex, bandSettings);
break;
}
_settings = WithActiveBands(newStart, newCenter, newEnd);
}
/// <summary>
@@ -252,11 +417,11 @@ public sealed partial class DockViewModel
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
var bandSettings = activeStart.FirstOrDefault(b => b.CommandId == bandId)
?? activeCenter.FirstOrDefault(b => b.CommandId == bandId)
?? activeEnd.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
@@ -265,12 +430,9 @@ public sealed partial class DockViewModel
}
// Remove from all sides (settings)
var newDock = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
var newStart = activeStart.RemoveAll(b => b.CommandId == bandId);
var newCenter = activeCenter.RemoveAll(b => b.CommandId == bandId);
var newEnd = activeEnd.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
@@ -282,8 +444,8 @@ public sealed partial class DockViewModel
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, newDock.StartBands.Count);
newDock = newDock with { StartBands = newDock.StartBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newStart.Count);
newStart = newStart.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
@@ -292,8 +454,8 @@ public sealed partial class DockViewModel
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, newDock.CenterBands.Count);
newDock = newDock with { CenterBands = newDock.CenterBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newCenter.Count);
newCenter = newCenter.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
@@ -302,8 +464,8 @@ public sealed partial class DockViewModel
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, newDock.EndBands.Count);
newDock = newDock with { EndBands = newDock.EndBands.Insert(settingsIndex, bandSettings) };
var settingsIndex = Math.Min(targetIndex, newEnd.Count);
newEnd = newEnd.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
@@ -311,7 +473,7 @@ public sealed partial class DockViewModel
}
}
_settings = newDock;
_settings = WithActiveBands(newStart, newCenter, newEnd);
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
@@ -331,29 +493,95 @@ public sealed partial class DockViewModel
// Preserve any per-band label edits made while in edit mode. Those edits are
// saved independently of reorder, so merge the latest band settings back into
// the local reordered snapshot before we persist dock settings.
var latestBandSettings = BuildBandSettingsLookup(_settingsService.Settings.DockSettings);
_settings = _settings with
{
StartBands = MergeBandSettings(_settings.StartBands, latestBandSettings),
CenterBands = MergeBandSettings(_settings.CenterBands, latestBandSettings),
EndBands = MergeBandSettings(_settings.EndBands, latestBandSettings),
};
var (latestStart, latestCenter, latestEnd) = GetActiveBandsFromSettings(_settingsService.Settings.DockSettings);
var latestBandSettings = BuildBandSettingsLookup(latestStart, latestCenter, latestEnd);
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
_settings = WithActiveBands(
MergeBandSettings(activeStart, latestBandSettings),
MergeBandSettings(activeCenter, latestBandSettings),
MergeBandSettings(activeEnd, latestBandSettings));
_snapshotDockSettings = null;
_snapshotBandViewModels = null;
// Save without hotReload to avoid triggering SettingsChanged → SetupBands,
// which could race with stale DockBands_CollectionChanged work items and
// re-add bands that were just unpinned.
_settingsService.UpdateSettings(s => s with { DockSettings = _settings }, false);
// Extract the final merged bands for this monitor
var (myStart, myCenter, myEnd) = GetActiveBands();
// Save only this monitor's bands into the CURRENT persisted settings,
// preserving other monitors' changes. Without this, each DockViewModel's
// save would overwrite the entire DockSettings, causing the last save to
// clobber changes from monitors that saved earlier.
_settingsService.UpdateSettings(
s =>
{
var currentDock = s.DockSettings;
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(currentDock, _monitorDeviceId);
if (config is not null && config.IsCustomized)
{
var updatedConfig = config with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
};
var configs = currentDock.MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
return s with
{
DockSettings = currentDock with
{
MonitorConfigs = ReplaceMonitorConfig(configs, updatedConfig),
},
};
}
}
return s with
{
DockSettings = currentDock with
{
StartBands = myStart,
CenterBands = myCenter,
EndBands = myEnd,
},
};
},
false);
// Refresh local settings from persisted state so all monitors' changes are visible
_settings = _settingsService.Settings.DockSettings;
_isEditing = false;
Logger.LogDebug("Saved band order to settings");
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(DockSettings dockSettings)
/// <summary>
/// Gets active bands from a given DockSettings, considering this dock's monitor.
/// </summary>
private (ImmutableList<DockBandSettings> Start, ImmutableList<DockBandSettings> Center, ImmutableList<DockBandSettings> End) GetActiveBandsFromSettings(DockSettings dockSettings)
{
if (_monitorDeviceId is not null)
{
var config = FindMonitorConfig(dockSettings, _monitorDeviceId);
if (config is not null)
{
return (
config.ResolveStartBands(dockSettings.StartBands),
config.ResolveCenterBands(dockSettings.CenterBands),
config.ResolveEndBands(dockSettings.EndBands));
}
}
return (dockSettings.StartBands, dockSettings.CenterBands, dockSettings.EndBands);
}
private static Dictionary<string, DockBandSettings> BuildBandSettingsLookup(
ImmutableList<DockBandSettings> start,
ImmutableList<DockBandSettings> center,
ImmutableList<DockBandSettings> end)
{
var lookup = new Dictionary<string, DockBandSettings>(StringComparer.Ordinal);
foreach (var band in dockSettings.StartBands.Concat(dockSettings.CenterBands).Concat(dockSettings.EndBands))
foreach (var band in start.Concat(center).Concat(end))
{
lookup[band.CommandId] = band;
}
@@ -450,13 +678,13 @@ public sealed partial class DockViewModel
return;
}
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeStart)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -464,7 +692,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCenter)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -472,7 +700,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -483,7 +711,7 @@ public sealed partial class DockViewModel
private void RebuildUICollections()
{
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
@@ -492,7 +720,7 @@ public sealed partial class DockViewModel
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
foreach (var bandSettings in activeStart)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -500,7 +728,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.CenterBands)
foreach (var bandSettings in activeCenter)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -508,7 +736,7 @@ public sealed partial class DockViewModel
}
}
foreach (var bandSettings in dockSettings.EndBands)
foreach (var bandSettings in activeEnd)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
@@ -561,6 +789,7 @@ public sealed partial class DockViewModel
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
@@ -569,15 +798,15 @@ public sealed partial class DockViewModel
switch (targetSide)
{
case DockPinSide.Start:
_settings = dockSettings with { StartBands = dockSettings.StartBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart.Add(bandSettings), activeCenter, activeEnd);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
_settings = dockSettings with { CenterBands = dockSettings.CenterBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart, activeCenter.Add(bandSettings), activeEnd);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
_settings = dockSettings with { EndBands = dockSettings.EndBands.Add(bandSettings) };
_settings = WithActiveBands(activeStart, activeCenter, activeEnd.Add(bandSettings));
EndItems.Add(bandVm);
break;
}
@@ -600,15 +829,13 @@ public sealed partial class DockViewModel
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settings;
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
// Remove from settings
_settings = dockSettings with
{
StartBands = dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId),
CenterBands = dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId),
EndBands = dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId),
};
_settings = WithActiveBands(
activeStart.RemoveAll(b => b.CommandId == bandId),
activeCenter.RemoveAll(b => b.CommandId == bandId),
activeEnd.RemoveAll(b => b.CommandId == bandId));
// Remove from UI collections
StartItems.Remove(band);
@@ -670,14 +897,16 @@ public sealed partial class DockViewModel
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsService.Settings.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
var dockSide = isDockEnabled ? GetEffectiveSide().ToString().ToLowerInvariant() : "none";
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
static string FormatBands(ImmutableList<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
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 startBands = isDockEnabled ? FormatBands(activeStart) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(activeCenter) : string.Empty;
var endBands = isDockEnabled ? FormatBands(activeEnd) : string.Empty;
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));

View File

@@ -0,0 +1,12 @@
// 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;
/// <summary>
/// Broadcast when the user exits dock edit mode on any monitor.
/// All DockControls should respond by saving or discarding their changes.
/// </summary>
/// <param name="Discard">True to discard changes; false to save them.</param>
public record ExitDockEditModeMessage(bool Discard);

View File

@@ -12,4 +12,5 @@ public record PinToDockMessage(
bool Pin,
DockPinSide Side = DockPinSide.Start,
bool? ShowTitles = null,
bool? ShowSubtitles = null);
bool? ShowSubtitles = null,
string? MonitorDeviceId = null);

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
@@ -12,4 +13,5 @@ public record ShowPinToDockDialogMessage(
string Title,
string Subtitle,
IconInfoViewModel? Icon,
DockSide DockSide);
DockSide DockSide,
IReadOnlyList<Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo>? AvailableMonitors = null);

View File

@@ -0,0 +1,45 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Service for enumerating and tracking connected display monitors.
/// </summary>
public interface IMonitorService
{
/// <summary>
/// Gets all currently connected monitors.
/// </summary>
IReadOnlyList<MonitorInfo> GetMonitors();
/// <summary>
/// Gets a specific monitor by its stable hardware identifier.
/// </summary>
MonitorInfo? GetMonitorByStableId(string stableId);
/// <summary>
/// Gets a specific monitor by its GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// Prefer <see cref="GetMonitorByStableId"/> for persistent lookups.
/// </summary>
MonitorInfo? GetMonitorByDeviceId(string deviceId);
/// <summary>
/// Gets the primary monitor.
/// </summary>
MonitorInfo? GetPrimaryMonitor();
/// <summary>
/// Invalidates the cached monitor list and raises <see cref="MonitorsChanged"/>.
/// Call this when a display settings change is detected (e.g. WM_DISPLAYCHANGE).
/// </summary>
void NotifyMonitorsChanged();
/// <summary>
/// Raised when the set of connected monitors changes (connect, disconnect, or resolution change).
/// </summary>
event System.EventHandler? MonitorsChanged;
}

View File

@@ -0,0 +1,60 @@
// 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 Microsoft.CmdPal.UI.ViewModels.Models;
/// <summary>
/// Represents a physical display monitor connected to the system.
/// </summary>
public sealed record MonitorInfo
{
/// <summary>
/// Gets the GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// This is volatile and may change across reboots or plug/unplug events.
/// Use <see cref="StableId"/> for persistent identification.
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Gets a stable hardware identifier derived from the Display Configuration API
/// device path (e.g. <c>\\?\DISPLAY#GSM1388#4&amp;125707d6&amp;0&amp;UID8388688#{guid}</c>).
/// Unlike <see cref="DeviceId"/>, this value survives reboots, driver updates,
/// and plug/unplug events on the same GPU port. Falls back to <see cref="DeviceId"/>
/// when the Display Configuration API is unavailable.
/// </summary>
public required string StableId { get; init; }
/// <summary>
/// Gets the human-readable display name (e.g. <c>DELL U2723QE</c>).
/// </summary>
public required string DisplayName { get; init; }
/// <summary>
/// Gets the full monitor rectangle in virtual-screen coordinates.
/// </summary>
public required ScreenRect Bounds { get; init; }
/// <summary>
/// Gets the work area (excludes the taskbar) in virtual-screen coordinates.
/// </summary>
public required ScreenRect WorkArea { get; init; }
/// <summary>
/// Gets the DPI value for this monitor (e.g. 96, 120, 144, 192).
/// </summary>
public required uint Dpi { get; init; }
/// <summary>
/// Gets a value indicating whether this is the primary monitor.
/// </summary>
public required bool IsPrimary { get; init; }
/// <summary>
/// Gets the scale factor for this monitor (e.g. 1.0 = 100%, 1.5 = 150%).
/// </summary>
[JsonIgnore]
public double ScaleFactor => Dpi / 96.0;
}

View File

@@ -0,0 +1,15 @@
// 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.Models;
/// <summary>
/// Represents the bounds of a monitor in virtual-screen coordinates.
/// </summary>
public readonly record struct ScreenRect(int Left, int Top, int Right, int Bottom)
{
public int Width => Right - Left;
public int Height => Bottom - Top;
}

View File

@@ -2,12 +2,14 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Immutable;
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels.Services;

View File

@@ -13,7 +13,8 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// settings are in <see cref="DockBandSettings"/>. Per-monitor overrides are
/// stored in <see cref="MonitorConfigs"/>.
/// </summary>
public record DockSettings
{
@@ -92,11 +93,165 @@ public record DockSettings
public bool ShowLabels { get; init; } = true;
/// <summary>
/// Gets the per-monitor dock configurations. Each entry overrides global
/// settings for a specific display. Empty by default (all monitors use global).
/// </summary>
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
public ImmutableList<DockMonitorConfig> MonitorConfigs
{
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
}
/// <summary>
/// Gets the dock side override for a specific monitor, or <c>null</c> if the
/// monitor has no override (inherits global <see cref="Side"/>).
/// </summary>
/// <param name="stableId">The monitor's stable hardware identifier (stored in <see cref="DockMonitorConfig.MonitorDeviceId"/>).</param>
public DockSide? GetSideForMonitor(string stableId)
{
foreach (var cfg in MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, stableId, StringComparison.OrdinalIgnoreCase))
{
return cfg.Side;
}
}
return null;
}
[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)));
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands
{
get
{
// Start with global bands
var result = StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
// Include per-monitor bands so that commands pinned to specific
// monitors are loaded as TopLevelViewModels and appear in the dock.
var configs = MonitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
foreach (var config in configs)
{
if (config.IsCustomized)
{
var start = config.StartBands ?? ImmutableList<DockBandSettings>.Empty;
var center = config.CenterBands ?? ImmutableList<DockBandSettings>.Empty;
var end = config.EndBands ?? ImmutableList<DockBandSettings>.Empty;
result = result
.Concat(start.Select(b => (b.ProviderId, b.CommandId)))
.Concat(center.Select(b => (b.ProviderId, b.CommandId)))
.Concat(end.Select(b => (b.ProviderId, b.CommandId)));
}
}
return result;
}
}
}
/// <summary>
/// Per-monitor configuration for the dock. Each monitor can override the global
/// dock side, enable/disable its dock, and optionally maintain independent band lists.
/// Uses a nullable-override pattern: <c>null</c> values inherit from global <see cref="DockSettings"/>.
/// </summary>
public sealed record DockMonitorConfig
{
/// <summary>
/// Gets the monitor device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string MonitorDeviceId { get; init; }
/// <summary>
/// Gets a value indicating whether the dock is enabled on this monitor. Defaults to <c>true</c>.
/// </summary>
public bool Enabled { get; init; } = true;
/// <summary>
/// Gets the dock side override for this monitor. When <c>null</c>, inherits the global
/// <see cref="DockSettings.Side"/> value.
/// </summary>
public DockSide? Side { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor is the primary display.
/// Used as a stable key for reconciliation when device IDs change across reboots.
/// </summary>
public bool IsPrimary { get; init; }
/// <summary>
/// Gets a value indicating whether this monitor has its own independent band lists.
/// When <c>false</c>, the monitor inherits bands from the global <see cref="DockSettings"/>.
/// </summary>
public bool IsCustomized { get; init; }
/// <summary>
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? StartBands { get; init; }
/// <summary>
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
/// <summary>
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
/// </summary>
public ImmutableList<DockBandSettings>? EndBands { get; init; }
/// <summary>
/// Gets the UTC timestamp when this monitor was last seen connected. Used for
/// staleness pruning: configs not seen for 6+ months are automatically removed
/// during reconciliation.
/// </summary>
public DateTime? LastSeen { get; init; }
/// <summary>
/// Resolves the effective dock side for this monitor.
/// </summary>
public DockSide ResolveSide(DockSide defaultSide) => Side ?? defaultSide;
/// <summary>
/// Resolves the effective start bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveStartBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && StartBands is not null ? StartBands : globalBands;
/// <summary>
/// Resolves the effective center bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveCenterBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && CenterBands is not null ? CenterBands : globalBands;
/// <summary>
/// Resolves the effective end bands for this monitor.
/// Returns per-monitor bands when customized; otherwise falls back to the global bands.
/// </summary>
public ImmutableList<DockBandSettings> ResolveEndBands(ImmutableList<DockBandSettings> globalBands) =>
IsCustomized && EndBands is not null ? EndBands : globalBands;
/// <summary>
/// Creates a new <see cref="DockMonitorConfig"/> that is a customized fork of the
/// given global dock settings. Copies global bands into per-monitor band lists so
/// they can be independently modified.
/// </summary>
public DockMonitorConfig ForkFromGlobal(DockSettings globalSettings) => this with
{
IsCustomized = true,
// Create independent copies by rebuilding the immutable lists
StartBands = ImmutableList.CreateRange(globalSettings.StartBands ?? ImmutableList<DockBandSettings>.Empty),
CenterBands = ImmutableList.CreateRange(globalSettings.CenterBands ?? ImmutableList<DockBandSettings>.Empty),
EndBands = ImmutableList.CreateRange(globalSettings.EndBands ?? ImmutableList<DockBandSettings>.Empty),
};
}
/// <summary>

View File

@@ -0,0 +1,225 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.CmdPal.UI.ViewModels.Models;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Reconciles persisted <see cref="DockMonitorConfig"/> entries against the
/// set of currently connected monitors. Uses <see cref="MonitorInfo.StableId"/>
/// (hardware device path) for persistent identification, with automatic
/// migration from legacy GDI device names (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
/// <remarks>
/// All operations are pure — they return new immutable lists rather than
/// mutating input collections.
/// </remarks>
public static class MonitorConfigReconciler
{
/// <summary>
/// Configs whose <see cref="DockMonitorConfig.LastSeen"/> is older than this
/// duration are pruned during reconciliation.
/// </summary>
internal static readonly TimeSpan StaleThreshold = TimeSpan.FromDays(180);
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact StableId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 1.5</b>: Legacy migration — match configs with GDI-style IDs by GDI name, then rewrite to StableId.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
/// </para>
/// </summary>
public static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors)
{
// Use Date (day granularity) so the value stabilizes across multiple reconciliations
// within the same day. This prevents infinite loops: SettingsChanged → SyncDocks →
// Reconcile → SettingsChanged when LastSeen changes by milliseconds each call.
return Reconcile(existingConfigs, currentMonitors, DateTime.UtcNow.Date);
}
/// <summary>
/// Overload accepting an explicit <paramref name="utcNow"/> for testability.
/// </summary>
internal static ImmutableList<DockMonitorConfig> Reconcile(
ImmutableList<DockMonitorConfig>? existingConfigs,
IReadOnlyList<MonitorInfo> currentMonitors,
DateTime utcNow)
{
existingConfigs ??= ImmutableList<DockMonitorConfig>.Empty;
if (currentMonitors.Count == 0)
{
return existingConfigs;
}
// Build a MonitorDeviceId → index lookup for O(1) matching
var configIndexById = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < existingConfigs.Count; i++)
{
configIndexById.TryAdd(existingConfigs[i].MonitorDeviceId, i);
}
var matchedMonitorStableIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Phase 1: Exact match on StableId (configs already migrated to stable paths)
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (configIndexById.TryGetValue(monitor.StableId, out var ci) && !matchedConfigIndices.Contains(ci))
{
result.Add(existingConfigs[ci] with { IsPrimary = monitor.IsPrimary, LastSeen = utcNow });
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
}
}
// Phase 1.5: Legacy migration — match configs that still have GDI-style IDs
// (e.g. "\\.\DISPLAY1") by matching against the monitor's GDI DeviceId,
// then rewrite the MonitorDeviceId to the monitor's stable hardware path.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
if (configIndexById.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Migrate: rewrite from GDI name to stable path
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.StableId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
}
}
// Phase 2: Fuzzy match — recover primary monitor config when its ID changed.
// Windows can reassign device paths across driver updates or cable swaps.
// When the primary monitor's StableId no longer matches any saved config,
// we look for an unmatched config that was previously marked as primary and
// reassociate it. Secondary monitors are not interchangeable, so we skip them.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (!monitor.IsPrimary || matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
for (var ci = 0; ci < existingConfigs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
if (existingConfigs[ci].IsPrimary)
{
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.StableId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
break;
}
}
}
// Phase 3: Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start disabled with empty band lists —
// users opt-in via Settings when they want the dock on additional displays.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
if (monitor.IsPrimary)
{
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.StableId,
Enabled = true,
IsPrimary = true,
LastSeen = utcNow,
});
}
else
{
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.StableId,
Enabled = false,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList<DockBandSettings>.Empty,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
LastSeen = utcNow,
});
}
}
// Phase 4: Retain disconnected monitor configs so settings survive reconnection.
// Prune entries not seen for longer than StaleThreshold (6 months).
for (var ci = 0; ci < existingConfigs.Count; ci++)
{
if (matchedConfigIndices.Contains(ci))
{
continue;
}
var config = existingConfigs[ci];
var lastSeen = config.LastSeen ?? utcNow; // Treat legacy entries (no LastSeen) as fresh
if ((utcNow - lastSeen) < StaleThreshold)
{
result.Add(config);
}
}
// Return the original reference when nothing actually changed so callers
// can use reference equality to skip no-op settings writes.
if (result.Count == existingConfigs.Count)
{
var changed = false;
for (var i = 0; i < result.Count; i++)
{
if (!result[i].Equals(existingConfigs[i]))
{
changed = true;
break;
}
}
if (!changed)
{
return existingConfigs;
}
}
return ImmutableList.CreateRange(result);
}
}

View File

@@ -196,6 +196,8 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(DockMonitorConfig))]
[JsonSerializable(typeof(ImmutableList<DockMonitorConfig>), TypeInfoPropertyName = "ImmutableDockMonitorConfigList")]
[JsonSerializable(typeof(ImmutableDictionary<string, ProviderSettings>), TypeInfoPropertyName = "ImmutableProviderSettingsDictionary")]
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]

View File

@@ -5,7 +5,9 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -29,6 +31,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -250,20 +253,26 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
public ObservableCollection<DockMonitorConfigViewModel> MonitorConfigs { get; } = new();
public SettingsExtensionsViewModel Extensions { get; }
public SettingsViewModel(
TopLevelCommandManager topLevelCommandManager,
TaskScheduler scheduler,
IThemeService themeService,
ISettingsService settingsService)
ISettingsService settingsService,
IMonitorService? monitorService = null)
{
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_monitorService = monitorService;
Appearance = new AppearanceSettingsViewModel(themeService, settingsService);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, settingsService);
PopulateMonitorConfigs();
var activeProviders = GetCommandProviders();
var allProviderSettings = _settingsService.Settings.ProviderSettings;
@@ -332,4 +341,44 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_settingsService.UpdateSettings(s => s with { FallbackRanks = FallbackRankings.Select(s2 => s2.Id).ToArray() });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
}
/// <summary>
/// Builds or refreshes the <see cref="MonitorConfigs"/> collection by reconciling
/// connected monitors with persisted per-monitor settings.
/// </summary>
public void PopulateMonitorConfigs()
{
if (_monitorService is null)
{
return;
}
var monitors = _monitorService.GetMonitors();
var currentSettings = _settingsService.Settings.DockSettings;
var reconciled = MonitorConfigReconciler.Reconcile(currentSettings.MonitorConfigs, monitors);
var currentMonitorConfigs = currentSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
if (!reconciled.SequenceEqual(currentMonitorConfigs))
{
_settingsService.UpdateSettings(s => s with
{
DockSettings = s.DockSettings with { MonitorConfigs = reconciled },
});
}
MonitorConfigs.Clear();
foreach (var monitor in monitors)
{
var config = reconciled.FirstOrDefault(c =>
string.Equals(c.MonitorDeviceId, monitor.StableId, StringComparison.OrdinalIgnoreCase));
if (config is not null)
{
MonitorConfigs.Add(new DockMonitorConfigViewModel(config, monitor, _settingsService));
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MonitorConfigs)));
}
}

View File

@@ -707,7 +707,7 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles);
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
}
else
{

View File

@@ -257,6 +257,14 @@ public partial class App : Application, IDisposable
services.AddSingleton<DockViewModel>();
services.AddSingleton<IContextMenuFactory, CommandPaletteContextMenuFactory>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
// Multi-monitor dock support
services.AddSingleton<IMonitorService, MonitorService>();
services.AddSingleton<Dock.DockWindowManager>(sp =>
new Dock.DockWindowManager(
sp.GetRequiredService<IMonitorService>(),
sp.GetRequiredService<ISettingsService>(),
Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread()));
}
public void Dispose()

View File

@@ -2,12 +2,15 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
@@ -20,11 +23,13 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
{
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
public CommandPaletteContextMenuFactory(ISettingsService settingsService, TopLevelCommandManager topLevelCommandManager)
public CommandPaletteContextMenuFactory(ISettingsService settingsService, TopLevelCommandManager topLevelCommandManager, IMonitorService? monitorService = null)
{
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
_monitorService = monitorService;
}
/// <summary>
@@ -212,7 +217,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
PinLocation.Dock,
_settingsService,
_topLevelCommandManager,
commandItemViewModel: commandItem);
commandItemViewModel: commandItem,
monitorService: _monitorService);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
@@ -261,6 +267,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
private readonly string _providerId;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly IMonitorService? _monitorService;
private readonly bool _pin;
private readonly PinLocation _pinLocation;
private readonly CommandItemViewModel? _commandItemViewModel;
@@ -282,7 +289,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
PinLocation pinLocation,
ISettingsService settingsService,
TopLevelCommandManager topLevelCommandManager,
CommandItemViewModel? commandItemViewModel = null)
CommandItemViewModel? commandItemViewModel = null,
IMonitorService? monitorService = null)
{
_commandId = commandId;
_providerId = providerId;
@@ -291,6 +299,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
_topLevelCommandManager = topLevelCommandManager;
_pin = pin;
_commandItemViewModel = commandItemViewModel;
_monitorService = monitorService;
}
public override CommandResult Invoke()
@@ -346,7 +355,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
var subtitle = _commandItemViewModel?.Subtitle ?? string.Empty;
var icon = _commandItemViewModel?.Icon;
var dockSide = _settingsService.Settings.DockSettings.Side;
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide);
IReadOnlyList<MonitorInfo>? monitors = _monitorService?.GetMonitors();
ShowPinToDockDialogMessage message = new(_providerId, _commandId, title, subtitle, icon, dockSide, monitors);
WeakReferenceMessenger.Default.Send(message);
}

View File

@@ -23,12 +23,18 @@ 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<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>
{
private DockViewModel _viewModel;
internal DockViewModel ViewModel => _viewModel;
/// <summary>
/// The HWND of the parent DockWindow that owns this control.
/// Used to target palette-show messages to the correct DockWindow in multi-monitor setups.
/// </summary>
internal IntPtr OwnerHwnd { get; set; }
public static readonly DependencyProperty ItemsOrientationProperty =
DependencyProperty.Register(nameof(ItemsOrientation), typeof(Orientation), typeof(DockControl), new PropertyMetadata(Orientation.Horizontal));
@@ -89,6 +95,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.UnregisterAll(this);
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -142,6 +149,21 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
});
}
public void Receive(ExitDockEditModeMessage message)
{
DispatcherQueue.TryEnqueue(() =>
{
if (message.Discard)
{
DiscardEditMode();
}
else
{
ExitEditMode();
}
});
}
private void UpdateEditMode(bool isEditMode)
{
// Update center visibility based on edit mode and center items
@@ -231,20 +253,21 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void DoneEditingButton_Click(object sender, RoutedEventArgs e)
{
ExitEditMode();
WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(Discard: false));
}
private void DiscardEditingButton_Click(object sender, RoutedEventArgs e)
{
DiscardEditMode();
WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(Discard: true));
}
internal void UpdateSettings(DockSettings settings)
internal void UpdateSettings(DockSettings settings, DockSide? effectiveSide = null)
{
DockSide = settings.Side;
var side = effectiveSide ?? settings.Side;
DockSide = side;
// Compact mode is only supported for Top/Bottom positions
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var isHorizontal = side == DockSide.Top || side == DockSide.Bottom;
var effectiveSize = isHorizontal ? settings.DockSize : DockSize.Default;
DockSize = effectiveSize;
@@ -378,7 +401,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
var isPage = command.Model.Unsafe is not IInvokableCommand invokable;
if (isPage)
{
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos));
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, OwnerHwnd));
}
}
catch (COMException e)

View File

@@ -9,6 +9,7 @@ using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -27,14 +28,15 @@ using Windows.Win32.UI.WindowsAndMessaging;
using WinRT;
using WinRT.Interop;
using WinUIEx;
using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo;
namespace Microsoft.CmdPal.UI.Dock;
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class DockWindow : WindowEx,
IRecipient<BringToTopMessage>,
IRecipient<RequestShowPaletteAtMessage>,
IRecipient<BringToTopMessage>,
IRecipient<RequestShowPaletteAtMessage>,
IRecipient<QuitMessage>,
IDisposable
{
@@ -46,6 +48,7 @@ public sealed partial class DockWindow : WindowEx,
private readonly IThemeService _themeService;
private readonly ISettingsService _settingsService;
private readonly IMonitorService _monitorService;
private readonly DockWindowViewModel _windowViewModel;
private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new();
@@ -65,21 +68,50 @@ public sealed partial class DockWindow : WindowEx,
private DockSize _lastSize;
private bool _isDisposed;
/// <summary>
/// The monitor this dock window is displayed on. Null means primary monitor (legacy behavior).
/// </summary>
private MonitorInfo? _targetMonitor;
/// <summary>
/// Per-monitor dock side override. Null means use the global setting.
/// </summary>
private DockSide? _sideOverride;
/// <summary>
/// Gets the effective dock side for this window, respecting per-monitor overrides.
/// </summary>
private DockSide EffectiveSide => _sideOverride ?? _settings.Side;
// Store the original WndProc
private WNDPROC? _originalWndProc;
private WNDPROC? _customWndProc;
// internal Settings CurrentSettings => _settings;
public DockWindow()
: this(App.Current.Services.GetService<DockViewModel>()!)
{
}
public DockWindow(DockViewModel dockViewModel)
: this(dockViewModel, null, null)
{
}
public DockWindow(DockViewModel dockViewModel, MonitorInfo? targetMonitor, DockSide? sideOverride)
{
_targetMonitor = targetMonitor;
_sideOverride = sideOverride;
var serviceProvider = App.Current.Services;
var mainSettings = serviceProvider.GetRequiredService<ISettingsService>().Settings;
_settingsService = serviceProvider.GetRequiredService<ISettingsService>();
_settingsService.SettingsChanged += SettingsChangedHandler;
_monitorService = serviceProvider.GetRequiredService<IMonitorService>();
_settings = mainSettings.DockSettings;
_lastSize = EffectiveDockSize(_settings);
viewModel = serviceProvider.GetService<DockViewModel>()!;
viewModel = dockViewModel;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
_themeService.ThemeChanged += ThemeService_ThemeChanged;
InitializeBackdropSupport();
@@ -108,6 +140,7 @@ public sealed partial class DockWindow : WindowEx,
WeakReferenceMessenger.Default.Register<QuitMessage>(this);
_hwnd = GetWindowHandle(this);
_dock.OwnerHwnd = (nint)_hwnd;
// Subclass the window to intercept messages
//
@@ -143,7 +176,13 @@ public sealed partial class DockWindow : WindowEx,
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
{
if (_isDisposed)
{
return;
}
_settings = args.DockSettings;
RefreshSideOverride();
DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread);
}
@@ -172,12 +211,17 @@ public sealed partial class DockWindow : WindowEx,
private void UpdateSettingsOnUiThread()
{
if (_isDisposed)
{
return;
}
this.viewModel.UpdateSettings(_settings);
UpdateBackdrop();
_dock.UpdateSettings(_settings);
_dock.UpdateSettings(_settings, EffectiveSide);
var side = DockSettingsToViews.GetAppBarEdge(_settings.Side);
var side = DockSettingsToViews.GetAppBarEdge(EffectiveSide);
if (_appBarData.hWnd != IntPtr.Zero)
{
@@ -394,7 +438,7 @@ public sealed partial class DockWindow : WindowEx,
var scaleFactor = dpi / 96.0;
var effectiveSize = EffectiveDockSize(_settings);
UpdateAppBarDataForEdge(_settings.Side, effectiveSize, scaleFactor);
UpdateAppBarDataForEdge(EffectiveSide, effectiveSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
@@ -405,7 +449,7 @@ public sealed partial class DockWindow : WindowEx,
// bar keeps its correct size. Without this, a second bar docked to
// the same side would get a zero-height/width rect and fail to
// reserve work-area space.
switch (_settings.Side)
switch (EffectiveSide)
{
case DockSide.Top:
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
@@ -442,6 +486,38 @@ public sealed partial class DockWindow : WindowEx,
true);
}
/// <summary>
/// Re-resolves <see cref="_targetMonitor"/> against the current monitor list.
/// <see cref="MonitorInfo"/> is an immutable record, so the instance captured
/// at construction time becomes stale whenever the display topography changes.
/// If the monitor is no longer connected we keep the stale reference; the
/// <see cref="DockWindowManager"/> will close this window shortly.
/// </summary>
private void RefreshTargetMonitor()
{
if (_targetMonitor is null)
{
return;
}
var refreshed = _monitorService.GetMonitorByStableId(_targetMonitor.StableId);
if (refreshed is not null)
{
_targetMonitor = refreshed;
}
}
private void RefreshSideOverride()
{
if (_targetMonitor is null)
{
_sideOverride = null;
return;
}
_sideOverride = _settings.GetSideForMonitor(_targetMonitor.StableId);
}
/// <summary>
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
@@ -457,46 +533,61 @@ public sealed partial class DockWindow : WindowEx,
Logger.LogDebug("UpdateAppBarDataForEdge");
var horizontalHeightDips = DockSettingsToViews.HeightForSize(size);
var verticalWidthDips = DockSettingsToViews.WidthForSize(size);
var screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
// Use monitor-specific bounds when available; fall back to primary screen metrics
int monLeft, monTop, monRight, monBottom;
if (_targetMonitor is not null)
{
monLeft = _targetMonitor.Bounds.Left;
monTop = _targetMonitor.Bounds.Top;
monRight = _targetMonitor.Bounds.Right;
monBottom = _targetMonitor.Bounds.Bottom;
}
else
{
monLeft = 0;
monTop = 0;
monRight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
monBottom = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
}
if (side == DockSide.Top)
{
_appBarData.uEdge = PInvoke.ABE_TOP;
_appBarData.rc.left = 0;
_appBarData.rc.top = 0;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = (int)(horizontalHeightDips * scaleFactor);
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monTop + (int)(horizontalHeightDips * scaleFactor);
}
else if (side == DockSide.Bottom)
{
var heightPixels = (int)(horizontalHeightDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_BOTTOM;
_appBarData.rc.left = 0;
_appBarData.rc.top = screenHeight - heightPixels;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monBottom - heightPixels;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monBottom;
}
else if (side == DockSide.Left)
{
var widthPixels = (int)(verticalWidthDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_LEFT;
_appBarData.rc.left = 0;
_appBarData.rc.top = 0;
_appBarData.rc.right = widthPixels;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monLeft;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monLeft + widthPixels;
_appBarData.rc.bottom = monBottom;
}
else if (side == DockSide.Right)
{
var widthPixels = (int)(verticalWidthDips * scaleFactor);
_appBarData.uEdge = PInvoke.ABE_RIGHT;
_appBarData.rc.left = screenWidth - widthPixels;
_appBarData.rc.top = 0;
_appBarData.rc.right = screenWidth;
_appBarData.rc.bottom = screenHeight;
_appBarData.rc.left = monRight - widthPixels;
_appBarData.rc.top = monTop;
_appBarData.rc.right = monRight;
_appBarData.rc.bottom = monBottom;
}
else
{
@@ -521,8 +612,27 @@ public sealed partial class DockWindow : WindowEx,
{
Logger.LogDebug("WM_DISPLAYCHANGE");
// Use dispatcher to ensure we're on the UI thread
DispatcherQueue.TryEnqueue(() => UpdateWindowPosition());
// Invalidate the monitor cache so DockWindowManager can reconcile
_monitorService.NotifyMonitorsChanged();
// Use dispatcher to ensure we're on the UI thread.
// Refresh _targetMonitor before re-positioning: the MonitorInfo
// captured at construction is an immutable record, so its Bounds
// are stale after a topology change (e.g. an external display was
// disconnected, shifting our monitor's virtual-screen origin).
// Without this, UpdateAppBarDataForEdge would compute the AppBar
// rect against the old coordinates and produce a wildly incorrect
// size/position.
DispatcherQueue.TryEnqueue(() =>
{
if (_isDisposed)
{
return;
}
RefreshTargetMonitor();
UpdateWindowPosition();
});
}
// Intercept WM_SYSCOMMAND to prevent minimize and maximize
@@ -659,6 +769,11 @@ public sealed partial class DockWindow : WindowEx,
void IRecipient<RequestShowPaletteAtMessage>.Receive(RequestShowPaletteAtMessage message)
{
if (_isDisposed || message.OwnerHwnd != (nint)_hwnd)
{
return;
}
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () => RequestShowPaletteOnUiThread(message.PosDips));
}
@@ -672,8 +787,23 @@ public sealed partial class DockWindow : WindowEx,
var scaleFactor = dpi / 96.0;
var screenPosPixels = new Point(screenPosDips.X * scaleFactor, screenPosDips.Y * scaleFactor);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
var screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
// Use monitor-specific bounds when available
int screenWidth, screenHeight;
if (_targetMonitor is not null)
{
screenWidth = _targetMonitor.Bounds.Width;
screenHeight = _targetMonitor.Bounds.Height;
// Adjust to monitor-local coordinates for quadrant calculation
screenPosPixels = new Point(
screenPosPixels.X - _targetMonitor.Bounds.Left,
screenPosPixels.Y - _targetMonitor.Bounds.Top);
}
else
{
screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
screenHeight = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CYSCREEN);
}
// Now we're going to find the best position for the palette.
@@ -695,7 +825,7 @@ public sealed partial class DockWindow : WindowEx,
var onRightHalf = !onLeftHalf;
var onBottomHalf = !onTopHalf;
var anchorPoint = _settings.Side switch
var anchorPoint = EffectiveSide switch
{
DockSide.Top => onLeftHalf ? AnchorPoint.TopLeft : AnchorPoint.TopRight,
DockSide.Bottom => onLeftHalf ? AnchorPoint.BottomLeft : AnchorPoint.BottomRight,
@@ -710,7 +840,7 @@ public sealed partial class DockWindow : WindowEx,
PInvoke.GetWindowRect(_hwnd, out var ourRect);
// Depending on the side we're on, we need to offset differently
switch (_settings.Side)
switch (EffectiveSide)
{
case DockSide.Top:
screenPosPixels.Y = ourRect.bottom + paddingPixels;
@@ -733,6 +863,8 @@ public sealed partial class DockWindow : WindowEx,
public DockWindowViewModel WindowViewModel => _windowViewModel;
public string? MonitorDeviceId => viewModel.MonitorDeviceId;
public void Dispose()
{
Cleanup();
@@ -850,7 +982,7 @@ internal static class ShowDesktop
internal sealed record BringToTopMessage(bool BringToFront);
internal sealed record RequestShowPaletteAtMessage(Point PosDips);
internal sealed record RequestShowPaletteAtMessage(Point PosDips, IntPtr OwnerHwnd);
internal sealed record ShowPaletteAtMessage(Point PosPixels, AnchorPoint Anchor);

View File

@@ -0,0 +1,260 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Dispatching;
using WinUIEx;
namespace Microsoft.CmdPal.UI.Dock;
/// <summary>
/// Manages multiple <see cref="DockWindow"/> instances, one per enabled monitor.
/// Replaces the single <c>_dockWindow</c> field previously held by ShellPage.
/// </summary>
public sealed partial class DockWindowManager : IDisposable
{
private readonly IMonitorService _monitorService;
private readonly ISettingsService _settingsService;
private readonly DispatcherQueue _dispatcherQueue;
private readonly Dictionary<string, (DockWindow Window, DockViewModel ViewModel)> _docks = new(StringComparer.OrdinalIgnoreCase);
private bool _disposed;
private int _syncing;
public DockWindowManager(
IMonitorService monitorService,
ISettingsService settingsService,
DispatcherQueue dispatcherQueue)
{
_monitorService = monitorService;
_settingsService = settingsService;
_dispatcherQueue = dispatcherQueue;
_monitorService.MonitorsChanged += OnMonitorsChanged;
_settingsService.SettingsChanged += OnSettingsChanged;
}
/// <summary>
/// Creates dock windows for all enabled monitors according to current settings.
/// Runs reconciliation to ensure configs match currently connected monitors.
/// </summary>
public void ShowDocks()
{
var settings = _settingsService.Settings;
if (!settings.EnableDock)
{
return;
}
SyncDocksToSettings();
}
/// <summary>
/// Destroys all dock windows.
/// </summary>
public void HideDocks()
{
foreach (var (_, (window, viewModel)) in _docks)
{
window.Close();
viewModel.Dispose();
}
_docks.Clear();
}
/// <summary>
/// Synchronizes running dock windows to match the current settings and connected monitors.
/// </summary>
public void SyncDocksToSettings()
{
if (Interlocked.CompareExchange(ref _syncing, 1, 0) != 0)
{
return;
}
try
{
SyncDocksToSettingsCore();
}
finally
{
Interlocked.Exchange(ref _syncing, 0);
}
}
private void SyncDocksToSettingsCore()
{
var settings = _settingsService.Settings;
if (!settings.EnableDock)
{
HideDocks();
return;
}
var dockSettings = settings.DockSettings;
// Reconcile stale monitor device IDs with currently connected monitors
var monitors = _monitorService.GetMonitors();
var currentConfigs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
var reconciled = MonitorConfigReconciler.Reconcile(currentConfigs, monitors);
if (reconciled != currentConfigs)
{
_settingsService.UpdateSettings(s => s with
{
DockSettings = s.DockSettings with { MonitorConfigs = reconciled },
});
// Re-read settings after update
dockSettings = _settingsService.Settings.DockSettings;
}
var configs = GetEffectiveConfigs(dockSettings);
var desiredMonitorIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// Refresh settings on existing ViewModels so they pick up new pins/changes
foreach (var (_, (_, viewModel)) in _docks)
{
viewModel.UpdateSettings(dockSettings);
}
for (var i = 0; i < configs.Count; i++)
{
var config = configs[i];
if (!config.Enabled)
{
continue;
}
var monitor = _monitorService.GetMonitorByStableId(config.MonitorDeviceId);
if (monitor is null)
{
continue;
}
desiredMonitorIds.Add(config.MonitorDeviceId);
if (!_docks.ContainsKey(config.MonitorDeviceId))
{
CreateDockForMonitor(config.MonitorDeviceId, dockSettings);
}
}
// Remove dock windows for monitors that are no longer desired
var toRemove = new List<string>();
foreach (var id in _docks.Keys)
{
if (!desiredMonitorIds.Contains(id))
{
toRemove.Add(id);
}
}
for (var i = 0; i < toRemove.Count; i++)
{
var id = toRemove[i];
if (_docks.Remove(id, out var dock))
{
dock.Window.Close();
dock.ViewModel.Dispose();
}
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_monitorService.MonitorsChanged -= OnMonitorsChanged;
_settingsService.SettingsChanged -= OnSettingsChanged;
HideDocks();
}
private void CreateDockForMonitor(string monitorDeviceId, DockSettings dockSettings)
{
var viewModel = CreateDockViewModel(monitorDeviceId);
var monitor = _monitorService.GetMonitorByStableId(monitorDeviceId);
var sideOverride = dockSettings.GetSideForMonitor(monitorDeviceId);
var window = new DockWindow(viewModel, monitor, sideOverride);
_docks[monitorDeviceId] = (window, viewModel);
window.Show();
viewModel.InitializeBands();
}
private DockViewModel CreateDockViewModel(string monitorDeviceId)
{
var serviceProvider = App.Current.Services;
var tlcManager = serviceProvider.GetRequiredService<TopLevelCommandManager>();
var contextMenuFactory = serviceProvider.GetRequiredService<IContextMenuFactory>();
var scheduler = serviceProvider.GetRequiredService<TaskScheduler>();
return new DockViewModel(tlcManager, contextMenuFactory, scheduler, _settingsService, monitorDeviceId);
}
private void OnMonitorsChanged(object? sender, EventArgs e)
{
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)
{
SyncDocksToSettings();
}
});
}
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
_dispatcherQueue.TryEnqueue(() =>
{
if (!_disposed)
{
SyncDocksToSettings();
}
});
}
/// <summary>
/// Returns the effective list of monitor configs. If settings have no explicit
/// configs (legacy / first-run), synthesizes one for the primary monitor.
/// </summary>
private IReadOnlyList<DockMonitorConfig> GetEffectiveConfigs(DockSettings dockSettings)
{
var configs = dockSettings.MonitorConfigs ?? System.Collections.Immutable.ImmutableList<DockMonitorConfig>.Empty;
if (configs.Count > 0)
{
return configs;
}
// Legacy / migration: no per-monitor configs saved yet.
// Synthesize a config for the primary monitor inheriting global Side.
var primary = _monitorService.GetPrimaryMonitor();
if (primary is null)
{
return Array.Empty<DockMonitorConfig>();
}
return new[]
{
new DockMonitorConfig
{
MonitorDeviceId = primary.StableId,
Enabled = true,
Side = null,
IsPrimary = true,
},
};
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Dock.PinToDockDialogContent"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
@@ -107,6 +107,18 @@
</tkcontrols:Segmented>
</StackPanel>
<!-- Monitor Selector (hidden when only one monitor) -->
<StackPanel
x:Name="MonitorSelectorPanel"
Spacing="8"
Visibility="Collapsed">
<TextBlock
x:Uid="PinToDock_MonitorHeader"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
<ComboBox x:Name="MonitorComboBox" HorizontalAlignment="Stretch" />
</StackPanel>
<!-- Label Options -->
<StackPanel Spacing="4">
<CheckBox

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -17,6 +19,7 @@ public sealed partial class PinToDockDialogContent : UserControl
{
private string _title = string.Empty;
private string _subtitle = string.Empty;
private IReadOnlyList<MonitorInfo>? _monitors;
public DockPinSide SelectedSide => SectionSegmented.SelectedIndex switch
{
@@ -30,12 +33,28 @@ public sealed partial class PinToDockDialogContent : UserControl
public bool? ShowSubtitles => ShowSubtitleCheckBox.IsChecked;
public string? SelectedMonitorDeviceId
{
get
{
// When only one monitor exists, return null so the pin lands in the
// global bands (visible on all monitors by default). The per-monitor
// path is only used when the user explicitly chooses from 2+ monitors.
if (_monitors is null or { Count: <= 1 } || MonitorComboBox.SelectedIndex < 0 || MonitorComboBox.SelectedIndex >= _monitors.Count)
{
return null;
}
return _monitors[MonitorComboBox.SelectedIndex].DeviceId;
}
}
public PinToDockDialogContent()
{
InitializeComponent();
}
public void Configure(string title, string subtitle, IconInfoViewModel? icon, DockSide dockSide)
public void Configure(string title, string subtitle, IconInfoViewModel? icon, DockSide dockSide, IReadOnlyList<MonitorInfo>? monitors = null)
{
_title = title;
_subtitle = subtitle;
@@ -63,6 +82,7 @@ public sealed partial class PinToDockDialogContent : UserControl
}
ApplyDockOrientation(dockSide);
ConfigureMonitorSelector(monitors);
}
public static async System.Threading.Tasks.Task<(ContentDialogResult Result, PinToDockDialogContent Content)> ShowAsync(
@@ -70,10 +90,11 @@ public sealed partial class PinToDockDialogContent : UserControl
string title,
string subtitle,
IconInfoViewModel? icon,
DockSide dockSide)
DockSide dockSide,
IReadOnlyList<MonitorInfo>? monitors = null)
{
var content = new PinToDockDialogContent();
content.Configure(title, subtitle, icon, dockSide);
content.Configure(title, subtitle, icon, dockSide, monitors);
var dialog = new ContentDialog
{
@@ -168,4 +189,31 @@ public sealed partial class PinToDockDialogContent : UserControl
PreviewTextPanel.Visibility = (showTitle || showSubtitle) ? Visibility.Visible : Visibility.Collapsed;
}
private void ConfigureMonitorSelector(IReadOnlyList<MonitorInfo>? monitors)
{
_monitors = monitors;
if (monitors is null || monitors.Count <= 1)
{
MonitorSelectorPanel.Visibility = Visibility.Collapsed;
return;
}
MonitorSelectorPanel.Visibility = Visibility.Visible;
var displayNames = new List<string>(monitors.Count);
var primaryIndex = 0;
for (var i = 0; i < monitors.Count; i++)
{
displayNames.Add(monitors[i].DisplayName);
if (monitors[i].IsPrimary)
{
primaryIndex = i;
}
}
MonitorComboBox.ItemsSource = displayNames;
MonitorComboBox.SelectedIndex = primaryIndex;
}
}

View File

@@ -117,4 +117,15 @@ WM_DPICHANGED
QUERY_USER_NOTIFICATION_STATE
EnumWindows
EnumDisplayMonitors
MONITORINFOEXW
IsWindowVisible
GetDisplayConfigBufferSizes
QueryDisplayConfig
DisplayConfigGetDeviceInfo
DISPLAYCONFIG_TARGET_DEVICE_NAME
DISPLAYCONFIG_SOURCE_DEVICE_NAME
DISPLAYCONFIG_PATH_INFO
DISPLAYCONFIG_MODE_INFO
DISPLAYCONFIG_DEVICE_INFO_TYPE
QUERY_DISPLAY_CONFIG_FLAGS

View File

@@ -26,7 +26,6 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Animation;
using WinUIEx;
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
using VirtualKey = Windows.System.VirtualKey;
@@ -68,7 +67,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly CompositeFormat _pageNavigatedAnnouncement;
private SettingsWindow? _settingsWindow;
private DockWindow? _dockWindow;
private DockWindowManager? _dockWindowManager;
private CancellationTokenSource? _focusAfterLoadedCts;
private WeakReference<Page>? _lastNavigatedPageRef;
@@ -116,8 +115,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (App.Current.Services.GetRequiredService<ISettingsService>().Settings.EnableDock)
{
_dockWindow = new DockWindow();
_dockWindow.Show();
_dockWindowManager = App.Current.Services.GetService<DockWindowManager>();
_dockWindowManager?.ShowDocks();
}
}
@@ -234,7 +233,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
message.Title,
message.Subtitle,
message.Icon,
message.DockSide);
message.DockSide,
message.AvailableMonitors);
if (result == ContentDialogResult.Primary)
{
@@ -244,7 +244,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
Pin: true,
Side: content.SelectedSide,
ShowTitles: content.ShowTitles,
ShowSubtitles: content.ShowSubtitles);
ShowSubtitles: content.ShowSubtitles,
MonitorDeviceId: content.SelectedMonitorDeviceId);
WeakReferenceMessenger.Default.Send(pinMessage);
}
}
@@ -543,17 +544,16 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (message.ShowDock)
{
if (_dockWindow is null)
if (_dockWindowManager is null)
{
_dockWindow = new DockWindow();
_dockWindowManager = App.Current.Services.GetService<DockWindowManager>();
}
_dockWindow.Show();
_dockWindowManager?.ShowDocks();
}
else if (_dockWindow is not null)
else
{
_dockWindow.Close();
_dockWindow = null;
_dockWindowManager?.HideDocks();
}
});
}
@@ -854,20 +854,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_focusAfterLoadedCts?.Dispose();
_focusAfterLoadedCts = null;
var dockWindow = _dockWindow;
_dockWindow = null;
if (dockWindow is not null)
{
if (DispatcherQueue.HasThreadAccess)
{
dockWindow.Close();
}
else
{
DispatcherQueue.TryEnqueue(dockWindow.Close);
}
}
var dockWindowManager = _dockWindowManager;
_dockWindowManager = null;
dockWindowManager?.Dispose();
GC.SuppressFinalize(this);
}

View File

@@ -0,0 +1,293 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Windows.Win32;
using Windows.Win32.Devices.Display;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Gdi;
using Windows.Win32.UI.HiDpi;
namespace Microsoft.CmdPal.UI.Services;
/// <summary>
/// Implementation of <see cref="IMonitorService"/> using Win32 monitor enumeration APIs.
/// </summary>
public sealed class MonitorService : IMonitorService
{
private const uint PrimaryFlag = 0x00000001;
private readonly object _lock = new();
private List<MonitorInfo>? _cachedMonitors;
private IReadOnlyList<MonitorInfo>? _cachedSnapshot;
/// <inheritdoc/>
public event EventHandler? MonitorsChanged;
/// <inheritdoc/>
public IReadOnlyList<MonitorInfo> GetMonitors()
{
lock (_lock)
{
if (_cachedSnapshot is not null)
{
return _cachedSnapshot;
}
_cachedMonitors = EnumerateMonitors();
_cachedSnapshot = _cachedMonitors.AsReadOnly();
return _cachedSnapshot;
}
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByStableId(string stableId)
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (string.Equals(monitor.StableId, stableId, StringComparison.OrdinalIgnoreCase))
{
return monitor;
}
}
return null;
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByDeviceId(string deviceId)
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (string.Equals(monitor.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase))
{
return monitor;
}
}
return null;
}
/// <inheritdoc/>
public MonitorInfo? GetPrimaryMonitor()
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (monitor.IsPrimary)
{
return monitor;
}
}
return null;
}
/// <summary>
/// Call this when a display settings change message is received (e.g. WM_DISPLAYCHANGE)
/// to invalidate the cached monitor list and raise <see cref="MonitorsChanged"/>.
/// </summary>
public void NotifyMonitorsChanged()
{
lock (_lock)
{
_cachedMonitors = null;
_cachedSnapshot = null;
}
Logger.LogDebug("Display topology changed, invalidating monitor cache");
MonitorsChanged?.Invoke(this, EventArgs.Empty);
}
private static unsafe List<MonitorInfo> EnumerateMonitors()
{
var monitors = new List<MonitorInfo>();
var displayInfo = BuildDisplayInfoMap();
PInvoke.EnumDisplayMonitors(
HDC.Null,
(RECT*)null,
(HMONITOR hMonitor, HDC hdcMonitor, RECT* lprcMonitor, LPARAM dwData) =>
{
var infoEx = default(MONITORINFOEXW);
infoEx.monitorInfo.cbSize = (uint)sizeof(MONITORINFOEXW);
if (PInvoke.GetMonitorInfo(hMonitor, (MONITORINFO*)&infoEx))
{
var hr = PInvoke.GetDpiForMonitor(
hMonitor,
MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI,
out var dpiX,
out _);
if (hr.Failed || dpiX == 0)
{
dpiX = 96;
}
var isPrimary = (infoEx.monitorInfo.dwFlags & PrimaryFlag) != 0;
var deviceName = new string(infoEx.szDevice.AsSpan()).TrimEnd('\0');
var friendlyName = string.Empty;
var stableId = deviceName; // Fall back to GDI name
if (displayInfo.TryGetValue(deviceName, out var info))
{
friendlyName = info.FriendlyName;
if (!string.IsNullOrEmpty(info.DevicePath))
{
stableId = info.DevicePath;
}
}
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyName);
var rcMonitor = infoEx.monitorInfo.rcMonitor;
var rcWork = infoEx.monitorInfo.rcWork;
monitors.Add(new MonitorInfo
{
DeviceId = deviceName,
StableId = stableId,
DisplayName = displayName,
Bounds = new ScreenRect(
rcMonitor.left,
rcMonitor.top,
rcMonitor.right,
rcMonitor.bottom),
WorkArea = new ScreenRect(
rcWork.left,
rcWork.top,
rcWork.right,
rcWork.bottom),
Dpi = dpiX,
IsPrimary = isPrimary,
});
}
return true;
},
0);
return monitors;
}
/// <summary>
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to display metadata
/// (friendly name and stable device path) using the Display Configuration APIs.
/// Returns an empty dictionary on failure so callers can fall back gracefully.
/// </summary>
private static unsafe Dictionary<string, (string FriendlyName, string DevicePath)> BuildDisplayInfoMap()
{
var map = new Dictionary<string, (string FriendlyName, string DevicePath)>(StringComparer.OrdinalIgnoreCase);
try
{
var result = PInvoke.GetDisplayConfigBufferSizes(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
out var pathCount,
out var modeCount);
if (result != WIN32_ERROR.NO_ERROR)
{
return map;
}
var paths = new DISPLAYCONFIG_PATH_INFO[pathCount];
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
fixed (DISPLAYCONFIG_PATH_INFO* pathsPtr = paths)
{
fixed (DISPLAYCONFIG_MODE_INFO* modesPtr = modes)
{
result = PInvoke.QueryDisplayConfig(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
ref pathCount,
pathsPtr,
ref modeCount,
modesPtr,
null);
if (result != WIN32_ERROR.NO_ERROR)
{
return map;
}
}
}
for (var i = 0; i < pathCount; i++)
{
var path = paths[i];
// Get the GDI device name from the source info
var sourceName = default(DISPLAYCONFIG_SOURCE_DEVICE_NAME);
sourceName.header.type = DISPLAYCONFIG_DEVICE_INFO_TYPE.DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME;
sourceName.header.size = (uint)sizeof(DISPLAYCONFIG_SOURCE_DEVICE_NAME);
sourceName.header.adapterId = path.sourceInfo.adapterId;
sourceName.header.id = path.sourceInfo.id;
if (PInvoke.DisplayConfigGetDeviceInfo(ref sourceName.header) != 0)
{
continue;
}
var gdiName = new string(sourceName.viewGdiDeviceName.AsSpan()).TrimEnd('\0');
if (string.IsNullOrEmpty(gdiName))
{
continue;
}
// Get the friendly name from the target info
var targetName = default(DISPLAYCONFIG_TARGET_DEVICE_NAME);
targetName.header.type = DISPLAYCONFIG_DEVICE_INFO_TYPE.DISPLAYCONFIG_DEVICE_INFO_GET_TARGET_NAME;
targetName.header.size = (uint)sizeof(DISPLAYCONFIG_TARGET_DEVICE_NAME);
targetName.header.adapterId = path.targetInfo.adapterId;
targetName.header.id = path.targetInfo.id;
if (PInvoke.DisplayConfigGetDeviceInfo(ref targetName.header) != 0)
{
continue;
}
var friendly = new string(targetName.monitorFriendlyDeviceName.AsSpan()).TrimEnd('\0');
var devicePath = new string(targetName.monitorDevicePath.AsSpan()).TrimEnd('\0');
if (!string.IsNullOrEmpty(friendly) || !string.IsNullOrEmpty(devicePath))
{
map.TryAdd(gdiName, (friendly ?? string.Empty, devicePath ?? string.Empty));
}
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Logger.LogError($"BuildDisplayInfoMap failed: {ex.Message}");
}
return map;
}
private static string FormatDisplayName(string deviceName, bool isPrimary, string friendlyName)
{
string name;
if (!string.IsNullOrEmpty(friendlyName))
{
name = friendlyName;
}
else if (deviceName.StartsWith(@"\\.\DISPLAY", StringComparison.OrdinalIgnoreCase))
{
// Fallback: convert "\\.\DISPLAY1" → "Display 1"
var number = deviceName.Substring(@"\\.\DISPLAY".Length);
name = $"Display {number}";
}
else
{
name = deviceName;
}
if (isPrimary)
{
name += " (Primary)";
}
return name;
}
}

View File

@@ -233,6 +233,40 @@
<ToggleSwitch IsOn="{x:Bind ViewModel.Dock_AlwaysOnTop, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- Monitors Section -->
<TextBlock x:Uid="DockMonitors_Header" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
<ItemsRepeater ItemsSource="{x:Bind ViewModel.MonitorConfigs, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Spacing="{StaticResource SettingsCardSpacing}" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="dockVm:DockMonitorConfigViewModel">
<controls:SettingsExpander
Description="{x:Bind Resolution, Mode=OneWay}"
Header="{x:Bind DisplayName, Mode=OneWay}"
HeaderIcon="{ui:FontIcon Glyph=&#xE7F4;}"
IsExpanded="False">
<ToggleSwitch IsOn="{x:Bind IsEnabled, Mode=TwoWay}" />
<controls:SettingsExpander.Items>
<controls:SettingsCard x:Uid="DockMonitor_Position_SettingsCard">
<ComboBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}"
SelectedIndex="{x:Bind SideOverrideIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="DockMonitor_Position_UseDefault" />
<ComboBoxItem x:Uid="DockMonitor_Position_Left" />
<ComboBoxItem x:Uid="DockMonitor_Position_Top" />
<ComboBoxItem x:Uid="DockMonitor_Position_Right" />
<ComboBoxItem x:Uid="DockMonitor_Position_Bottom" />
</ComboBox>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
<!-- Bands Section -->
<!-- <TextBlock x:Uid="DockBandsSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />

View File

@@ -6,6 +6,7 @@ using System.Diagnostics;
using ManagedCommon;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.Extensions.DependencyInjection;
@@ -31,8 +32,9 @@ public sealed partial class DockSettingsPage : Page
var themeService = App.Current.Services.GetService<IThemeService>()!;
var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
var settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
var monitorService = App.Current.Services.GetService<IMonitorService>();
ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
ViewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService, monitorService);
// Initialize UI state
InitializeSettings();

View File

@@ -1118,6 +1118,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Choose where to pin this command</value>
<comment>Header for the dock section selector in the pin to dock dialog</comment>
</data>
<data name="PinToDock_MonitorHeader.Text" xml:space="preserve">
<value>Choose which monitor</value>
<comment>Header for the monitor selector in the pin to dock dialog</comment>
</data>
<data name="PinToDock_Start.Content" xml:space="preserve">
<value>Start</value>
<comment>Start section option in pin to dock dialog</comment>
@@ -1170,4 +1174,36 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Right</value>
<comment>Right section label in pin to dock dialog (code access, horizontal end)</comment>
</data>
<data name="DockMonitors_Header.Text" xml:space="preserve">
<value>Monitors</value>
<comment>Section header for per-monitor dock settings</comment>
</data>
<data name="DockMonitor_Position_UseDefault.Content" xml:space="preserve">
<value>Use default</value>
<comment>Option to inherit the global dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Left.Content" xml:space="preserve">
<value>Left</value>
<comment>Left dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Top.Content" xml:space="preserve">
<value>Top</value>
<comment>Top dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Right.Content" xml:space="preserve">
<value>Right</value>
<comment>Right dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Position_Bottom.Content" xml:space="preserve">
<value>Bottom</value>
<comment>Bottom dock position for a specific monitor</comment>
</data>
<data name="DockMonitor_Primary" xml:space="preserve">
<value>Primary</value>
<comment>Label indicating a monitor is the primary display</comment>
</data>
<data name="DockMonitor_Position_SettingsCard.Header" xml:space="preserve">
<value>Dock position</value>
<comment>Per-monitor dock position settings card header</comment>
</data>
</root>

View File

@@ -0,0 +1,782 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class DockMultiMonitorTests
{
private static readonly MonitorInfo PrimaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY1",
StableId = @"\\?\DISPLAY#PRI1234#4&aaa&0&UID111#{guid1}",
DisplayName = "Display 1 (Primary)",
Bounds = new ScreenRect(0, 0, 1920, 1080),
WorkArea = new ScreenRect(0, 0, 1920, 1040),
Dpi = 96,
IsPrimary = true,
};
private static readonly MonitorInfo SecondaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY2",
StableId = @"\\?\DISPLAY#SEC5678#4&bbb&0&UID222#{guid2}",
DisplayName = "Display 2",
Bounds = new ScreenRect(1920, 0, 3840, 1080),
WorkArea = new ScreenRect(1920, 0, 3840, 1040),
Dpi = 144,
IsPrimary = false,
};
// --- ScreenRect tests ---
[TestMethod]
public void ScreenRect_WidthAndHeight_ComputedCorrectly()
{
var rect = new ScreenRect(100, 200, 500, 600);
Assert.AreEqual(400, rect.Width);
Assert.AreEqual(400, rect.Height);
}
// --- MonitorInfo tests ---
[TestMethod]
public void MonitorInfo_ScaleFactor_ComputedFromDpi()
{
Assert.AreEqual(1.0, PrimaryMonitor.ScaleFactor, 0.001);
Assert.AreEqual(1.5, SecondaryMonitor.ScaleFactor, 0.001);
}
// --- DockMonitorConfig tests ---
[TestMethod]
public void DockMonitorConfig_ResolveSide_ReturnsOverrideWhenSet()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Side = DockSide.Left,
};
Assert.AreEqual(DockSide.Left, config.ResolveSide(DockSide.Bottom));
}
[TestMethod]
public void DockMonitorConfig_ResolveSide_ReturnsGlobalWhenNull()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Side = null,
};
Assert.AreEqual(DockSide.Bottom, config.ResolveSide(DockSide.Bottom));
}
[TestMethod]
public void DockMonitorConfig_ResolveBands_ReturnsOwnBandsWhenCustomized()
{
var customBand = new DockBandSettings { ProviderId = "test", CommandId = "cmd1" };
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = true,
StartBands = ImmutableList.Create(customBand),
};
var globalBands = ImmutableList.Create(new DockBandSettings { ProviderId = "global", CommandId = "g1" });
var resolved = config.ResolveStartBands(globalBands);
Assert.AreEqual(1, resolved.Count);
Assert.AreEqual("cmd1", resolved[0].CommandId);
}
[TestMethod]
public void DockMonitorConfig_ResolveBands_ReturnsGlobalWhenNotCustomized()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = false,
};
var globalBands = ImmutableList.Create(new DockBandSettings { ProviderId = "global", CommandId = "g1" });
var resolved = config.ResolveStartBands(globalBands);
Assert.AreEqual(1, resolved.Count);
Assert.AreEqual("g1", resolved[0].CommandId);
}
[TestMethod]
public void DockMonitorConfig_ForkFromGlobal_CopiesBandsAndSetsCustomized()
{
var globalSettings = CreateMinimalDockSettings() with
{
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p1", CommandId = "c1" }),
CenterBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p2", CommandId = "c2" }),
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
IsCustomized = false,
};
var forked = config.ForkFromGlobal(globalSettings);
Assert.IsTrue(forked.IsCustomized);
Assert.IsNotNull(forked.StartBands);
Assert.AreEqual(1, forked.StartBands!.Count);
Assert.AreEqual("c1", forked.StartBands![0].CommandId);
Assert.IsNotNull(forked.CenterBands);
Assert.AreEqual(1, forked.CenterBands!.Count);
Assert.AreEqual("c2", forked.CenterBands![0].CommandId);
Assert.IsNotNull(forked.EndBands);
Assert.AreEqual(0, forked.EndBands!.Count);
}
[TestMethod]
public void DockMonitorConfig_ForkFromGlobal_ProducesIndependentCopy()
{
var band = new DockBandSettings { ProviderId = "p1", CommandId = "c1" };
var globalSettings = CreateMinimalDockSettings() with
{
StartBands = ImmutableList.Create(band),
};
var config = new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1" };
var forked = config.ForkFromGlobal(globalSettings);
// Modify forked bands — global should be unaffected
var newForked = forked with { StartBands = forked.StartBands!.Add(new DockBandSettings { ProviderId = "p2", CommandId = "c2" }) };
Assert.AreEqual(1, globalSettings.StartBands.Count);
Assert.AreEqual(2, newForked.StartBands.Count);
}
// --- MonitorConfigReconciler tests ---
[TestMethod]
public void Reconciler_ExactMatch_PreservesExistingConfigs()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
}
[TestMethod]
public void Reconciler_NewMonitor_CreatesDefaultConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
var newConfig = result[1];
Assert.AreEqual(SecondaryMonitor.StableId, newConfig.MonitorDeviceId);
Assert.IsFalse(newConfig.Enabled, "New secondary monitor should be disabled by default");
}
[TestMethod]
public void Reconciler_DisconnectedMonitor_PreservesConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "Disconnected monitor should preserve its customizations.");
}
[TestMethod]
public void Reconciler_EmptyConfigs_CreatesForAllMonitors()
{
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(2, result.Count);
}
[TestMethod]
public void Reconciler_NewPrimaryMonitor_InheritsGlobalBands()
{
// On first run / upgrade, the primary monitor should inherit global bands
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.IsFalse(result[0].IsCustomized, "Primary monitor should inherit global bands (IsCustomized = false).");
}
[TestMethod]
public void Reconciler_NewSecondaryMonitor_StartsWithEmptyBands()
{
// On first run / upgrade with multi-monitor, secondary monitors should start
// with empty bands so users are not forced to manually unpin from every display.
var configs = ImmutableList<DockMonitorConfig>.Empty;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
var secondary = result.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondary, "A secondary monitor config should have been created.");
Assert.IsTrue(secondary!.IsCustomized, "Secondary monitor should be customized (IsCustomized = true).");
Assert.AreEqual(0, secondary.StartBands?.Count ?? 0, "Secondary monitor should start with empty StartBands.");
Assert.AreEqual(0, secondary.CenterBands?.Count ?? 0, "Secondary monitor should start with empty CenterBands.");
Assert.AreEqual(0, secondary.EndBands?.Count ?? 0, "Secondary monitor should start with empty EndBands.");
}
[TestMethod]
public void Reconciler_FuzzyMatch_UpdatesPrimaryFlag()
{
// Config has old stable ID but marked as primary
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#OLD#4&ddd&0&UID000#{guidOld}", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.IsTrue(result[0].IsPrimary);
}
[TestMethod]
public void Reconciler_FuzzyMatch_DoesNotMatchNonPrimaryMonitors()
{
// Config has stale stable ID for a non-primary monitor
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
// Current monitors have primary + a different secondary
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Primary keeps its config, new secondary gets a fresh customized config,
// stale secondary is retained at end for future reconnection
Assert.AreEqual(3, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "New secondary should get an empty-bands customized config.");
Assert.AreEqual(0, result[1].StartBands?.Count ?? 0, "New secondary should start with empty bands.");
Assert.AreEqual(@"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.IsTrue(result[2].IsCustomized, "Stale config should retain its customizations.");
}
// --- JSON serialization round-trip ---
[TestMethod]
public void DockMonitorConfig_JsonRoundTrip_PreservesAllFields()
{
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY1",
Enabled = true,
Side = DockSide.Left,
IsPrimary = true,
IsCustomized = true,
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "p1", CommandId = "c1" }),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var json = JsonSerializer.Serialize(config, JsonSerializationContext.Default.DockMonitorConfig);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockMonitorConfig);
Assert.IsNotNull(deserialized);
Assert.AreEqual(config.MonitorDeviceId, deserialized!.MonitorDeviceId);
Assert.AreEqual(config.Enabled, deserialized.Enabled);
Assert.AreEqual(config.Side, deserialized.Side);
Assert.AreEqual(config.IsPrimary, deserialized.IsPrimary);
Assert.AreEqual(config.IsCustomized, deserialized.IsCustomized);
Assert.IsNotNull(deserialized.StartBands);
Assert.AreEqual(1, deserialized.StartBands!.Count);
Assert.AreEqual("c1", deserialized.StartBands![0].CommandId);
}
[TestMethod]
public void DockSettings_MonitorConfigs_JsonRoundTrip()
{
var settings = CreateMinimalDockSettings() with
{
MonitorConfigs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = false }),
};
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.DockSettings);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings);
Assert.IsNotNull(deserialized);
Assert.AreEqual(2, deserialized.MonitorConfigs.Count);
Assert.AreEqual(@"\\.\DISPLAY1", deserialized.MonitorConfigs[0].MonitorDeviceId);
Assert.IsTrue(deserialized.MonitorConfigs[0].Enabled);
Assert.AreEqual(@"\\.\DISPLAY2", deserialized.MonitorConfigs[1].MonitorDeviceId);
Assert.IsFalse(deserialized.MonitorConfigs[1].Enabled);
}
private static DockSettings CreateMinimalDockSettings()
{
// Deserialize from minimal JSON to avoid WinUI3 dependencies
var json = "{}";
return JsonSerializer.Deserialize(json, JsonSerializationContext.Default.DockSettings)
?? new DockSettings();
}
[TestMethod]
public void Reconciler_ReturnsOriginalReference_WhenNothingChanged()
{
var now = DateTime.UtcNow;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true, IsPrimary = false, LastSeen = now });
var reconciled = MonitorConfigReconciler.Reconcile(configs, monitors, now);
Assert.AreSame(configs, reconciled, "Reconciler should return the same reference when nothing changed");
}
// --- Per-monitor save isolation test ---
[TestMethod]
public void WithActiveBands_PerMonitor_DoesNotClobberOtherMonitorConfig()
{
var bandA = new DockBandSettings { ProviderId = "provA", CommandId = "cmdA" };
var bandB = new DockBandSettings { ProviderId = "provB", CommandId = "cmdB" };
var monitorOneConfig = new DockMonitorConfig
{
MonitorDeviceId = PrimaryMonitor.StableId,
Enabled = true,
IsPrimary = true,
IsCustomized = true,
StartBands = ImmutableList.Create(bandA),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var monitorTwoConfig = new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.StableId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList.Create(bandB),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
};
var settings = new DockSettings
{
MonitorConfigs = ImmutableList.Create(monitorOneConfig, monitorTwoConfig),
};
// Simulate Monitor A saving new bands — only A's config should change
var newBandA = new DockBandSettings { ProviderId = "provA2", CommandId = "cmdA2" };
var configA = settings.MonitorConfigs[0];
var updatedConfigA = configA with { StartBands = ImmutableList.Create(newBandA) };
var afterSaveA = settings with
{
MonitorConfigs = ImmutableList.Create(updatedConfigA, monitorTwoConfig),
};
// Verify Monitor A's config was updated
Assert.AreEqual("provA2", afterSaveA.MonitorConfigs![0].StartBands![0].ProviderId);
// Verify Monitor B's config was NOT changed
Assert.AreEqual("provB", afterSaveA.MonitorConfigs![1].StartBands![0].ProviderId);
Assert.AreEqual("cmdB", afterSaveA.MonitorConfigs![1].StartBands![0].CommandId);
}
[TestMethod]
public void AllPinnedCommands_IncludesPerMonitorBands()
{
// Set up global bands
var globalBand = new DockBandSettings { ProviderId = "prov1", CommandId = "globalCmd" };
// Set up a customized per-monitor config with a unique band
var perMonitorBand = new DockBandSettings { ProviderId = "prov1", CommandId = "monitorOnlyCmd" };
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY2",
IsCustomized = true,
StartBands = ImmutableList.Create(globalBand, perMonitorBand),
};
var settings = new DockSettings
{
StartBands = ImmutableList.Create(globalBand),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
MonitorConfigs = ImmutableList.Create(config),
};
var allPinned = new List<(string ProviderId, string CommandId)>(settings.AllPinnedCommands);
// Should include the global band AND the per-monitor band
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "globalCmd"), "Global band should be included");
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "monitorOnlyCmd"), "Per-monitor band should be included");
}
[TestMethod]
public void AllPinnedCommands_ExcludesNonCustomizedMonitorBands()
{
var globalBand = new DockBandSettings { ProviderId = "prov1", CommandId = "globalCmd" };
// Non-customized config should NOT contribute its own bands
var config = new DockMonitorConfig
{
MonitorDeviceId = @"\\.\DISPLAY2",
IsCustomized = false,
StartBands = ImmutableList.Create(new DockBandSettings { ProviderId = "prov1", CommandId = "shouldNotAppear" }),
};
var settings = new DockSettings
{
StartBands = ImmutableList.Create(globalBand),
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
MonitorConfigs = ImmutableList.Create(config),
};
var allPinned = new List<(string ProviderId, string CommandId)>(settings.AllPinnedCommands);
Assert.IsTrue(allPinned.Exists(p => p.CommandId == "globalCmd"), "Global band should be included");
Assert.IsFalse(allPinned.Exists(p => p.CommandId == "shouldNotAppear"), "Non-customized per-monitor band should NOT be included");
}
// --- DockMonitorConfigViewModel tests ---
[TestMethod]
public void DockMonitorConfigViewModel_IsEnabled_ReadsFromConfig()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = false, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.IsFalse(vm.IsEnabled);
}
[TestMethod]
public void DockMonitorConfigViewModel_IsEnabled_PersistsChange()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
vm.IsEnabled = false;
mockSettings.Verify(s => s.UpdateSettings(It.IsAny<Func<SettingsModel, SettingsModel>>(), It.IsAny<bool>()), Times.Once);
}
[TestMethod]
public void DockMonitorConfigViewModel_SideOverrideIndex_ReturnsZeroWhenNull()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = null });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual(0, vm.SideOverrideIndex);
Assert.IsFalse(vm.HasSideOverride);
}
[TestMethod]
public void DockMonitorConfigViewModel_SideOverrideIndex_MapsCorrectly()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = DockSide.Right });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual(3, vm.SideOverrideIndex);
Assert.IsTrue(vm.HasSideOverride);
}
[TestMethod]
public void DockMonitorConfigViewModel_DisplayInfo_ExposesMonitorProperties()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
settings.DockSettings.MonitorConfigs[0], PrimaryMonitor, mockSettings.Object);
Assert.AreEqual("Display 1 (Primary)", vm.DisplayName);
Assert.AreEqual(PrimaryMonitor.DeviceId, vm.DeviceId);
Assert.IsTrue(vm.IsPrimary);
Assert.AreEqual("1920 \u00D7 1080", vm.Resolution);
}
[TestMethod]
public void Reconciler_EmptyConfigs_CreatesDefaultsForAllMonitors()
{
// Simulate upgrade: no per-monitor configs, 3 monitors connected
var tertiary = new MonitorInfo
{
DeviceId = @"\\.\DISPLAY3",
StableId = @"\\?\DISPLAY#TER9012#4&fff&0&UID333#{guid3}",
DisplayName = "Display 3",
Bounds = new ScreenRect(3840, 0, 5760, 1080),
WorkArea = new ScreenRect(3840, 0, 5760, 1040),
Dpi = 96,
IsPrimary = false,
};
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor, tertiary };
var emptyConfigs = ImmutableList<DockMonitorConfig>.Empty;
var reconciled = MonitorConfigReconciler.Reconcile(emptyConfigs, monitors);
Assert.AreEqual(3, reconciled.Count, "Should create configs for all 3 monitors");
// Only the primary monitor should be enabled by default
foreach (var config in reconciled)
{
if (config.IsPrimary)
{
Assert.IsTrue(config.Enabled, $"Primary monitor {config.MonitorDeviceId} should be enabled");
}
else
{
Assert.IsFalse(config.Enabled, $"Secondary monitor {config.MonitorDeviceId} should be disabled by default");
}
Assert.IsNull(config.Side, $"Monitor {config.MonitorDeviceId} should inherit global side");
}
// Primary inherits global bands (IsCustomized=false); secondary starts with
// empty bands (IsCustomized=true) so users choose what to pin per-monitor.
var primaryCfg = reconciled.Find(c => c.IsPrimary);
Assert.IsFalse(primaryCfg!.IsCustomized, "Primary should inherit global bands");
foreach (var config in reconciled)
{
if (!config.IsPrimary)
{
Assert.IsTrue(config.IsCustomized, $"Monitor {config.MonitorDeviceId} (secondary) should be customized with empty bands");
}
}
// Primary should be flagged correctly
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.StableId);
Assert.IsNotNull(primaryConfig, "Primary monitor config should exist");
Assert.IsTrue(primaryConfig.IsPrimary, "Primary config should be marked as primary");
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.StableId);
Assert.IsNotNull(secondaryConfig, "Secondary monitor config should exist");
Assert.IsFalse(secondaryConfig.IsPrimary, "Secondary config should not be marked as primary");
}
[TestMethod]
public void Reconcile_NullExistingConfigs_CreatesDefaultsForAllMonitors()
{
// Simulate upgrade from a version that didn't have multi-monitor settings —
// MonitorConfigs is null because it didn't exist in the older settings.json.
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var reconciled = MonitorConfigReconciler.Reconcile(null, monitors);
Assert.AreEqual(2, reconciled.Count, "Should create configs for all monitors even when existing configs is null");
var primaryConfig = reconciled.Find(c => c.IsPrimary);
Assert.IsNotNull(primaryConfig, "Primary config should be created");
Assert.IsTrue(primaryConfig.Enabled, "Primary should be enabled by default");
Assert.IsFalse(primaryConfig.IsCustomized, "Primary should inherit global bands");
var secondaryConfig = reconciled.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondaryConfig, "Secondary config should be created");
Assert.IsFalse(secondaryConfig.Enabled, "Secondary should be disabled by default");
Assert.IsTrue(secondaryConfig.IsCustomized, "Secondary should start with custom (empty) bands");
}
[TestMethod]
public void Reconcile_DisconnectThenReconnect_PreservesCustomizations()
{
// Step 1: Both monitors connected with customized secondary
var customBands = ImmutableList.Create(new DockBandSettings { ProviderId = "custom", CommandId = "cmd1" });
var now = DateTime.UtcNow;
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.StableId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = customBands,
CenterBands = ImmutableList<DockBandSettings>.Empty,
EndBands = ImmutableList<DockBandSettings>.Empty,
Side = DockSide.Left,
LastSeen = now,
});
// Step 2: Disconnect secondary monitor
var onlyPrimary = new List<MonitorInfo> { PrimaryMonitor };
var afterDisconnect = MonitorConfigReconciler.Reconcile(configs, onlyPrimary, now);
Assert.AreEqual(2, afterDisconnect.Count, "Disconnected monitor config should be retained");
// Step 3: Reconnect secondary monitor
var bothMonitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var afterReconnect = MonitorConfigReconciler.Reconcile(afterDisconnect, bothMonitors, now);
// Verify customizations survived the round-trip
var secondaryConfig = afterReconnect.Find(c =>
string.Equals(c.MonitorDeviceId, SecondaryMonitor.StableId, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(secondaryConfig, "Secondary config should be found after reconnection");
Assert.IsTrue(secondaryConfig.IsCustomized, "Customization flag should survive");
Assert.AreEqual(DockSide.Left, secondaryConfig.Side, "Side override should survive");
Assert.AreEqual(1, secondaryConfig.StartBands?.Count ?? 0, "Custom start bands should survive");
Assert.AreEqual("custom", secondaryConfig.StartBands![0].ProviderId);
}
[TestMethod]
public void Reconcile_StaleConfig_PrunedAfterSixMonths()
{
var now = DateTime.UtcNow;
var sevenMonthsAgo = now - TimeSpan.FromDays(210);
var fiveMonthsAgo = now - TimeSpan.FromDays(150);
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&sss&0&UID444#{guidStale}", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors, now);
// Primary is matched, RECENT is retained (< 6 months), STALE is pruned (> 6 months)
Assert.AreEqual(2, result.Count, "Should have matched primary + recently-seen disconnected config");
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", result[1].MonitorDeviceId, "Recently-seen config should be retained");
}
[TestMethod]
public void Reconcile_LegacyConfigWithoutLastSeen_TreatedAsFresh()
{
// Configs from before LastSeen was added (LastSeen is null)
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Legacy config (null LastSeen) should be treated as fresh and retained
Assert.AreEqual(2, result.Count, "Legacy config without LastSeen should be retained");
Assert.AreEqual(@"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", result[1].MonitorDeviceId);
}
[TestMethod]
public void Reconciler_LegacyGdiName_MigratedToStableId()
{
// Simulate upgrade from pre-stable-ID settings: configs use GDI device names
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true, Side = DockSide.Left },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Phase 1.5 should detect GDI-style names and rewrite to stable IDs
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId, "Primary should be migrated to stable ID");
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId, "Secondary should be migrated to stable ID");
Assert.AreEqual(DockSide.Left, result[0].Side, "Side override should survive migration");
Assert.IsTrue(result[1].IsCustomized, "Customization flag should survive migration");
}
private static SettingsModel CreateSettingsModelWithConfigs(params DockMonitorConfig[] configs)
{
var dockSettings = CreateMinimalDockSettings() with
{
MonitorConfigs = ImmutableList.Create(configs),
};
var minimalJson = "{}";
var settingsModel = JsonSerializer.Deserialize(
minimalJson,
JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel();
return settingsModel with { DockSettings = dockSettings };
}
[TestMethod]
public void FreshInstall_MonitorConfigs_SurvivesRecordWithExpression()
{
// Simulate fresh install: JSON has no MonitorConfigs key at all.
// System.Text.Json passes null to the init setter. Verify the backing field is
// properly coalesced so that record `with` clones remain non-null.
var json = "{}";
var deserialized = JsonSerializer.Deserialize(
json,
JsonSerializationContext.Default.DockSettings) ?? new DockSettings();
// Direct read through getter should be non-null
Assert.IsNotNull(deserialized.MonitorConfigs, "MonitorConfigs should never be null after deserialization");
Assert.AreEqual(0, deserialized.MonitorConfigs.Count);
// Crucially: a `with` clone must also have non-null MonitorConfigs
var clone = deserialized with { ShowLabels = false };
Assert.IsNotNull(clone.MonitorConfigs, "MonitorConfigs should survive record 'with' expression");
Assert.AreEqual(0, clone.MonitorConfigs.Count);
// Double-clone to be thorough
var clone2 = clone with { Side = DockSide.Left };
Assert.IsNotNull(clone2.MonitorConfigs, "MonitorConfigs should survive multiple 'with' expressions");
}
private static Mock<ISettingsService> CreateMockSettingsService(SettingsModel settings)
{
var mock = new Mock<ISettingsService>();
mock.Setup(s => s.Settings).Returns(settings);
mock.Setup(s => s.UpdateSettings(It.IsAny<Func<SettingsModel, SettingsModel>>(), It.IsAny<bool>()))
.Callback<Func<SettingsModel, SettingsModel>, bool>((transform, _) =>
{
var updated = transform(settings);
mock.Setup(s => s.Settings).Returns(updated);
});
return mock;
}
}