diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 7f72cdd78bce..87bce5468cac 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -8,7 +8,6 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Runtime.CompilerServices; using System.Threading.Tasks; using PowerToys.Interop; @@ -136,29 +135,8 @@ public static void LogError(string message, [System.Runtime.CompilerServices.Cal public static void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { - if (ex == null) - { - Log(message, Error, memberName, sourceFilePath, sourceLineNumber); - } - else - { - var exMessage = - message + Environment.NewLine + - ex.GetType() + " (" + ex.HResult + "): " + ex.Message + Environment.NewLine; - - if (ex.InnerException != null) - { - exMessage += - "Inner exception: " + Environment.NewLine + - ex.InnerException.GetType() + " (" + ex.InnerException.HResult + "): " + ex.InnerException.Message + Environment.NewLine; - } - - exMessage += - "Stack trace: " + Environment.NewLine + - ex.StackTrace; - - Log(exMessage, Error, memberName, sourceFilePath, sourceLineNumber); - } + var logMessage = GenerateExceptionMessage(message, ex); + LogError(logMessage, memberName, sourceFilePath, sourceLineNumber); } public static void LogWarning(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) @@ -166,11 +144,23 @@ public static void LogWarning(string message, [System.Runtime.CompilerServices.C Log(message, Warning, memberName, sourceFilePath, sourceLineNumber); } + public static void LogWarning(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + var logMessage = GenerateExceptionMessage(message, ex); + LogWarning(logMessage, memberName, sourceFilePath, sourceLineNumber); + } + public static void LogInfo(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { Log(message, Info, memberName, sourceFilePath, sourceLineNumber); } + public static void LogInfo(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + var logMessage = GenerateExceptionMessage(message, ex); + LogInfo(logMessage, memberName, sourceFilePath, sourceLineNumber); + } + public static void LogDebug(string message, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { #if DEBUG @@ -178,6 +168,12 @@ public static void LogDebug(string message, [System.Runtime.CompilerServices.Cal #endif } + public static void LogDebug(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) + { + var logMessage = GenerateExceptionMessage(message, ex); + LogDebug(logMessage, memberName, sourceFilePath, sourceLineNumber); + } + public static void LogTrace([System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0) { Log(string.Empty, TraceFlag, memberName, sourceFilePath, sourceLineNumber); @@ -195,6 +191,29 @@ private static void Log(string message, string type, string memberName, string s Trace.Unindent(); } + private static string GenerateExceptionMessage(string message, Exception ex) + { + var finalMessage = message; + if (ex is not null) + { + finalMessage = message + Environment.NewLine + + ex.GetType() + " (" + ex.HResult + "): " + ex.Message + Environment.NewLine; + + if (ex.InnerException != null) + { + finalMessage += + "Inner exception: " + Environment.NewLine + + ex.InnerException.GetType() + " (" + ex.InnerException.HResult + "): " + ex.InnerException.Message + Environment.NewLine; + } + + finalMessage += + "Stack trace: " + Environment.NewLine + + ex.StackTrace; + } + + return finalMessage; + } + private static string GetCallerInfo(string memberName, string sourceFilePath, int sourceLineNumber) { string callerFileName = "Unknown"; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/CmdPalLogger.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/CmdPalLogger.cs new file mode 100644 index 000000000000..fbb7e6e3d9f6 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/CmdPalLogger.cs @@ -0,0 +1,140 @@ +// 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 ManagedCommon; +using Microsoft.Extensions.Logging; + +namespace Microsoft.CmdPal.Common; + +// Adapter implementing Microsoft.Extensions.Logging.ILogger, +// delegating to ManagedCommon.Logger. +public sealed partial class CmdPalLogger : Extensions.Logging.ILogger +{ + private static readonly AsyncLocal> _scopeStack = new(); + private readonly LogLevel _minLevel; + + public string CurrentVersionLogDirectoryPath => Logger.CurrentVersionLogDirectoryPath; + + public CmdPalLogger(LogLevel minLevel = LogLevel.Information) + { + _minLevel = minLevel; + + // Ensure underlying logger initialized (idempotent if already done elsewhere). + Logger.InitializeLogger("\\CmdPal\\Logs\\"); + } + + public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None && logLevel >= _minLevel; + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + var stack = _scopeStack.Value; + if (stack is null) + { + stack = new Stack(); + _scopeStack.Value = stack; + } + + stack.Push(state); + return new Scope(stack); + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (!IsEnabled(logLevel)) + { + return; + } + + ArgumentNullException.ThrowIfNull(formatter); + var message = formatter(state, exception); + if (string.IsNullOrEmpty(message) && exception is null) + { + return; + } + + var scopeSuffix = BuildScopeSuffix(); + var eventPrefix = eventId.Id != 0 ? $"[{eventId.Id}/{eventId.Name}] " : string.Empty; + var finalMessage = $"{eventPrefix}{message}{scopeSuffix}"; + + switch (logLevel) + { + case LogLevel.Trace: + // Existing stack: Trace logs an empty line; append message via Debug. + Logger.LogTrace(); + + if (!string.IsNullOrEmpty(message)) + { + Logger.LogDebug(finalMessage, exception); + } + + break; + + case LogLevel.Debug: + Logger.LogDebug(finalMessage, exception); + + break; + + case LogLevel.Information: + Logger.LogInfo(finalMessage, exception); + + break; + + case LogLevel.Warning: + Logger.LogWarning(finalMessage, exception); + + break; + + case LogLevel.Error: + case LogLevel.Critical: + Logger.LogError(finalMessage, exception); + + break; + + case LogLevel.None: + default: + break; + } + } + + private static string BuildScopeSuffix() + { + var stack = _scopeStack.Value; + if (stack is null || stack.Count == 0) + { + return string.Empty; + } + + // Show most-recent first. + return $" [Scopes: {string.Join(" => ", stack.ToArray())}]"; + } + + private sealed partial class Scope : IDisposable + { + private readonly Stack _stack; + private bool _disposed; + + public Scope(Stack stack) => _stack = stack; + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_stack.Count > 0) + { + _stack.Pop(); + } + + _disposed = true; + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj b/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj index 255561ef8b18..a341e75153af 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj @@ -25,6 +25,7 @@ + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/PersistenceService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/PersistenceService.cs new file mode 100644 index 000000000000..b212800421bc --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/PersistenceService.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.CmdPal.Common.Services; +using Microsoft.Extensions.Logging; + +namespace Microsoft.CmdPal.Common; + +public partial class PersistenceService +{ + private readonly IApplicationInfoService _applicationInfoService; + private readonly Microsoft.Extensions.Logging.ILogger _logger; + + public PersistenceService(IApplicationInfoService applicationInfoService, Microsoft.Extensions.Logging.ILogger logger) + { + _applicationInfoService = applicationInfoService; + this._logger = logger; + } + + private bool TryParseJsonObject(string json, [NotNullWhen(true)] out JsonObject? obj) + { + obj = null; + try + { + obj = JsonNode.Parse(json) as JsonObject; + return obj is not null; + } + catch (Exception ex) + { + Log_PersistenceParseFailure(ex); + return false; + } + } + + private bool TryReadSavedObject(string filePath, [NotNullWhen(true)] out JsonObject? saved) + { + saved = null; + + string oldContent; + try + { + if (!File.Exists(filePath)) + { + saved = new JsonObject(); + return true; + } + + oldContent = File.ReadAllText(filePath); + } + catch (Exception ex) + { + Log_PersistenceReadFileFailure(filePath, ex); + return false; + } + + if (string.IsNullOrWhiteSpace(oldContent)) + { + Log_FileEmpty(filePath); + return false; + } + + return TryParseJsonObject(oldContent, out saved); + } + + public T LoadObject(string fileName, JsonTypeInfo typeInfo) + where T : new() + { + if (string.IsNullOrEmpty(fileName)) + { + throw new InvalidOperationException($"You must set a valid file name before loading {typeof(T).Name}"); + } + + var filePath = SettingsJsonPath(fileName); + + if (!File.Exists(filePath)) + { + Log_FileDoesntExist(typeof(T).Name, filePath); + return new T(); + } + + try + { + var jsonContent = File.ReadAllText(filePath); + var loaded = JsonSerializer.Deserialize(jsonContent, typeInfo); + return loaded ?? new T(); + } + catch (Exception ex) + { + Log_PersistenceReadFailure(typeof(T).Name, filePath, ex); + return new T(); + } + } + + public void SaveObject( + T model, + string fileName, + JsonTypeInfo typeInfo, + JsonSerializerOptions optionsForWrite, + Action? beforeWriteMutation, + Action? afterWriteCallback) + { + if (string.IsNullOrEmpty(fileName)) + { + throw new InvalidOperationException($"You must set a valid file name before saving {typeof(T).Name}"); + } + + var filePath = SettingsJsonPath(fileName); + + try + { + var json = JsonSerializer.Serialize(model, typeInfo); + + if (!TryParseJsonObject(json, out var newObj)) + { + Log_SerializationError(typeof(T).Name); + return; + } + + if (!TryReadSavedObject(filePath, out var savedObj)) + { + savedObj = new JsonObject(); + } + + foreach (var kvp in newObj) + { + savedObj[kvp.Key] = kvp.Value?.DeepClone(); + } + + beforeWriteMutation?.Invoke(savedObj); + + var serialized = savedObj.ToJsonString(optionsForWrite); + File.WriteAllText(filePath, serialized); + + afterWriteCallback?.Invoke(model); + } + catch (Exception ex) + { + Log_PersistenceSaveFailure(typeof(T).Name, filePath, ex); + } + } + + public string SettingsJsonPath(string fileName) + { + var configDirectory = _applicationInfoService.ConfigDirectory; + Directory.CreateDirectory(configDirectory); + + // now, the settings is just next to the exe + return Path.Combine(configDirectory, fileName); + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to save {typeName} to '{filePath}'.")] + partial void Log_PersistenceSaveFailure(string typeName, string filePath, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to read {typeName} from '{filePath}'.")] + partial void Log_PersistenceReadFailure(string typeName, string filePath, Exception exception); + + [LoggerMessage(Level = LogLevel.Debug, Message = "Failed to serialize {typeName} to JsonObject.")] + partial void Log_SerializationError(string typeName); + + [LoggerMessage(Level = LogLevel.Debug, Message = "The provided {typeName} file does not exist ({filePath})")] + partial void Log_FileDoesntExist(string typeName, string filePath); + + [LoggerMessage(Level = LogLevel.Debug, Message = "The file at '{filePath}' is empty.")] + partial void Log_FileEmpty(string filePath); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to read file at '{filePath}'.")] + partial void Log_PersistenceReadFileFailure(string filePath, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Failed to parse persisted JSON.")] + partial void Log_PersistenceParseFailure(Exception exception); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs index 94c19e688ce0..014e9b1704fb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AliasManager.cs @@ -15,9 +15,10 @@ public partial class AliasManager : ObservableObject // REMEMBER, CommandAlias.SearchPrefix is what we use as keys private readonly Dictionary _aliases; - public AliasManager(TopLevelCommandManager tlcManager, SettingsModel settings) + public AliasManager(TopLevelCommandManager tlcManager, SettingsService settingsService) { _topLevelCommandManager = tlcManager; + var settings = settingsService.CurrentSettings; _aliases = settings.Aliases; if (_aliases.Count == 0) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs index b445bf881d64..d1c87edf3efb 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs @@ -2,170 +2,18 @@ // 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.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; -using Microsoft.CommandPalette.Extensions.Toolkit; -using Windows.Foundation; - namespace Microsoft.CmdPal.UI.ViewModels; -public partial class AppStateModel : ObservableObject +public sealed record AppStateModel { - [JsonIgnore] - public static readonly string FilePath; - - public event TypedEventHandler? StateChanged; - /////////////////////////////////////////////////////////////////////////// // STATE HERE // Make sure that you make the setters public (JsonSerializer.Deserialize will fail silently otherwise)! // Make sure that any new types you add are added to JsonSerializationContext! - public RecentCommandsManager RecentCommands { get; set; } = new(); + public RecentCommandsManager RecentCommands { get; init; } = new(); - public List RunHistory { get; set; } = []; + public List RunHistory { get; init; } = []; // END SETTINGS /////////////////////////////////////////////////////////////////////////// - - static AppStateModel() - { - FilePath = StateJsonPath(); - } - - public static AppStateModel LoadState() - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(LoadState)}"); - } - - if (!File.Exists(FilePath)) - { - Debug.WriteLine("The provided settings file does not exist"); - return new(); - } - - try - { - // Read the JSON content from the file - var jsonContent = File.ReadAllText(FilePath); - - var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.AppStateModel); - - Debug.WriteLine(loaded is not null ? "Loaded settings file" : "Failed to parse"); - - return loaded ?? new(); - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - - return new(); - } - - public static void SaveState(AppStateModel model) - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveState)}"); - } - - try - { - // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.AppStateModel!); - - // validate JSON - if (JsonNode.Parse(settingsJson) is not JsonObject newSettings) - { - Logger.LogError("Failed to parse app state as a JsonObject."); - return; - } - - // read previous settings - if (!TryReadSavedState(out var savedSettings)) - { - savedSettings = new JsonObject(); - } - - // merge new settings into old ones - foreach (var item in newSettings) - { - savedSettings[item.Key] = item.Value?.DeepClone(); - } - - var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel!.Options); - File.WriteAllText(FilePath, serialized); - - // TODO: Instead of just raising the event here, we should - // have a file change watcher on the settings file, and - // reload the settings then - model.StateChanged?.Invoke(model, null); - } - catch (Exception ex) - { - Logger.LogError($"Failed to save application state to {FilePath}:", ex); - } - } - - private static bool TryReadSavedState([NotNullWhen(true)] out JsonObject? savedSettings) - { - savedSettings = null; - - // read existing content from the file - string oldContent; - try - { - if (File.Exists(FilePath)) - { - oldContent = File.ReadAllText(FilePath); - } - else - { - // file doesn't exist (might not have been created yet), so consider this a success - // and return empty settings - savedSettings = new JsonObject(); - return true; - } - } - catch (Exception ex) - { - Logger.LogWarning($"Failed to read app state file {FilePath}:\n{ex}"); - return false; - } - - // detect empty file, just for sake of logging - if (string.IsNullOrWhiteSpace(oldContent)) - { - Logger.LogInfo($"App state file is empty: {FilePath}"); - return false; - } - - // is it valid JSON? - try - { - savedSettings = JsonNode.Parse(oldContent) as JsonObject; - return savedSettings != null; - } - catch (Exception ex) - { - Logger.LogWarning($"Failed to parse app state from {FilePath}:\n{ex}"); - return false; - } - } - - internal static string StateJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the settings is just next to the exe - return Path.Combine(directory, "state.json"); - } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateService.cs new file mode 100644 index 000000000000..f3b9aa9f73cd --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateService.cs @@ -0,0 +1,48 @@ +// 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.Common; +using Windows.Foundation; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class AppStateService +{ + private const string FileName = "state.json"; + + private readonly PersistenceService _persistenceService; + private AppStateModel _appStateModel; + + public event TypedEventHandler? StateChanged; + + public AppStateModel CurrentSettings => _appStateModel; + + public AppStateService(PersistenceService persistenceService) + { + _persistenceService = persistenceService; + _appStateModel = LoadState(); + } + + private AppStateModel LoadState() + { + return _persistenceService.LoadObject(FileName, JsonSerializationContext.Default.AppStateModel!); + } + + public void SaveSettings(AppStateModel model) + { + _persistenceService.SaveObject( + model, + FileName, + JsonSerializationContext.Default.AppStateModel, + JsonSerializationContext.Default.Options, + null, + afterWriteCallback: m => FinalizeStateSave(m)); + } + + private void FinalizeStateSave(AppStateModel model) + { + _appStateModel = model; + StateChanged?.Invoke(model, null); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs index e817ce8d9631..6efe0da7894b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppearanceSettingsViewModel.cs @@ -87,12 +87,13 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis Color.FromArgb(255, 126, 115, 95), // #7e735f ]; - private readonly SettingsModel _settings; + private readonly SettingsService _settingsService; private readonly UISettings _uiSettings; private readonly IThemeService _themeService; private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + private SettingsModel _settings; private ElementTheme? _elementThemeOverride; private Color _currentSystemAccentColor; @@ -111,10 +112,9 @@ public UserTheme Theme { if (_settings.Theme != value) { - _settings.Theme = value; + Save(_settings with { Theme = value }); OnPropertyChanged(); OnPropertyChanged(nameof(ThemeIndex)); - Save(); } } } @@ -126,7 +126,7 @@ public ColorizationMode ColorizationMode { if (_settings.ColorizationMode != value) { - _settings.ColorizationMode = value; + Save(_settings with { ColorizationMode = value }); OnPropertyChanged(); OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(IsCustomTintVisible)); @@ -144,8 +144,6 @@ public ColorizationMode ColorizationMode } IsColorizationDetailsExpanded = value != ColorizationMode.None; - - Save(); } } } @@ -163,16 +161,13 @@ public Color ThemeColor { if (_settings.CustomThemeColor != value) { - _settings.CustomThemeColor = value; - + Save(_settings with { CustomThemeColor = value }); OnPropertyChanged(); if (ColorIntensity == 0) { ColorIntensity = 100; } - - Save(); } } } @@ -182,10 +177,9 @@ public int ColorIntensity get => _settings.CustomThemeColorIntensity; set { - _settings.CustomThemeColorIntensity = value; + Save(_settings with { CustomThemeColorIntensity = value }); OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveTintIntensity)); - Save(); } } @@ -194,10 +188,9 @@ public int BackgroundImageTintIntensity get => _settings.BackgroundImageTintIntensity; set { - _settings.BackgroundImageTintIntensity = value; + Save(_settings with { BackgroundImageTintIntensity = value }); OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveTintIntensity)); - Save(); } } @@ -208,15 +201,13 @@ public string BackgroundImagePath { if (_settings.BackgroundImagePath != value) { - _settings.BackgroundImagePath = value; + Save(_settings with { BackgroundImagePath = value }); OnPropertyChanged(); if (BackgroundImageOpacity == 0) { BackgroundImageOpacity = 100; } - - Save(); } } } @@ -228,9 +219,8 @@ public int BackgroundImageOpacity { if (_settings.BackgroundImageOpacity != value) { - _settings.BackgroundImageOpacity = value; + Save(_settings with { BackgroundImageOpacity = value }); OnPropertyChanged(); - Save(); } } } @@ -242,9 +232,8 @@ public int BackgroundImageBrightness { if (_settings.BackgroundImageBrightness != value) { - _settings.BackgroundImageBrightness = value; + Save(_settings with { BackgroundImageBrightness = value }); OnPropertyChanged(); - Save(); } } } @@ -256,9 +245,8 @@ public int BackgroundImageBlurAmount { if (_settings.BackgroundImageBlurAmount != value) { - _settings.BackgroundImageBlurAmount = value; + Save(_settings with { BackgroundImageBlurAmount = value }); OnPropertyChanged(); - Save(); } } } @@ -270,10 +258,9 @@ public BackgroundImageFit BackgroundImageFit { if (_settings.BackgroundImageFit != value) { - _settings.BackgroundImageFit = value; + Save(_settings with { BackgroundImageFit = value }); OnPropertyChanged(); OnPropertyChanged(nameof(BackgroundImageFitIndex)); - Save(); } } } @@ -304,11 +291,10 @@ public int BackdropOpacity { if (_settings.BackdropOpacity != value) { - _settings.BackdropOpacity = value; + Save(_settings with { BackdropOpacity = value }); OnPropertyChanged(); OnPropertyChanged(nameof(EffectiveBackdropStyle)); OnPropertyChanged(nameof(EffectiveImageOpacity)); - Save(); } } } @@ -321,8 +307,7 @@ public int BackdropStyleIndex var newStyle = (BackdropStyle)value; if (_settings.BackdropStyle != newStyle) { - _settings.BackdropStyle = newStyle; - + Save(_settings with { BackdropStyle = newStyle }); OnPropertyChanged(); OnPropertyChanged(nameof(IsBackdropOpacityVisible)); OnPropertyChanged(nameof(IsMicaBackdropDescriptionVisible)); @@ -333,32 +318,30 @@ public int BackdropStyleIndex { IsColorizationDetailsExpanded = false; } - - Save(); } } } /// - /// Gets whether the backdrop opacity slider should be visible. + /// Gets a value indicating whether the backdrop opacity slider should be visible. /// public bool IsBackdropOpacityVisible => BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; /// - /// Gets whether the backdrop description (for styles without options) should be visible. + /// Gets a value indicating whether the backdrop description (for styles without options) should be visible. /// public bool IsMicaBackdropDescriptionVisible => !BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity; /// - /// Gets whether background/colorization settings are available. + /// Gets a value indicating whether background/colorization settings are available. /// public bool IsBackgroundSettingsEnabled => BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; /// - /// Gets whether the "not available" message should be shown (inverse of IsBackgroundSettingsEnabled). + /// Gets a value indicating whether the "not available" message should be shown (inverse of IsBackgroundSettingsEnabled). /// public bool IsBackgroundNotAvailableVisible => !BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization; @@ -436,11 +419,14 @@ EffectiveBackdropStyle is not null ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) : null; - public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + public AppearanceSettingsViewModel(IThemeService themeService, SettingsService settingsService) { _themeService = themeService; _themeService.ThemeChanged += ThemeServiceOnThemeChanged; - _settings = settings; + _settingsService = settingsService; + _settings = _settingsService.CurrentSettings; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; _uiSettings = new UISettings(); _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; @@ -451,6 +437,13 @@ public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel set IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled; } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settings = args.NewSettingsModel; + Reapply(); + IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled; + } + private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); private void UpdateAccentColor(UISettings sender) @@ -467,9 +460,10 @@ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } - private void Save() + private void Save(SettingsModel settings) { - SettingsModel.SaveSettings(_settings); + _settings = settings; + _settingsService.SaveSettings(settings, true); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } @@ -530,5 +524,6 @@ public void Dispose() { _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged; _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandAlias.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandAlias.cs index 7179ac7660b3..6d104b0666ac 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandAlias.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandAlias.cs @@ -6,13 +6,13 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public class CommandAlias +public record CommandAlias { - public string CommandId { get; set; } + public string CommandId { get; init; } - public string Alias { get; set; } + public string Alias { get; init; } - public bool IsDirect { get; set; } + public bool IsDirect { get; init; } [JsonIgnore] public string SearchPrefix => Alias + (IsDirect ? string.Empty : " "); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs index 48f4174c5d8c..79b64c2995fe 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandProviderWrapper.cs @@ -139,7 +139,8 @@ public async Task LoadTopLevelCommands(IServiceProvider serviceProvider) return; } - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; var providerSettings = GetProviderSettings(settings); IsActive = providerSettings.IsEnabled; @@ -249,7 +250,8 @@ private void InitializeCommands( IServiceProvider serviceProvider, ICommandProvider4? four) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; var contextMenuFactory = serviceProvider.GetService()!; var state = serviceProvider.GetService()!; var providerSettings = GetProviderSettings(settings); @@ -258,7 +260,7 @@ private void InitializeCommands( var make = (ICommandItem? i, TopLevelType t) => { CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory); - TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory); + TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settingsService, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory); topLevelViewModel.InitializeProperties(); return topLevelViewModel; @@ -407,7 +409,8 @@ private void UnsafePreCacheApiAdditions(ICommandProvider2 provider) public void PinCommand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; var providerSettings = GetProviderSettings(settings); if (!providerSettings.PinnedCommandIds.Contains(commandId)) @@ -416,27 +419,33 @@ public void PinCommand(string commandId, IServiceProvider serviceProvider) // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.SaveSettings(settings); } } public void UnpinCommand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; + var providerSettings = GetProviderSettings(settings); + this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); + settingsService.SaveSettings(settings); if (providerSettings.PinnedCommandIds.Remove(commandId)) { // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.SaveSettings(settings, false); } } public void PinDockBand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; + var bandSettings = new DockBandSettings { CommandId = commandId, @@ -447,19 +456,21 @@ public void PinDockBand(string commandId, IServiceProvider serviceProvider) // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.SaveSettings(settings, false); } public void UnpinDockBand(string commandId, IServiceProvider serviceProvider) { - var settings = serviceProvider.GetService()!; + var settingsService = serviceProvider.GetService()!; + var settings = settingsService.CurrentSettings; + settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId); // Raise CommandsChanged so the TopLevelCommandManager reloads our commands this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1)); - SettingsModel.SaveSettings(settings, false); + settingsService.SaveSettings(settings, false); } public ICommandProviderContext GetProviderContext() => this; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 81d3a631a7c0..831b8014c752 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -18,6 +18,7 @@ using Microsoft.CmdPal.UI.ViewModels.Commands; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.ViewModels.Properties; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -42,7 +43,8 @@ public sealed partial class MainListPage : DynamicListPage, private readonly ThrottledDebouncedAction _refreshThrottledDebouncedAction; private readonly TopLevelCommandManager _tlcManager; private readonly AliasManager _aliasManager; - private readonly SettingsModel _settings; + private readonly SettingsService _settingsService; + private readonly AppStateService _appStateService; private readonly AppStateModel _appStateModel; private readonly ScoringFunction _scoringFunction; private readonly ScoringFunction _fallbackScoringFunction; @@ -62,6 +64,7 @@ public sealed partial class MainListPage : DynamicListPage, private IEnumerable>? _scoredFallbackItems; private IEnumerable>? _fallbackItems; + private SettingsModel _settings; private bool _includeApps; private bool _filteredItemsIncludesApps; @@ -79,9 +82,9 @@ public sealed partial class MainListPage : DynamicListPage, public MainListPage( TopLevelCommandManager topLevelCommandManager, - SettingsModel settings, + SettingsService settingsService, AliasManager aliasManager, - AppStateModel appStateModel, + AppStateService appStateService, IFuzzyMatcherProvider fuzzyMatcherProvider) { Id = "com.microsoft.cmdpal.home"; @@ -89,14 +92,18 @@ public MainListPage( Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png"); PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder; - _settings = settings; + _settingsService = settingsService; + _settings = _settingsService.CurrentSettings; + _appStateService = appStateService; + _appStateModel = _appStateService.CurrentSettings; + _aliasManager = aliasManager; - _appStateModel = appStateModel; _tlcManager = topLevelCommandManager; _fuzzyMatcherProvider = fuzzyMatcherProvider; _scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current); _fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks); + _settingsService.SettingsChanged += SettingsChangedHandler; _tlcManager.PropertyChanged += TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged; @@ -150,8 +157,7 @@ public MainListPage( WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - settings.SettingsChanged += SettingsChangedHandler; - HotReloadSettings(settings); + HotReloadSettings(_settings); _includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId); IsLoading = true; @@ -680,7 +686,7 @@ public void UpdateHistory(IListItem topLevelOrAppItem) var id = IdForTopLevelOrAppItem(topLevelOrAppItem); var history = _appStateModel.RecentCommands; history.AddHistoryItem(id); - AppStateModel.SaveState(_appStateModel); + _appStateService.SaveSettings(_appStateModel); } private static string IdForTopLevelOrAppItem(IListItem topLevelOrAppItem) @@ -703,7 +709,11 @@ public void Receive(UpdateFallbackItemsMessage message) RequestRefresh(fullRefresh: false); } - private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender); + private void SettingsChangedHandler(SettingsService sender, SettingsChangedEventArgs args) + { + _settings = args.NewSettingsModel; + HotReloadSettings(args.NewSettingsModel); + } private void HotReloadSettings(SettingsModel settings) => ShowDetails = settings.ShowAppDetails; @@ -713,14 +723,10 @@ public void Dispose() _cancellationTokenSource?.Dispose(); _fallbackUpdateManager.Dispose(); + _settingsService.SettingsChanged -= SettingsChangedHandler; _tlcManager.PropertyChanged -= TlcManager_PropertyChanged; _tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged; - if (_settings is not null) - { - _settings.SettingsChanged -= SettingsChangedHandler; - } - WeakReferenceMessenger.Default.UnregisterAll(this); GC.SuppressFinalize(this); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs index d2b5dc14357e..18419ca1ddda 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandSettingsViewModel.cs @@ -5,17 +5,19 @@ using System.Globalization; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; namespace Microsoft.CmdPal.UI.ViewModels.Dock; -public partial class DockBandSettingsViewModel : ObservableObject +public partial class DockBandSettingsViewModel : ObservableObject, IDisposable { private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural); - private readonly SettingsModel _settingsModel; + private readonly SettingsService _settingsService; private readonly DockBandSettings _dockSettingsModel; private readonly TopLevelViewModel _adapter; private readonly DockBandViewModel? _bandViewModel; + private SettingsModel _settingsModel; public string Title => _adapter.Title; @@ -43,45 +45,6 @@ public string Description public IconInfoViewModel Icon => _adapter.IconViewModel; - private ShowLabelsOption _showLabels; - - public ShowLabelsOption ShowLabels - { - get => _showLabels; - set - { - if (value != _showLabels) - { - _showLabels = value; - _dockSettingsModel.ShowLabels = value switch - { - ShowLabelsOption.Default => null, - ShowLabelsOption.ShowLabels => true, - ShowLabelsOption.HideLabels => false, - _ => null, - }; - Save(); - } - } - } - - private ShowLabelsOption FetchShowLabels() - { - if (_dockSettingsModel.ShowLabels == null) - { - return ShowLabelsOption.Default; - } - - return _dockSettingsModel.ShowLabels.Value ? ShowLabelsOption.ShowLabels : ShowLabelsOption.HideLabels; - } - - // used to map to ComboBox selection - public int ShowLabelsIndex - { - get => (int)ShowLabels; - set => ShowLabels = (ShowLabelsOption)value; - } - private DockPinSide PinSide { get => _pinSide; @@ -128,14 +91,23 @@ public DockBandSettingsViewModel( DockBandSettings dockSettingsModel, TopLevelViewModel topLevelAdapter, DockBandViewModel? bandViewModel, - SettingsModel settingsModel) + SettingsService settingsService) { _dockSettingsModel = dockSettingsModel; _adapter = topLevelAdapter; _bandViewModel = bandViewModel; - _settingsModel = settingsModel; + _settingsService = settingsService; + + _settingsModel = _settingsService.CurrentSettings; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; + _pinSide = FetchPinSide(); - _showLabels = FetchShowLabels(); + } + + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settingsModel = args.NewSettingsModel; } private DockPinSide FetchPinSide() @@ -175,7 +147,7 @@ private int NumItemsInBand() private void Save() { - SettingsModel.SaveSettings(_settingsModel); + _settingsService.SaveSettings(_settingsModel); } private void UpdatePinSide(DockPinSide value) @@ -233,6 +205,12 @@ private void OnPinSideChanged(DockPinSide value) SetBandPosition(value, null); _pinSide = value; } + + public void Dispose() + { + GC.SuppressFinalize(this); + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } } public enum DockPinSide diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandViewModel.cs index f20031d80a71..7e1704ba204b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockBandViewModel.cs @@ -15,11 +15,10 @@ namespace Microsoft.CmdPal.UI.ViewModels.Dock; public sealed partial class DockBandViewModel : ExtensionObjectViewModel { private readonly CommandItemViewModel _rootItem; - private readonly DockBandSettings _bandSettings; - private readonly DockSettings _dockSettings; - private readonly Action _saveSettings; private readonly IContextMenuFactory _contextMenuFactory; + private DockBandSettings _bandSettings; + public ObservableCollection Items { get; } = new(); private bool _showTitles = true; @@ -103,8 +102,7 @@ internal void SnapshotShowLabels() /// internal void SaveShowLabels() { - _bandSettings.ShowTitles = _showTitles; - _bandSettings.ShowSubtitles = _showSubtitles; + _bandSettings = _bandSettings with { ShowTitles = _showTitles, ShowSubtitles = _showSubtitles }; _showTitlesSnapshot = null; _showSubtitlesSnapshot = null; } @@ -132,14 +130,11 @@ internal DockBandViewModel( WeakReference errorContext, DockBandSettings settings, DockSettings dockSettings, - Action saveSettings, IContextMenuFactory contextMenuFactory) : base(errorContext) { _rootItem = commandItemViewModel; _bandSettings = settings; - _dockSettings = dockSettings; - _saveSettings = saveSettings; _contextMenuFactory = contextMenuFactory; _showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockMonitorConfigViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockMonitorConfigViewModel.cs new file mode 100644 index 000000000000..e633498cecca --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockMonitorConfigViewModel.cs @@ -0,0 +1,148 @@ +// 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.Globalization; +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Settings; + +namespace Microsoft.CmdPal.UI.ViewModels.Dock; + +/// +/// ViewModel wrapping a paired with its +/// . Exposes bindable properties for the monitor +/// config UI and persists changes through . +/// +public partial class DockMonitorConfigViewModel : ObservableObject +{ + private static readonly CompositeFormat ResolutionFormat = CompositeFormat.Parse("{0} \u00D7 {1}"); + + private readonly DockMonitorConfig _config; + private readonly MonitorInfo _monitorInfo; + private readonly SettingsService _settingsService; + + public DockMonitorConfigViewModel( + DockMonitorConfig config, + MonitorInfo monitorInfo, + SettingsService settingsService) + { + _config = config; + _monitorInfo = monitorInfo; + _settingsService = settingsService; + } + + /// Gets the underlying monitor config for band population. + internal DockMonitorConfig Config => _config; + + /// Gets the human-readable display name from the monitor hardware. + public string DisplayName => _monitorInfo.DisplayName; + + /// Gets the stable device identifier for this monitor. + public string DeviceId => _monitorInfo.DeviceId; + + /// Gets a value indicating whether this is the primary monitor. + public bool IsPrimary => _monitorInfo.IsPrimary; + + /// Gets the monitor resolution formatted as "W × H". + public string Resolution => string.Format( + CultureInfo.CurrentCulture, + ResolutionFormat, + _monitorInfo.Bounds.Width, + _monitorInfo.Bounds.Height); + + /// + /// Gets or sets a value indicating whether the dock is enabled on this monitor. + /// Persists the change immediately. + /// + public bool IsEnabled + { + get => _config.Enabled; + set + { + if (_config.Enabled != value) + { + _config.Enabled = value; + Save(); + OnPropertyChanged(); + } + } + } + + /// + /// Gets or sets the side-override index for ComboBox binding. + /// 0 = "Use default" (inherit), 1 = Left, 2 = Top, 3 = Right, 4 = Bottom. + /// Persists the change immediately. + /// + public int SideOverrideIndex + { + get => _config.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, + }; + + if (_config.Side != newSide) + { + _config.Side = newSide; + Save(); + OnPropertyChanged(); + OnPropertyChanged(nameof(HasSideOverride)); + } + } + } + + /// Gets a value indicating whether this monitor has a per-monitor side override. + public bool HasSideOverride => _config.Side is not null; + + /// + /// 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. + /// + public bool IsCustomized + { + get => _config.IsCustomized; + set + { + if (_config.IsCustomized != value) + { + _config.IsCustomized = value; + + if (value) + { + _config.ForkFromGlobal(_settingsService.CurrentSettings.DockSettings); + } + else + { + _config.StartBands = null; + _config.CenterBands = null; + _config.EndBands = null; + } + + Save(); + OnPropertyChanged(); + } + } + } + + private void Save() + { + _settingsService.SaveSettings(_settingsService.CurrentSettings, true); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs index e7063e0c47a1..3d31a18ee5a5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/DockViewModel.cs @@ -7,19 +7,23 @@ using ManagedCommon; using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; 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 SettingsModel _settingsModel; + private readonly SettingsService _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 SettingsModel _settingsModel; private DockSettings _settings; + private DockMonitorConfig? _monitorConfig; public TaskScheduler Scheduler { get; } @@ -34,13 +38,22 @@ public sealed partial class DockViewModel public DockViewModel( TopLevelCommandManager tlcManager, IContextMenuFactory contextMenuFactory, - SettingsModel settings, - TaskScheduler scheduler) + SettingsService settingsService, + TaskScheduler scheduler, + string? monitorDeviceId = null) { _topLevelCommandManager = tlcManager; _contextMenuFactory = contextMenuFactory; - _settingsModel = settings; - _settings = settings.DockSettings; + _settingsService = settingsService; + _monitorDeviceId = monitorDeviceId; + + _settingsModel = _settingsService.CurrentSettings; + _settings = _settingsModel.DockSettings; + + RefreshMonitorConfig(); + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; + Scheduler = scheduler; _pageContext = new(this); @@ -49,6 +62,105 @@ public DockViewModel( EmitDockConfiguration(); } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settingsModel = args.NewSettingsModel; + _settings = _settingsModel.DockSettings; + RefreshMonitorConfig(); + } + + /// + /// Looks up the for this VM's monitor from + /// the current settings. Called on construction and whenever settings change + /// so the reference stays fresh. + /// + private void RefreshMonitorConfig() + { + if (_monitorDeviceId is null) + { + _monitorConfig = null; + return; + } + + _monitorConfig = null; + foreach (var config in _settings.MonitorConfigs) + { + if (string.Equals(config.MonitorDeviceId, _monitorDeviceId, StringComparison.OrdinalIgnoreCase)) + { + _monitorConfig = config; + break; + } + } + } + + /// + /// Returns the band lists this VM should read from and write to. + /// When a per-monitor config is active and customized, returns the + /// monitor's own lists; otherwise the global dock settings lists. + /// + private (List Start, List Center, List End) GetActiveBandLists() + { + if (_monitorConfig is not null && _monitorConfig.IsCustomized + && _monitorConfig.StartBands is not null + && _monitorConfig.CenterBands is not null + && _monitorConfig.EndBands is not null) + { + return (_monitorConfig.StartBands, _monitorConfig.CenterBands, _monitorConfig.EndBands); + } + + return (_settings.StartBands, _settings.CenterBands, _settings.EndBands); + } + + /// + /// If this VM targets a specific monitor that hasn't been customized yet, + /// forks the global bands into per-monitor lists so subsequent mutations + /// only affect this monitor. + /// + private void EnsureMonitorForked() + { + if (_monitorConfig is not null && !_monitorConfig.IsCustomized) + { + _monitorConfig.ForkFromGlobal(_settings); + } + } + + /// + /// Finds a by command ID across the three + /// supplied lists without LINQ. + /// + private static DockBandSettings? FindBandSettingsById( + string bandId, + List start, + List center, + List end) + { + foreach (var b in start) + { + if (b.CommandId == bandId) + { + return b; + } + } + + foreach (var b in center) + { + if (b.CommandId == bandId) + { + return b; + } + } + + foreach (var b in end) + { + if (b.CommandId == bandId) + { + return b; + } + } + + return null; + } + private void DockBands_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { Logger.LogDebug("Starting DockBands_CollectionChanged"); @@ -63,12 +175,21 @@ public void UpdateSettings(DockSettings settings) SetupBands(); } + /// + /// Performs the initial band setup. Call once after the DockWindow has been + /// created and shown so the UI scheduler is available. The constructor + /// intentionally skips this to avoid + /// in AOT builds where the scheduler isn't ready during construction. + /// + public void InitializeBands() => SetupBands(); + private void SetupBands() { Logger.LogDebug($"Setting up dock bands"); - SetupBands(_settings.StartBands, StartItems); - SetupBands(_settings.CenterBands, CenterItems); - SetupBands(_settings.EndBands, EndItems); + var (startBands, centerBands, endBands) = GetActiveBandLists(); + SetupBands(startBands, StartItems); + SetupBands(centerBands, CenterItems); + SetupBands(endBands, EndItems); } private void SetupBands( @@ -140,7 +261,7 @@ private DockBandViewModel CreateBandItem( DockBandSettings bandSettings, CommandItemViewModel commandItem) { - DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, SaveSettings, _contextMenuFactory); + DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, _contextMenuFactory); // the band is NOT initialized here! return band; @@ -148,7 +269,7 @@ private DockBandViewModel CreateBandItem( private void SaveSettings() { - SettingsModel.SaveSettings(_settingsModel); + _settingsService.SaveSettings(_settingsModel); } public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc) @@ -193,11 +314,10 @@ private void SaveSettings() public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + EnsureMonitorForked(); + var (startBands, centerBands, endBands) = GetActiveBandLists(); - 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 = FindBandSettingsById(bandId, startBands, centerBands, endBands); if (bandSettings == null) { @@ -205,17 +325,17 @@ public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int } // Remove from all settings lists - dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); + startBands.RemoveAll(b => b.CommandId == bandId); + centerBands.RemoveAll(b => b.CommandId == bandId); + endBands.RemoveAll(b => b.CommandId == bandId); // Add to target settings list at the correct index var targetSettings = targetSide switch { - DockPinSide.Start => dockSettings.StartBands, - DockPinSide.Center => dockSettings.CenterBands, - DockPinSide.End => dockSettings.EndBands, - _ => dockSettings.StartBands, + DockPinSide.Start => startBands, + DockPinSide.Center => centerBands, + DockPinSide.End => endBands, + _ => startBands, }; var insertIndex = Math.Min(targetIndex, targetSettings.Count); targetSettings.Insert(insertIndex, bandSettings); @@ -228,11 +348,10 @@ public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + EnsureMonitorForked(); + var (startBands, centerBands, endBands) = GetActiveBandLists(); - 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 = FindBandSettingsById(bandId, startBands, centerBands, endBands); if (bandSettings == null) { @@ -241,9 +360,9 @@ public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide } // Remove from all sides (settings and UI) - dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); + startBands.RemoveAll(b => b.CommandId == bandId); + centerBands.RemoveAll(b => b.CommandId == bandId); + endBands.RemoveAll(b => b.CommandId == bandId); StartItems.Remove(band); CenterItems.Remove(band); EndItems.Remove(band); @@ -253,8 +372,8 @@ public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide { case DockPinSide.Start: { - var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count); - dockSettings.StartBands.Insert(settingsIndex, bandSettings); + var settingsIndex = Math.Min(targetIndex, startBands.Count); + startBands.Insert(settingsIndex, bandSettings); var uiIndex = Math.Min(targetIndex, StartItems.Count); StartItems.Insert(uiIndex, band); @@ -263,8 +382,8 @@ public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide case DockPinSide.Center: { - var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count); - dockSettings.CenterBands.Insert(settingsIndex, bandSettings); + var settingsIndex = Math.Min(targetIndex, centerBands.Count); + centerBands.Insert(settingsIndex, bandSettings); var uiIndex = Math.Min(targetIndex, CenterItems.Count); CenterItems.Insert(uiIndex, band); @@ -273,8 +392,8 @@ public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide case DockPinSide.End: { - var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count); - dockSettings.EndBands.Insert(settingsIndex, bandSettings); + var settingsIndex = Math.Min(targetIndex, endBands.Count); + endBands.Insert(settingsIndex, bandSettings); var uiIndex = Math.Min(targetIndex, EndItems.Count); EndItems.Insert(uiIndex, band); @@ -292,7 +411,17 @@ public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide public void SaveBandOrder() { // Save ShowLabels for all bands - foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems)) + foreach (var band in StartItems) + { + band.SaveShowLabels(); + } + + foreach (var band in CenterItems) + { + band.SaveShowLabels(); + } + + foreach (var band in EndItems) { band.SaveShowLabels(); } @@ -301,7 +430,7 @@ public void SaveBandOrder() _snapshotCenterBands = null; _snapshotEndBands = null; _snapshotBandViewModels = null; - SettingsModel.SaveSettings(_settingsModel); + _settingsService.SaveSettings(_settingsModel); Logger.LogDebug("Saved band order to settings"); } @@ -316,15 +445,40 @@ public void SaveBandOrder() /// public void SnapshotBandOrder() { - var dockSettings = _settingsModel.DockSettings; - _snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList(); - _snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList(); - _snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList(); + var (startBands, centerBands, endBands) = GetActiveBandLists(); + + _snapshotStartBands = new List(startBands.Count); + foreach (var b in startBands) + { + _snapshotStartBands.Add(b with { }); + } + + _snapshotCenterBands = new List(centerBands.Count); + foreach (var b in centerBands) + { + _snapshotCenterBands.Add(b with { }); + } + + _snapshotEndBands = new List(endBands.Count); + foreach (var b in endBands) + { + _snapshotEndBands.Add(b with { }); + } // Snapshot band ViewModels so we can restore unpinned bands // Use a dictionary but handle potential duplicates gracefully _snapshotBandViewModels = new Dictionary(); - foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems)) + foreach (var band in StartItems) + { + _snapshotBandViewModels.TryAdd(band.Id, band); + } + + foreach (var band in CenterItems) + { + _snapshotBandViewModels.TryAdd(band.Id, band); + } + + foreach (var band in EndItems) { _snapshotBandViewModels.TryAdd(band.Id, band); } @@ -358,29 +512,29 @@ public void RestoreBandOrder() band.RestoreShowLabels(); } - var dockSettings = _settingsModel.DockSettings; + var (startBands, centerBands, endBands) = GetActiveBandLists(); // Restore settings from snapshot - dockSettings.StartBands.Clear(); - dockSettings.CenterBands.Clear(); - dockSettings.EndBands.Clear(); + startBands.Clear(); + centerBands.Clear(); + endBands.Clear(); foreach (var bandSnapshot in _snapshotStartBands) { - var bandSettings = bandSnapshot.Clone(); - dockSettings.StartBands.Add(bandSettings); + var bandSettings = bandSnapshot with { }; + startBands.Add(bandSettings); } foreach (var bandSnapshot in _snapshotCenterBands) { - var bandSettings = bandSnapshot.Clone(); - dockSettings.CenterBands.Add(bandSettings); + var bandSettings = bandSnapshot with { }; + centerBands.Add(bandSettings); } foreach (var bandSnapshot in _snapshotEndBands) { - var bandSettings = bandSnapshot.Clone(); - dockSettings.EndBands.Add(bandSettings); + var bandSettings = bandSnapshot with { }; + endBands.Add(bandSettings); } // Rebuild UI collections from restored settings using the snapshotted ViewModels @@ -400,13 +554,13 @@ private void RebuildUICollectionsFromSnapshot() return; } - var dockSettings = _settingsModel.DockSettings; + var (startBands, centerBands, endBands) = GetActiveBandLists(); StartItems.Clear(); CenterItems.Clear(); EndItems.Clear(); - foreach (var bandSettings in dockSettings.StartBands) + foreach (var bandSettings in startBands) { if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -414,7 +568,7 @@ private void RebuildUICollectionsFromSnapshot() } } - foreach (var bandSettings in dockSettings.CenterBands) + foreach (var bandSettings in centerBands) { if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -422,7 +576,7 @@ private void RebuildUICollectionsFromSnapshot() } } - foreach (var bandSettings in dockSettings.EndBands) + foreach (var bandSettings in endBands) { if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -433,16 +587,30 @@ private void RebuildUICollectionsFromSnapshot() private void RebuildUICollections() { - var dockSettings = _settingsModel.DockSettings; + var (startBands, centerBands, endBands) = GetActiveBandLists(); // Create a lookup of all current band ViewModels - var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id); + var allBands = new Dictionary(); + foreach (var band in StartItems) + { + allBands.TryAdd(band.Id, band); + } + + foreach (var band in CenterItems) + { + allBands.TryAdd(band.Id, band); + } + + foreach (var band in EndItems) + { + allBands.TryAdd(band.Id, band); + } StartItems.Clear(); CenterItems.Clear(); EndItems.Clear(); - foreach (var bandSettings in dockSettings.StartBands) + foreach (var bandSettings in startBands) { if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -450,7 +618,7 @@ private void RebuildUICollections() } } - foreach (var bandSettings in dockSettings.CenterBands) + foreach (var bandSettings in centerBands) { if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -458,7 +626,7 @@ private void RebuildUICollections() } } - foreach (var bandSettings in dockSettings.EndBands) + foreach (var bandSettings in endBands) { if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM)) { @@ -470,7 +638,7 @@ private void RebuildUICollections() /// /// Gets the list of dock bands that are not currently pinned to any section. /// - public IEnumerable GetAvailableBandsToAdd() + public List GetAvailableBandsToAdd() { // Get IDs of all bands currently in the dock var pinnedBandIds = new HashSet(); @@ -490,7 +658,16 @@ public IEnumerable GetAvailableBandsToAdd() } // Return all dock bands that are not already pinned - return AllItems.Where(tlc => !pinnedBandIds.Contains(tlc.Id)); + var result = new List(); + foreach (var tlc in AllItems) + { + if (!pinnedBandIds.Contains(tlc.Id)) + { + result.Add(tlc); + } + } + + return result; } /// @@ -508,9 +685,11 @@ public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide) return; } + EnsureMonitorForked(); + var (startBands, centerBands, endBands) = GetActiveBandLists(); + // Create settings for the new band - var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null }; - var dockSettings = _settingsModel.DockSettings; + var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId }; // Create the band view model var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel); @@ -519,15 +698,15 @@ public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide) switch (targetSide) { case DockPinSide.Start: - dockSettings.StartBands.Add(bandSettings); + startBands.Add(bandSettings); StartItems.Add(bandVm); break; case DockPinSide.Center: - dockSettings.CenterBands.Add(bandSettings); + centerBands.Add(bandSettings); CenterItems.Add(bandVm); break; case DockPinSide.End: - dockSettings.EndBands.Add(bandSettings); + endBands.Add(bandSettings); EndItems.Add(bandVm); break; } @@ -550,12 +729,13 @@ public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide) public void UnpinBand(DockBandViewModel band) { var bandId = band.Id; - var dockSettings = _settingsModel.DockSettings; + EnsureMonitorForked(); + var (startBands, centerBands, endBands) = GetActiveBandLists(); // Remove from settings - dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId); - dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId); + startBands.RemoveAll(b => b.CommandId == bandId); + centerBands.RemoveAll(b => b.CommandId == bandId); + endBands.RemoveAll(b => b.CommandId == bandId); // Remove from UI collections StartItems.Remove(band); @@ -619,17 +799,36 @@ private void EmitDockConfiguration() var isDockEnabled = _settingsModel.EnableDock; var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none"; - static string FormatBands(List bands) => - string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}")); + static string FormatBands(List bands) + { + if (bands.Count == 0) + { + return string.Empty; + } + + var parts = new string[bands.Count]; + for (var i = 0; i < bands.Count; i++) + { + parts[i] = $"{bands[i].ProviderId}/{bands[i].CommandId}"; + } + + return string.Join("\n", parts); + } - 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 (activeBandStart, activeBandCenter, activeBandEnd) = GetActiveBandLists(); + var startBands = isDockEnabled ? FormatBands(activeBandStart) : string.Empty; + var centerBands = isDockEnabled ? FormatBands(activeBandCenter) : string.Empty; + var endBands = isDockEnabled ? FormatBands(activeBandEnd) : string.Empty; WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage( isDockEnabled, dockSide, startBands, centerBands, endBands)); } + public void Dispose() + { + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } + /// /// Provides an empty page context, for the dock's own context menu. We're /// building the context menu for the dock using literally our own cmdpal diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/MonitorBandSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/MonitorBandSettingsViewModel.cs new file mode 100644 index 000000000000..e9c5bee1ad4e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Dock/MonitorBandSettingsViewModel.cs @@ -0,0 +1,186 @@ +// 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 CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Settings; + +namespace Microsoft.CmdPal.UI.ViewModels.Dock; + +/// +/// ViewModel for a single band entry within a per-monitor band configuration. +/// Unlike which operates on global +/// lists, this targets the band lists on a specific +/// . +/// +public partial class MonitorBandSettingsViewModel : ObservableObject +{ + private readonly DockMonitorConfig _monitorConfig; + private readonly string _providerId; + private readonly string _commandId; + private readonly SettingsService _settingsService; + private DockPinSide _pinSide; + + public MonitorBandSettingsViewModel( + string name, + string providerId, + string commandId, + DockMonitorConfig monitorConfig, + SettingsService settingsService) + { + Name = name; + _providerId = providerId; + _commandId = commandId; + _monitorConfig = monitorConfig; + _settingsService = settingsService; + + _pinSide = FetchPinSide(); + } + + /// Gets the display name of the band. + public string Name { get; } + + /// + /// Gets or sets a value indicating whether this band is pinned on the monitor. + /// When enabled, pins to Center. When disabled, removes from all sides. + /// + public bool IsPinned + { + get => _pinSide != DockPinSide.None; + set + { + if (value && _pinSide == DockPinSide.None) + { + PinSide = DockPinSide.Center; + } + else if (!value && _pinSide != DockPinSide.None) + { + PinSide = DockPinSide.None; + } + } + } + + /// + /// Gets or sets the pin side index for ComboBox binding. + /// 0 = Start, 1 = Center, 2 = End. + /// + public int PinSideIndex + { + get => _pinSide switch + { + DockPinSide.Start => 0, + DockPinSide.Center => 1, + DockPinSide.End => 2, + _ => 1, + }; + set + { + var side = value switch + { + 0 => DockPinSide.Start, + 1 => DockPinSide.Center, + 2 => DockPinSide.End, + _ => DockPinSide.Center, + }; + + PinSide = side; + } + } + + private DockPinSide PinSide + { + get => _pinSide; + set + { + if (value != _pinSide) + { + ApplyPinSide(value); + _pinSide = value; + OnPropertyChanged(nameof(PinSide)); + OnPropertyChanged(nameof(PinSideIndex)); + OnPropertyChanged(nameof(IsPinned)); + } + } + } + + private DockPinSide FetchPinSide() + { + if (_monitorConfig.StartBands is not null) + { + foreach (var b in _monitorConfig.StartBands) + { + if (b.CommandId == _commandId) + { + return DockPinSide.Start; + } + } + } + + if (_monitorConfig.CenterBands is not null) + { + foreach (var b in _monitorConfig.CenterBands) + { + if (b.CommandId == _commandId) + { + return DockPinSide.Center; + } + } + } + + if (_monitorConfig.EndBands is not null) + { + foreach (var b in _monitorConfig.EndBands) + { + if (b.CommandId == _commandId) + { + return DockPinSide.End; + } + } + } + + return DockPinSide.None; + } + + private void ApplyPinSide(DockPinSide newSide) + { + // Remove from all monitor band lists + _monitorConfig.StartBands?.RemoveAll(b => b.CommandId == _commandId); + _monitorConfig.CenterBands?.RemoveAll(b => b.CommandId == _commandId); + _monitorConfig.EndBands?.RemoveAll(b => b.CommandId == _commandId); + + if (newSide == DockPinSide.None) + { + Save(); + return; + } + + var entry = new DockBandSettings + { + ProviderId = _providerId, + CommandId = _commandId, + }; + + switch (newSide) + { + case DockPinSide.Start: + _monitorConfig.StartBands ??= new List(); + _monitorConfig.StartBands.Add(entry); + break; + case DockPinSide.Center: + _monitorConfig.CenterBands ??= new List(); + _monitorConfig.CenterBands.Add(entry); + break; + case DockPinSide.End: + _monitorConfig.EndBands ??= new List(); + _monitorConfig.EndBands.Add(entry); + break; + } + + Save(); + } + + private void Save() + { + _settingsService.SaveSettings(_settingsService.CurrentSettings, true); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs index 68751a5882c2..56a9c427b184 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/DockAppearanceSettingsViewModel.cs @@ -23,13 +23,15 @@ namespace Microsoft.CmdPal.UI.ViewModels; /// public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, IDisposable { - private readonly SettingsModel _settings; + private readonly SettingsService _settingsService; private readonly DockSettings _dockSettings; private readonly UISettings _uiSettings; private readonly IThemeService _themeService; private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread(); + private SettingsModel _settings; + private ElementTheme? _elementThemeOverride; private Color _currentSystemAccentColor; @@ -48,10 +50,10 @@ public UserTheme Theme { if (_dockSettings.Theme != value) { - _dockSettings.Theme = value; + var dockSettings = _settings.DockSettings with { Theme = value }; OnPropertyChanged(); OnPropertyChanged(nameof(ThemeIndex)); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -69,10 +71,10 @@ public DockBackdrop Backdrop { if (_dockSettings.Backdrop != value) { - _dockSettings.Backdrop = value; + var dockSettings = _settings.DockSettings with { Backdrop = value }; OnPropertyChanged(); OnPropertyChanged(nameof(BackdropIndex)); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -84,7 +86,7 @@ public ColorizationMode ColorizationMode { if (_dockSettings.ColorizationMode != value) { - _dockSettings.ColorizationMode = value; + var dockSettings = _settings.DockSettings with { ColorizationMode = value }; OnPropertyChanged(); OnPropertyChanged(nameof(ColorizationModeIndex)); OnPropertyChanged(nameof(IsCustomTintVisible)); @@ -100,7 +102,7 @@ public ColorizationMode ColorizationMode IsColorizationDetailsExpanded = value != ColorizationMode.None; - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -118,7 +120,7 @@ public Color ThemeColor { if (_dockSettings.CustomThemeColor != value) { - _dockSettings.CustomThemeColor = value; + var dockSettings = _settings.DockSettings with { CustomThemeColor = value }; OnPropertyChanged(); @@ -127,7 +129,7 @@ public Color ThemeColor ColorIntensity = 100; } - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -137,9 +139,9 @@ public int ColorIntensity get => _dockSettings.CustomThemeColorIntensity; set { - _dockSettings.CustomThemeColorIntensity = value; + var dockSettings = _settings.DockSettings with { CustomThemeColorIntensity = value }; OnPropertyChanged(); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } @@ -150,7 +152,7 @@ public string BackgroundImagePath { if (_dockSettings.BackgroundImagePath != value) { - _dockSettings.BackgroundImagePath = value; + var dockSettings = _settings.DockSettings with { BackgroundImagePath = value }; OnPropertyChanged(); if (BackgroundImageOpacity == 0) @@ -158,7 +160,7 @@ public string BackgroundImagePath BackgroundImageOpacity = 100; } - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -170,9 +172,9 @@ public int BackgroundImageOpacity { if (_dockSettings.BackgroundImageOpacity != value) { - _dockSettings.BackgroundImageOpacity = value; + var dockSettings = _settings.DockSettings with { BackgroundImageOpacity = value }; OnPropertyChanged(); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -184,9 +186,9 @@ public int BackgroundImageBrightness { if (_dockSettings.BackgroundImageBrightness != value) { - _dockSettings.BackgroundImageBrightness = value; + var dockSettings = _settings.DockSettings with { BackgroundImageBrightness = value }; OnPropertyChanged(); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -198,9 +200,9 @@ public int BackgroundImageBlurAmount { if (_dockSettings.BackgroundImageBlurAmount != value) { - _dockSettings.BackgroundImageBlurAmount = value; + var dockSettings = _settings.DockSettings with { BackgroundImageBlurAmount = value }; OnPropertyChanged(); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -212,10 +214,10 @@ public BackgroundImageFit BackgroundImageFit { if (_dockSettings.BackgroundImageFit != value) { - _dockSettings.BackgroundImageFit = value; + var dockSettings = _settings.DockSettings with { BackgroundImageFit = value }; OnPropertyChanged(); OnPropertyChanged(nameof(BackgroundImageFitIndex)); - Save(); + Save(_settings with { DockSettings = dockSettings }); } } } @@ -268,12 +270,16 @@ ColorizationMode is ColorizationMode.Image ? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri) : null; - public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings) + public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsService settingsService) { _themeService = themeService; _themeService.ThemeChanged += ThemeServiceOnThemeChanged; - _settings = settings; - _dockSettings = settings.DockSettings; + _settingsService = settingsService; + _settings = settingsService.CurrentSettings; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; + + _dockSettings = _settings.DockSettings; _uiSettings = new UISettings(); _uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged; @@ -284,6 +290,11 @@ public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel IsColorizationDetailsExpanded = _dockSettings.ColorizationMode != ColorizationMode.None; } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settings = args.NewSettingsModel; + } + private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender)); private void UpdateAccentColor(UISettings sender) @@ -300,9 +311,9 @@ private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e) _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } - private void Save() + private void Save(SettingsModel settings) { - SettingsModel.SaveSettings(_settings); + _settingsService.SaveSettings(settings, true); _saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200)); } @@ -335,6 +346,7 @@ private void ResetBackgroundImageProperties() public void Dispose() { + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; _uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged; _themeService.ThemeChanged -= ThemeServiceOnThemeChanged; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs index db1e8c0f1495..d56771fb86f4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/FallbackSettingsViewModel.cs @@ -10,6 +10,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class FallbackSettingsViewModel : ObservableObject { + private readonly SettingsService _settingsService; private readonly SettingsModel _settings; private readonly FallbackSettings _fallbackSettings; @@ -62,10 +63,11 @@ public bool IncludeInGlobalResults public FallbackSettingsViewModel( TopLevelViewModel fallback, FallbackSettings fallbackSettings, - SettingsModel settingsModel, + SettingsService settingsService, ProviderSettingsViewModel providerSettings) { - _settings = settingsModel; + _settingsService = settingsService; + _settings = _settingsService.CurrentSettings; _fallbackSettings = fallbackSettings; Id = fallback.Id; @@ -79,7 +81,7 @@ public FallbackSettingsViewModel( private void Save() { - SettingsModel.SaveSettings(_settings); + _settingsService.SaveSettings(_settingsService.CurrentSettings, true); WeakReferenceMessenger.Default.Send(new()); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs index 7fcd2ec7fd71..474618ff3a49 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/HotkeyManager.cs @@ -12,23 +12,30 @@ public partial class HotkeyManager : ObservableObject private readonly TopLevelCommandManager _topLevelCommandManager; private readonly List _commandHotkeys; - public HotkeyManager(TopLevelCommandManager tlcManager, SettingsModel settings) + public HotkeyManager(TopLevelCommandManager tlcManager, SettingsService settingsService) { _topLevelCommandManager = tlcManager; - _commandHotkeys = settings.CommandHotkeys; + _commandHotkeys = settingsService.CurrentSettings.CommandHotkeys; } public void UpdateHotkey(string commandId, HotkeySettings? hotkey) { // If any of the commands were already bound to this hotkey, remove that + TopLevelHotkey? existingItem = null; + foreach (var item in _commandHotkeys) { if (item.Hotkey == hotkey) { - item.Hotkey = null; + existingItem = item; } } + if (existingItem is not null) + { + existingItem = existingItem with { Hotkey = null }; + } + _commandHotkeys.RemoveAll(item => item.Hotkey is null); foreach (var item in _commandHotkeys) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/JsonSerializationContext.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/JsonSerializationContext.cs new file mode 100644 index 000000000000..ada1e373b86a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/JsonSerializationContext.cs @@ -0,0 +1,29 @@ +// 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; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(HistoryItem))] +[JsonSerializable(typeof(SettingsModel))] +[JsonSerializable(typeof(WindowPosition))] +[JsonSerializable(typeof(AppStateModel))] +[JsonSerializable(typeof(RecentCommandsManager))] +[JsonSerializable(typeof(Settings.DockMonitorConfig))] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "DockMonitorConfigList")] +[JsonSerializable(typeof(Settings.DockBandSettings))] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "DockBandSettingsList")] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "StringList")] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "HistoryList")] +[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] +internal sealed partial class JsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ExitDockEditModeMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ExitDockEditModeMessage.cs new file mode 100644 index 000000000000..0ac699d1b5f4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/ExitDockEditModeMessage.cs @@ -0,0 +1,7 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels.Messages; + +public record ExitDockEditModeMessage(bool Discard); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/IMonitorService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/IMonitorService.cs new file mode 100644 index 000000000000..8efc1c3c4562 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/IMonitorService.cs @@ -0,0 +1,33 @@ +// 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; + +/// +/// Service for enumerating and tracking display monitors. +/// Implemented in the UI layer with Win32 APIs; consumed by ViewModels. +/// +public interface IMonitorService +{ + /// + /// Gets a snapshot of all currently connected monitors. + /// + IReadOnlyList GetMonitors(); + + /// + /// Gets the monitor matching the given device ID, or null if not found. + /// + MonitorInfo? GetMonitorByDeviceId(string deviceId); + + /// + /// Gets the primary monitor. Always returns a value when at least one display is connected. + /// + MonitorInfo GetPrimaryMonitor(); + + /// + /// Raised when the set of connected monitors changes (connect, disconnect, + /// resolution change, DPI change). Listeners should re-query . + /// + event Action? MonitorsChanged; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/MonitorInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/MonitorInfo.cs new file mode 100644 index 000000000000..91f3feef806f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/MonitorInfo.cs @@ -0,0 +1,59 @@ +// 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; + +/// +/// Represents a physical display monitor with its bounds and DPI. +/// Coordinates are in virtual screen pixels (absolute, not relative to primary). +/// +public sealed record MonitorInfo +{ + /// + /// Gets the device identifier string (e.g. "\\.\DISPLAY1"). Survives reboots + /// and uniquely identifies a physical display output. + /// + public required string DeviceId { get; init; } + + /// + /// Gets a human-readable display name (e.g. "DELL U2723QE" or "Display 1"). + /// + public required string DisplayName { get; init; } + + /// + /// Gets the full monitor rectangle in virtual-screen pixels. + /// + public required ScreenRect Bounds { get; init; } + + /// + /// Gets the work area (excludes taskbar/app bars) in virtual-screen pixels. + /// + public required ScreenRect WorkArea { get; init; } + + /// + /// Gets the DPI for this monitor (e.g. 96, 120, 144, 192). + /// + public required uint Dpi { get; init; } + + /// + /// Gets a value indicating whether this is the primary monitor. + /// + public required bool IsPrimary { get; init; } + + /// + /// Gets the scale factor relative to 96 DPI (1.0 = 100%, 1.5 = 150%, 2.0 = 200%). + /// + public double ScaleFactor => Dpi / 96.0; +} + +/// +/// A simple rectangle in virtual-screen pixel coordinates. +/// Avoids dependency on platform-specific RECT types in the ViewModel layer. +/// +public readonly record struct ScreenRect(int Left, int Top, int Right, int Bottom) +{ + public int Width => Right - Left; + + public int Height => Bottom - Top; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/SettingsChangedEventArgs.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/SettingsChangedEventArgs.cs new file mode 100644 index 000000000000..bb1694f3d390 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/SettingsChangedEventArgs.cs @@ -0,0 +1,17 @@ +// 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.Services; + +/// +/// Event arguments for settings changes. +public class SettingsChangedEventArgs : EventArgs +{ + public SettingsModel NewSettingsModel { get; set; } + + public SettingsChangedEventArgs(SettingsModel newSettingsModel) + { + NewSettingsModel = newSettingsModel; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs index 15787344ee61..7876244d9cc2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ProviderSettingsViewModel.cs @@ -21,6 +21,7 @@ public partial class ProviderSettingsViewModel : ObservableObject private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled); private readonly CommandProviderWrapper _provider; + private readonly SettingsService _settingsService; private readonly ProviderSettings _providerSettings; private readonly SettingsModel _settings; private readonly Lock _initializeSettingsLock = new(); @@ -30,11 +31,12 @@ public partial class ProviderSettingsViewModel : ObservableObject public ProviderSettingsViewModel( CommandProviderWrapper provider, ProviderSettings providerSettings, - SettingsModel settings) + SettingsService settingsService) { _provider = provider; _providerSettings = providerSettings; - _settings = settings; + _settingsService = settingsService; + _settings = settingsService.CurrentSettings; LoadingSettings = _provider.Settings?.HasSettings ?? false; @@ -179,18 +181,18 @@ private void BuildFallbackViewModels() { if (_providerSettings.FallbackCommands.TryGetValue(fallbackItem.Id, out var fallbackSettings)) { - fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, fallbackSettings, _settings, this)); + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, fallbackSettings, _settingsService, this)); } else { - fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, new(), _settings, this)); + fallbackViewModels.Add(new FallbackSettingsViewModel(fallbackItem, new(), _settingsService, this)); } } FallbackCommands = fallbackViewModels; } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save() => _settingsService.SaveSettings(_settingsService.CurrentSettings, true); private void InitializeSettingsPage() { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs index 60bade639e69..00eb61fc909a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/DockSettings.cs @@ -14,44 +14,55 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings; /// Settings for the Dock. These are settings for _the whole dock_. Band-specific /// settings are in . /// -public class DockSettings +public record DockSettings { - public DockSide Side { get; set; } = DockSide.Top; + /// + /// Gets the default dock side. Used as fallback for monitors that + /// don't have a per-monitor config entry. + /// + public DockSide Side { get; init; } = DockSide.Top; - public DockSize DockSize { get; set; } = DockSize.Small; + public DockSize DockSize { get; init; } = DockSize.Small; - public DockSize DockIconsSize { get; set; } = DockSize.Small; + public DockSize DockIconsSize { get; init; } = DockSize.Small; // - public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic; + public DockBackdrop Backdrop { get; init; } = DockBackdrop.Acrylic; - public UserTheme Theme { get; set; } = UserTheme.Default; + public UserTheme Theme { get; init; } = UserTheme.Default; - public ColorizationMode ColorizationMode { get; set; } + public ColorizationMode ColorizationMode { get; init; } - public Color CustomThemeColor { get; set; } = Colors.Transparent; + public Color CustomThemeColor { get; init; } = Colors.Transparent; - public int CustomThemeColorIntensity { get; set; } = 100; + public int CustomThemeColorIntensity { get; init; } = 100; - public int BackgroundImageOpacity { get; set; } = 20; + public int BackgroundImageOpacity { get; init; } = 20; - public int BackgroundImageBlurAmount { get; set; } + public int BackgroundImageBlurAmount { get; init; } - public int BackgroundImageBrightness { get; set; } + public int BackgroundImageBrightness { get; init; } - public BackgroundImageFit BackgroundImageFit { get; set; } + public BackgroundImageFit BackgroundImageFit { get; init; } - public string? BackgroundImagePath { get; set; } + public string? BackgroundImagePath { get; init; } // // public List PinnedCommands { get; set; } = []; - public List StartBands { get; set; } = []; + public List StartBands { get; init; } = []; - public List CenterBands { get; set; } = []; + public List CenterBands { get; init; } = []; - public List EndBands { get; set; } = []; + public List EndBands { get; init; } = []; - public bool ShowLabels { get; set; } = true; + public bool ShowLabels { get; init; } = true; + + /// + /// Gets or sets per-monitor dock configurations. Each entry enables the dock + /// on a specific monitor and optionally overrides the dock side. + /// When empty, the dock displays only on the primary monitor using . + /// + public List MonitorConfigs { get; set; } = []; [JsonIgnore] public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands => @@ -62,9 +73,6 @@ public class DockSettings public DockSettings() { // Initialize with default values - // PinnedCommands = [ - // "com.microsoft.cmdpal.winget" - // ]; StartBands.Add(new DockBandSettings { ProviderId = "com.microsoft.cmdpal.builtin.core", @@ -74,7 +82,8 @@ public DockSettings() { ProviderId = "WinGet", CommandId = "com.microsoft.cmdpal.winget", - ShowLabels = false, + ShowTitles = false, + ShowSubtitles = false, }); EndBands.Add(new DockBandSettings @@ -94,33 +103,23 @@ public DockSettings() /// Settings for a specific dock band. These are per-band settings stored /// within the overall . /// -public class DockBandSettings +public record DockBandSettings { - public required string ProviderId { get; set; } + public required string ProviderId { get; init; } - public required string CommandId { get; set; } + public required string CommandId { get; init; } /// - /// Gets or sets whether titles are shown for items in this band. + /// Gets whether titles are shown for items in this band. /// If null, falls back to dock-wide ShowLabels setting. /// - public bool? ShowTitles { get; set; } + public bool? ShowTitles { get; init; } = true; /// - /// Gets or sets whether subtitles are shown for items in this band. + /// Gets whether subtitles are shown for items in this band. /// If null, falls back to dock-wide ShowLabels setting. /// - public bool? ShowSubtitles { get; set; } - - /// - /// Gets or sets a value for backward compatibility. Maps to ShowTitles. - /// - [System.Text.Json.Serialization.JsonIgnore] - public bool? ShowLabels - { - get => ShowTitles; - set => ShowTitles = value; - } + public bool? ShowSubtitles { get; init; } = true; /// /// Resolves the effective value of for this band. @@ -135,16 +134,116 @@ public bool? ShowLabels /// dock-wide setting (passed as ). /// public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue; +} + +/// +/// Per-monitor dock configuration. Allows the user to enable the dock on +/// specific monitors with an optional side override and per-monitor band pinning. +/// +public class DockMonitorConfig +{ + /// + /// Gets or sets the device identifier of the target monitor (e.g. "\\.\DISPLAY1"). + /// This value may change across reboots; is used as a + /// stable fallback when the device id no longer matches any connected monitor. + /// + public required string MonitorDeviceId { get; set; } + + /// + /// Gets or sets a value indicating whether the dock is enabled on this monitor. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the dock side for this monitor. If null, falls back to + /// the dock-wide value. + /// + public DockSide? Side { get; set; } + + /// + /// Gets or sets a value indicating whether this config was for the primary + /// monitor. Used as a stable matching key when + /// changes across reboots. + /// + public bool IsPrimary { get; set; } + + /// + /// Gets or sets a value indicating whether this monitor has custom band + /// pinning. When false, the monitor inherits bands from the global + /// . When true, , + /// , and are used. + /// + public bool IsCustomized { get; set; } - public DockBandSettings Clone() + /// + /// Gets or sets per-monitor start bands. Only used when + /// is true. Null means inherit from global dock settings. + /// + public List? StartBands { get; set; } + + /// + /// Gets or sets per-monitor center bands. Only used when + /// is true. Null means inherit from global dock settings. + /// + public List? CenterBands { get; set; } + + /// + /// Gets or sets per-monitor end bands. Only used when + /// is true. Null means inherit from global dock settings. + /// + public List? EndBands { get; set; } + + /// + /// Resolves the effective dock side for this monitor, falling back to the + /// dock-wide default when no per-monitor override is set. + /// + public DockSide ResolveSide(DockSide defaultSide) => Side ?? defaultSide; + + /// + /// Resolves the effective start bands for this monitor. Returns per-monitor + /// bands when customized, otherwise the global bands. + /// + public List ResolveStartBands(List globalBands) + => IsCustomized && StartBands is not null ? StartBands : globalBands; + + /// + /// Resolves the effective center bands for this monitor. + /// + public List ResolveCenterBands(List globalBands) + => IsCustomized && CenterBands is not null ? CenterBands : globalBands; + + /// + /// Resolves the effective end bands for this monitor. + /// + public List ResolveEndBands(List globalBands) + => IsCustomized && EndBands is not null ? EndBands : globalBands; + + /// + /// Forks this monitor's band configuration from the global settings. + /// Copies the global bands into per-monitor lists and sets + /// to true. + /// + public void ForkFromGlobal(DockSettings globalSettings) { - return new() + StartBands = new List(); + foreach (var b in globalSettings.StartBands) + { + StartBands.Add(b with { }); + } + + CenterBands = new List(); + foreach (var b in globalSettings.CenterBands) { - ProviderId = this.ProviderId, - CommandId = this.CommandId, - ShowTitles = this.ShowTitles, - ShowSubtitles = this.ShowSubtitles, - }; + CenterBands.Add(b with { }); + } + + EndBands = new List(); + foreach (var b in globalSettings.EndBands) + { + EndBands.Add(b with { }); + } + + IsCustomized = true; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/HotkeySettings.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/HotkeySettings.cs index c3c3a59e6545..deb9796a2970 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/HotkeySettings.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/HotkeySettings.cs @@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings; -public record HotkeySettings// : ICmdLineRepresentable +public record HotkeySettings { private const int VKTAB = 0x09; @@ -39,24 +39,24 @@ public HotkeySettings(bool win, bool ctrl, bool alt, bool shift, int code) } [JsonPropertyName("win")] - public bool Win { get; set; } + public bool Win { get; init; } [JsonPropertyName("ctrl")] - public bool Ctrl { get; set; } + public bool Ctrl { get; init; } [JsonPropertyName("alt")] - public bool Alt { get; set; } + public bool Alt { get; init; } [JsonPropertyName("shift")] - public bool Shift { get; set; } + public bool Shift { get; init; } [JsonPropertyName("code")] - public int Code { get; set; } + public int Code { get; init; } // This is currently needed for FancyZones, we need to unify these two objects // see src\common\settings_objects.h [JsonPropertyName("key")] - public string Key { get; set; } = string.Empty; + public string Key { get; init; } = string.Empty; public override string ToString() { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/MonitorConfigReconciler.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/MonitorConfigReconciler.cs new file mode 100644 index 000000000000..5b9dd5fbe366 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Settings/MonitorConfigReconciler.cs @@ -0,0 +1,128 @@ +// 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.Models; + +namespace Microsoft.CmdPal.UI.ViewModels.Settings; + +/// +/// Reconciles saved entries with the current +/// set of connected monitors. Windows GDI device names (e.g. "\\.\DISPLAY49") +/// are not stable across reboots, so this helper re-associates configs using +/// as a fallback matching key and +/// removes orphaned entries. +/// +public static class MonitorConfigReconciler +{ + /// + /// Reconciles against . + /// Stale DeviceIds are updated in-place, orphaned configs are removed, and + /// missing monitors get new default configs. + /// + /// if any config was added, updated, or removed. + public static bool Reconcile(List configs, IReadOnlyList monitors) + { + var changed = false; + + // Build lookup of current monitor DeviceIds for fast membership checks. + var currentIds = new HashSet(monitors.Count, StringComparer.Ordinal); + foreach (var m in monitors) + { + currentIds.Add(m.DeviceId); + } + + // Phase 1: Exact DeviceId match — mark as used and keep IsPrimary up-to-date. + var usedConfigs = new HashSet(configs.Count); + var matchedMonitors = new HashSet(monitors.Count, StringComparer.Ordinal); + + foreach (var monitor in monitors) + { + foreach (var cfg in configs) + { + if (usedConfigs.Contains(cfg)) + { + continue; + } + + if (string.Equals(cfg.MonitorDeviceId, monitor.DeviceId, StringComparison.Ordinal)) + { + usedConfigs.Add(cfg); + matchedMonitors.Add(monitor.DeviceId); + + if (cfg.IsPrimary != monitor.IsPrimary) + { + cfg.IsPrimary = monitor.IsPrimary; + changed = true; + } + + break; + } + } + } + + // Phase 2: Fuzzy match — for each unmatched monitor, find an unmatched + // config whose IsPrimary flag agrees. Update the config's DeviceId. + foreach (var monitor in monitors) + { + if (matchedMonitors.Contains(monitor.DeviceId)) + { + continue; + } + + DockMonitorConfig? best = null; + foreach (var cfg in configs) + { + if (usedConfigs.Contains(cfg)) + { + continue; + } + + if (cfg.IsPrimary == monitor.IsPrimary) + { + best = cfg; + break; + } + } + + if (best is not null) + { + best.MonitorDeviceId = monitor.DeviceId; + best.IsPrimary = monitor.IsPrimary; + usedConfigs.Add(best); + matchedMonitors.Add(monitor.DeviceId); + changed = true; + } + } + + // Phase 3: Create default configs for monitors that still have no match. + foreach (var monitor in monitors) + { + if (matchedMonitors.Contains(monitor.DeviceId)) + { + continue; + } + + configs.Add(new DockMonitorConfig + { + MonitorDeviceId = monitor.DeviceId, + Enabled = monitor.IsPrimary, + IsPrimary = monitor.IsPrimary, + }); + changed = true; + } + + // Phase 4: Remove orphaned configs (DeviceIds that don't match any + // current monitor after reconciliation). + for (var i = configs.Count - 1; i >= 0; i--) + { + if (!currentIds.Contains(configs[i].MonitorDeviceId)) + { + configs.RemoveAt(i); + changed = true; + } + } + + return changed; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 97a8713e0766..6b6c59fc5fbd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -2,111 +2,90 @@ // 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.Diagnostics; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; -using CommunityToolkit.Mvvm.ComponentModel; -using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; -using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.UI; -using Windows.Foundation; using Windows.UI; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class SettingsModel : ObservableObject +public sealed record SettingsModel { - private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; - - [JsonIgnore] - public static readonly string FilePath; - - public event TypedEventHandler? SettingsChanged; - /////////////////////////////////////////////////////////////////////////// // SETTINGS HERE public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space - public HotkeySettings? Hotkey { get; set; } = DefaultActivationShortcut; + public HotkeySettings? Hotkey { get; init; } = DefaultActivationShortcut; - public bool UseLowLevelGlobalHotkey { get; set; } + public bool UseLowLevelGlobalHotkey { get; init; } - public bool ShowAppDetails { get; set; } + public bool ShowAppDetails { get; init; } - public bool BackspaceGoesBack { get; set; } + public bool BackspaceGoesBack { get; init; } - public bool SingleClickActivates { get; set; } + public bool SingleClickActivates { get; init; } - public bool HighlightSearchOnActivate { get; set; } = true; + public bool HighlightSearchOnActivate { get; init; } = true; - public bool KeepPreviousQuery { get; set; } + public bool KeepPreviousQuery { get; init; } - public bool ShowSystemTrayIcon { get; set; } = true; + public bool ShowSystemTrayIcon { get; init; } = true; - public bool IgnoreShortcutWhenFullscreen { get; set; } + public bool IgnoreShortcutWhenFullscreen { get; init; } - public bool AllowExternalReload { get; set; } + public bool AllowExternalReload { get; init; } - public Dictionary ProviderSettings { get; set; } = []; + public Dictionary ProviderSettings { get; init; } = []; - public string[] FallbackRanks { get; set; } = []; + public string[] FallbackRanks { get; init; } = []; - public Dictionary Aliases { get; set; } = []; + public Dictionary Aliases { get; init; } = []; - public List CommandHotkeys { get; set; } = []; + public List CommandHotkeys { get; init; } = []; - public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse; + public MonitorBehavior SummonOn { get; init; } = MonitorBehavior.ToMouse; - public bool DisableAnimations { get; set; } = true; + public bool DisableAnimations { get; init; } = true; - public WindowPosition? LastWindowPosition { get; set; } + public WindowPosition? LastWindowPosition { get; init; } - public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan; + public TimeSpan AutoGoHomeInterval { get; init; } = Timeout.InfiniteTimeSpan; - public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; + public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; init; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack; - public bool EnableDock { get; set; } + public bool EnableDock { get; init; } - public DockSettings DockSettings { get; set; } = new(); + public DockSettings DockSettings { get; init; } = new(); // Theme settings - public UserTheme Theme { get; set; } = UserTheme.Default; + public UserTheme Theme { get; init; } = UserTheme.Default; - public ColorizationMode ColorizationMode { get; set; } + public ColorizationMode ColorizationMode { get; init; } - public Color CustomThemeColor { get; set; } = Colors.Transparent; + public Color CustomThemeColor { get; init; } = Colors.Transparent; - public int CustomThemeColorIntensity { get; set; } = 100; + public int CustomThemeColorIntensity { get; init; } = 100; - public int BackgroundImageTintIntensity { get; set; } + public int BackgroundImageTintIntensity { get; init; } - public int BackgroundImageOpacity { get; set; } = 20; + public int BackgroundImageOpacity { get; init; } = 20; - public int BackgroundImageBlurAmount { get; set; } + public int BackgroundImageBlurAmount { get; init; } - public int BackgroundImageBrightness { get; set; } + public int BackgroundImageBrightness { get; init; } - public BackgroundImageFit BackgroundImageFit { get; set; } + public BackgroundImageFit BackgroundImageFit { get; init; } - public string? BackgroundImagePath { get; set; } + public string? BackgroundImagePath { get; init; } - public BackdropStyle BackdropStyle { get; set; } + public BackdropStyle BackdropStyle { get; init; } - public int BackdropOpacity { get; set; } = 100; + public int BackdropOpacity { get; init; } = 100; // // END SETTINGS /////////////////////////////////////////////////////////////////////////// - static SettingsModel() - { - FilePath = SettingsJsonPath(); - } - public ProviderSettings GetProviderSettings(CommandProviderWrapper provider) { ProviderSettings? settings; @@ -142,198 +121,6 @@ public string[] GetGlobalFallbacks() return globalFallbacks.ToArray(); } - - public static SettingsModel LoadSettings() - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(SettingsModel.FilePath)} before calling {nameof(LoadSettings)}"); - } - - if (!File.Exists(FilePath)) - { - Debug.WriteLine("The provided settings file does not exist"); - return new(); - } - - try - { - // Read the JSON content from the file - var jsonContent = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new(); - - var migratedAny = false; - try - { - if (JsonNode.Parse(jsonContent) is JsonObject root) - { - migratedAny |= ApplyMigrations(root, loaded); - } - } - catch (Exception ex) - { - Debug.WriteLine($"Migration check failed: {ex}"); - } - - Debug.WriteLine("Loaded settings file"); - - if (migratedAny) - { - SaveSettings(loaded); - } - - return loaded; - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - - return new(); - } - - private static bool ApplyMigrations(JsonObject root, SettingsModel model) - { - var migrated = false; - - // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan) - // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false). - // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never. - migrated |= TryMigrate( - "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", - root, - model, - nameof(AutoGoHomeInterval), - DeprecatedHotkeyGoesHomeKey, - (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, - JsonSerializationContext.Default.Boolean); - - return migrated; - } - - private static bool TryMigrate(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action apply, JsonTypeInfo jsonTypeInfo) - { - try - { - // If new key already present, skip migration - if (root.ContainsKey(newKey) && root[newKey] is not null) - { - return false; - } - - // If old key present, try to deserialize and apply - if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) - { - var value = oldNode.Deserialize(jsonTypeInfo); - apply(model, value!); - return true; - } - } - catch (Exception ex) - { - Logger.LogError($"Error during migration {migrationName}.", ex); - } - - return false; - } - - public static void SaveSettings(SettingsModel model, bool hotReload = true) - { - if (string.IsNullOrEmpty(FilePath)) - { - throw new InvalidOperationException($"You must set a valid {nameof(FilePath)} before calling {nameof(SaveSettings)}"); - } - - try - { - // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel); - - // Is it valid JSON? - if (JsonNode.Parse(settingsJson) is JsonObject newSettings) - { - // Now, read the existing content from the file - var oldContent = File.Exists(FilePath) ? File.ReadAllText(FilePath) : "{}"; - - // Is it valid JSON? - if (JsonNode.Parse(oldContent) is JsonObject savedSettings) - { - foreach (var item in newSettings) - { - savedSettings[item.Key] = item.Value?.DeepClone(); - } - - // Remove deprecated keys - savedSettings.Remove(DeprecatedHotkeyGoesHomeKey); - - var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); - File.WriteAllText(FilePath, serialized); - - // TODO: Instead of just raising the event here, we should - // have a file change watcher on the settings file, and - // reload the settings then - if (hotReload) - { - model.SettingsChanged?.Invoke(model, null); - } - } - else - { - Debug.WriteLine("Failed to parse settings file as JsonObject."); - } - } - else - { - Debug.WriteLine("Failed to parse settings file as JsonObject."); - } - } - catch (Exception ex) - { - Debug.WriteLine(ex.ToString()); - } - } - - internal static string SettingsJsonPath() - { - var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); - Directory.CreateDirectory(directory); - - // now, the settings is just next to the exe - return Path.Combine(directory, "settings.json"); - } - - // [UnconditionalSuppressMessage("AOT", "IL3050:Calling members annotated with 'RequiresDynamicCodeAttribute' may break functionality when AOT compiling.", Justification = "")] - // private static readonly JsonSerializerOptions _serializerOptions = new() - // { - // WriteIndented = true, - // Converters = { new JsonStringEnumConverter() }, - // }; - // private static readonly JsonSerializerOptions _deserializerOptions = new() - // { - // PropertyNameCaseInsensitive = true, - // IncludeFields = true, - // Converters = { new JsonStringEnumConverter() }, - // AllowTrailingCommas = true, - // }; -} - -[JsonSerializable(typeof(float))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(Color))] -[JsonSerializable(typeof(HistoryItem))] -[JsonSerializable(typeof(SettingsModel))] -[JsonSerializable(typeof(WindowPosition))] -[JsonSerializable(typeof(AppStateModel))] -[JsonSerializable(typeof(RecentCommandsManager))] -[JsonSerializable(typeof(List), TypeInfoPropertyName = "StringList")] -[JsonSerializable(typeof(List), TypeInfoPropertyName = "HistoryList")] -[JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] -[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] -internal sealed partial class JsonSerializationContext : JsonSerializerContext -{ } public enum MonitorBehavior diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsService.cs new file mode 100644 index 000000000000..7d286ef10708 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsService.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; +using Microsoft.CmdPal.Common; +using Microsoft.CmdPal.UI.ViewModels.Services; +using Microsoft.Extensions.Logging; +using Windows.Foundation; +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class SettingsService +{ + private const string FileName = "settings.json"; + + private readonly ILogger _logger; + private readonly PersistenceService _persistenceService; + private readonly string _filePath; + private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; + private SettingsModel _settingsModel; + + public event TypedEventHandler? SettingsChanged; + + public SettingsModel CurrentSettings => _settingsModel; + + public SettingsService(PersistenceService persistenceService, ILogger logger) + { + _logger = logger; + _persistenceService = persistenceService; + + _filePath = _persistenceService.SettingsJsonPath(FileName); + _settingsModel = LoadSettings(); + } + + private SettingsModel LoadSettings() + { + var settings = _persistenceService.LoadObject(FileName, JsonSerializationContext.Default.SettingsModel!); + + var migratedAny = false; + try + { + var jsonContent = File.Exists(_filePath) ? File.ReadAllText(_filePath) : "{}"; + if (JsonNode.Parse(jsonContent) is JsonObject root) + { + migratedAny |= ApplyMigrations(root, ref settings); + } + } + catch (Exception ex) + { + Log_MigrationCheckFailure(ex); + } + + if (migratedAny) + { + SaveSettings(settings, false); + } + + return settings; + } + + public void SaveSettings(SettingsModel model, bool hotReload = false) + { + _persistenceService.SaveObject( + model, + FileName, + JsonSerializationContext.Default.SettingsModel, + JsonSerializationContext.Default.Options, + beforeWriteMutation: obj => obj.Remove(DeprecatedHotkeyGoesHomeKey), + afterWriteCallback: m => FinalizeSettingsSave(m, hotReload)); + } + + private void FinalizeSettingsSave(SettingsModel model, bool hotReload) + { + _settingsModel = model; + + // TODO: Instead of just raising the event here, we should + // have a file change watcher on the settings file, and + // reload the settings then + if (hotReload) + { + SettingsChanged?.Invoke(this, new(_settingsModel)); + } + } + + private bool ApplyMigrations(JsonObject root, ref SettingsModel model) + { + var migrated = false; + + migrated |= TryMigrate( + "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", + root, + ref model, + nameof(SettingsModel.AutoGoHomeInterval), + DeprecatedHotkeyGoesHomeKey, + (settingsModel, goesHome) => settingsModel with { AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan }, + JsonSerializationContext.Default.Boolean); + + return migrated; + } + + private bool TryMigrate(string migrationName, JsonObject root, ref SettingsModel model, string newKey, string oldKey, Func apply, JsonTypeInfo jsonTypeInfo) + { + try + { + if (root.ContainsKey(newKey) && root[newKey] is not null) + { + return false; + } + + if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) + { + var value = oldNode.Deserialize(jsonTypeInfo); + model = apply(model, value!); + return true; + } + } + catch (Exception ex) + { + Log_MigrationFailure(migrationName, ex); + } + + return false; + } + + [LoggerMessage(Level = LogLevel.Error, Message = "Settings migration '{MigrationName}' failed.")] + partial void Log_MigrationFailure(string MigrationName, Exception exception); + + [LoggerMessage(Level = LogLevel.Error, Message = "Settings migration check failed.")] + partial void Log_MigrationCheckFailure(Exception exception); +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 34e958d57bc5..e923fa4f5727 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -5,14 +5,16 @@ 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; namespace Microsoft.CmdPal.UI.ViewModels; -public partial class SettingsViewModel : INotifyPropertyChanged +public partial class SettingsViewModel : INotifyPropertyChanged, IDisposable { private static readonly List AutoGoHomeIntervals = [ @@ -27,8 +29,11 @@ public partial class SettingsViewModel : INotifyPropertyChanged TimeSpan.FromSeconds(180), ]; - private readonly SettingsModel _settings; + private readonly SettingsService _settingsService; private readonly TopLevelCommandManager _topLevelCommandManager; + private readonly IMonitorService? _monitorService; + + private SettingsModel _settings; public event PropertyChangedEventHandler? PropertyChanged; @@ -41,9 +46,8 @@ public HotkeySettings? Hotkey get => _settings.Hotkey; set { - _settings.Hotkey = value ?? SettingsModel.DefaultActivationShortcut; + Save(_settings with { Hotkey = value ?? SettingsModel.DefaultActivationShortcut }); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); - Save(); } } @@ -52,9 +56,8 @@ public bool UseLowLevelGlobalHotkey get => _settings.UseLowLevelGlobalHotkey; set { - _settings.UseLowLevelGlobalHotkey = value; + Save(_settings with { UseLowLevelGlobalHotkey = value }); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Hotkey))); - Save(); } } @@ -63,8 +66,7 @@ public bool AllowExternalReload get => _settings.AllowExternalReload; set { - _settings.AllowExternalReload = value; - Save(); + Save(_settings with { AllowExternalReload = value }); } } @@ -73,8 +75,7 @@ public bool ShowAppDetails get => _settings.ShowAppDetails; set { - _settings.ShowAppDetails = value; - Save(); + Save(_settings with { ShowAppDetails = value }); } } @@ -83,8 +84,7 @@ public bool BackspaceGoesBack get => _settings.BackspaceGoesBack; set { - _settings.BackspaceGoesBack = value; - Save(); + Save(_settings with { BackspaceGoesBack = value }); } } @@ -93,8 +93,7 @@ public bool SingleClickActivates get => _settings.SingleClickActivates; set { - _settings.SingleClickActivates = value; - Save(); + Save(_settings with { SingleClickActivates = value }); } } @@ -103,8 +102,7 @@ public bool HighlightSearchOnActivate get => _settings.HighlightSearchOnActivate; set { - _settings.HighlightSearchOnActivate = value; - Save(); + Save(_settings with { HighlightSearchOnActivate = value }); } } @@ -113,8 +111,7 @@ public bool KeepPreviousQuery get => _settings.KeepPreviousQuery; set { - _settings.KeepPreviousQuery = value; - Save(); + Save(_settings with { KeepPreviousQuery = value }); } } @@ -123,8 +120,7 @@ public int MonitorPositionIndex get => (int)_settings.SummonOn; set { - _settings.SummonOn = (MonitorBehavior)value; - Save(); + Save(_settings with { SummonOn = (MonitorBehavior)value }); } } @@ -133,8 +129,7 @@ public bool ShowSystemTrayIcon get => _settings.ShowSystemTrayIcon; set { - _settings.ShowSystemTrayIcon = value; - Save(); + Save(_settings with { ShowSystemTrayIcon = value }); } } @@ -143,8 +138,7 @@ public bool IgnoreShortcutWhenFullscreen get => _settings.IgnoreShortcutWhenFullscreen; set { - _settings.IgnoreShortcutWhenFullscreen = value; - Save(); + Save(_settings with { IgnoreShortcutWhenFullscreen = value }); } } @@ -153,8 +147,7 @@ public bool DisableAnimations get => _settings.DisableAnimations; set { - _settings.DisableAnimations = value; - Save(); + Save(_settings with { DisableAnimations = value }); } } @@ -170,10 +163,8 @@ public int AutoGoBackIntervalIndex { if (value >= 0 && value < AutoGoHomeIntervals.Count) { - _settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; + Save(_settings with { AutoGoHomeInterval = AutoGoHomeIntervals[value] }); } - - Save(); } } @@ -182,8 +173,7 @@ public int EscapeKeyBehaviorIndex get => (int)_settings.EscapeKeyBehaviorSetting; set { - _settings.EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value; - Save(); + Save(_settings with { EscapeKeyBehaviorSetting = (EscapeKeyBehavior)value }); } } @@ -192,8 +182,8 @@ public DockSide Dock_Side get => _settings.DockSettings.Side; set { - _settings.DockSettings.Side = value; - Save(); + var dockSettings = _settings.DockSettings with { Side = value }; + Save(_settings with { DockSettings = dockSettings }); } } @@ -202,8 +192,8 @@ public DockSize Dock_DockSize get => _settings.DockSettings.DockSize; set { - _settings.DockSettings.DockSize = value; - Save(); + var dockSettings = _settings.DockSettings with { DockSize = value }; + Save(_settings with { DockSettings = dockSettings }); } } @@ -212,8 +202,8 @@ public DockBackdrop Dock_Backdrop get => _settings.DockSettings.Backdrop; set { - _settings.DockSettings.Backdrop = value; - Save(); + var dockSettings = _settings.DockSettings with { Backdrop = value }; + Save(_settings with { DockSettings = dockSettings }); } } @@ -222,8 +212,8 @@ public bool Dock_ShowLabels get => _settings.DockSettings.ShowLabels; set { - _settings.DockSettings.ShowLabels = value; - Save(); + var dockSettings = _settings.DockSettings with { ShowLabels = value }; + Save(_settings with { DockSettings = dockSettings }); } } @@ -232,8 +222,7 @@ public bool EnableDock get => _settings.EnableDock; set { - _settings.EnableDock = value; - Save(); + Save(_settings with { EnableDock = value }); WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value)); WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload } @@ -245,13 +234,81 @@ public bool EnableDock public SettingsExtensionsViewModel Extensions { get; } - public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevelCommandManager, TaskScheduler scheduler, IThemeService themeService) + /// + /// Gets the list of per-monitor dock configuration ViewModels, one per + /// connected monitor. Built by merging data + /// with existing . + /// + public List MonitorConfigItems { get; private set; } = []; + + /// + /// Rebuilds from the current monitor set + /// and persisted settings. Call when monitors change or on init. + /// + public void RefreshMonitorConfigs() { - _settings = settings; + if (_monitorService is null) + { + MonitorConfigItems = []; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MonitorConfigItems))); + return; + } + + var monitors = _monitorService.GetMonitors(); + var dockSettings = _settings.DockSettings; + var existingConfigs = dockSettings.MonitorConfigs; + + // Reconcile stale DeviceIds (they change across reboots). + var needsSave = MonitorConfigReconciler.Reconcile(existingConfigs, monitors); + + // Build ViewModels for each monitor's config. + var items = new List(monitors.Count); + foreach (var monitor in monitors) + { + DockMonitorConfig? config = null; + foreach (var existing in existingConfigs) + { + if (string.Equals(existing.MonitorDeviceId, monitor.DeviceId, StringComparison.Ordinal)) + { + config = existing; + break; + } + } + + if (config is not null) + { + var vm = new DockMonitorConfigViewModel(config, monitor, _settingsService); + items.Add(vm); + } + } + + MonitorConfigItems = items; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(MonitorConfigItems))); + + if (needsSave) + { + _settingsService.SaveSettings(_settings, true); + } + } + + public SettingsViewModel( + SettingsService settingsService, + TopLevelCommandManager topLevelCommandManager, + TaskScheduler scheduler, + IThemeService themeService, + IMonitorService? monitorService = null) + { + _settingsService = settingsService; + _settings = _settingsService.CurrentSettings; _topLevelCommandManager = topLevelCommandManager; + _monitorService = monitorService; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; - Appearance = new AppearanceSettingsViewModel(themeService, _settings); - DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settings); + Appearance = new AppearanceSettingsViewModel(themeService, _settingsService); + DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settingsService); + + RefreshMonitorConfigs(); var activeProviders = GetCommandProviders(); var allProviderSettings = _settings.ProviderSettings; @@ -262,9 +319,9 @@ public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevel foreach (var item in activeProviders) { - var providerSettings = settings.GetProviderSettings(item); + var providerSettings = _settings.GetProviderSettings(item); - var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _settings); + var settingsModel = new ProviderSettingsViewModel(item, providerSettings, _settingsService); CommandProviders.Add(settingsModel); fallbacks.AddRange(settingsModel.FallbackCommands); @@ -298,6 +355,11 @@ public SettingsViewModel(SettingsModel settings, TopLevelCommandManager topLevel } } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settings = args.NewSettingsModel; + } + private IEnumerable GetCommandProviders() { var allProviders = _topLevelCommandManager.CommandProviders; @@ -306,10 +368,19 @@ private IEnumerable GetCommandProviders() public void ApplyFallbackSort() { - _settings.FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray(); - Save(); + Save(_settings with { FallbackRanks = FallbackRankings.Select(s => s.Id).ToArray() }); PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings))); } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save(SettingsModel settings) + { + _settings = settings; + _settingsService.SaveSettings(settings, true); + } + + public void Dispose() + { + GC.SuppressFinalize(this); + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelHotkey.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelHotkey.cs index 64ffbdb461aa..e0e0ed053408 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelHotkey.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelHotkey.cs @@ -2,14 +2,8 @@ // 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; using Microsoft.CmdPal.UI.ViewModels.Settings; namespace Microsoft.CmdPal.UI.ViewModels; -public class TopLevelHotkey(HotkeySettings? hotkey, string commandId) -{ - public string CommandId { get; set; } = commandId; - - public HotkeySettings? Hotkey { get; set; } = hotkey; -} +public record TopLevelHotkey(HotkeySettings? Hotkey, string CommandId); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs index 70f07745241c..6b8aa06f036c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelViewModel.cs @@ -9,6 +9,7 @@ using Microsoft.CmdPal.Common.Helpers; using Microsoft.CmdPal.Common.Text; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -19,14 +20,21 @@ namespace Microsoft.CmdPal.UI.ViewModels; [DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")] -public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider, IPrecomputedListItem +public sealed partial class TopLevelViewModel : + ObservableObject, + IListItem, + IExtendedAttributesProvider, + IPrecomputedListItem, + IDisposable { - private readonly SettingsModel _settings; private readonly ProviderSettings _providerSettings; private readonly IServiceProvider _serviceProvider; + private readonly SettingsService _settingsService; private readonly CommandItemViewModel _commandItemViewModel; private readonly IContextMenuFactory _contextMenuFactory; + private SettingsModel _settings; + public ICommandProviderContext ProviderContext { get; private set; } private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id; @@ -120,7 +128,7 @@ public string AliasText { if (Alias is CommandAlias a) { - a.Alias = value; + a = a with { Alias = value }; } else { @@ -145,7 +153,7 @@ public bool IsDirectAlias { if (Alias is CommandAlias a) { - a.IsDirect = value; + a = a with { IsDirect = value }; } HandleChangeAlias(); @@ -195,7 +203,6 @@ public DockBandSettings? DockBandSettings { ProviderId = this.CommandProviderId, CommandId = this.Id, - ShowLabels = true, }; } @@ -208,19 +215,20 @@ public TopLevelViewModel( TopLevelType topLevelType, CommandPaletteHost extensionHost, ICommandProviderContext commandProviderContext, - SettingsModel settings, + SettingsService settingsService, ProviderSettings providerSettings, IServiceProvider serviceProvider, ICommandItem? commandItem, IContextMenuFactory? contextMenuFactory) { _serviceProvider = serviceProvider; - _settings = settings; + _settingsService = settingsService; _providerSettings = providerSettings; ProviderContext = commandProviderContext; _commandItemViewModel = item; _contextMenuFactory = contextMenuFactory ?? DefaultContextMenuFactory.Instance; + _settings = settingsService.CurrentSettings; IsFallback = topLevelType == TopLevelType.Fallback; IsDockBand = topLevelType == TopLevelType.DockBand; @@ -231,6 +239,12 @@ public TopLevelViewModel( } item.PropertyChangedBackground += Item_PropertyChanged; + _settingsService.SettingsChanged += SettingsService_SettingsChanged; + } + + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settings = args.NewSettingsModel; } internal void InitializeProperties() @@ -313,7 +327,7 @@ private void UpdateInitialIcon(bool raiseNotification = true) } } - private void Save() => SettingsModel.SaveSettings(_settings); + private void Save() => _settingsService.SaveSettings(_settings); private void HandleChangeAlias() { @@ -507,6 +521,11 @@ internal ICommandItem ToPinnedDockBandItem() return item; } + + public void Dispose() + { + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } } public enum TopLevelType diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs index ac0dfddf5810..4a6270895094 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/WindowPosition.cs @@ -6,40 +6,40 @@ namespace Microsoft.CmdPal.UI.ViewModels; -public sealed class WindowPosition +public sealed record WindowPosition { /// - /// Gets or sets left position in device pixels. + /// Gets left position in device pixels. /// public int X { get; init; } /// - /// Gets or sets top position in device pixels. + /// Gets top position in device pixels. /// public int Y { get; init; } /// - /// Gets or sets width in device pixels. + /// Gets width in device pixels. /// public int Width { get; init; } /// - /// Gets or sets height in device pixels. + /// Gets height in device pixels. /// public int Height { get; init; } /// - /// Gets or sets width of the screen in device pixels where the window is located. + /// Gets width of the screen in device pixels where the window is located. /// public int ScreenWidth { get; init; } /// - /// Gets or sets height of the screen in device pixels where the window is located. + /// Gets height of the screen in device pixels where the window is located. /// public int ScreenHeight { get; init; } /// - /// Gets or sets DPI (dots per inch) of the display where the window is located. + /// Gets DPI (dots per inch) of the display where the window is located. /// public int Dpi { get; init; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 32f1161e97bf..bc2f3d19be1e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -37,6 +37,8 @@ using Microsoft.UI.Dispatching; using Microsoft.UI.Xaml; +using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo; + // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. namespace Microsoft.CmdPal.UI; @@ -129,7 +131,7 @@ private static ServiceProvider ConfigureServices(IApplicationInfoService appInfo AddCoreServices(services, appInfoService); - AddUIServices(services, dispatcherQueue); + AddUIServices(services, dispatcherQueue, appInfoService); return services.BuildServiceProvider(); } @@ -181,15 +183,24 @@ private static void AddBuiltInCommands(ServiceCollection services) services.AddSingleton(); } - private static void AddUIServices(ServiceCollection services, DispatcherQueue dispatcherQueue) + private static void AddUIServices( + ServiceCollection services, + DispatcherQueue dispatcherQueue, + IApplicationInfoService appInfoService) { // Models - var sm = SettingsModel.LoadSettings(); - services.AddSingleton(sm); - var state = AppStateModel.LoadState(); - services.AddSingleton(state); + Extensions.Logging.ILogger logger = new CmdPalLogger(); + PersistenceService persistenceService = new PersistenceService(appInfoService, logger); + services.AddSingleton(persistenceService); + + var settingsService = new SettingsService(persistenceService, logger); + services.AddSingleton(settingsService); + + var appStateService = new AppStateService(persistenceService); + services.AddSingleton(appStateService); // Services + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -221,7 +232,6 @@ private static void AddCoreServices(ServiceCollection services, IApplicationInfo // ViewModels services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs index 2f15e4b2135f..5fa216b9476b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/CommandPaletteContextMenuFactory.cs @@ -6,6 +6,7 @@ using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -13,17 +14,28 @@ namespace Microsoft.CmdPal.UI; -internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFactory +internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFactory, IDisposable { - private readonly SettingsModel _settingsModel; + private readonly SettingsService _settingsService; private readonly TopLevelCommandManager _topLevelCommandManager; - public CommandPaletteContextMenuFactory(SettingsModel settingsModel, TopLevelCommandManager topLevelCommandManager) + private SettingsModel _settingsModel; + + public CommandPaletteContextMenuFactory(SettingsService settingsService, TopLevelCommandManager topLevelCommandManager) { - _settingsModel = settingsModel; + _settingsService = settingsService; + _settingsModel = _settingsService.CurrentSettings; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; + _topLevelCommandManager = topLevelCommandManager; } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settingsModel = args.NewSettingsModel; + } + /// /// Constructs the view models for the MoreCommands of a /// CommandItemViewModel. In our case, we can use our settings to add a @@ -196,6 +208,11 @@ internal static bool MatchesBand(DockBandSettings bandSettings, string commandId bandSettings.ProviderId == providerId; } + public void Dispose() + { + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } + internal enum PinLocation { TopLevel, diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs index 57ad2c1d8c4b..269583bb77aa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/FallbackRanker.xaml.cs @@ -5,23 +5,45 @@ using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Microsoft.CmdPal.UI.Controls; -public sealed partial class FallbackRanker : UserControl +public sealed partial class FallbackRanker : UserControl, IDisposable { private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); private SettingsViewModel? viewModel; + private bool _disposed; public FallbackRanker() { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + viewModel = new SettingsViewModel(settingsService, topLevelCommandManager, _mainTaskScheduler, themeService); + Unloaded += OnUnloaded; + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + Dispose(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + Unloaded -= OnUnloaded; + viewModel?.Dispose(); + GC.SuppressFinalize(this); } private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs index bebc5b1e99cf..319ae413f046 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -31,6 +31,8 @@ public sealed partial class SearchBar : UserControl, /// Gets the that we create to track keyboard input and throttle/debounce before we make queries. /// private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + private readonly SettingsService _settingsService; + private SettingsModel _settings; private bool _isBackspaceHeld; // Inline text suggestions @@ -49,8 +51,6 @@ public sealed partial class SearchBar : UserControl, // 0.6+ suggestions private string? _textToSuggest; - private SettingsModel Settings => App.Current.Services.GetRequiredService(); - public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -86,6 +86,10 @@ private static void OnCurrentPageViewModelChanged(DependencyObject d, Dependency public SearchBar() { + _settingsService = App.Current.Services.GetRequiredService(); + _settings = _settingsService.CurrentSettings; + _settingsService.SettingsChanged += (_, args) => _settings = args.NewSettingsModel; + this.InitializeComponent(); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); @@ -132,7 +136,7 @@ private void FilterBox_KeyDown(object sender, KeyRoutedEventArgs e) } else if (e.Key == VirtualKey.Escape) { - switch (Settings.EscapeKeyBehaviorSetting) + switch (_settings.EscapeKeyBehaviorSetting) { case EscapeKeyBehavior.AlwaysGoBack: WeakReferenceMessenger.Default.Send(new()); @@ -427,7 +431,7 @@ private void Page_PropertyChanged(object? sender, System.ComponentModel.Property public void Receive(GoHomeMessage message) { - if (!Settings.KeepPreviousQuery) + if (!_settings.KeepPreviousQuery) { ClearSearch(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs index b8aad6c32363..13d058821626 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/ShortcutControl/ShortcutControl.xaml.cs @@ -186,7 +186,7 @@ private void KeyEventHandler(int key, bool matchValue, int matchValueCode) _ = _modifierKeysOnEntering.Remove(virtualKey); } - internalSettings.Win = matchValue; + internalSettings = internalSettings with { Win = matchValue }; break; case VirtualKey.Control: case VirtualKey.LeftControl: @@ -197,7 +197,7 @@ private void KeyEventHandler(int key, bool matchValue, int matchValueCode) _ = _modifierKeysOnEntering.Remove(VirtualKey.Control); } - internalSettings.Ctrl = matchValue; + internalSettings = internalSettings with { Ctrl = matchValue }; break; case VirtualKey.Menu: case VirtualKey.LeftMenu: @@ -208,7 +208,7 @@ private void KeyEventHandler(int key, bool matchValue, int matchValueCode) _ = _modifierKeysOnEntering.Remove(VirtualKey.Menu); } - internalSettings.Alt = matchValue; + internalSettings = internalSettings with { Alt = matchValue }; break; case VirtualKey.Shift: case VirtualKey.LeftShift: @@ -219,14 +219,14 @@ private void KeyEventHandler(int key, bool matchValue, int matchValueCode) _ = _modifierKeysOnEntering.Remove(VirtualKey.Shift); } - internalSettings.Shift = matchValue; + internalSettings = internalSettings with { Shift = matchValue }; break; case VirtualKey.Escape: internalSettings = new HotkeySettings(); shortcutDialog.IsPrimaryButtonEnabled = false; return; default: - internalSettings.Code = matchValueCode; + internalSettings = internalSettings with { Code = matchValueCode }; break; } } @@ -276,7 +276,7 @@ private bool FilterAccessibleKeyboardEvents(int key, UIntPtr extraInfo) else if (internalSettings.Shift && !_modifierKeysOnEntering.Contains(VirtualKey.Shift) && !internalSettings.Win && !internalSettings.Alt && !internalSettings.Ctrl) { // This is to reset the shift key press within the control as it was not used within the control but rather was used to leave the hotkey. - internalSettings.Shift = false; + internalSettings = internalSettings with { Shift = false }; SendSingleKeyboardInput((short)VirtualKey.Shift, (uint)NativeKeyboardHelper.KeyEventF.KeyDown); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs index 3b4249a8f612..bab83879dcf6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockControl.xaml.cs @@ -22,7 +22,7 @@ namespace Microsoft.CmdPal.UI.Dock; -public sealed partial class DockControl : UserControl, IRecipient, IRecipient +public sealed partial class DockControl : UserControl, IRecipient, IRecipient, IRecipient { private DockViewModel _viewModel; @@ -69,6 +69,7 @@ internal DockControl(DockViewModel viewModel) InitializeComponent(); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged; @@ -151,13 +152,30 @@ internal void DiscardEditMode() ViewModel.RestoreBandOrder(); } + public void Receive(ExitDockEditModeMessage message) + { + DispatcherQueue.TryEnqueue(() => + { + if (message.Discard) + { + DiscardEditMode(); + } + else + { + ExitEditMode(); + } + }); + } + private void DoneEditingButton_Click(object sender, RoutedEventArgs e) { + WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(false)); ExitEditMode(); } private void DiscardEditingButton_Click(object sender, RoutedEventArgs e) { + WeakReferenceMessenger.Default.Send(new ExitDockEditModeMessage(true)); DiscardEditMode(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs index f51e83790db8..c72df3607b27 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindow.xaml.cs @@ -5,10 +5,11 @@ using System.Runtime.InteropServices; using CommunityToolkit.Mvvm.Messaging; using ManagedCommon; -using Microsoft.CmdPal.UI.Helpers; +using Microsoft.CmdPal.UI.Services; 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,7 +28,8 @@ using WinRT; using WinRT.Interop; using WinUIEx; -using WindowExtensions = Microsoft.CmdPal.UI.Helpers.WindowExtensions; + +using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo; namespace Microsoft.CmdPal.UI.Dock; @@ -47,6 +49,7 @@ public sealed partial class DockWindow : WindowEx, private readonly IThemeService _themeService; private readonly DockWindowViewModel _windowViewModel; + private readonly SettingsService _settingsService; private readonly HiddenOwnerWindowBehavior _hiddenOwnerWindowBehavior = new(); private HWND _hwnd = HWND.Null; @@ -60,20 +63,67 @@ public sealed partial class DockWindow : WindowEx, private SystemBackdropConfiguration? _configurationSource; private DockSize _lastSize; + /// + /// Set when the window has been closed. Guards against accessing WinUI + /// properties after the native backing has been torn down. + /// + private bool _closed; + + /// + /// The monitor this dock window is displayed on. Null means primary monitor (legacy behavior). + /// + private MonitorInfo? _targetMonitor; + + /// + /// Gets or sets the per-monitor dock side override. Null means use the global setting. + /// + private DockSide? _sideOverride; + + /// + /// Gets the effective dock side for this window. + /// + private DockSide EffectiveSide => _sideOverride ?? _settings.Side; + + private readonly IMonitorService? _monitorService; + // Store the original WndProc private WNDPROC? _originalWndProc; private WNDPROC? _customWndProc; // internal Settings CurrentSettings => _settings; - public DockWindow() + + /// + /// Creates a dock window for a specific monitor with an optional side override. + /// + public DockWindow(MonitorInfo targetMonitor, DockSide? sideOverride, DockViewModel dockViewModel) + : this(dockViewModel) + { + _targetMonitor = targetMonitor; + _sideOverride = sideOverride; + + // The base constructor positioned the AppBar for the primary monitor + // before these fields were set. Destroy and recreate at the correct + // monitor / side now that the target is known. + if (_appBarData.hWnd != IntPtr.Zero) + { + DestroyAppBar(_hwnd); + } + + CreateAppBar(_hwnd); + } + + private DockWindow(DockViewModel dockViewModel) { var serviceProvider = App.Current.Services; - var mainSettings = serviceProvider.GetService()!; - mainSettings.SettingsChanged += SettingsChangedHandler; + _settingsService = serviceProvider.GetService()!; + _settingsService.SettingsChanged += SettingsChangedHandler; + _monitorService = serviceProvider.GetService(); + var mainSettings = _settingsService.CurrentSettings; + _settings = mainSettings.DockSettings; _lastSize = _settings.DockSize; - viewModel = serviceProvider.GetService()!; + viewModel = dockViewModel; _themeService = serviceProvider.GetRequiredService(); _themeService.ThemeChanged += ThemeService_ThemeChanged; _windowViewModel = new DockWindowViewModel(_themeService); @@ -128,9 +178,25 @@ public DockWindow() UpdateSettingsOnUiThread(); } - private void SettingsChangedHandler(SettingsModel sender, object? args) + private void SettingsChangedHandler(SettingsService sender, SettingsChangedEventArgs args) { - _settings = sender.DockSettings; + _settings = args.NewSettingsModel.DockSettings; + + // Refresh the per-monitor side override from persisted config so that + // EffectiveSide returns the current value, not the stale construction-time snapshot. + if (_targetMonitor is not null) + { + _sideOverride = null; + foreach (var cfg in _settings.MonitorConfigs) + { + if (string.Equals(cfg.MonitorDeviceId, _targetMonitor.DeviceId, StringComparison.Ordinal)) + { + _sideOverride = cfg.Side; + break; + } + } + } + DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread); } @@ -153,6 +219,11 @@ private HWND GetWindowHandle(Window window) private void UpdateSettingsOnUiThread() { + if (_closed) + { + return; + } + this.viewModel.UpdateSettings(_settings); SystemBackdrop = DockSettingsToViews.GetSystemBackdrop(_settings.Backdrop); @@ -164,7 +235,7 @@ private void UpdateSettingsOnUiThread() } _dock.UpdateSettings(_settings); - var side = DockSettingsToViews.GetAppBarEdge(_settings.Side); + var side = DockSettingsToViews.GetAppBarEdge(EffectiveSide); if (_appBarData.hWnd != IntPtr.Zero) { @@ -292,7 +363,7 @@ private void UpdateWindowPosition() var frameWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXFRAME); var scaleFactor = dpi / 96.0; - UpdateAppBarDataForEdge(_settings.Side, _settings.DockSize, scaleFactor); + UpdateAppBarDataForEdge(EffectiveSide, _settings.DockSize, scaleFactor); // Query and set position PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData); @@ -303,7 +374,7 @@ private void UpdateWindowPosition() // 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(_settings.DockSize) * scaleFactor); @@ -350,46 +421,64 @@ private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleF 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); + } + + var screenWidth = monRight - monLeft; + var screenHeight = monBottom - monTop; 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 { @@ -414,6 +503,9 @@ private LRESULT CustomWndProc(HWND hwnd, uint msg, WPARAM wParam, LPARAM lParam) { Logger.LogDebug("WM_DISPLAYCHANGE"); + // Refresh the monitor enumeration so DockWindowManager can react + (_monitorService as MonitorService)?.RefreshMonitors(); + // Use dispatcher to ensure we're on the UI thread DispatcherQueue.TryEnqueue(() => UpdateWindowPosition()); } @@ -552,8 +644,23 @@ private void RequestShowPaletteOnUiThread(Point posDips) 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. @@ -575,7 +682,7 @@ private void RequestShowPaletteOnUiThread(Point posDips) 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, @@ -590,7 +697,7 @@ private void RequestShowPaletteOnUiThread(Point posDips) 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; @@ -621,9 +728,8 @@ public void Dispose() private void DockWindow_Closed(object sender, WindowEventArgs args) { - var serviceProvider = App.Current.Services; - var settings = serviceProvider.GetService(); - settings?.SettingsChanged -= SettingsChangedHandler; + _closed = true; + _settingsService?.SettingsChanged -= SettingsChangedHandler; _themeService.ThemeChanged -= ThemeService_ThemeChanged; DisposeAcrylic(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindowManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindowManager.cs new file mode 100644 index 000000000000..9a392ddb1c38 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Dock/DockWindowManager.cs @@ -0,0 +1,269 @@ +// 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; + +using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo; + +namespace Microsoft.CmdPal.UI.Dock; + +/// +/// Manages multiple instances, one per enabled monitor. +/// Replaces the single _dockWindow field previously held by ShellPage. +/// +public sealed partial class DockWindowManager : IDisposable +{ + private readonly IMonitorService _monitorService; + private readonly SettingsService _settingsService; + private readonly DispatcherQueue _dispatcherQueue; + private readonly Dictionary _dockWindows = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _dockViewModels = new(StringComparer.OrdinalIgnoreCase); + private bool _disposed; + + public DockWindowManager( + IMonitorService monitorService, + SettingsService settingsService, + DispatcherQueue dispatcherQueue) + { + _monitorService = monitorService; + _settingsService = settingsService; + _dispatcherQueue = dispatcherQueue; + + _monitorService.MonitorsChanged += OnMonitorsChanged; + _settingsService.SettingsChanged += OnSettingsChanged; + } + + /// + /// Creates dock windows for all enabled monitors according to current settings. + /// Call this on startup when the dock is enabled. + /// + public void ShowDocks() + { + var settings = _settingsService.CurrentSettings; + if (!settings.EnableDock) + { + return; + } + + var dockSettings = settings.DockSettings; + var configs = GetEffectiveConfigs(dockSettings); + + foreach (var config in configs) + { + if (!config.Enabled) + { + continue; + } + + var monitor = _monitorService.GetMonitorByDeviceId(config.MonitorDeviceId); + if (monitor is null) + { + continue; // Monitor not connected + } + + if (!_dockWindows.ContainsKey(config.MonitorDeviceId)) + { + CreateDockForMonitor(monitor, config, dockSettings); + } + } + } + + /// + /// Destroys all dock windows. + /// + public void HideDocks() + { + foreach (var (_, window) in _dockWindows) + { + window.Close(); + } + + _dockWindows.Clear(); + + foreach (var (_, vm) in _dockViewModels) + { + vm.Dispose(); + } + + _dockViewModels.Clear(); + } + + /// + /// Synchronizes running dock windows to match the current settings. + /// Creates new windows for newly enabled monitors, destroys windows + /// for disabled or disconnected monitors, and repositions existing ones. + /// + public void SyncDocksToSettings() + { + var settings = _settingsService.CurrentSettings; + if (!settings.EnableDock) + { + HideDocks(); + return; + } + + var dockSettings = settings.DockSettings; + + // Reconcile stale DeviceIds before matching configs to monitors. + if (MonitorConfigReconciler.Reconcile(dockSettings.MonitorConfigs, _monitorService.GetMonitors())) + { + _settingsService.SaveSettings(settings, true); + } + + var configs = GetEffectiveConfigs(dockSettings); + var desiredMonitorIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var config in configs) + { + if (!config.Enabled) + { + continue; + } + + var monitor = _monitorService.GetMonitorByDeviceId(config.MonitorDeviceId); + if (monitor is null) + { + continue; + } + + desiredMonitorIds.Add(config.MonitorDeviceId); + + if (!_dockWindows.ContainsKey(config.MonitorDeviceId)) + { + CreateDockForMonitor(monitor, config, dockSettings); + } + } + + // Remove dock windows for monitors that are no longer desired + List toRemove = []; + foreach (var id in _dockWindows.Keys) + { + if (!desiredMonitorIds.Contains(id)) + { + toRemove.Add(id); + } + } + + foreach (var id in toRemove) + { + if (_dockWindows.Remove(id, out var window)) + { + window.Close(); + } + + if (_dockViewModels.Remove(id, out var vm)) + { + vm.Dispose(); + } + } + } + + private void CreateDockForMonitor(MonitorInfo monitor, DockMonitorConfig config, DockSettings dockSettings) + { + var viewModel = CreateDockViewModel(config.MonitorDeviceId); + _dockViewModels[config.MonitorDeviceId] = viewModel; + + // Pass the per-monitor override (nullable) so EffectiveSide follows + // the global setting dynamically when no override is configured. + var window = new DockWindow(monitor, config.Side, viewModel); + _dockWindows[config.MonitorDeviceId] = window; + window.Show(); + + // Initialize bands after the window is shown so the UI scheduler + // is available. Calling SetupBands() during construction causes + // ExecutionEngineException in AOT because the scheduler isn't ready. + viewModel.InitializeBands(); + } + + private DockViewModel CreateDockViewModel(string monitorDeviceId) + { + var serviceProvider = App.Current.Services; + var tlcManager = serviceProvider.GetRequiredService(); + var contextMenuFactory = serviceProvider.GetRequiredService(); + var scheduler = serviceProvider.GetRequiredService(); + + return new DockViewModel(tlcManager, contextMenuFactory, _settingsService, scheduler, monitorDeviceId); + } + + private void OnMonitorsChanged() + { + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + SyncDocksToSettings(); + } + }); + } + + private void OnSettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _dispatcherQueue.TryEnqueue(() => + { + if (!_disposed) + { + SyncDocksToSettings(); + } + }); + } + + /// + /// Returns the effective list of monitor configs. If the settings have + /// no explicit configs (legacy / first-run), synthesizes one for the + /// primary monitor. + /// + private List GetEffectiveConfigs(DockSettings dockSettings) + { + if (dockSettings.MonitorConfigs.Count > 0) + { + return dockSettings.MonitorConfigs; + } + + // Legacy / migration path: no per-monitor configs saved yet. + // Synthesize a config for the primary monitor using the global Side. + var primary = _monitorService.GetPrimaryMonitor(); + return + [ + new DockMonitorConfig + { + MonitorDeviceId = primary.DeviceId, + Enabled = true, + Side = null, // Inherit from global Side + }, + ]; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _monitorService.MonitorsChanged -= OnMonitorsChanged; + _settingsService.SettingsChanged -= OnSettingsChanged; + + foreach (var (_, window) in _dockWindows) + { + window.Dispose(); + } + + _dockWindows.Clear(); + + foreach (var (_, vm) in _dockViewModels) + { + vm.Dispose(); + } + + _dockViewModels.Clear(); + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs index 4a7b33b960d6..ed1f626167f6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml.cs @@ -183,7 +183,8 @@ private void Items_ItemClick(object sender, ItemClickEventArgs e) return; } - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; if (settings.SingleClickActivates) { ViewModel?.InvokeItemCommand.Execute(item); @@ -203,7 +204,8 @@ private void Items_DoubleTapped(object sender, DoubleTappedRoutedEventArgs e) { if (ItemView.SelectedItem is ListItemViewModel vm) { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; if (!settings.SingleClickActivates) { ViewModel?.InvokeItemCommand.Execute(vm); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs index 79bd2a509fbb..f8812c000a79 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/TrayIconService.cs @@ -8,6 +8,7 @@ using Microsoft.CmdPal.UI.Messages; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Services; using Microsoft.UI.Xaml; using Windows.Win32; using Windows.Win32.Foundation; @@ -20,14 +21,15 @@ namespace Microsoft.CmdPal.UI.Helpers; [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_*")] [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_*")] -internal sealed partial class TrayIconService +internal sealed partial class TrayIconService : IDisposable { private const uint MY_NOTIFY_ID = 1000; private const uint WM_TRAY_ICON = PInvoke.WM_USER + 1; - private readonly SettingsModel _settingsModel; + private readonly SettingsService _settingsService; private readonly uint WM_TASKBAR_RESTART; + private SettingsModel _settingsModel; private Window? _window; private HWND _hwnd; private WNDPROC? _originalWndProc; @@ -36,9 +38,12 @@ internal sealed partial class TrayIconService private DestroyIconSafeHandle? _largeIcon; private DestroyMenuSafeHandle? _popupMenu; - public TrayIconService(SettingsModel settingsModel) + public TrayIconService(SettingsService settingsService) { - _settingsModel = settingsModel; + _settingsService = settingsService; + _settingsModel = settingsService.CurrentSettings; + + _settingsService.SettingsChanged += SettingsService_SettingsChanged; // TaskbarCreated is the message that's broadcast when explorer.exe // restarts. We need to know when that happens to be able to bring our @@ -46,6 +51,11 @@ public TrayIconService(SettingsModel settingsModel) WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); } + private void SettingsService_SettingsChanged(SettingsService sender, SettingsChangedEventArgs args) + { + _settingsModel = args.NewSettingsModel; + } + public void SetupTrayIcon(bool? showSystemTrayIcon = null) { if (showSystemTrayIcon ?? _settingsModel.ShowSystemTrayIcon) @@ -210,4 +220,9 @@ private LRESULT WindowProc( return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam); } + + public void Dispose() + { + _settingsService.SettingsChanged -= SettingsService_SettingsChanged; + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index f6d95e401d9f..ac4c3597f547 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -169,7 +169,7 @@ public MainWindow() // Load our settings, and then also wire up a settings changed handler HotReloadSettings(); - App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; + App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; // Make sure that we update the acrylic theme when the OS theme changes RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop); @@ -210,7 +210,7 @@ private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyb } } - private void SettingsChangedHandler(SettingsModel sender, object? args) + private void SettingsChangedHandler(SettingsService sender, SettingsChangedEventArgs args) { DispatcherQueue.TryEnqueue(HotReloadSettings); } @@ -258,7 +258,8 @@ private void PositionCentered(DisplayArea displayArea) private void RestoreWindowPosition() { - var settings = App.Current.Services.GetService(); + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition) { // don't try to restore if the saved position is invalid, just recenter @@ -336,7 +337,8 @@ private void UpdateWindowPositionInMemory() private void HotReloadSettings() { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; SetupHotkey(settings); App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon); @@ -678,7 +680,8 @@ private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target) public void Receive(ShowWindowMessage message) { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; // Start session tracking _sessionStopwatch = Stopwatch.StartNew(); @@ -862,14 +865,15 @@ internal void MainWindow_Closed(object sender, WindowEventArgs args) var serviceProvider = App.Current.Services; UpdateWindowPositionInMemory(); - var settings = serviceProvider.GetService(); - if (settings is not null) + var settingsService = serviceProvider.GetService(); + if (settingsService is not null) { + var settings = settingsService.CurrentSettings; + // a quick sanity check, so we don't overwrite correct values if (_currentWindowPosition.IsSizeValid) { - settings.LastWindowPosition = _currentWindowPosition; - SettingsModel.SaveSettings(settings); + settingsService.SaveSettings(settings with { LastWindowPosition = _currentWindowPosition }); } } @@ -1035,7 +1039,8 @@ public void HandleLaunchNonUI(AppActivationArguments? activatedEventArgs) } else if (uri.StartsWith("x-cmdpal://reload", StringComparison.OrdinalIgnoreCase)) { - var settings = App.Current.Services.GetService(); + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; if (settings?.AllowExternalReload == true) { Logger.LogInfo("External Reload triggered"); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt index 7d0a2c71f3ca..95ea17af9cf1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/NativeMethods.txt @@ -112,3 +112,6 @@ AttachThreadInput GetWindowPlacement WINDOWPLACEMENT WM_DPICHANGED +EnumDisplayMonitors +MONITORINFOEXW +MONITORINFOF_PRIMARY diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index 0c821dda5caf..e92fcd5c5f56 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -16,6 +16,7 @@ using Microsoft.CmdPal.UI.Settings; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CmdPal.UI.ViewModels.Models; using Microsoft.CommandPalette.Extensions; using Microsoft.Extensions.DependencyInjection; using Microsoft.PowerToys.Telemetry; @@ -25,9 +26,10 @@ using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Media.Animation; -using Windows.UI.Core; using WinUIEx; + using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; +using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo; using VirtualKey = Windows.System.VirtualKey; namespace Microsoft.CmdPal.UI.Pages; @@ -67,7 +69,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, private readonly CompositeFormat _pageNavigatedAnnouncement; private SettingsWindow? _settingsWindow; - private DockWindow? _dockWindow; + private DockWindowManager? _dockManager; private CancellationTokenSource? _focusAfterLoadedCts; private WeakReference? _lastNavigatedPageRef; @@ -111,10 +113,20 @@ public ShellPage() var pageAnnouncementFormat = ResourceLoaderInstance.GetString("ScreenReader_Announcement_NavigatedToPage0"); _pageNavigatedAnnouncement = CompositeFormat.Parse(pageAnnouncementFormat); - if (App.Current.Services.GetService()!.EnableDock) + Loaded += ShellPage_Loaded; + } + + private void ShellPage_Loaded(object sender, RoutedEventArgs e) + { + Loaded -= ShellPage_Loaded; + + var settingsService = App.Current.Services.GetService()!; + + if (settingsService.CurrentSettings.EnableDock) { - _dockWindow = new DockWindow(); - _dockWindow.Show(); + var monitorService = App.Current.Services.GetService()!; + _dockManager = new DockWindowManager(monitorService, settingsService, DispatcherQueue); + _dockManager.ShowDocks(); } } @@ -125,14 +137,16 @@ private NavigationTransitionInfo DefaultPageAnimation { get { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; return settings.DisableAnimations ? _noAnimation : _slideRightTransition; } } public void Receive(NavigateBackMessage message) { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; if (RootFrame.CanGoBack) { @@ -361,7 +375,8 @@ private void HideDetails() private void SummonOnUiThread(HotkeySummonMessage message) { - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + var settings = settingsService.CurrentSettings; var commandId = message.CommandId; var isRoot = string.IsNullOrEmpty(commandId); if (isRoot) @@ -489,17 +504,18 @@ public void Receive(ShowHideDockMessage message) { if (message.ShowDock) { - if (_dockWindow is null) + if (_dockManager is null) { - _dockWindow = new DockWindow(); + var monitorService = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; + _dockManager = new DockWindowManager(monitorService, settingsService, DispatcherQueue); } - _dockWindow.Show(); + _dockManager.ShowDocks(); } - else if (_dockWindow is not null) + else { - _dockWindow.Close(); - _dockWindow = null; + _dockManager?.HideDocks(); } }); } @@ -791,6 +807,6 @@ public void Dispose() _focusAfterLoadedCts?.Dispose(); _focusAfterLoadedCts = null; - _dockWindow?.Dispose(); + _dockManager?.Dispose(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs index 76a4e8b5e35d..04d5e37d9d0a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/PowerToysRootPageService.cs @@ -23,13 +23,18 @@ internal sealed class PowerToysRootPageService : IRootPageService private IExtensionWrapper? _activeExtension; private Lazy _mainListPage; - public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel, IFuzzyMatcherProvider fuzzyMatcherProvider) + public PowerToysRootPageService( + TopLevelCommandManager topLevelCommandManager, + SettingsService settingsService, + AliasManager aliasManager, + AppStateService appStateService, + IFuzzyMatcherProvider fuzzyMatcherProvider) { _tlcManager = topLevelCommandManager; _mainListPage = new Lazy(() => { - return new MainListPage(_tlcManager, settings, aliasManager, appStateModel, fuzzyMatcherProvider); + return new MainListPage(_tlcManager, settingsService, aliasManager, appStateService, fuzzyMatcherProvider); }); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs index e1ef0cb57f32..3bb72a4f0318 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/RunHistoryService.cs @@ -9,11 +9,13 @@ namespace Microsoft.CmdPal.UI; internal sealed class RunHistoryService : IRunHistoryService { + private readonly AppStateService _appStateService; private readonly AppStateModel _appStateModel; - public RunHistoryService(AppStateModel appStateModel) + public RunHistoryService(AppStateService appStateService) { - _appStateModel = appStateModel; + _appStateService = appStateService; + _appStateModel = _appStateService.CurrentSettings; } public IReadOnlyList GetRunHistory() @@ -45,6 +47,6 @@ public void AddRunHistoryItem(string item) // Add the item to the front of the history _appStateModel.RunHistory.Insert(0, item); - AppStateModel.SaveState(_appStateModel); + _appStateService.SaveSettings(_appStateModel); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MonitorService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MonitorService.cs new file mode 100644 index 000000000000..19243ef1460e --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/MonitorService.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Runtime.InteropServices; +using Microsoft.CmdPal.UI.ViewModels.Models; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Graphics.Gdi; +using Windows.Win32.UI.HiDpi; + +using MonitorInfo = Microsoft.CmdPal.UI.ViewModels.Models.MonitorInfo; + +namespace Microsoft.CmdPal.UI.Services; + +/// +/// Enumerates connected display monitors using Win32 APIs. +/// Register as a singleton in DI; call when +/// WM_DISPLAYCHANGE is received. +/// +public sealed class MonitorService : IMonitorService +{ + private readonly object _lock = new(); + private List _monitors = []; + + public event Action? MonitorsChanged; + + public MonitorService() + { + RefreshMonitors(); + } + + /// + /// Re-enumerates all monitors. Call this from the WM_DISPLAYCHANGE handler. + /// Fires if the set of monitors changed. + /// + public void RefreshMonitors() + { + var newMonitors = EnumerateMonitors(); + + lock (_lock) + { + _monitors = newMonitors; + } + + MonitorsChanged?.Invoke(); + } + + public IReadOnlyList GetMonitors() + { + lock (_lock) + { + return _monitors.AsReadOnly(); + } + } + + public MonitorInfo? GetMonitorByDeviceId(string deviceId) + { + lock (_lock) + { + return _monitors.Find(m => string.Equals(m.DeviceId, deviceId, StringComparison.OrdinalIgnoreCase)); + } + } + + public MonitorInfo GetPrimaryMonitor() + { + lock (_lock) + { + return _monitors.Find(m => m.IsPrimary) + ?? _monitors[0]; // Fallback: first monitor + } + } + + private static unsafe List EnumerateMonitors() + { + var results = new List(); + + PInvoke.EnumDisplayMonitors(HDC.Null, (RECT*)null, EnumProc, 0); + + return results; + + BOOL EnumProc(HMONITOR hMonitor, HDC hdcMonitor, RECT* lprcMonitor, LPARAM dwData) + { + var info = default(MONITORINFOEXW); + info.monitorInfo.cbSize = (uint)Marshal.SizeOf(); + + if (PInvoke.GetMonitorInfo(hMonitor, ref info.monitorInfo)) + { + var deviceName = info.szDevice.ToString(); + var isPrimary = (info.monitorInfo.dwFlags & PInvoke.MONITORINFOF_PRIMARY) != 0; + + // Get per-monitor DPI via the existing GetDpiForMonitor P/Invoke + uint dpiX = 96; + try + { + PInvoke.GetDpiForMonitor( + hMonitor, + MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, + out dpiX, + out _); + } + catch + { + // Fallback to 96 DPI if the API isn't available + } + + var bounds = info.monitorInfo.rcMonitor; + var workArea = info.monitorInfo.rcWork; + + results.Add(new MonitorInfo + { + DeviceId = deviceName, + DisplayName = FormatDisplayName(deviceName, isPrimary), + Bounds = new ScreenRect(bounds.left, bounds.top, bounds.right, bounds.bottom), + WorkArea = new ScreenRect(workArea.left, workArea.top, workArea.right, workArea.bottom), + Dpi = dpiX, + IsPrimary = isPrimary, + }); + } + + return true; + } + } + + private static string FormatDisplayName(string deviceName, bool isPrimary) + { + // Extract display number from "\\.\DISPLAY1" → "Display 1" + var name = deviceName.Replace(@"\\.\DISPLAY", "Display ", StringComparison.OrdinalIgnoreCase).Trim(); + if (isPrimary) + { + name += " (Primary)"; + } + + return name; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs index c1f548eaeba7..3c744c96b01a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Services/ThemeService.cs @@ -27,11 +27,12 @@ internal sealed partial class ThemeService : IThemeService, IDisposable private static readonly TimeSpan ReloadDebounceInterval = TimeSpan.FromMilliseconds(500); private readonly UISettings _uiSettings; - private readonly SettingsModel _settings; + private readonly SettingsService _settingsService; private readonly ResourceSwapper _resourceSwapper; private readonly NormalThemeProvider _normalThemeProvider; private readonly ColorfulThemeProvider _colorfulThemeProvider; + private SettingsModel _settings; private DispatcherQueue? _dispatcherQueue; private DispatcherQueueTimer? _dispatcherQueueTimer; private bool _isInitialized; @@ -241,13 +242,14 @@ private bool UseColorfulProvider(int effectiveColorIntensity) } } - public ThemeService(SettingsModel settings, ResourceSwapper resourceSwapper) + public ThemeService(SettingsService settingsService, ResourceSwapper resourceSwapper) { - ArgumentNullException.ThrowIfNull(settings); + ArgumentNullException.ThrowIfNull(settingsService); ArgumentNullException.ThrowIfNull(resourceSwapper); - _settings = settings; - _settings.SettingsChanged += SettingsOnSettingsChanged; + _settingsService = settingsService; + _settings = _settingsService.CurrentSettings; + _settingsService.SettingsChanged += SettingsOnSettingsChanged; _resourceSwapper = resourceSwapper; @@ -319,8 +321,9 @@ private ElementTheme GetElementTheme(ElementTheme theme) }; } - private void SettingsOnSettingsChanged(SettingsModel sender, object? args) + private void SettingsOnSettingsChanged(SettingsService sender, SettingsChangedEventArgs args) { + _settings = args.NewSettingsModel; RequestReload(); } @@ -339,7 +342,7 @@ public void Dispose() _disposed = true; _dispatcherQueueTimer?.Stop(); _uiSettings.ColorValuesChanged -= UiSettings_ColorValuesChanged; - _settings.SettingsChanged -= SettingsOnSettingsChanged; + _settingsService.SettingsChanged -= SettingsOnSettingsChanged; } private sealed class InternalThemeState diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs index e047255c43a1..58892ccc8ccf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/AppearancePage.xaml.cs @@ -31,10 +31,10 @@ public AppearancePage() { InitializeComponent(); - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetRequiredService(); var topLevelCommandManager = App.Current.Services.GetService()!; - ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + ViewModel = new SettingsViewModel(settingsService, topLevelCommandManager, _mainTaskScheduler, themeService); } private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml index 8219a65860ba..ff413f554702 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml @@ -67,6 +67,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs index 666049285dd9..0e080c6fa731 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/DockSettingsPage.xaml.cs @@ -6,6 +6,7 @@ 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; @@ -24,15 +25,18 @@ public sealed partial class DockSettingsPage : Page public List AllDockBandItems => GetAllBandSettings(); + public List MonitorConfigItems => ViewModel.MonitorConfigItems; + public DockSettingsPage() { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; + var monitorService = App.Current.Services.GetService(); - ViewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + ViewModel = new SettingsViewModel(settingsService, topLevelCommandManager, _mainTaskScheduler, themeService, monitorService); // Initialize UI state InitializeSettings(); @@ -117,12 +121,6 @@ public int SelectedBackdropIndex set => ViewModel.Dock_Backdrop = SelectedIndexToBackdrop(value); } - public bool ShowLabels - { - get => ViewModel.Dock_ShowLabels; - set => ViewModel.Dock_ShowLabels = value; - } - // Conversion methods for ComboBox bindings private static int DockSizeToSelectedIndex(DockSize size) => size switch { @@ -195,20 +193,18 @@ private List GetAllBandSettings() // var allBands = GetAllBands(); var tlcManager = App.Current.Services.GetService()!; - var settingsModel = App.Current.Services.GetService()!; - var dockViewModel = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var allBands = tlcManager.GetDockBandsSnapshot(); foreach (var band in allBands) { var setting = band.DockBandSettings; if (setting is not null) { - var bandVm = dockViewModel.FindBandByTopLevel(band); allSettings.Add(new( dockSettingsModel: setting, topLevelAdapter: band, - bandViewModel: bandVm, - settingsModel: settingsModel + bandViewModel: null, + settingsService: settingsService )); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs index f19be9f0cff8..ee931b77a9fa 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/ExtensionsPage.xaml.cs @@ -14,7 +14,7 @@ namespace Microsoft.CmdPal.UI.Settings; -public sealed partial class ExtensionsPage : Page +public sealed partial class ExtensionsPage : Page, IDisposable { private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -24,10 +24,10 @@ public ExtensionsPage() { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + viewModel = new SettingsViewModel(settingsService, topLevelCommandManager, _mainTaskScheduler, themeService); } private void SettingsCard_Click(object sender, RoutedEventArgs e) @@ -58,4 +58,10 @@ private async void MenuFlyoutItem_OnClick(object sender, RoutedEventArgs e) Logger.LogError("Error when showing FallbackRankerDialog", ex); } } + + public void Dispose() + { + GC.SuppressFinalize(this); + viewModel?.Dispose(); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs index 2016f8edb752..82b7f8da8816 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml.cs @@ -12,7 +12,7 @@ namespace Microsoft.CmdPal.UI.Settings; -public sealed partial class GeneralPage : Page +public sealed partial class GeneralPage : Page, IDisposable { private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); @@ -23,11 +23,11 @@ public GeneralPage() { this.InitializeComponent(); - var settings = App.Current.Services.GetService()!; + var settingsService = App.Current.Services.GetService()!; var topLevelCommandManager = App.Current.Services.GetService()!; var themeService = App.Current.Services.GetService()!; _appInfoService = App.Current.Services.GetRequiredService(); - viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService); + viewModel = new SettingsViewModel(settingsService, topLevelCommandManager, _mainTaskScheduler, themeService); } public string ApplicationVersion @@ -39,4 +39,10 @@ public string ApplicationVersion return string.Format(CultureInfo.CurrentCulture, versionNo, version); } } + + public void Dispose() + { + GC.SuppressFinalize(this); + viewModel?.Dispose(); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index ea73381b1a78..b8a7a19c6e6f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -973,4 +973,28 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Learn more about Command Palette Dock + + Monitors + + + Configure which monitors display the dock + + + Use default + + + Left + + + Top + + + Right + + + Bottom + + + Customize bands + \ No newline at end of file diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/DockMultiMonitorTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/DockMultiMonitorTests.cs new file mode 100644 index 000000000000..57c592ab4242 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/DockMultiMonitorTests.cs @@ -0,0 +1,871 @@ +// 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 System.Linq; +using System.Runtime.Serialization; +using Microsoft.CmdPal.UI.ViewModels.Models; +using Microsoft.CmdPal.UI.ViewModels.Settings; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +public class DockMultiMonitorTests +{ + private static MonitorInfo MakeMonitor( + string deviceId = @"\\.\DISPLAY1", + string displayName = "Test Monitor", + uint dpi = 96, + bool isPrimary = true, + int left = 0, + int top = 0, + int right = 1920, + int bottom = 1080) + { + var bounds = new ScreenRect(left, top, right, bottom); + return new MonitorInfo + { + DeviceId = deviceId, + DisplayName = displayName, + Bounds = bounds, + WorkArea = bounds, + Dpi = dpi, + IsPrimary = isPrimary, + }; + } + + /// + /// Creates a DockSettings instance without running the constructor, which + /// calls Microsoft.UI.Colors.Transparent and throws a COM exception + /// outside a WinUI host. Band lists are initialised manually. + /// + private static DockSettings MakeDockSettings( + List? startBands = null, + List? centerBands = null, + List? endBands = null) + { +#pragma warning disable SYSLIB0050 // FormatterServices is obsolete – acceptable in test helper + var settings = (DockSettings)FormatterServices.GetUninitializedObject(typeof(DockSettings)); +#pragma warning restore SYSLIB0050 + settings.Side = DockSide.Top; + settings.StartBands = startBands ?? []; + settings.CenterBands = centerBands ?? []; + settings.EndBands = endBands ?? []; + settings.MonitorConfigs = []; + return settings; + } + + // ScreenRect tests + [TestMethod] + public void ScreenRect_Width_IsRightMinusLeft() + { + var rect = new ScreenRect(100, 50, 1920, 1080); + Assert.AreEqual(1820, rect.Width); + } + + [TestMethod] + public void ScreenRect_Height_IsBottomMinusTop() + { + var rect = new ScreenRect(100, 50, 1920, 1080); + Assert.AreEqual(1030, rect.Height); + } + + [TestMethod] + public void ScreenRect_ZeroSize_WhenLeftEqualsRight() + { + var rect = new ScreenRect(500, 200, 500, 200); + Assert.AreEqual(0, rect.Width); + Assert.AreEqual(0, rect.Height); + } + + [TestMethod] + public void ScreenRect_NegativeOrigin_ComputesCorrectly() + { + // Secondary monitor to the left of primary + var rect = new ScreenRect(-1920, 0, 0, 1080); + Assert.AreEqual(1920, rect.Width); + Assert.AreEqual(1080, rect.Height); + } + + [TestMethod] + public void ScreenRect_Equality_SameValues() + { + var a = new ScreenRect(0, 0, 1920, 1080); + var b = new ScreenRect(0, 0, 1920, 1080); + Assert.AreEqual(a, b); + } + + [TestMethod] + public void ScreenRect_Inequality_DifferentValues() + { + var a = new ScreenRect(0, 0, 1920, 1080); + var b = new ScreenRect(0, 0, 2560, 1440); + Assert.AreNotEqual(a, b); + } + + // MonitorInfo tests + [TestMethod] + public void MonitorInfo_ScaleFactor_96Dpi_Returns1() + { + var monitor = MakeMonitor(dpi: 96); + Assert.AreEqual(1.0, monitor.ScaleFactor, 0.001); + } + + [TestMethod] + public void MonitorInfo_ScaleFactor_120Dpi_Returns1_25() + { + var monitor = MakeMonitor(dpi: 120); + Assert.AreEqual(1.25, monitor.ScaleFactor, 0.001); + } + + [TestMethod] + public void MonitorInfo_ScaleFactor_144Dpi_Returns1_5() + { + var monitor = MakeMonitor(dpi: 144); + Assert.AreEqual(1.5, monitor.ScaleFactor, 0.001); + } + + [TestMethod] + public void MonitorInfo_ScaleFactor_192Dpi_Returns2() + { + var monitor = MakeMonitor(dpi: 192); + Assert.AreEqual(2.0, monitor.ScaleFactor, 0.001); + } + + [TestMethod] + public void MonitorInfo_RecordEquality_SameValues() + { + var a = MakeMonitor(deviceId: "A", dpi: 96); + var b = MakeMonitor(deviceId: "A", dpi: 96); + Assert.AreEqual(a, b); + } + + [TestMethod] + public void MonitorInfo_RecordInequality_DifferentDpi() + { + var a = MakeMonitor(deviceId: "A", dpi: 96); + var b = MakeMonitor(deviceId: "A", dpi: 192); + Assert.AreNotEqual(a, b); + } + + [TestMethod] + public void MonitorInfo_RecordInequality_DifferentDeviceId() + { + var a = MakeMonitor(deviceId: "A"); + var b = MakeMonitor(deviceId: "B"); + Assert.AreNotEqual(a, b); + } + + // DockMonitorConfig.ResolveSide tests + [TestMethod] + public void ResolveSide_ReturnsPerMonitorSide_WhenSet() + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + Enabled = true, + Side = DockSide.Left, + }; + + Assert.AreEqual(DockSide.Left, config.ResolveSide(DockSide.Top)); + } + + [TestMethod] + public void ResolveSide_FallsBackToDefault_WhenNull() + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + Enabled = true, + Side = null, + }; + + Assert.AreEqual(DockSide.Bottom, config.ResolveSide(DockSide.Bottom)); + } + + [TestMethod] + [DataRow(DockSide.Left)] + [DataRow(DockSide.Top)] + [DataRow(DockSide.Right)] + [DataRow(DockSide.Bottom)] + public void ResolveSide_AllEnumValues_ReturnedWhenSetExplicitly(DockSide side) + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + Side = side, + }; + + Assert.AreEqual(side, config.ResolveSide(DockSide.Top)); + } + + [TestMethod] + [DataRow(DockSide.Left)] + [DataRow(DockSide.Top)] + [DataRow(DockSide.Right)] + [DataRow(DockSide.Bottom)] + public void ResolveSide_AllEnumValues_UsedAsFallback(DockSide defaultSide) + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + Side = null, + }; + + Assert.AreEqual(defaultSide, config.ResolveSide(defaultSide)); + } + + [TestMethod] + public void ResolveSide_PerMonitorOverridesDefault_EvenWhenSame() + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + Side = DockSide.Top, + }; + + // Even when the per-monitor value matches the default, ResolveSide + // returns the explicit per-monitor value (not a fallback). + Assert.AreEqual(DockSide.Top, config.ResolveSide(DockSide.Top)); + } + + // DockMonitorConfig.Enabled tests + [TestMethod] + public void DockMonitorConfig_EnabledDefaultsToTrue() + { + var config = new DockMonitorConfig { MonitorDeviceId = "X" }; + Assert.IsTrue(config.Enabled); + } + + [TestMethod] + public void DockMonitorConfig_CanBeDisabled() + { + var config = new DockMonitorConfig + { + MonitorDeviceId = "X", + Enabled = false, + }; + + Assert.IsFalse(config.Enabled); + } + + // DockSettings.MonitorConfigs default state + [TestMethod] + public void DockSettings_MonitorConfigs_DefaultsToEmpty() + { + var settings = new DockSettings(); + Assert.AreEqual(0, settings.MonitorConfigs.Count); + } + + [TestMethod] + public void DockSettings_DefaultSide_IsTop() + { + var settings = new DockSettings(); + Assert.AreEqual(DockSide.Top, settings.Side); + } + + // GetEffectiveConfigs logic (tested via data shape) + // + // DockWindowManager.GetEffectiveConfigs is private, but the core + // fallback behavior is: "when MonitorConfigs is empty, treat the + // primary monitor as the single enabled config with Side = null". + // We verify this contract through the config list itself. + [TestMethod] + public void EffectiveConfigs_WhenConfigsExist_ReturnsThemDirectly() + { + var dockSettings = new DockSettings(); + dockSettings.MonitorConfigs.Add(new DockMonitorConfig + { + MonitorDeviceId = "MON1", + Enabled = true, + Side = DockSide.Left, + }); + dockSettings.MonitorConfigs.Add(new DockMonitorConfig + { + MonitorDeviceId = "MON2", + Enabled = false, + }); + + // The manager should use MonitorConfigs as-is when non-empty. + Assert.AreEqual(2, dockSettings.MonitorConfigs.Count); + Assert.AreEqual("MON1", dockSettings.MonitorConfigs[0].MonitorDeviceId); + Assert.IsTrue(dockSettings.MonitorConfigs[0].Enabled); + Assert.AreEqual("MON2", dockSettings.MonitorConfigs[1].MonitorDeviceId); + Assert.IsFalse(dockSettings.MonitorConfigs[1].Enabled); + } + + [TestMethod] + public void EffectiveConfigs_WhenConfigsEmpty_FallbackShouldUsePrimary() + { + // When MonitorConfigs is empty the manager synthesizes a config + // for the primary monitor with Enabled=true and Side=null. + // We verify the data contract that the fallback config should use. + var dockSettings = new DockSettings(); + Assert.AreEqual(0, dockSettings.MonitorConfigs.Count); + + // The synthesized config should inherit the global Side via null. + var synthetic = new DockMonitorConfig + { + MonitorDeviceId = "PRIMARY", + Enabled = true, + Side = null, + }; + + Assert.IsTrue(synthetic.Enabled); + Assert.IsNull(synthetic.Side); + Assert.AreEqual(DockSide.Top, synthetic.ResolveSide(dockSettings.Side)); + } + + [TestMethod] + public void EffectiveConfigs_MultipleMonitors_OnlyEnabledAreActive() + { + var configs = new List + { + new() { MonitorDeviceId = "A", Enabled = true }, + new() { MonitorDeviceId = "B", Enabled = false }, + new() { MonitorDeviceId = "C", Enabled = true, Side = DockSide.Right }, + }; + + // Simulates the ShowDocks filter: only enabled configs are acted upon. + var active = configs.Where(c => c.Enabled).ToList(); + Assert.AreEqual(2, active.Count); + Assert.AreEqual("A", active[0].MonitorDeviceId); + Assert.AreEqual("C", active[1].MonitorDeviceId); + } + + [TestMethod] + public void EffectiveConfigs_SynthesizedConfig_InheritsGlobalSide() + { + // Verifies the full fallback chain: + // Empty MonitorConfigs → synthesize with Side=null → ResolveSide returns global + var dockSettings = new DockSettings { Side = DockSide.Right }; + + var synthetic = new DockMonitorConfig + { + MonitorDeviceId = "PRIMARY", + Enabled = true, + Side = null, + }; + + Assert.AreEqual(DockSide.Right, synthetic.ResolveSide(dockSettings.Side)); + } + + // DockBandSettings.ResolveShowTitles / ResolveShowSubtitles + // (related resolve-with-fallback pattern) + [TestMethod] + public void DockBandSettings_ResolveShowTitles_UsesExplicitValue() + { + var band = new DockBandSettings + { + ProviderId = "P", + CommandId = "C", + ShowTitles = false, + }; + + Assert.IsFalse(band.ResolveShowTitles(defaultValue: true)); + } + + [TestMethod] + public void DockBandSettings_ResolveShowTitles_FallsBackToDefault() + { + var band = new DockBandSettings + { + ProviderId = "P", + CommandId = "C", + ShowTitles = null, + }; + + Assert.IsTrue(band.ResolveShowTitles(defaultValue: true)); + } + + [TestMethod] + public void DockBandSettings_ResolveShowSubtitles_FallsBackToDefault() + { + var band = new DockBandSettings + { + ProviderId = "P", + CommandId = "C", + ShowSubtitles = null, + }; + + Assert.IsFalse(band.ResolveShowSubtitles(defaultValue: false)); + } + + // MonitorConfigReconciler tests + [TestMethod] + public void Reconciler_ExactMatch_NoChanges() + { + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY1", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY2", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List + { + new() { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, Side = DockSide.Bottom, IsPrimary = true }, + new() { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true, Side = DockSide.Right, IsPrimary = false }, + }; + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsFalse(changed); + Assert.AreEqual(2, configs.Count); + } + + [TestMethod] + public void Reconciler_StaleDeviceIds_ReassociatesByIsPrimary() + { + // Simulate reboot: DISPLAY1 → DISPLAY49, DISPLAY2 → DISPLAY50 + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY49", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY50", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List + { + new() { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, Side = DockSide.Top, IsPrimary = true }, + new() { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true, Side = DockSide.Right, IsPrimary = false }, + }; + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsTrue(changed); + Assert.AreEqual(2, configs.Count); + + var primary = configs.First(c => c.IsPrimary); + Assert.AreEqual(@"\\.\DISPLAY49", primary.MonitorDeviceId); + Assert.AreEqual(DockSide.Top, primary.Side); + Assert.IsTrue(primary.Enabled); + + var secondary = configs.First(c => !c.IsPrimary); + Assert.AreEqual(@"\\.\DISPLAY50", secondary.MonitorDeviceId); + Assert.AreEqual(DockSide.Right, secondary.Side); + Assert.IsTrue(secondary.Enabled); + } + + [TestMethod] + public void Reconciler_OrphanedConfigs_AreRemoved() + { + // 3 stale configs from prior sessions, 2 current monitors + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY113", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY114", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List + { + new() { MonitorDeviceId = @"\\.\DISPLAY49", Enabled = true, Side = DockSide.Top, IsPrimary = true }, + new() { MonitorDeviceId = @"\\.\DISPLAY50", Enabled = true, Side = DockSide.Right, IsPrimary = false }, + new() { MonitorDeviceId = @"\\.\DISPLAY81", Enabled = true, Side = DockSide.Top, IsPrimary = true }, + new() { MonitorDeviceId = @"\\.\DISPLAY82", Enabled = false, Side = null, IsPrimary = false }, + }; + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsTrue(changed); + + // Should have exactly 2 configs after reconciliation + Assert.AreEqual(2, configs.Count); + Assert.IsTrue(configs.Any(c => c.MonitorDeviceId == @"\\.\DISPLAY113")); + Assert.IsTrue(configs.Any(c => c.MonitorDeviceId == @"\\.\DISPLAY114")); + } + + [TestMethod] + public void Reconciler_NewMonitor_GetsDefaultConfig() + { + // One existing monitor, one brand new monitor + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY1", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY3", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List + { + new() { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, Side = DockSide.Bottom, IsPrimary = true }, + }; + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsTrue(changed); + Assert.AreEqual(2, configs.Count); + + var newConfig = configs.First(c => c.MonitorDeviceId == @"\\.\DISPLAY3"); + Assert.IsFalse(newConfig.Enabled); // Non-primary defaults to disabled + Assert.IsNull(newConfig.Side); // Inherits global side + Assert.IsFalse(newConfig.IsPrimary); + } + + [TestMethod] + public void Reconciler_UpdatesIsPrimaryWhenMonitorChanges() + { + // Config was saved as primary, but monitor is now secondary + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY1", isPrimary: false), + }; + var configs = new List + { + new() { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true }, + }; + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsTrue(changed); + Assert.IsFalse(configs[0].IsPrimary); + } + + [TestMethod] + public void Reconciler_EmptyConfigs_CreatesDefaultsForAllMonitors() + { + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY1", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY2", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List(); + + var changed = MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.IsTrue(changed); + Assert.AreEqual(2, configs.Count); + + var primary = configs.First(c => c.IsPrimary); + Assert.IsTrue(primary.Enabled); + + var secondary = configs.First(c => !c.IsPrimary); + Assert.IsFalse(secondary.Enabled); + } + + // Band resolution — uncustomized monitor + [TestMethod] + public void ResolveStartBands_Uncustomized_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = false, + }; + + var result = config.ResolveStartBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + [TestMethod] + public void ResolveCenterBands_Uncustomized_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = false, + }; + + var result = config.ResolveCenterBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + [TestMethod] + public void ResolveEndBands_Uncustomized_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = false, + }; + + var result = config.ResolveEndBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + // Band resolution — customized monitor with bands set + [TestMethod] + public void ResolveStartBands_Customized_ReturnsPerMonitorBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var perMonitorBands = new List + { + new() { ProviderId = "Local", CommandId = "L1" }, + new() { ProviderId = "Local", CommandId = "L2" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + StartBands = perMonitorBands, + }; + + var result = config.ResolveStartBands(globalBands); + + Assert.AreSame(perMonitorBands, result); + Assert.AreEqual(2, result.Count); + } + + [TestMethod] + public void ResolveCenterBands_Customized_ReturnsPerMonitorBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var perMonitorBands = new List + { + new() { ProviderId = "Local", CommandId = "L1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + CenterBands = perMonitorBands, + }; + + var result = config.ResolveCenterBands(globalBands); + + Assert.AreSame(perMonitorBands, result); + } + + [TestMethod] + public void ResolveEndBands_Customized_ReturnsPerMonitorBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var perMonitorBands = new List + { + new() { ProviderId = "Local", CommandId = "L1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + EndBands = perMonitorBands, + }; + + var result = config.ResolveEndBands(globalBands); + + Assert.AreSame(perMonitorBands, result); + } + + // Band resolution — customized but null bands falls back to global + [TestMethod] + public void ResolveStartBands_CustomizedButNull_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + StartBands = null, + }; + + var result = config.ResolveStartBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + [TestMethod] + public void ResolveCenterBands_CustomizedButNull_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + CenterBands = null, + }; + + var result = config.ResolveCenterBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + [TestMethod] + public void ResolveEndBands_CustomizedButNull_ReturnsGlobalBands() + { + var globalBands = new List + { + new() { ProviderId = "Global", CommandId = "G1" }, + }; + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = true, + EndBands = null, + }; + + var result = config.ResolveEndBands(globalBands); + + Assert.AreSame(globalBands, result); + } + + // ForkFromGlobal — copies all global bands and sets IsCustomized + [TestMethod] + public void ForkFromGlobal_CopiesGlobalBandsAndSetsIsCustomized() + { + var global = MakeDockSettings( + startBands: + [ + new() { ProviderId = "S1", CommandId = "C1" }, + new() { ProviderId = "S2", CommandId = "C2" }, + ], + centerBands: + [ + new() { ProviderId = "M1", CommandId = "C3" }, + ], + endBands: + [ + new() { ProviderId = "E1", CommandId = "C4" }, + ]); + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = false, + }; + + config.ForkFromGlobal(global); + + Assert.IsTrue(config.IsCustomized); + Assert.IsNotNull(config.StartBands); + Assert.IsNotNull(config.CenterBands); + Assert.IsNotNull(config.EndBands); + Assert.AreEqual(global.StartBands.Count, config.StartBands!.Count); + Assert.AreEqual(global.CenterBands.Count, config.CenterBands!.Count); + Assert.AreEqual(global.EndBands.Count, config.EndBands!.Count); + + for (var i = 0; i < global.StartBands.Count; i++) + { + Assert.AreEqual(global.StartBands[i].ProviderId, config.StartBands[i].ProviderId); + Assert.AreEqual(global.StartBands[i].CommandId, config.StartBands[i].CommandId); + } + + for (var i = 0; i < global.EndBands.Count; i++) + { + Assert.AreEqual(global.EndBands[i].ProviderId, config.EndBands[i].ProviderId); + Assert.AreEqual(global.EndBands[i].CommandId, config.EndBands[i].CommandId); + } + } + + // ForkFromGlobal independence — modifying per-monitor bands doesn't affect global + [TestMethod] + public void ForkFromGlobal_ProducesIndependentCopies() + { + var global = MakeDockSettings( + startBands: + [ + new() { ProviderId = "S1", CommandId = "C1" }, + ], + endBands: + [ + new() { ProviderId = "E1", CommandId = "C2" }, + new() { ProviderId = "E2", CommandId = "C3" }, + ]); + var originalStartCount = global.StartBands.Count; + var originalEndCount = global.EndBands.Count; + + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + IsCustomized = false, + }; + + config.ForkFromGlobal(global); + + // Mutate the per-monitor bands + config.StartBands!.Add(new DockBandSettings { ProviderId = "Extra", CommandId = "X" }); + config.EndBands!.RemoveAt(0); + + // Global bands must remain untouched + Assert.AreEqual(originalStartCount, global.StartBands.Count); + Assert.AreEqual(originalEndCount, global.EndBands.Count); + } + + [TestMethod] + public void ForkFromGlobal_ClonedBandPropertiesAreIndependent() + { + var global = MakeDockSettings( + startBands: + [ + new() { ProviderId = "S1", CommandId = "C1", ShowTitles = true }, + ]); + + var config = new DockMonitorConfig + { + MonitorDeviceId = "DISPLAY1", + }; + + config.ForkFromGlobal(global); + + // Mutate the cloned band's ShowTitles + config.StartBands![0].ShowTitles = false; + + // Original global band should still be true + Assert.IsTrue(global.StartBands[0].ShowTitles!.Value); + } + + // Reconciler Phase 3 — new configs default to IsCustomized = false + [TestMethod] + public void Reconciler_NewMonitor_DefaultsToIsCustomizedFalse() + { + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY1", isPrimary: true), + MakeMonitor(@"\\.\DISPLAY2", isPrimary: false, left: 1920, right: 3840), + }; + var configs = new List(); + + MonitorConfigReconciler.Reconcile(configs, monitors); + + Assert.AreEqual(2, configs.Count); + Assert.IsTrue(configs.All(c => !c.IsCustomized)); + Assert.IsTrue(configs.All(c => c.StartBands is null)); + Assert.IsTrue(configs.All(c => c.CenterBands is null)); + Assert.IsTrue(configs.All(c => c.EndBands is null)); + } + + [TestMethod] + public void Reconciler_NewMonitor_InheritsGlobalBandsViaResolve() + { + var monitors = new List + { + MakeMonitor(@"\\.\DISPLAY5", isPrimary: true), + }; + var configs = new List(); + + MonitorConfigReconciler.Reconcile(configs, monitors); + + var globalStart = new List { new() { ProviderId = "S", CommandId = "C1" } }; + var globalCenter = new List { new() { ProviderId = "M", CommandId = "C2" } }; + var globalEnd = new List { new() { ProviderId = "E", CommandId = "C3" } }; + var newConfig = configs[0]; + + // Uncustomized config should resolve to global bands + Assert.AreSame(globalStart, newConfig.ResolveStartBands(globalStart)); + Assert.AreSame(globalCenter, newConfig.ResolveCenterBands(globalCenter)); + Assert.AreSame(globalEnd, newConfig.ResolveEndBands(globalEnd)); + } +}