mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-05-18 05:05:25 +02:00
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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&125707d6&0&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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
260
src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindowManager.cs
Normal file
260
src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindowManager.cs
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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=}"
|
||||
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}" />
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user