mirror of
https://github.com/microsoft/PowerToys.git
synced 2025-12-16 11:48:06 +01:00
## 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
295 lines
10 KiB
C#
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,
|
|
}
|