Files
PowerToys/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs
Jiří Polášek e68526b8d8 CmdPal: Add configuration option for Escape key behavior (#43354)
## Summary of the Pull Request

This PR adds a new option to the **General** page in **Settings**:

Escape key behavior — a dropdown with the following choices:

- Clear search first, then go back
  - Current behavior.
  - If the search box contains text, it is cleared; otherwise goes back.
  - On the home page, CmdPal is dismissed.

- Go back
  - Leaves the search text intact.
- If the page is not transient, the search text reappears when
returning.
  - On the home page, CmdPal is dismissed.

- Hide window and go home (Always dismiss)
  - Immediately dismisses CmdPal and navigates to the home page.
  - Ignores the **Go home when activated** setting.
  - Search text is cleared.

- Hide window
  - Just hides the window.
  - Intended to be used with #43355.

This implementation preserves existing behavior, except for **Always
dismiss**, which always forces navigation to the home page.

## Pictures? Pictures!

<img width="1305" height="892" alt="image"
src="https://github.com/user-attachments/assets/562e5604-1da6-4fc6-8358-5053df9c573d"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #38311
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-12-05 16:32:24 -06:00

295 lines
10 KiB
C#

// 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;
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 Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public partial class SettingsModel : ObservableObject
{
private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome";
[JsonIgnore]
public static readonly string FilePath;
public event TypedEventHandler<SettingsModel, object?>? 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 bool UseLowLevelGlobalHotkey { get; set; }
public bool ShowAppDetails { get; set; }
public bool BackspaceGoesBack { get; set; }
public bool SingleClickActivates { get; set; }
public bool HighlightSearchOnActivate { get; set; } = true;
public bool ShowSystemTrayIcon { get; set; } = true;
public bool IgnoreShortcutWhenFullscreen { get; set; }
public bool AllowExternalReload { get; set; }
public Dictionary<string, ProviderSettings> ProviderSettings { get; set; } = [];
public Dictionary<string, CommandAlias> Aliases { get; set; } = [];
public List<TopLevelHotkey> CommandHotkeys { get; set; } = [];
public MonitorBehavior SummonOn { get; set; } = MonitorBehavior.ToMouse;
public bool DisableAnimations { get; set; } = true;
public WindowPosition? LastWindowPosition { get; set; }
public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan;
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
static SettingsModel()
{
FilePath = SettingsJsonPath();
}
public ProviderSettings GetProviderSettings(CommandProviderWrapper provider)
{
ProviderSettings? settings;
if (!ProviderSettings.TryGetValue(provider.ProviderId, out settings))
{
settings = new ProviderSettings(provider);
settings.Connect(provider);
ProviderSettings[provider.ProviderId] = settings;
}
else
{
settings.Connect(provider);
}
return settings;
}
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<SettingsModel>(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<T>(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action<SettingsModel, T> apply, JsonTypeInfo<T> 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<T>(jsonTypeInfo);
apply(model, value!);
return true;
}
}
catch (Exception ex)
{
Logger.LogError($"Error during migration {migrationName}.", ex);
}
return false;
}
public static void SaveSettings(SettingsModel model)
{
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
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 = "<Pending>")]
// 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(HistoryItem))]
[JsonSerializable(typeof(SettingsModel))]
[JsonSerializable(typeof(WindowPosition))]
[JsonSerializable(typeof(AppStateModel))]
[JsonSerializable(typeof(RecentCommandsManager))]
[JsonSerializable(typeof(List<string>), TypeInfoPropertyName = "StringList")]
[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryList")]
[JsonSerializable(typeof(Dictionary<string, object>), 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
{
ToMouse = 0,
ToPrimary = 1,
ToFocusedWindow = 2,
InPlace = 3,
ToLast = 4,
}
public enum EscapeKeyBehavior
{
ClearSearchFirstThenGoBack = 0,
AlwaysGoBack = 1,
AlwaysDismiss = 2,
AlwaysHide = 3,
}