diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 2148c78663..43447793a0 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1279,6 +1279,7 @@ pstm PStr pstream pstrm +pswd PSYSTEM psz ptb @@ -1425,6 +1426,7 @@ searchterm SEARCHUI SECONDARYDISPLAY secpol +securestring SEEMASKINVOKEIDLIST SELCHANGE SENDCHANGE @@ -1980,4 +1982,12 @@ zoomit ZOOMITX ZXk ZXNs -zzz \ No newline at end of file +zzz +ACIE +AOklab +BCIE +BOklab +culori +Evercoder +LCh +CIELCh diff --git a/.pipelines/tsa.json b/.pipelines/tsa.json index 351545613f..2f1e84c7f1 100644 --- a/.pipelines/tsa.json +++ b/.pipelines/tsa.json @@ -3,5 +3,5 @@ "notificationAliases": ["powertoys@microsoft.com"], "instanceUrl": "https://microsoft.visualstudio.com", "projectName": "OS", - "areaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\SHINE\\PowerToys" + "areaPath": "OS\\Windows Client and Services\\WinPD\\DEEP-Developer Experience, Ecosystem and Partnerships\\DIVE\\PowerToys" } diff --git a/src/common/ManagedCommon/ColorFormatHelper.cs b/src/common/ManagedCommon/ColorFormatHelper.cs index 08e62b921d..471104f215 100644 --- a/src/common/ManagedCommon/ColorFormatHelper.cs +++ b/src/common/ManagedCommon/ColorFormatHelper.cs @@ -141,6 +141,40 @@ namespace ManagedCommon return lab; } + /// + /// Convert a given to a Oklab color + /// + /// The to convert + /// The perceptual lightness [0..1] and two chromaticities [-0.5..0.5] + public static (double Lightness, double ChromaticityA, double ChromaticityB) ConvertToOklabColor(Color color) + { + var linear = ConvertSRGBToLinearRGB(color.R / 255d, color.G / 255d, color.B / 255d); + var oklab = GetOklabColorFromLinearRGB(linear.R, linear.G, linear.B); + return oklab; + } + + /// + /// Convert a given to a Oklch color + /// + /// The to convert + /// The perceptual lightness [0..1], the chroma [0..0.5], and the hue angle [0°..360°] + public static (double Lightness, double Chroma, double Hue) ConvertToOklchColor(Color color) + { + var oklab = ConvertToOklabColor(color); + var oklch = GetOklchColorFromOklab(oklab.Lightness, oklab.ChromaticityA, oklab.ChromaticityB); + + return oklch; + } + + public static (double R, double G, double B) ConvertSRGBToLinearRGB(double r, double g, double b) + { + // inverse companding, gamma correction must be undone + double rLinear = (r > 0.04045) ? Math.Pow((r + 0.055) / 1.055, 2.4) : (r / 12.92); + double gLinear = (g > 0.04045) ? Math.Pow((g + 0.055) / 1.055, 2.4) : (g / 12.92); + double bLinear = (b > 0.04045) ? Math.Pow((b + 0.055) / 1.055, 2.4) : (b / 12.92); + return (rLinear, gLinear, bLinear); + } + /// /// Convert a given to a CIE XYZ color (XYZ) /// The constants of the formula matches this Wikipedia page, but at a higher precision: @@ -156,10 +190,7 @@ namespace ManagedCommon double g = color.G / 255d; double b = color.B / 255d; - // inverse companding, gamma correction must be undone - double rLinear = (r > 0.04045) ? Math.Pow((r + 0.055) / 1.055, 2.4) : (r / 12.92); - double gLinear = (g > 0.04045) ? Math.Pow((g + 0.055) / 1.055, 2.4) : (g / 12.92); - double bLinear = (b > 0.04045) ? Math.Pow((b + 0.055) / 1.055, 2.4) : (b / 12.92); + (double rLinear, double gLinear, double bLinear) = ConvertSRGBToLinearRGB(r, g, b); return ( (rLinear * 0.41239079926595948) + (gLinear * 0.35758433938387796) + (bLinear * 0.18048078840183429), @@ -210,6 +241,63 @@ namespace ManagedCommon return (l, a, b); } + /// + /// Convert a linear RGB color to an Oklab color. + /// The constants of this formula come from https://github.com/Evercoder/culori/blob/2bedb8f0507116e75f844a705d0b45cf279b15d0/src/oklab/convertLrgbToOklab.js + /// and the implementation is based on https://bottosson.github.io/posts/oklab/ + /// + /// Linear R value + /// Linear G value + /// Linear B value + /// The perceptual lightness [0..1] and two chromaticities [-0.5..0.5] + private static (double Lightness, double ChromaticityA, double ChromaticityB) + GetOklabColorFromLinearRGB(double r, double g, double b) + { + double l = (0.41222147079999993 * r) + (0.5363325363 * g) + (0.0514459929 * b); + double m = (0.2119034981999999 * r) + (0.6806995450999999 * g) + (0.1073969566 * b); + double s = (0.08830246189999998 * r) + (0.2817188376 * g) + (0.6299787005000002 * b); + + double l_ = Math.Cbrt(l); + double m_ = Math.Cbrt(m); + double s_ = Math.Cbrt(s); + + return ( + (0.2104542553 * l_) + (0.793617785 * m_) - (0.0040720468 * s_), + (1.9779984951 * l_) - (2.428592205 * m_) + (0.4505937099 * s_), + (0.0259040371 * l_) + (0.7827717662 * m_) - (0.808675766 * s_) + ); + } + + /// + /// Convert an Oklab color from Cartesian form to its polar form Oklch + /// https://bottosson.github.io/posts/oklab/#the-oklab-color-space + /// + /// The + /// The + /// The + /// The perceptual lightness [0..1], the chroma [0..0.5], and the hue angle [0°..360°] + private static (double Lightness, double Chroma, double Hue) + GetOklchColorFromOklab(double lightness, double chromaticity_a, double chromaticity_b) + { + return GetLCHColorFromLAB(lightness, chromaticity_a, chromaticity_b); + } + + /// + /// Convert a color in Cartesian form (Lab) to its polar form (LCh) + /// + /// The + /// The + /// The + /// The lightness, chroma, and hue angle + private static (double Lightness, double Chroma, double Hue) + GetLCHColorFromLAB(double lightness, double chromaticity_a, double chromaticity_b) + { + // Lab to LCh transformation + double chroma = Math.Sqrt(Math.Pow(chromaticity_a, 2) + Math.Pow(chromaticity_b, 2)); + double hue = Math.Round(chroma, 3) == 0 ? 0.0 : ((Math.Atan2(chromaticity_b, chromaticity_a) * 180d / Math.PI) + 360d) % 360d; + return (lightness, chroma, hue); + } + /// /// Convert a given to a natural color (hue, whiteness, blackness) /// @@ -276,12 +364,17 @@ namespace ManagedCommon { "Br", 'p' }, // brightness percent { "In", 'p' }, // intensity percent { "Ll", 'p' }, // lightness (HSL) percent - { "Lc", 'p' }, // lightness(CIELAB)percent { "Va", 'p' }, // value percent { "Wh", 'p' }, // whiteness percent { "Bn", 'p' }, // blackness percent - { "Ca", 'p' }, // chromaticityA percent - { "Cb", 'p' }, // chromaticityB percent + { "Lc", 'p' }, // lightness (CIE) percent + { "Ca", 'p' }, // chromaticityA (CIELAB) percent + { "Cb", 'p' }, // chromaticityB (CIELAB) percent + { "Lo", 'p' }, // lightness (Oklab/Oklch) percent + { "Oa", 'p' }, // chromaticityA (Oklab) percent + { "Ob", 'p' }, // chromaticityB (Oklab) percent + { "Oc", 'p' }, // chroma (Oklch) percent + { "Oh", 'p' }, // hue angle (Oklch) percent { "Xv", 'i' }, // X value int { "Yv", 'i' }, // Y value int { "Zv", 'i' }, // Z value int @@ -424,6 +517,10 @@ namespace ManagedCommon var (lightnessC, _, _) = ConvertToCIELABColor(color); lightnessC = Math.Round(lightnessC, 2); return lightnessC.ToString(CultureInfo.InvariantCulture); + case "Lo": + var (lightnessO, _, _) = ConvertToOklabColor(color); + lightnessO = Math.Round(lightnessO, 2); + return lightnessO.ToString(CultureInfo.InvariantCulture); case "Wh": var (_, whiteness, _) = ConvertToHWBColor(color); whiteness = Math.Round(whiteness * 100); @@ -440,6 +537,22 @@ namespace ManagedCommon var (_, _, chromaticityB) = ConvertToCIELABColor(color); chromaticityB = Math.Round(chromaticityB, 2); return chromaticityB.ToString(CultureInfo.InvariantCulture); + case "Oa": + var (_, chromaticityAOklab, _) = ConvertToOklabColor(color); + chromaticityAOklab = Math.Round(chromaticityAOklab, 2); + return chromaticityAOklab.ToString(CultureInfo.InvariantCulture); + case "Ob": + var (_, _, chromaticityBOklab) = ConvertToOklabColor(color); + chromaticityBOklab = Math.Round(chromaticityBOklab, 2); + return chromaticityBOklab.ToString(CultureInfo.InvariantCulture); + case "Oc": + var (_, chromaOklch, _) = ConvertToOklchColor(color); + chromaOklch = Math.Round(chromaOklch, 2); + return chromaOklch.ToString(CultureInfo.InvariantCulture); + case "Oh": + var (_, _, hueOklch) = ConvertToOklchColor(color); + hueOklch = Math.Round(hueOklch, 2); + return hueOklch.ToString(CultureInfo.InvariantCulture); case "Xv": var (x, _, _) = ConvertToCIEXYZColor(color); x = Math.Round(x * 100, 4); @@ -495,8 +608,10 @@ namespace ManagedCommon case "HSI": return "hsi(%Hu, %Si%, %In%)"; case "HWB": return "hwb(%Hu, %Wh%, %Bn%)"; case "NCol": return "%Hn, %Wh%, %Bn%"; - case "CIELAB": return "CIELab(%Lc, %Ca, %Cb)"; case "CIEXYZ": return "XYZ(%Xv, %Yv, %Zv)"; + case "CIELAB": return "CIELab(%Lc, %Ca, %Cb)"; + case "Oklab": return "oklab(%Lo, %Oa, %Ob)"; + case "Oklch": return "oklch(%Lo, %Oc, %Oh)"; case "VEC4": return "(%Reff, %Grff, %Blff, 1f)"; case "Decimal": return "%Dv"; case "HEX Int": return "0xFF%ReX%GrX%BlX"; diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs index b56868ece8..218349b32b 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs @@ -18,10 +18,19 @@ public static class OcrHelpers { public static async Task ExtractTextAsync(SoftwareBitmap bitmap, CancellationToken cancellationToken) { - var ocrLanguage = GetOCRLanguage() ?? throw new InvalidOperationException("Unable to determine OCR language"); + var ocrLanguage = GetOCRLanguage(); cancellationToken.ThrowIfCancellationRequested(); - var ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine"); + OcrEngine ocrEngine; + if (ocrLanguage is not null) + { + ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine from specified language"); + } + else + { + ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages() ?? throw new InvalidOperationException("Unable to create OCR engine from user profile language"); + } + cancellationToken.ThrowIfCancellationRequested(); var ocrResult = await ocrEngine.RecognizeAsync(bitmap); diff --git a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj index 5bd12f316e..2d70013009 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj +++ b/src/modules/cmdpal/CmdPalModuleInterface/CmdPalModuleInterface.vcxproj @@ -2,7 +2,7 @@ - + 17.0 Win32Proj @@ -53,7 +53,6 @@ EXAMPLEPOWERTOY_EXPORTS;_WINDOWS;_USRDLL; %(PreprocessorDefinitions); - $(CommandPaletteBranding) IS_DEV_BRANDING;%(PreprocessorDefinitions) diff --git a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp index be3eb6a3b7..bff7279b68 100644 --- a/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp +++ b/src/modules/cmdpal/CmdPalModuleInterface/dllmain.cpp @@ -3,6 +3,7 @@ #include +#include #include #include #include @@ -10,10 +11,11 @@ #include #include #include +#include #include #include #include -#include +#include HINSTANCE g_hInst_cmdPal = 0; @@ -37,8 +39,6 @@ BOOL APIENTRY DllMain(HMODULE hInstance, class CmdPal : public PowertoyModuleIface { private: - bool m_enabled = false; - std::wstring app_name; //contains the non localized key of the powertoy @@ -46,7 +46,10 @@ private: HANDLE m_hTerminateEvent; - void LaunchApp(const std::wstring& appPath, const std::wstring& commandLineArgs, bool elevated) + // Track if this is the first call to enable + bool firstEnableCall = true; + + static bool LaunchApp(const std::wstring& appPath, const std::wstring& commandLineArgs, bool elevated, bool silentFail) { std::wstring dir = std::filesystem::path(appPath).parent_path(); @@ -54,6 +57,10 @@ private: sei.cbSize = sizeof(SHELLEXECUTEINFO); sei.hwnd = nullptr; sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NO_CONSOLE; + if (silentFail) + { + sei.fMask = sei.fMask | SEE_MASK_FLAG_NO_UI; + } sei.lpVerb = elevated ? L"runas" : L"open"; sei.lpFile = appPath.c_str(); sei.lpParameters = commandLineArgs.c_str(); @@ -64,7 +71,11 @@ private: { std::wstring error = get_last_error_or_default(GetLastError()); Logger::error(L"Failed to launch process. {}", error); + return false; } + + m_launched.store(true); + return true; } std::vector GetProcessesIdByName(const std::wstring& processName) @@ -122,6 +133,9 @@ private: } public: + static std::atomic m_enabled; + static std::atomic m_launched; + CmdPal() { app_name = L"CmdPal"; @@ -133,10 +147,7 @@ public: ~CmdPal() { - if (m_enabled) - { - } - m_enabled = false; + CmdPal::m_enabled.store(false); } // Destroy the powertoy and free memory @@ -203,15 +214,18 @@ public: { Logger::trace("CmdPal::enable()"); - m_enabled = true; + CmdPal::m_enabled.store(true); - try - { - std::wstring packageName = L"Microsoft.CommandPalette"; + std::wstring packageName = L"Microsoft.CommandPalette"; + std::wstring launchPath = L"shell:AppsFolder\\Microsoft.CommandPalette_8wekyb3d8bbwe!App"; #ifdef IS_DEV_BRANDING - packageName = L"Microsoft.CommandPalette.Dev"; + packageName = L"Microsoft.CommandPalette.Dev"; + launchPath = L"shell:AppsFolder\\Microsoft.CommandPalette.Dev_8wekyb3d8bbwe!App"; #endif - if (!package::GetRegisteredPackage(packageName, false).has_value()) + + if (!package::GetRegisteredPackage(packageName, false).has_value()) + { + try { Logger::info(L"CmdPal not installed. Installing..."); @@ -238,28 +252,34 @@ public: } } } + catch (std::exception& e) + { + std::string errorMessage{ "Exception thrown while trying to install CmdPal package: " }; + errorMessage += e.what(); + Logger::error(errorMessage); + } } - catch (std::exception& e) + + if (!package::GetRegisteredPackage(packageName, false).has_value()) { - std::string errorMessage{ "Exception thrown while trying to install CmdPal package: " }; - errorMessage += e.what(); - Logger::error(errorMessage); + Logger::error("Cmdpal is not registered, quit.."); + return; } - try + + if (!firstEnableCall) { -#ifdef IS_DEV_BRANDING - LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette.Dev_8wekyb3d8bbwe!App", L"RunFromPT", false); -#else - LaunchApp(std::wstring{ L"shell:AppsFolder\\" } + L"Microsoft.CommandPalette_8wekyb3d8bbwe!App", L"RunFromPT", false); -#endif + Logger::trace("Not first attempt, try to launch"); + LaunchApp(launchPath, L"RunFromPT", false /*no elevated*/, false /*error pop up*/); } - catch (std::exception& e) + else { - std::string errorMessage{ "Exception thrown while trying to launch CmdPal: " }; - errorMessage += e.what(); - Logger::error(errorMessage); - throw; + // If first time enable, do retry launch. + Logger::trace("First attempt, try to launch"); + std::thread launchThread(&CmdPal::RetryLaunch, launchPath); + launchThread.detach(); } + + firstEnableCall = false; } virtual void disable() @@ -267,7 +287,44 @@ public: Logger::trace("CmdPal::disable()"); TerminateCmdPal(); - m_enabled = false; + CmdPal::m_enabled.store(false); + } + + static void RetryLaunch(std::wstring path) + { + const int base_delay_milliseconds = 1000; + int max_retry = 9; // 2**9 - 1 seconds. Control total wait time within 10 min. + int retry = 0; + do + { + auto launch_result = LaunchApp(path, L"RunFromPT", false, retry < max_retry); + if (launch_result) + { + Logger::info(L"CmdPal launched successfully after {} retries.", retry); + return; + } + else + { + Logger::error(L"Retry {} launch CmdPal launch failed.", retry); + } + + // When we got max retry, we don't need to wait for the next retry. + if (retry < max_retry) + { + int delay = base_delay_milliseconds * (1 << (retry)); + std::this_thread::sleep_for(std::chrono::milliseconds(delay)); + } + ++retry; + } while (retry <= max_retry && m_enabled.load() && !m_launched.load()); + + if (!m_enabled.load() || m_launched.load()) + { + Logger::error(L"Retry cancelled. CmdPal is disabled or already launched."); + } + else + { + Logger::error(L"CmdPal launch failed after {} attempts.", retry); + } } virtual bool on_hotkey(size_t) override @@ -282,11 +339,14 @@ public: virtual bool is_enabled() override { - return m_enabled; + return CmdPal::m_enabled.load(); } }; +std::atomic CmdPal::m_enabled{ false }; +std::atomic CmdPal::m_launched{ false }; + extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() { return new CmdPal(); -} +} \ No newline at end of file diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs deleted file mode 100644 index b9348eb520..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/IFileService.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Common.Contracts; - -public interface IFileService -{ - T Read(string folderPath, string fileName); - - void Save(string folderPath, string fileName, T content); - - void Delete(string folderPath, string fileName); -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs deleted file mode 100644 index 2350050e3e..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Contracts/ILocalSettingsService.cs +++ /dev/null @@ -1,16 +0,0 @@ -// 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.Threading.Tasks; - -namespace Microsoft.CmdPal.Common.Contracts; - -public interface ILocalSettingsService -{ - Task HasSettingAsync(string key); - - Task ReadSettingAsync(string key); - - Task SaveSettingAsync(string key, T value); -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs deleted file mode 100644 index a975083c7c..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/ApplicationExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// 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.Services; -using Microsoft.UI.Xaml; - -namespace Microsoft.CmdPal.Common.Extensions; - -/// -/// Extension class implementing extension methods for . -/// -public static class ApplicationExtensions -{ - /// - /// Get registered services at the application level from anywhere in the - /// application. - /// - /// Note: - /// https://learn.microsoft.com/uwp/api/windows.ui.xaml.application.current?view=winrt-22621#windows-ui-xaml-application-current - /// "Application is a singleton that implements the static Current property - /// to provide shared access to the Application instance for the current - /// application. The singleton pattern ensures that state managed by - /// Application, including shared resources and properties, is available - /// from a single, shared location." - /// - /// Example of usage: - /// - /// Application.Current.GetService() - /// - /// - /// Service type. - /// Current application. - /// Service reference. - public static T GetService(this Application application) - where T : class - { - return (application as IApp)!.GetService(); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs deleted file mode 100644 index 660dcd2931..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Extensions/IHostExtensions.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace Microsoft.CmdPal.Common.Extensions; - -public static class IHostExtensions -{ - /// - /// - /// - public static T CreateInstance(this IHost host, params object[] parameters) - { - return ActivatorUtilities.CreateInstance(host.Services, parameters); - } - - /// - /// Gets the service object for the specified type, or throws an exception - /// if type was not registered. - /// - /// Service type - /// Host object - /// Service object - /// Throw an exception if the specified - /// type is not registered - public static T GetService(this IHost host) - where T : class - { - if (host.Services.GetService(typeof(T)) is not T service) - { - throw new ArgumentException($"{typeof(T)} needs to be registered in ConfigureServices."); - } - - return service; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs deleted file mode 100644 index d865e10bdb..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/Json.cs +++ /dev/null @@ -1,36 +0,0 @@ -// 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.IO; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; - -namespace Microsoft.CmdPal.Common.Helpers; - -public static class Json -{ - public static async Task ToObjectAsync(string value) - { - if (typeof(T) == typeof(bool)) - { - return (T)(object)bool.Parse(value); - } - - await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(value)); - return (await JsonSerializer.DeserializeAsync(stream))!; - } - - public static async Task StringifyAsync(T value) - { - if (typeof(T) == typeof(bool)) - { - return value!.ToString()!.ToLowerInvariant(); - } - - await using var stream = new MemoryStream(); - await JsonSerializer.SerializeAsync(stream, value); - return Encoding.UTF8.GetString(stream.ToArray()); - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs index 6ec1885a4c..2344fbb917 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/NativeEventWaiter.cs @@ -9,7 +9,7 @@ using Microsoft.UI.Dispatching; namespace Microsoft.CmdPal.Common.Helpers; -public static class NativeEventWaiter +public static partial class NativeEventWaiter { public static void WaitForEventLoop(string eventName, Action callback) { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs index 46dce07e5e..342667cf83 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Helpers/RuntimeHelper.cs @@ -9,7 +9,7 @@ using Windows.Win32.Foundation; namespace Microsoft.CmdPal.Common.Helpers; -public static class RuntimeHelper +public static partial class RuntimeHelper { public static bool IsMSIX { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs index ed698d1024..097aefdee9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Messages/HideWindowMessage.cs @@ -4,6 +4,6 @@ namespace Microsoft.CmdPal.Common.Messages; -public record HideWindowMessage() +public partial record HideWindowMessage() { } 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 970df0df58..5f83ca54e1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/Microsoft.CmdPal.Common.csproj @@ -1,5 +1,6 @@ + Microsoft.CmdPal.Common enable diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs deleted file mode 100644 index bae7422878..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Models/LocalSettingsOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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.Common.Models; - -public class LocalSettingsOptions -{ - public string? ApplicationDataFolder - { - get; set; - } - - public string? LocalSettingsFile - { - get; set; - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt b/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt index 0d456bde31..996bbd7153 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt +++ b/src/modules/cmdpal/Microsoft.CmdPal.Common/NativeMethods.txt @@ -1,16 +1,7 @@ -EnableWindow -CoCreateInstance -FileOpenDialog -FileSaveDialog -IFileOpenDialog -IFileSaveDialog -SHCreateItemFromParsingName GetCurrentPackageFullName SetWindowLong GetWindowLong WINDOW_EX_STYLE -SHLoadIndirectString -StrFormatByteSizeEx SFBS_FLAGS MAX_PATH GetDpiForWindow diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs deleted file mode 100644 index cc6ef96098..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/FileService.cs +++ /dev/null @@ -1,48 +0,0 @@ -// 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.IO; -using System.Text; -using System.Text.Json; -using Microsoft.CmdPal.Common.Contracts; - -namespace Microsoft.CmdPal.Common.Services; - -public class FileService : IFileService -{ - private static readonly Encoding _encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); - -#pragma warning disable CS8603 // Possible null reference return. - public T Read(string folderPath, string fileName) - { - var path = Path.Combine(folderPath, fileName); - if (File.Exists(path)) - { - using var fileStream = File.OpenText(path); - return JsonSerializer.Deserialize(fileStream.BaseStream); - } - - return default; - } -#pragma warning restore CS8603 // Possible null reference return. - - public void Save(string folderPath, string fileName, T content) - { - if (!Directory.Exists(folderPath)) - { - Directory.CreateDirectory(folderPath); - } - - var fileContent = JsonSerializer.Serialize(content); - File.WriteAllText(Path.Combine(folderPath, fileName), fileContent, _encoding); - } - - public void Delete(string folderPath, string fileName) - { - if (fileName != null && File.Exists(Path.Combine(folderPath, fileName))) - { - File.Delete(Path.Combine(folderPath, fileName)); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs deleted file mode 100644 index 92980dfaff..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/IApp.cs +++ /dev/null @@ -1,18 +0,0 @@ -// 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.Common.Services; - -/// -/// Interface for the current application singleton object exposing the API -/// that can be accessed from anywhere in the application. -/// -public interface IApp -{ - /// - /// Gets services registered at the application level. - /// - public T GetService() - where T : class; -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs b/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs deleted file mode 100644 index e4cd2a174b..0000000000 --- a/src/modules/cmdpal/Microsoft.CmdPal.Common/Services/LocalSettingsService.cs +++ /dev/null @@ -1,120 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using Microsoft.CmdPal.Common.Contracts; -using Microsoft.CmdPal.Common.Helpers; -using Microsoft.CmdPal.Common.Models; -using Microsoft.Extensions.Options; -using Windows.Storage; - -namespace Microsoft.CmdPal.Common.Services; - -public class LocalSettingsService : ILocalSettingsService -{ - // TODO! for now, we're hardcoding the path as effectively: - // %localappdata%\CmdPal\LocalSettings.json - private const string DefaultApplicationDataFolder = "CmdPal"; - private const string DefaultLocalSettingsFile = "LocalSettings.json"; - - private readonly IFileService _fileService; - private readonly LocalSettingsOptions _options; - - private readonly string _localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - private readonly string _applicationDataFolder; - private readonly string _localSettingsFile; - - private readonly bool _isMsix; - - private Dictionary _settings; - private bool _isInitialized; - - public LocalSettingsService(IFileService fileService, IOptions options) - { - _isMsix = false; // RuntimeHelper.IsMSIX; - - _fileService = fileService; - _options = options.Value; - - _applicationDataFolder = Path.Combine(_localApplicationData, _options.ApplicationDataFolder ?? DefaultApplicationDataFolder); - _localSettingsFile = _options.LocalSettingsFile ?? DefaultLocalSettingsFile; - - _settings = new Dictionary(); - } - - private async Task InitializeAsync() - { - if (!_isInitialized) - { - _settings = await Task.Run(() => _fileService.Read>(_applicationDataFolder, _localSettingsFile)) ?? new Dictionary(); - - _isInitialized = true; - } - } - - public async Task HasSettingAsync(string key) - { - if (_isMsix) - { - return ApplicationData.Current.LocalSettings.Values.ContainsKey(key); - } - else - { - await InitializeAsync(); - - if (_settings != null) - { - return _settings.ContainsKey(key); - } - } - - return false; - } - - public async Task ReadSettingAsync(string key) - { - if (_isMsix) - { - if (ApplicationData.Current.LocalSettings.Values.TryGetValue(key, out var obj)) - { - return await Json.ToObjectAsync((string)obj); - } - } - else - { - await InitializeAsync(); - - if (_settings != null && _settings.TryGetValue(key, out var obj)) - { - var s = obj.ToString(); - - if (s != null) - { - return await Json.ToObjectAsync(s); - } - } - } - - return default; - } - - public async Task SaveSettingAsync(string key, T value) - { - if (_isMsix) - { - ApplicationData.Current.LocalSettings.Values[key] = await Json.StringifyAsync(value!); - } - else - { - await InitializeAsync(); - - _settings[key] = await Json.StringifyAsync(value!); - - await Task.Run(() => _fileService.Save(_applicationDataFolder, _localSettingsFile, _settings)); - } - } -} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs index 69d38a8655..649e49fbc7 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/AppStateModel.cs @@ -49,7 +49,7 @@ public partial class AppStateModel : ObservableObject // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize(jsonContent, _deserializerOptions); + var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.AppStateModel); Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse"); @@ -73,7 +73,7 @@ public partial class AppStateModel : ObservableObject try { // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, _serializerOptions); + var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.AppStateModel); // Is it valid JSON? if (JsonNode.Parse(settingsJson) is JsonObject newSettings) @@ -89,7 +89,7 @@ public partial class AppStateModel : ObservableObject savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null; } - var serialized = savedSettings.ToJsonString(_serializerOptions); + var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.AppStateModel.Options); File.WriteAllText(FilePath, serialized); // TODO: Instead of just raising the event here, we should @@ -122,18 +122,19 @@ public partial class AppStateModel : ObservableObject return Path.Combine(directory, "state.json"); } - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() }, - }; + // [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, - AllowTrailingCommas = true, - PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, - ReadCommentHandling = JsonCommentHandling.Skip, - }; + // private static readonly JsonSerializerOptions _deserializerOptions = new() + // { + // PropertyNameCaseInsensitive = true, + // IncludeFields = true, + // AllowTrailingCommas = true, + // PreferredObjectCreationHandling = JsonObjectCreationHandling.Populate, + // ReadCommentHandling = JsonCommentHandling.Skip, + // }; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs index a476fba179..06f55ccf02 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandBarViewModel.cs @@ -4,18 +4,14 @@ using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; using Microsoft.CmdPal.UI.ViewModels.Messages; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.System; namespace Microsoft.CmdPal.UI.ViewModels; public partial class CommandBarViewModel : ObservableObject, - IRecipient, - IRecipient + IRecipient { public ICommandBarContext? SelectedItem { @@ -53,20 +49,17 @@ public partial class CommandBarViewModel : ObservableObject, public partial PageViewModel? CurrentPage { get; set; } [ObservableProperty] - public partial ObservableCollection ContextCommands { get; set; } = []; + public partial ObservableCollection ContextMenuStack { get; set; } = []; - private Dictionary? _contextKeybindings; + public ContextMenuStackViewModel? ContextMenu => ContextMenuStack.LastOrDefault(); public CommandBarViewModel() { WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); } public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel; - public void Receive(UpdateItemKeybindingsMessage message) => _contextKeybindings = message.Keys; - private void SetSelectedItem(ICommandBarContext? value) { if (value != null) @@ -111,7 +104,10 @@ public partial class CommandBarViewModel : ObservableObject, if (SelectedItem.MoreCommands.Count() > 1) { ShouldShowContextMenu = true; - ContextCommands = [.. SelectedItem.AllCommands.Where(c => c.ShouldBeVisible)]; + + ContextMenuStack.Clear(); + ContextMenuStack.Add(new ContextMenuStackViewModel(SelectedItem)); + OnPropertyChanged(nameof(ContextMenu)); } else { @@ -125,43 +121,80 @@ public partial class CommandBarViewModel : ObservableObject, // InvokeItemCommand is what this will be in Xaml due to source generator // this comes in when an item in the list is tapped - [RelayCommand] - private void InvokeItem(CommandContextItemViewModel item) => - WeakReferenceMessenger.Default.Send(new(item.Command.Model, item.Model)); + // [RelayCommand] + public ContextKeybindingResult InvokeItem(CommandContextItemViewModel item) => + PerformCommand(item); // this comes in when the primary button is tapped public void InvokePrimaryCommand() { - if (PrimaryCommand != null) - { - WeakReferenceMessenger.Default.Send(new(PrimaryCommand.Command.Model, PrimaryCommand.Model)); - } + PerformCommand(SecondaryCommand); } // this comes in when the secondary button is tapped public void InvokeSecondaryCommand() { - if (SecondaryCommand != null) + PerformCommand(SecondaryCommand); + } + + public ContextKeybindingResult CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var matchedItem = ContextMenu?.CheckKeybinding(ctrl, alt, shift, win, key); + return matchedItem != null ? PerformCommand(matchedItem) : ContextKeybindingResult.Unhandled; + } + + private ContextKeybindingResult PerformCommand(CommandItemViewModel? command) + { + if (command == null) { - WeakReferenceMessenger.Default.Send(new(SecondaryCommand.Command.Model, SecondaryCommand.Model)); + return ContextKeybindingResult.Unhandled; + } + + if (command.HasMoreCommands) + { + ContextMenuStack.Add(new ContextMenuStackViewModel(command)); + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); + return ContextKeybindingResult.KeepOpen; + } + else + { + WeakReferenceMessenger.Default.Send(new(command.Command.Model, command.Model)); + return ContextKeybindingResult.Hide; } } - public bool CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + public bool CanPopContextStack() { - if (_contextKeybindings != null) + return ContextMenuStack.Count > 1; + } + + public void PopContextStack() + { + if (ContextMenuStack.Count > 1) { - // Does the pressed key match any of the keybindings? - var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); - if (_contextKeybindings.TryGetValue(pressedKeyChord, out var item)) - { - // TODO GH #245: This is a bit of a hack, but we need to make sure that the keybindings are updated before we send the message - // so that the correct item is activated. - WeakReferenceMessenger.Default.Send(new(item)); - return true; - } + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); } - return false; + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); + } + + public void ClearContextStack() + { + while (ContextMenuStack.Count > 1) + { + ContextMenuStack.RemoveAt(ContextMenuStack.Count - 1); + } + + OnPropertyChanging(nameof(ContextMenu)); + OnPropertyChanged(nameof(ContextMenu)); } } + +public enum ContextKeybindingResult +{ + Unhandled, + Hide, + KeepOpen, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs index 8634b63278..24dd9e1788 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/CommandItemViewModel.cs @@ -48,7 +48,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa public List MoreCommands { get; private set; } = []; - IEnumerable ICommandBarContext.MoreCommands => MoreCommands; + IEnumerable IContextMenuContext.MoreCommands => MoreCommands; public bool HasMoreCommands => MoreCommands.Count > 0; @@ -187,23 +187,26 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa // use Initialize straight up MoreCommands.ForEach(contextItem => { - contextItem.InitializeProperties(); + contextItem.SlowInitializeProperties(); }); - _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + if (!string.IsNullOrEmpty(model.Command.Name)) { - _itemTitle = Name, - Subtitle = Subtitle, - Command = Command, + _defaultCommandContextItem = new(new CommandContextItem(model.Command!), PageContext) + { + _itemTitle = Name, + Subtitle = Subtitle, + Command = Command, - // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever - }; + // TODO this probably should just be a CommandContextItemViewModel(CommandItemViewModel) ctor, or a copy ctor or whatever + }; - // Only set the icon on the context item for us if our command didn't - // have its own icon - if (!Command.HasIcon) - { - _defaultCommandContextItem._listItemIcon = _listItemIcon; + // Only set the icon on the context item for us if our command didn't + // have its own icon + if (!Command.HasIcon) + { + _defaultCommandContextItem._listItemIcon = _listItemIcon; + } } Initialized |= InitializedState.SelectionInitialized; @@ -398,23 +401,6 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa base.SafeCleanup(); Initialized |= InitializedState.CleanedUp; } - - /// - /// Generates a mapping of key -> command item for this particular item's - /// MoreCommands. (This won't include the primary Command, but it will - /// include the secondary one). This map can be used to quickly check if a - /// shortcut key was pressed - /// - /// a dictionary of KeyChord -> Context commands, for all commands - /// that have a shortcut key set. - internal Dictionary Keybindings() - { - return MoreCommands - .Where(c => c.HasRequestedShortcut) - .ToDictionary( - c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), - c => c); - } } [Flags] diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs index 1939162662..44bcb49cb3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/CreatedExtensionForm.cs @@ -13,12 +13,13 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase { public CreatedExtensionForm(string name, string displayName, string path) { + var serializeString = (string? s) => JsonSerializer.Serialize(s, JsonSerializationContext.Default.String); TemplateJson = CardTemplate; DataJson = $$""" { - "name": {{JsonSerializer.Serialize(name)}}, - "directory": {{JsonSerializer.Serialize(path)}}, - "displayName": {{JsonSerializer.Serialize(displayName)}} + "name": {{serializeString(name)}}, + "directory": {{serializeString(path)}}, + "displayName": {{serializeString(displayName)}} } """; _name = name; @@ -28,13 +29,13 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase public override ICommandResult SubmitForm(string inputs, string data) { - JsonObject? dataInput = JsonNode.Parse(data)?.AsObject(); + var dataInput = JsonNode.Parse(data)?.AsObject(); if (dataInput == null) { return CommandResult.KeepOpen(); } - string verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty; + var verb = dataInput["x"]?.AsValue()?.ToString() ?? string.Empty; return verb switch { "sln" => OpenSolution(), @@ -47,7 +48,7 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase private ICommandResult OpenSolution() { string[] parts = [_path, _name, $"{_name}.sln"]; - string pathToSolution = Path.Combine(parts); + var pathToSolution = Path.Combine(parts); ShellHelpers.OpenInShell(pathToSolution); return CommandResult.Hide(); } @@ -55,7 +56,7 @@ internal sealed partial class CreatedExtensionForm : NewExtensionFormBase private ICommandResult OpenDirectory() { string[] parts = [_path, _name]; - string pathToDir = Path.Combine(parts); + var pathToDir = Path.Combine(parts); ShellHelpers.OpenInShell(pathToDir); return CommandResult.Hide(); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs index 698faf0335..aca45f3494 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/NewExtensionForm.cs @@ -194,9 +194,8 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase Directory.Delete(tempDir, true); } - private string FormatJsonString(string str) - { + private string FormatJsonString(string str) => + // Escape the string for JSON - return JsonSerializer.Serialize(str); - } + JsonSerializer.Serialize(str, JsonSerializationContext.Default.String); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs index e1bbe0b604..5af1959cd6 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContentFormViewModel.cs @@ -51,15 +51,16 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference JsonSerializer.Serialize(s, JsonSerializationContext.Default.String); // todo: we could probably stick Card.Errors in there too var dataJson = $$""" { - "error_message": {{JsonSerializer.Serialize(e.Message)}}, - "error_stack": {{JsonSerializer.Serialize(e.StackTrace)}}, - "inner_exception": {{JsonSerializer.Serialize(e.InnerException?.Message)}}, - "template_json": {{JsonSerializer.Serialize(TemplateJson)}}, - "data_json": {{JsonSerializer.Serialize(DataJson)}} + "error_message": {{serializeString(e.Message)}}, + "error_stack": {{serializeString(e.StackTrace)}}, + "inner_exception": {{serializeString(e.InnerException?.Message)}}, + "template_json": {{serializeString(TemplateJson)}}, + "data_json": {{serializeString(DataJson)}} } """; var cardJson = template.Expand(dataJson); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs new file mode 100644 index 0000000000..2b16bd8f47 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ContextMenuStackViewModel.cs @@ -0,0 +1,82 @@ +// 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.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Microsoft.CmdPal.UI.ViewModels.Messages; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; + +namespace Microsoft.CmdPal.UI.ViewModels; + +public partial class ContextMenuStackViewModel : ObservableObject +{ + [ObservableProperty] + public partial ObservableCollection FilteredItems { get; set; } + + private readonly IContextMenuContext _context; + private string _lastSearchText = string.Empty; + + // private Dictionary? _contextKeybindings; + public ContextMenuStackViewModel(IContextMenuContext context) + { + _context = context; + FilteredItems = [.. context.AllCommands]; + } + + public void SetSearchText(string searchText) + { + if (searchText == _lastSearchText) + { + return; + } + + _lastSearchText = searchText; + + var commands = _context.AllCommands.Where(c => c.ShouldBeVisible); + if (string.IsNullOrEmpty(searchText)) + { + ListHelpers.InPlaceUpdateList(FilteredItems, commands); + return; + } + + var newResults = ListHelpers.FilterList(commands, searchText, ScoreContextCommand); + ListHelpers.InPlaceUpdateList(FilteredItems, newResults); + } + + private static int ScoreContextCommand(string query, CommandContextItemViewModel item) + { + if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query)) + { + return 1; + } + + if (string.IsNullOrEmpty(item.Title)) + { + return 0; + } + + var nameMatch = StringMatcher.FuzzySearch(query, item.Title); + + var descriptionMatch = StringMatcher.FuzzySearch(query, item.Subtitle); + + return new[] { nameMatch.Score, (descriptionMatch.Score - 4) / 2, 0 }.Max(); + } + + public CommandContextItemViewModel? CheckKeybinding(bool ctrl, bool alt, bool shift, bool win, VirtualKey key) + { + var keybindings = _context.Keybindings(); + if (keybindings != null) + { + // Does the pressed key match any of the keybindings? + var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrl, alt, shift, win, key, 0); + if (keybindings.TryGetValue(pressedKeyChord, out var item)) + { + return item; + } + } + + return null; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index bcafb0235e..83dc4018f9 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -344,8 +344,6 @@ public partial class ListViewModel : PageViewModel, IDisposable { WeakReferenceMessenger.Default.Send(new(item)); - WeakReferenceMessenger.Default.Send(new(item.Keybindings())); - if (ShowDetails && item.HasDetails) { WeakReferenceMessenger.Default.Send(new(item.Details)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs similarity index 59% rename from src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs rename to src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs index 2054d3d8fd..3df48ec3a0 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateItemKeybindingsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/TryCommandKeybindingMessage.cs @@ -2,8 +2,11 @@ // 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.CommandPalette.Extensions; +using Windows.System; namespace Microsoft.CmdPal.UI.ViewModels.Messages; -public record UpdateItemKeybindingsMessage(Dictionary? Keys); +public record TryCommandKeybindingMessage(bool Ctrl, bool Alt, bool Shift, bool Win, VirtualKey Key) +{ + public bool Handled { get; set; } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs index 0a540c7408..929b5995c5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/UpdateCommandBarMessage.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.ComponentModel; +using Microsoft.CommandPalette.Extensions; namespace Microsoft.CmdPal.UI.ViewModels.Messages; @@ -13,22 +14,42 @@ public record UpdateCommandBarMessage(ICommandBarContext? ViewModel) { } -// Represents everything the command bar needs to know about to show command -// buttons at the bottom. -// -// This is implemented by both ListItemViewModel and ContentPageViewModel, -// the two things with sub-commands. -public interface ICommandBarContext : INotifyPropertyChanged +public interface IContextMenuContext : INotifyPropertyChanged { public IEnumerable MoreCommands { get; } public bool HasMoreCommands { get; } + public List AllCommands { get; } + + /// + /// Generates a mapping of key -> command item for this particular item's + /// MoreCommands. (This won't include the primary Command, but it will + /// include the secondary one). This map can be used to quickly check if a + /// shortcut key was pressed + /// + /// a dictionary of KeyChord -> Context commands, for all commands + /// that have a shortcut key set. + public Dictionary Keybindings() + { + return MoreCommands + .Where(c => c.HasRequestedShortcut) + .ToDictionary( + c => c.RequestedShortcut ?? new KeyChord(0, 0, 0), + c => c); + } +} + +// Represents everything the command bar needs to know about to show command +// buttons at the bottom. +// +// This is implemented by both ListItemViewModel and ContentPageViewModel, +// the two things with sub-commands. +public interface ICommandBarContext : IContextMenuContext +{ public string SecondaryCommandName { get; } public CommandItemViewModel? PrimaryCommand { get; } public CommandItemViewModel? SecondaryCommand { get; } - - public List AllCommands { get; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj index 342dbe251c..8057bb09be 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj @@ -1,5 +1,7 @@  + + enable enable @@ -67,4 +69,15 @@ + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs index 576ea08140..6d59aa66b4 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs @@ -11,7 +11,7 @@ using Windows.Foundation.Collections; namespace Microsoft.CmdPal.UI.ViewModels.Models; -public class ExtensionService : IExtensionService, IDisposable +public partial class ExtensionService : IExtensionService, IDisposable { public event TypedEventHandler>? OnExtensionAdded; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs index ed15268507..83644c8d44 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionWrapper.cs @@ -12,6 +12,7 @@ using Windows.Win32; using Windows.Win32.System.Com; using WinRT; +// [assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling] namespace Microsoft.CmdPal.UI.ViewModels.Models; public class ExtensionWrapper : IExtensionWrapper @@ -113,25 +114,36 @@ public class ExtensionWrapper : IExtensionWrapper // -2147467262: E_NOINTERFACE // -2147024893: E_PATH_NOT_FOUND var guid = typeof(IExtension).GUID; - var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out var extensionObj); - if (hr.Value == -2147024893) + unsafe { - Logger.LogDebug($"Failed to find {ExtensionDisplayName}: {hr}. It may have been uninstalled or deleted."); + var hr = PInvoke.CoCreateInstance(Guid.Parse(ExtensionClassId), null, CLSCTX.CLSCTX_LOCAL_SERVER, guid, out var extensionObj); - // We don't really need to throw this exception. - // We'll just return out nothing. - return; + if (hr.Value == -2147024893) + { + Logger.LogDebug($"Failed to find {ExtensionDisplayName}: {hr}. It may have been uninstalled or deleted."); + + // We don't really need to throw this exception. + // We'll just return out nothing. + return; + } + + extensionPtr = Marshal.GetIUnknownForObject((nint)extensionObj); + if (hr < 0) + { + Logger.LogDebug($"Failed to instantiate {ExtensionDisplayName}: {hr}"); + Marshal.ThrowExceptionForHR(hr); + } + + // extensionPtr = Marshal.GetIUnknownForObject(extensionObj); + extensionPtr = (nint)extensionObj; + if (hr < 0) + { + Marshal.ThrowExceptionForHR(hr); + } + + _extensionObject = MarshalInterface.FromAbi(extensionPtr); } - - extensionPtr = Marshal.GetIUnknownForObject(extensionObj); - if (hr < 0) - { - Logger.LogDebug($"Failed to instantiate {ExtensionDisplayName}: {hr}"); - Marshal.ThrowExceptionForHR(hr); - } - - _extensionObject = MarshalInterface.FromAbi(extensionPtr); } finally { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json new file mode 100644 index 0000000000..59fa7259c4 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/NativeMethods.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "allowMarshaling": false +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs index a082f0acd2..126efa83ca 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/PageViewModel.cs @@ -99,7 +99,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext //// Run on background thread from ListPage.xaml.cs [RelayCommand] - private Task InitializeAsync() + internal Task InitializeAsync() { // TODO: We may want a SemaphoreSlim lock here. @@ -182,6 +182,7 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext return; // throw? } + var updateProperty = true; switch (propertyName) { case nameof(Name): @@ -198,9 +199,21 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext case nameof(Icon): this.Icon = new(model.Icon); break; + default: + updateProperty = false; + break; } - UpdateProperty(propertyName); + // GH #38829: If we always UpdateProperty here, then there's a possible + // race condition, where we raise the PropertyChanged(SearchText) + // before the subclass actually retrieves the new SearchText from the + // model. In that race situation, if the UI thread handles the + // PropertyChanged before ListViewModel fetches the SearchText, it'll + // think that the old search text is the _new_ value. + if (updateProperty) + { + UpdateProperty(propertyName); + } } public new void ShowException(Exception ex, string? extensionHint = null) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs index 9e971ae510..c740341c7a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/RecentCommandsManager.cs @@ -10,7 +10,7 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class RecentCommandsManager : ObservableObject { [JsonInclude] - private List History { get; set; } = []; + internal List History { get; set; } = []; public RecentCommandsManager() { diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index 27dd119b48..ae97849f7a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -93,7 +93,7 @@ public partial class SettingsModel : ObservableObject // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); - var loaded = JsonSerializer.Deserialize(jsonContent, _deserializerOptions); + var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel); Debug.WriteLine(loaded != null ? "Loaded settings file" : "Failed to parse"); @@ -117,7 +117,7 @@ public partial class SettingsModel : ObservableObject try { // Serialize the main dictionary to JSON and save it to the file - var settingsJson = JsonSerializer.Serialize(model, _serializerOptions); + var settingsJson = JsonSerializer.Serialize(model, JsonSerializationContext.Default.SettingsModel); // Is it valid JSON? if (JsonNode.Parse(settingsJson) is JsonObject newSettings) @@ -133,7 +133,7 @@ public partial class SettingsModel : ObservableObject savedSettings[item.Key] = item.Value != null ? item.Value.DeepClone() : null; } - var serialized = savedSettings.ToJsonString(_serializerOptions); + var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); File.WriteAllText(FilePath, serialized); // TODO: Instead of just raising the event here, we should @@ -166,19 +166,34 @@ public partial class SettingsModel : ObservableObject return Path.Combine(directory, "settings.json"); } - private static readonly JsonSerializerOptions _serializerOptions = new() - { - WriteIndented = true, - Converters = { new JsonStringEnumConverter() }, - }; + // [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, + // }; +} - 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(AppStateModel))] +[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/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs index 043598196b..d86831d0a1 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs @@ -109,9 +109,12 @@ public partial class ShellViewModel(IServiceProvider _serviceProvider, TaskSched // TODO GH #239 switch back when using the new MD text block // _ = _queue.EnqueueAsync(() => _ = Task.Factory.StartNew( - () => + async () => { - var result = (bool)viewModel.InitializeCommand.ExecutionTask.GetResultOrDefault()!; + // bool f = await viewModel.InitializeCommand.ExecutionTask.; + // var result = viewModel.InitializeCommand.ExecutionTask.GetResultOrDefault()!; + // var result = viewModel.InitializeCommand.ExecutionTask.GetResultOrDefault()!; + var result = await viewModel.InitializeAsync(); CurrentPage = viewModel; // result ? viewModel : null; ////LoadedState = result ? ViewModelLoadedState.Loaded : ViewModelLoadedState.Error; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml index 896ee11d0d..203009f763 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml @@ -225,27 +225,42 @@ ToolTipService.ToolTip="Ctrl+K" Visibility="{x:Bind ViewModel.ShouldShowContextMenu, Mode=OneWay}"> - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index e8ca659097..f079f4b513 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -18,9 +18,10 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class CommandBar : UserControl, IRecipient, + IRecipient, ICurrentPageAware { - public CommandBarViewModel ViewModel { get; set; } = new(); + public CommandBarViewModel ViewModel { get; } = new(); public PageViewModel? CurrentPageViewModel { @@ -38,6 +39,9 @@ public sealed partial class CommandBar : UserControl, // RegisterAll isn't AOT compatible WeakReferenceMessenger.Default.Register(this); + WeakReferenceMessenger.Default.Register(this); + + ViewModel.PropertyChanged += ViewModel_PropertyChanged; } public void Receive(OpenContextMenuMessage message) @@ -52,8 +56,41 @@ public sealed partial class CommandBar : UserControl, ShowMode = FlyoutShowMode.Standard, }; MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); - CommandsDropdown.SelectedIndex = 0; - CommandsDropdown.Focus(FocusState.Programmatic); + UpdateUiForStackChange(); + } + + public void Receive(TryCommandKeybindingMessage msg) + { + if (!ViewModel.ShouldShowContextMenu) + { + return; + } + + var result = ViewModel?.CheckKeybinding(msg.Ctrl, msg.Alt, msg.Shift, msg.Win, msg.Key); + + if (result == ContextKeybindingResult.Hide) + { + msg.Handled = true; + } + else if (result == ContextKeybindingResult.KeepOpen) + { + if (!MoreCommandsButton.Flyout.IsOpen) + { + var options = new FlyoutShowOptions + { + ShowMode = FlyoutShowMode.Standard, + }; + MoreCommandsButton.Flyout.ShowAt(MoreCommandsButton, options); + } + + UpdateUiForStackChange(); + + msg.Handled = true; + } + else if (result == ContextKeybindingResult.Unhandled) + { + msg.Handled = false; + } } [System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0051:Remove unused private members", Justification = "VS has a tendency to delete XAML bound methods over-aggressively")] @@ -88,8 +125,14 @@ public sealed partial class CommandBar : UserControl, { if (e.ClickedItem is CommandContextItemViewModel item) { - ViewModel?.InvokeItemCommand.Execute(item); - MoreCommandsButton.Flyout.Hide(); + if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) + { + MoreCommandsButton.Flyout.Hide(); + } + else + { + UpdateUiForStackChange(); + } } } @@ -106,9 +149,136 @@ public sealed partial class CommandBar : UserControl, var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); - if (ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key) ?? false) + var result = ViewModel?.CheckKeybinding(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + + if (result == ContextKeybindingResult.Hide) + { + e.Handled = true; + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + else if (result == ContextKeybindingResult.KeepOpen) { e.Handled = true; } + else if (result == ContextKeybindingResult.Unhandled) + { + e.Handled = false; + } + } + + private void Flyout_Opened(object sender, object e) + { + UpdateUiForStackChange(); + } + + private void Flyout_Closing(FlyoutBase sender, FlyoutBaseClosingEventArgs args) + { + ViewModel?.ClearContextStack(); + WeakReferenceMessenger.Default.Send(); + } + + private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + var prop = e.PropertyName; + if (prop == nameof(ViewModel.ContextMenu)) + { + UpdateUiForStackChange(); + } + } + + private void ContextFilterBox_TextChanged(object sender, TextChangedEventArgs e) + { + ViewModel.ContextMenu?.SetSearchText(ContextFilterBox.Text); + + if (CommandsDropdown.SelectedIndex == -1) + { + CommandsDropdown.SelectedIndex = 0; + } + } + + private void ContextFilterBox_KeyDown(object sender, KeyRoutedEventArgs e) + { + var ctrlPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Control).HasFlag(CoreVirtualKeyStates.Down); + var altPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Menu).HasFlag(CoreVirtualKeyStates.Down); + var shiftPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down); + var winPressed = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.LeftWindows).HasFlag(CoreVirtualKeyStates.Down) || + InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.RightWindows).HasFlag(CoreVirtualKeyStates.Down); + + if (e.Key == VirtualKey.Enter) + { + if (CommandsDropdown.SelectedItem is CommandContextItemViewModel item) + { + if (ViewModel?.InvokeItem(item) == ContextKeybindingResult.Hide) + { + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + else + { + UpdateUiForStackChange(); + } + + e.Handled = true; + } + } + else if (e.Key == VirtualKey.Escape || + (e.Key == VirtualKey.Left && altPressed)) + { + if (ViewModel.CanPopContextStack()) + { + ViewModel.PopContextStack(); + UpdateUiForStackChange(); + } + else + { + MoreCommandsButton.Flyout.Hide(); + WeakReferenceMessenger.Default.Send(); + } + + e.Handled = true; + } + + CommandsDropdown_KeyDown(sender, e); + } + + private void ContextFilterBox_PreviewKeyDown(object sender, KeyRoutedEventArgs e) + { + if (e.Key == VirtualKey.Up) + { + // navigate previous + if (CommandsDropdown.SelectedIndex > 0) + { + CommandsDropdown.SelectedIndex--; + } + else + { + CommandsDropdown.SelectedIndex = CommandsDropdown.Items.Count - 1; + } + + e.Handled = true; + } + else if (e.Key == VirtualKey.Down) + { + // navigate next + if (CommandsDropdown.SelectedIndex < CommandsDropdown.Items.Count - 1) + { + CommandsDropdown.SelectedIndex++; + } + else + { + CommandsDropdown.SelectedIndex = 0; + } + + e.Handled = true; + } + } + + private void UpdateUiForStackChange() + { + ContextFilterBox.Text = string.Empty; + ViewModel.ContextMenu?.SetSearchText(string.Empty); + CommandsDropdown.SelectedIndex = 0; + ContextFilterBox.Focus(FocusState.Programmatic); } } 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 d939381fb0..c868e3dd5e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/SearchBar.xaml.cs @@ -8,8 +8,6 @@ using CommunityToolkit.WinUI; using Microsoft.CmdPal.UI.ViewModels; using Microsoft.CmdPal.UI.ViewModels.Messages; using Microsoft.CmdPal.UI.Views; -using Microsoft.CommandPalette.Extensions; -using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.UI.Dispatching; using Microsoft.UI.Input; using Microsoft.UI.Xaml; @@ -23,7 +21,6 @@ namespace Microsoft.CmdPal.UI.Controls; public sealed partial class SearchBar : UserControl, IRecipient, IRecipient, - IRecipient, ICurrentPageAware { private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread(); @@ -34,8 +31,6 @@ public sealed partial class SearchBar : UserControl, private readonly DispatcherQueueTimer _debounceTimer = DispatcherQueue.GetForCurrentThread().CreateTimer(); private bool _isBackspaceHeld; - private Dictionary? _keyBindings; - public PageViewModel? CurrentPageViewModel { get => (PageViewModel?)GetValue(CurrentPageViewModelProperty); @@ -74,7 +69,6 @@ public sealed partial class SearchBar : UserControl, this.InitializeComponent(); WeakReferenceMessenger.Default.Register(this); WeakReferenceMessenger.Default.Register(this); - WeakReferenceMessenger.Default.Register(this); } public void ClearSearch() @@ -173,17 +167,14 @@ public sealed partial class SearchBar : UserControl, WeakReferenceMessenger.Default.Send(new()); } - if (_keyBindings != null) + if (!e.Handled) { - // Does the pressed key match any of the keybindings? - var pressedKeyChord = KeyChordHelpers.FromModifiers(ctrlPressed, altPressed, shiftPressed, winPressed, (int)e.Key, 0); - if (_keyBindings.TryGetValue(pressedKeyChord, out var item)) - { - // TODO GH #245: This is a bit of a hack, but we need to make sure that the keybindings are updated before we send the message - // so that the correct item is activated. - WeakReferenceMessenger.Default.Send(new(item)); - e.Handled = true; - } + // The CommandBar is responsible for handling all the item keybindings, + // since the bound context item may need to then show another + // context menu + TryCommandKeybindingMessage msg = new(ctrlPressed, altPressed, shiftPressed, winPressed, e.Key); + WeakReferenceMessenger.Default.Send(msg); + e.Handled = msg.Handled; } } @@ -302,10 +293,5 @@ public sealed partial class SearchBar : UserControl, public void Receive(GoHomeMessage message) => ClearSearch(); - public void Receive(FocusSearchBoxMessage message) => this.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); - - public void Receive(UpdateItemKeybindingsMessage message) - { - _keyBindings = message.Keys; - } + public void Receive(FocusSearchBoxMessage message) => FilterBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic); } 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 bc0999ca02..869b048dbd 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -187,8 +187,6 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, WeakReferenceMessenger.Default.Send(new(null)); - WeakReferenceMessenger.Default.Send(new(null)); - var isMainPage = command is MainListPage; // Construct our ViewModel of the appropriate type and pass it the UI Thread context. 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 51b33f8ede..6c63eeff16 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 @@ -394,6 +394,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Behavior + + Search commands... + Show system tray icon diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs index 9b3f54a21f..5da419cd40 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/AddBookmarkForm.cs @@ -34,7 +34,7 @@ internal sealed partial class AddBookmarkForm : FormContent "style": "text", "id": "name", "label": "{{Resources.bookmarks_form_name_label}}", - "value": {{JsonSerializer.Serialize(name)}}, + "value": {{JsonSerializer.Serialize(name, BookmarkSerializationContext.Default.String)}}, "isRequired": true, "errorMessage": "{{Resources.bookmarks_form_name_required}}" }, @@ -42,7 +42,7 @@ internal sealed partial class AddBookmarkForm : FormContent "type": "Input.Text", "style": "text", "id": "bookmark", - "value": {{JsonSerializer.Serialize(url)}}, + "value": {{JsonSerializer.Serialize(url, BookmarkSerializationContext.Default.String)}}, "label": "{{Resources.bookmarks_form_bookmark_label}}", "isRequired": true, "errorMessage": "{{Resources.bookmarks_form_bookmark_required}}" diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs new file mode 100644 index 0000000000..9730bf214d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/BookmarkSerializationContext.cs @@ -0,0 +1,20 @@ +// 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.Text.Json.Serialization; + +namespace Microsoft.CmdPal.Ext.Bookmarks; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(BookmarkData))] +[JsonSerializable(typeof(Bookmarks))] +[JsonSerializable(typeof(List), TypeInfoPropertyName = "BookmarkList")] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class BookmarkSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs index 7c3a1dd1e0..8f2e257782 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Bookmarks.cs @@ -28,7 +28,7 @@ public sealed class Bookmarks if (!string.IsNullOrEmpty(jsonStringReading)) { - data = JsonSerializer.Deserialize(jsonStringReading, _jsonOptions) ?? new Bookmarks(); + data = JsonSerializer.Deserialize(jsonStringReading, BookmarkSerializationContext.Default.Bookmarks) ?? new Bookmarks(); } } @@ -37,7 +37,7 @@ public sealed class Bookmarks public static void WriteToFile(string path, Bookmarks data) { - var jsonString = JsonSerializer.Serialize(data, _jsonOptions); + var jsonString = JsonSerializer.Serialize(data, BookmarkSerializationContext.Default.Bookmarks); File.WriteAllText(BookmarksCommandProvider.StateJsonPath(), jsonString); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj index 7bbf1efc5f..40c3cca9f2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Bookmark/Microsoft.CmdPal.Ext.Bookmarks.csproj @@ -1,5 +1,6 @@  + Microsoft.CmdPal.Ext.Bookmarks enable @@ -16,7 +17,7 @@ - + Resources.resx @@ -24,7 +25,7 @@ True - + PreserveNewest @@ -39,5 +40,5 @@ PublicResXFileCodeGenerator - + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs index 50ff346103..f4b6089229 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Helpers/NativeMethods.cs @@ -17,7 +17,7 @@ internal static class NativeMethods internal INPUTTYPE type; internal InputUnion data; - internal static int Size => Marshal.SizeOf(typeof(INPUT)); + internal static int Size => Marshal.SizeOf(); } [StructLayout(LayoutKind.Explicit)] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj index 1d583e279b..774753f31d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.ClipboardHistory/Microsoft.CmdPal.Ext.ClipboardHistory.csproj @@ -1,5 +1,6 @@  + Microsoft.CmdPal.Ext.ClipboardHistory $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs index 9afb39b6f1..486eeaa8b5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Helpers/NetworkConnectionProperties.cs @@ -311,14 +311,14 @@ internal sealed class NetworkConnectionProperties { switch (property) { - case string: - return string.IsNullOrWhiteSpace(property) ? string.Empty : $"\n\n{title}{property}"; + case string str: + return string.IsNullOrWhiteSpace(str) ? string.Empty : $"\n\n{title}{str}"; case List listString: - return listString.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return listString.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", listString)}"; case List listIP: - return listIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return listIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", listIP)}"; case IPAddressCollection collectionIP: - return collectionIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", property)}"; + return collectionIP.Count == 0 ? string.Empty : $"\n\n{title}{string.Join("\n\n* ", collectionIP)}"; case null: return string.Empty; default: diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj index 4c619bc5e5..48e9d6ba82 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.System/Microsoft.CmdPal.Ext.System.csproj @@ -1,5 +1,6 @@  + enable Microsoft.CmdPal.Ext.System diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs index 84a1c249ba..d381c1e4cc 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/HistoryItem.cs @@ -13,5 +13,5 @@ public class HistoryItem(string searchString, DateTime timestamp) public DateTime Timestamp { get; private set; } = timestamp; - public string ToJson() => JsonSerializer.Serialize(this); + public string ToJson() => JsonSerializer.Serialize(this, WebSearchJsonSerializationContext.Default.HistoryItem); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs index b83ba47a73..8a39bca35b 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs @@ -80,7 +80,7 @@ public class SettingsManager : JsonSettingsManager if (File.Exists(_historyPath)) { var existingContent = File.ReadAllText(_historyPath); - historyItems = JsonSerializer.Deserialize>(existingContent) ?? []; + historyItems = JsonSerializer.Deserialize>(existingContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? []; } else { @@ -101,7 +101,7 @@ public class SettingsManager : JsonSettingsManager } // Serialize the updated list back to JSON and save it - var historyJson = JsonSerializer.Serialize(historyItems); + var historyJson = JsonSerializer.Serialize(historyItems, WebSearchJsonSerializationContext.Default.ListHistoryItem); File.WriteAllText(_historyPath, historyJson); } catch (Exception ex) @@ -121,7 +121,7 @@ public class SettingsManager : JsonSettingsManager // Read and deserialize JSON into a list of HistoryItem objects var fileContent = File.ReadAllText(_historyPath); - var historyItems = JsonSerializer.Deserialize>(fileContent) ?? []; + var historyItems = JsonSerializer.Deserialize>(fileContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? []; // Convert each HistoryItem to a ListItem var listItems = new List(); @@ -198,7 +198,7 @@ public class SettingsManager : JsonSettingsManager if (File.Exists(_historyPath)) { var existingContent = File.ReadAllText(_historyPath); - var historyItems = JsonSerializer.Deserialize>(existingContent) ?? []; + var historyItems = JsonSerializer.Deserialize>(existingContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? []; // Check if trimming is needed if (historyItems.Count > maxHistoryItems) @@ -207,7 +207,7 @@ public class SettingsManager : JsonSettingsManager historyItems = historyItems.Skip(historyItems.Count - maxHistoryItems).ToList(); // Save the trimmed history back to the file - var trimmedHistoryJson = JsonSerializer.Serialize(historyItems); + var trimmedHistoryJson = JsonSerializer.Serialize(historyItems, WebSearchJsonSerializationContext.Default.ListHistoryItem); File.WriteAllText(_historyPath, trimmedHistoryJson); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.cs new file mode 100644 index 0000000000..443c9cdf40 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/WebSearchJsonSerializationContext.cs @@ -0,0 +1,20 @@ +// 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.Text.Json.Serialization; +using Microsoft.CmdPal.Ext.WebSearch.Helpers; + +namespace Microsoft.CmdPal.Ext.WebSearch; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(HistoryItem))] +[JsonSerializable(typeof(List))] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class WebSearchJsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj index 3ddedfcd71..3fbaeb30a7 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Microsoft.CmdPal.Ext.WebSearch.csproj @@ -1,5 +1,6 @@  + Microsoft.CmdPal.Ext.WebSearch enable diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs index f99f0ce3b3..18b2548ce2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Helpers/JsonSettingsListHelper.cs @@ -4,10 +4,8 @@ using System; using System.IO; -using System.Linq; using System.Reflection; using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.CmdPal.Ext.WindowsSettings.Helpers; @@ -21,6 +19,8 @@ internal static class JsonSettingsListHelper /// private const string _settingsFile = "WindowsSettings.json"; + private const string _extTypeNamespace = "Microsoft.CmdPal.Ext.WindowsSettings"; + private static readonly JsonSerializerOptions _serializerOptions = new() { }; @@ -32,7 +32,6 @@ internal static class JsonSettingsListHelper internal static Classes.WindowsSettings ReadAllPossibleSettings() { var assembly = Assembly.GetExecutingAssembly(); - var type = assembly.GetTypes().FirstOrDefault(x => x.Name == nameof(WindowsSettingsCommandsProvider)); #pragma warning disable CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. Classes.WindowsSettings? settings = null; @@ -40,7 +39,7 @@ internal static class JsonSettingsListHelper try { - var resourceName = $"{type?.Namespace}.{_settingsFile}"; + var resourceName = $"{_extTypeNamespace}.{_settingsFile}"; using var stream = assembly.GetManifestResourceStream(resourceName); if (stream is null) { @@ -48,12 +47,13 @@ internal static class JsonSettingsListHelper } var options = _serializerOptions; - options.Converters.Add(new JsonStringEnumConverter()); + // Why we need it? I don't see any enum usage in WindowsSettings + // options.Converters.Add(new JsonStringEnumConverter()); using var reader = new StreamReader(stream); var text = reader.ReadToEnd(); - settings = JsonSerializer.Deserialize(text, options); + settings = JsonSerializer.Deserialize(text, WindowsSettingsJsonSerializationContext.Default.WindowsSettings); } #pragma warning disable CS0168 catch (Exception exception) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs new file mode 100644 index 0000000000..e267e8f52e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/JsonSerializationContext.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace Microsoft.CmdPal.Ext.WindowsSettings; + +[JsonSerializable(typeof(float))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(Classes.WindowsSettings))] +[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)] +internal sealed partial class WindowsSettingsJsonSerializationContext : JsonSerializerContext +{ +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj index d44e907606..9f2d72bc8a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WindowsSettings/Microsoft.CmdPal.Ext.WindowsSettings.csproj @@ -1,5 +1,6 @@  + Microsoft.CmdPal.Ext.WindowsSettings $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs index 373a1f7891..2fc1218bd7 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/EvilSamplesPage.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Windows.System; namespace SamplePagesExtension; @@ -76,7 +77,136 @@ public partial class EvilSamplesPage : ListPage { Body = "This is a test for GH#512. If it doesn't appear immediately, it's likely InvokeCommand is happening on the UI thread.", }, - } + }, + + // More edge cases than truly evil + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "anonymous command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem("nested...") + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 + { + Title = "I'm a second command", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), + }, + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + new ListItem( + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 + { + Title = "noop secondary command test", + Subtitle = "Try pressing Ctrl+1 with me selected", + Icon = new IconInfo("\uE712"), // "More" dots + MoreCommands = [ + new CommandContextItem(new NoOpCommand()) + { + Title = "We can go deeper...", + Icon = new IconInfo("\uF148"), + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], + } + ], + }, + ]; public EvilSamplesPage() diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs index 954a79ce04..3cf987e417 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleListPage.cs @@ -69,62 +69,47 @@ internal sealed partial class SampleListPage : ListPage }, new ListItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Primary command invoked", - State = MessageState.Info, - }); - t.Show(); - }) - { - Result = CommandResult.KeepOpen(), - Icon = new IconInfo("\uE712"), - }) + new ToastCommand("Primary command invoked", MessageState.Info) { Name = "Primary command", Icon = new IconInfo("\uF146") }) // dial 1 { Title = "You can add context menu items too. Press Ctrl+k", Subtitle = "Try pressing Ctrl+1 with me selected", - Icon = new IconInfo("\uE712"), + Icon = new IconInfo("\uE712"), // "More" dots MoreCommands = [ new CommandContextItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Secondary command invoked", - State = MessageState.Warning, - }); - t.Show(); - }) - { - Name = "Secondary command", - Icon = new IconInfo("\uF147"), // Dial 2 - Result = CommandResult.KeepOpen(), - }) + new ToastCommand("Secondary command invoked", MessageState.Warning) { Name = "Secondary command", Icon = new IconInfo("\uF147") }) // dial 2 { Title = "I'm a second command", RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number1), }, new CommandContextItem( - new AnonymousCommand(() => - { - var t = new ToastStatusMessage(new StatusMessage() - { - Message = "Third command invoked", - State = MessageState.Error, - }); - t.Show(); - }) - { - Name = "Do it", - Icon = new IconInfo("\uF148"), // dial 3 - Result = CommandResult.KeepOpen(), - }) + new ToastCommand("Third command invoked", MessageState.Error) { Name = "Do 3", Icon = new IconInfo("\uF148") }) // dial 3 { - Title = "A third command too", + Title = "We can go deeper...", Icon = new IconInfo("\uF148"), RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.Number2), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested A invoked") { Name = "Do it", Icon = new IconInfo("A") }) + { + Title = "Nested A", + RequestedShortcut = KeyChordHelpers.FromModifiers(alt: true, vkey: VirtualKey.A), + }, + + new CommandContextItem( + new ToastCommand("Nested B invoked") { Name = "Do it", Icon = new IconInfo("B") }) + { + Title = "Nested B...", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + MoreCommands = [ + new CommandContextItem( + new ToastCommand("Nested C invoked") { Name = "Do it" }) + { + Title = "You get it", + RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, vkey: VirtualKey.B), + } + ], + }, + ], } ], }, @@ -183,7 +168,6 @@ internal sealed partial class SampleListPage : ListPage { Title = "Get the name of the Foreground window", }, - ]; } } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs new file mode 100644 index 0000000000..dfbeb5225a --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/ToastCommand.cs @@ -0,0 +1,23 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class ToastCommand(string message, MessageState state = MessageState.Info) : InvokableCommand +{ + public override ICommandResult Invoke() + { + var t = new ToastStatusMessage(new StatusMessage() + { + Message = message, + State = state, + }); + t.Show(); + + return CommandResult.KeepOpen(); + } +} diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs index 6fbc734560..6d92cdc146 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/JsonSerializationContext.cs @@ -16,7 +16,6 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Dictionary), TypeInfoPropertyName = "Dictionary")] [JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true)] -[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")] internal partial class JsonSerializationContext : JsonSerializerContext { } diff --git a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs index 91d715b509..ffd20643aa 100644 --- a/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs +++ b/src/modules/cmdpal/extensionsdk/Microsoft.CommandPalette.Extensions.Toolkit/ListItem.cs @@ -61,4 +61,9 @@ public partial class ListItem : CommandItem, IListItem : base(command) { } + + public ListItem() + : base() + { + } } diff --git a/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs b/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs index 82b238993d..3f0feaaae3 100644 --- a/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs +++ b/src/modules/colorPicker/ColorPickerUI/Helpers/ColorRepresentationHelper.cs @@ -243,6 +243,40 @@ namespace ColorPicker.Helpers $", {chromaticityB.ToString(CultureInfo.InvariantCulture)})"; } + /// + /// Returns a representation of a Oklab color + /// + /// The for the Oklab color presentation + /// A representation of a Oklab color + private static string ColorToOklab(Color color) + { + var (lightness, chromaticityA, chromaticityB) = ColorFormatHelper.ConvertToOklabColor(color); + lightness = Math.Round(lightness, 2); + chromaticityA = Math.Round(chromaticityA, 2); + chromaticityB = Math.Round(chromaticityB, 2); + + return $"oklab({lightness.ToString(CultureInfo.InvariantCulture)}" + + $", {chromaticityA.ToString(CultureInfo.InvariantCulture)}" + + $", {chromaticityB.ToString(CultureInfo.InvariantCulture)})"; + } + + /// + /// Returns a representation of a CIE LCh color + /// + /// The for the CIE LCh color presentation + /// A representation of a CIE LCh color + private static string ColorToOklch(Color color) + { + var (lightness, chroma, hue) = ColorFormatHelper.ConvertToOklchColor(color); + lightness = Math.Round(lightness, 2); + chroma = Math.Round(chroma, 2); + hue = Math.Round(hue, 2); + + return $"oklch({lightness.ToString(CultureInfo.InvariantCulture)}" + + $", {chroma.ToString(CultureInfo.InvariantCulture)}" + + $", {hue.ToString(CultureInfo.InvariantCulture)})"; + } + /// /// Returns a representation of a CIE XYZ color /// diff --git a/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs b/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs index 129f365e0d..2f8d5a2348 100644 --- a/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs +++ b/src/modules/colorPicker/ColorPickerUI/ViewModels/ColorEditorViewModel.cs @@ -301,6 +301,12 @@ namespace ColorPicker.ViewModels FormatName = ColorRepresentationType.NCol.ToString(), Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.NCol.ToString()), }); + _allColorRepresentations.Add( + new ColorFormatModel() + { + FormatName = ColorRepresentationType.CIEXYZ.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.CIEXYZ.ToString()), + }); _allColorRepresentations.Add( new ColorFormatModel() { @@ -310,8 +316,14 @@ namespace ColorPicker.ViewModels _allColorRepresentations.Add( new ColorFormatModel() { - FormatName = ColorRepresentationType.CIEXYZ.ToString(), - Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.CIEXYZ.ToString()), + FormatName = ColorRepresentationType.Oklab.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.Oklab.ToString()), + }); + _allColorRepresentations.Add( + new ColorFormatModel() + { + FormatName = ColorRepresentationType.Oklch.ToString(), + Convert = (Color color) => ColorRepresentationHelper.GetStringRepresentationFromMediaColor(color, ColorRepresentationType.Oklch.ToString()), }); _allColorRepresentations.Add( new ColorFormatModel() diff --git a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs b/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs index eaa5369dd6..288ed0f599 100644 --- a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs +++ b/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorConverterTest.cs @@ -364,9 +364,6 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("8080FF", 59.20, 33.10, -63.46)] // blue [DataRow("BF40BF", 50.10, 65.50, -41.48)] // magenta [DataRow("BFBF00", 75.04, -17.35, 76.03)] // yellow - [DataRow("008000", 46.23, -51.70, 49.90)] // green - [DataRow("8080FF", 59.20, 33.10, -63.46)] // blue - [DataRow("BF40BF", 50.10, 65.50, -41.48)] // magenta [DataRow("0048BA", 34.35, 27.94, -64.80)] // absolute zero [DataRow("B0BF1A", 73.91, -23.39, 71.15)] // acid green [DataRow("D0FF14", 93.87, -40.20, 88.97)] // arctic lime @@ -401,13 +398,121 @@ namespace Microsoft.ColorPicker.UnitTests var result = ColorFormatHelper.ConvertToCIELABColor(color); // lightness[0..100] - Assert.AreEqual(Math.Round(result.Lightness, 2), lightness); + Assert.AreEqual(lightness, Math.Round(result.Lightness, 2)); // chromaticityA[-128..127] - Assert.AreEqual(Math.Round(result.ChromaticityA, 2), chromaticityA); + Assert.AreEqual(chromaticityA, Math.Round(result.ChromaticityA, 2)); // chromaticityB[-128..127] - Assert.AreEqual(Math.Round(result.ChromaticityB, 2), chromaticityB); + Assert.AreEqual(chromaticityB, Math.Round(result.ChromaticityB, 2)); + } + + // Test data calculated using https://oklch.com (which uses https://github.com/Evercoder/culori) + [TestMethod] + [DataRow("FFFFFF", 1.00, 0.00, 0.00)] // white + [DataRow("808080", 0.6, 0.00, 0.00)] // gray + [DataRow("000000", 0.00, 0.00, 0.00)] // black + [DataRow("FF0000", 0.628, 0.22, 0.13)] // red + [DataRow("008000", 0.52, -0.14, 0.11)] // green + [DataRow("80FFFF", 0.928, -0.11, -0.03)] // cyan + [DataRow("8080FF", 0.661, 0.03, -0.18)] // blue + [DataRow("BF40BF", 0.598, 0.18, -0.11)] // magenta + [DataRow("BFBF00", 0.779, -0.06, 0.16)] // yellow + [DataRow("0048BA", 0.444, -0.03, -0.19)] // absolute zero + [DataRow("B0BF1A", 0.767, -0.07, 0.15)] // acid green + [DataRow("D0FF14", 0.934, -0.12, 0.19)] // arctic lime + [DataRow("1B4D3E", 0.382, -0.06, 0.01)] // brunswick green + [DataRow("FFEF00", 0.935, -0.05, 0.19)] // canary yellow + [DataRow("FFA600", 0.794, 0.06, 0.16)] // cheese + [DataRow("1A2421", 0.25, -0.02, 0)] // dark jungle green + [DataRow("003399", 0.371, -0.02, -0.17)] // dark powder blue + [DataRow("D70A53", 0.563, 0.22, 0.04)] // debian red + [DataRow("80FFD5", 0.916, -0.13, 0.02)] // fathom secret green + [DataRow("EFDFBB", 0.907, 0, 0.05)] // dutch white + [DataRow("5218FA", 0.489, 0.05, -0.28)] // han purple + [DataRow("FF496C", 0.675, 0.21, 0.05)] // infra red + [DataRow("545AA7", 0.5, 0.02, -0.12)] // liberty + [DataRow("E6A8D7", 0.804, 0.09, -0.04)] // light orchid + [DataRow("ADDFAD", 0.856, -0.07, 0.05)] // light moss green + [DataRow("E3F988", 0.942, -0.07, 0.12)] // mindaro + public void ColorRGBtoOklabTest(string hexValue, double lightness, double chromaticityA, double chromaticityB) + { + if (string.IsNullOrWhiteSpace(hexValue)) + { + Assert.IsNotNull(hexValue); + } + + Assert.IsTrue(hexValue.Length >= 6); + + var red = int.Parse(hexValue.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var green = int.Parse(hexValue.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var blue = int.Parse(hexValue.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + var color = Color.FromArgb(255, red, green, blue); + var result = ColorFormatHelper.ConvertToOklabColor(color); + + // lightness[0..1] + Assert.AreEqual(lightness, Math.Round(result.Lightness, 3)); + + // chromaticityA[-0.5..0.5] + Assert.AreEqual(chromaticityA, Math.Round(result.ChromaticityA, 2)); + + // chromaticityB[-0.5..0.5] + Assert.AreEqual(chromaticityB, Math.Round(result.ChromaticityB, 2)); + } + + // Test data calculated using https://oklch.com (which uses https://github.com/Evercoder/culori) + [TestMethod] + [DataRow("FFFFFF", 1.00, 0.00, 0.00)] // white + [DataRow("808080", 0.6, 0.00, 0.00)] // gray + [DataRow("000000", 0.00, 0.00, 0.00)] // black + [DataRow("FF0000", 0.628, 0.258, 29.23)] // red + [DataRow("008000", 0.52, 0.177, 142.5)] // green + [DataRow("80FFFF", 0.928, 0.113, 195.38)] // cyan + [DataRow("8080FF", 0.661, 0.184, 280.13)] // blue + [DataRow("BF40BF", 0.598, 0.216, 327.86)] // magenta + [DataRow("BFBF00", 0.779, 0.17, 109.77)] // yellow + [DataRow("0048BA", 0.444, 0.19, 260.86)] // absolute zero + [DataRow("B0BF1A", 0.767, 0.169, 115.4)] // acid green + [DataRow("D0FF14", 0.934, 0.224, 122.28)] // arctic lime + [DataRow("1B4D3E", 0.382, 0.06, 170.28)] // brunswick green + [DataRow("FFEF00", 0.935, 0.198, 104.67)] // canary yellow + [DataRow("FFA600", 0.794, 0.171, 71.19)] // cheese + [DataRow("1A2421", 0.25, 0.015, 174.74)] // dark jungle green + [DataRow("003399", 0.371, 0.173, 262.12)] // dark powder blue + [DataRow("D70A53", 0.563, 0.222, 11.5)] // debian red + [DataRow("80FFD5", 0.916, 0.129, 169.38)] // fathom secret green + [DataRow("EFDFBB", 0.907, 0.05, 86.89)] // dutch white + [DataRow("5218FA", 0.489, 0.286, 279.13)] // han purple + [DataRow("FF496C", 0.675, 0.217, 14.37)] // infra red + [DataRow("545AA7", 0.5, 0.121, 277.7)] // liberty + [DataRow("E6A8D7", 0.804, 0.095, 335.4)] // light orchid + [DataRow("ADDFAD", 0.856, 0.086, 144.78)] // light moss green + [DataRow("E3F988", 0.942, 0.141, 118.24)] // mindaro + public void ColorRGBtoOklchTest(string hexValue, double lightness, double chroma, double hue) + { + if (string.IsNullOrWhiteSpace(hexValue)) + { + Assert.IsNotNull(hexValue); + } + + Assert.IsTrue(hexValue.Length >= 6); + + var red = int.Parse(hexValue.AsSpan(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var green = int.Parse(hexValue.AsSpan(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var blue = int.Parse(hexValue.AsSpan(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + + var color = Color.FromArgb(255, red, green, blue); + var result = ColorFormatHelper.ConvertToOklchColor(color); + + // lightness[0..1] + Assert.AreEqual(lightness, Math.Round(result.Lightness, 3)); + + // chroma[0..0.5] + Assert.AreEqual(chroma, Math.Round(result.Chroma, 3)); + + // hue[0°..360°] + Assert.AreEqual(hue, Math.Round(result.Hue, 2)); } // The following results are computed using LittleCMS2, an open-source color management engine, @@ -428,9 +533,6 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("8080FF", 34.6688, 27.2469, 98.0434)] // blue [DataRow("BF40BF", 32.7217, 18.5062, 51.1405)] // magenta [DataRow("BFBF00", 40.1154, 48.3384, 7.2171)] // yellow - [DataRow("008000", 7.7188, 15.4377, 2.5729)] // green - [DataRow("8080FF", 34.6688, 27.2469, 98.0434)] // blue - [DataRow("BF40BF", 32.7217, 18.5062, 51.1405)] // magenta [DataRow("0048BA", 11.1792, 8.1793, 47.4455)] // absolute zero [DataRow("B0BF1A", 36.7205, 46.5663, 8.0311)] // acid green [DataRow("D0FF14", 61.8965, 84.9797, 13.8037)] // arctic lime diff --git a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs b/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs index a96310dc82..f1f0c99e3d 100644 --- a/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs +++ b/src/modules/colorPicker/UnitTest-ColorPickerUI/Helpers/ColorRepresentationHelperTest.cs @@ -23,8 +23,10 @@ namespace Microsoft.ColorPicker.UnitTests [DataRow("HSV", "hsv(0, 0%, 0%)")] [DataRow("HWB", "hwb(0, 0%, 100%)")] [DataRow("RGB", "rgb(0, 0, 0)")] - [DataRow("CIELAB", "CIELab(0, 0, 0)")] [DataRow("CIEXYZ", "XYZ(0, 0, 0)")] + [DataRow("CIELAB", "CIELab(0, 0, 0)")] + [DataRow("Oklab", "oklab(0, 0, 0)")] + [DataRow("Oklch", "oklch(0, 0, 0)")] [DataRow("VEC4", "(0f, 0f, 0f, 1f)")] [DataRow("Decimal", "0")] [DataRow("HEX Int", "0xFF000000")] diff --git a/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs b/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs index 02e6138b30..64a52e8385 100644 --- a/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs +++ b/src/modules/launcher/PowerLauncher/ViewModel/ResultsViewModel.cs @@ -282,11 +282,11 @@ namespace PowerLauncher.ViewModel if (options.SearchQueryTuningEnabled) { - sorted = Results.OrderByDescending(x => (x.Result.Metadata.WeightBoost + x.Result.Score + (x.Result.SelectedCount * options.SearchClickedItemWeight))).ToList(); + sorted = Results.OrderByDescending(x => x.Result.GetSortOrderScore(options.SearchClickedItemWeight)).ToList(); } else { - sorted = Results.OrderByDescending(x => (x.Result.Metadata.WeightBoost + x.Result.Score + (x.Result.SelectedCount * 5))).ToList(); + sorted = Results.OrderByDescending(x => x.Result.GetSortOrderScore(5)).ToList(); } // remove history items in they are in the list as non-history items diff --git a/src/modules/launcher/Wox.Plugin/Result.cs b/src/modules/launcher/Wox.Plugin/Result.cs index 91f026bbb2..3bbb6dbf5e 100644 --- a/src/modules/launcher/Wox.Plugin/Result.cs +++ b/src/modules/launcher/Wox.Plugin/Result.cs @@ -187,5 +187,20 @@ namespace Wox.Plugin /// Gets plugin ID that generated this result /// public string PluginID { get; internal set; } + + /// + /// Gets or sets a value indicating whether usage based sorting should be applied to this result. + /// + public bool DisableUsageBasedScoring { get; set; } + + public int GetSortOrderScore(int selectedItemMultiplier) + { + if (DisableUsageBasedScoring) + { + return Metadata.WeightBoost + Score; + } + + return Metadata.WeightBoost + Score + (SelectedCount * selectedItemMultiplier); + } } } diff --git a/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs b/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs index 406f67c2a4..9102609b6b 100644 --- a/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs +++ b/src/settings-ui/Settings.UI.Library/CmdPalProperties.cs @@ -4,9 +4,7 @@ using System; using System.IO; -using System.IO.Abstractions; using System.Text.Json; -using System.Text.Json.Serialization; namespace Microsoft.PowerToys.Settings.UI.Library { @@ -46,17 +44,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library if (doc.RootElement.TryGetProperty(nameof(Hotkey), out JsonElement hotkeyElement)) { Hotkey = JsonSerializer.Deserialize(hotkeyElement.GetRawText()); - - if (Hotkey == null) - { - Hotkey = DefaultHotkeyValue; - } } } catch (Exception) { - Hotkey = DefaultHotkeyValue; } + + Hotkey ??= DefaultHotkeyValue; } } } diff --git a/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs b/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs index 0d3fc918d6..b82ef56888 100644 --- a/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ColorPickerProperties.cs @@ -32,8 +32,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library VisibleColorFormats.Add("HSI", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("HSI"))); VisibleColorFormats.Add("HWB", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("HWB"))); VisibleColorFormats.Add("NCol", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("NCol"))); - VisibleColorFormats.Add("CIELAB", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("CIELAB"))); VisibleColorFormats.Add("CIEXYZ", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("CIEXYZ"))); + VisibleColorFormats.Add("CIELAB", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("CIELAB"))); + VisibleColorFormats.Add("Oklab", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("Oklab"))); + VisibleColorFormats.Add("Oklch", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("Oklch"))); VisibleColorFormats.Add("VEC4", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("VEC4"))); VisibleColorFormats.Add("Decimal", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("Decimal"))); VisibleColorFormats.Add("HEX Int", new KeyValuePair(false, ColorFormatHelper.GetDefaultFormat("HEX Int"))); diff --git a/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs b/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs index 09ef003e88..7e57f5a730 100644 --- a/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs +++ b/src/settings-ui/Settings.UI.Library/Enumerations/ColorRepresentationType.cs @@ -80,5 +80,20 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations /// Color presentation as an 8-digit hexadecimal integer (0xFFFFFFFF) /// HexInteger = 13, + + /// + /// Color representation as CIELCh color space (L[0..100], C[0..230], h[0°..360°]) + /// + CIELCh = 14, + + /// + /// Color representation as Oklab color space (L[0..1], a[-0.5..0.5], b[-0.5..0.5]) + /// + Oklab = 15, + + /// + /// Color representation as Oklch color space (L[0..1], C[0..0.5], h[0°..360°]) + /// + Oklch = 16, } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml index 32f0ee488e..ea6d1c9f22 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml @@ -32,7 +32,8 @@ Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind Description}" TextTrimming="CharacterEllipsis" - TextWrapping="NoWrap" /> + TextWrapping="NoWrap" + ToolTipService.ToolTip="{x:Bind Description}" /> diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs index 76681d8b46..475d399674 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/ColorFormatEditor.xaml.cs @@ -47,12 +47,17 @@ namespace Microsoft.PowerToys.Settings.UI.Controls new ColorFormatParameter() { Parameter = "%In", Description = resourceLoader.GetString("Help_intensity") }, new ColorFormatParameter() { Parameter = "%Hn", Description = resourceLoader.GetString("Help_hueNat") }, new ColorFormatParameter() { Parameter = "%Ll", Description = resourceLoader.GetString("Help_lightnessNat") }, - new ColorFormatParameter() { Parameter = "%Lc", Description = resourceLoader.GetString("Help_lightnessCIE") }, new ColorFormatParameter() { Parameter = "%Va", Description = resourceLoader.GetString("Help_value") }, new ColorFormatParameter() { Parameter = "%Wh", Description = resourceLoader.GetString("Help_whiteness") }, new ColorFormatParameter() { Parameter = "%Bn", Description = resourceLoader.GetString("Help_blackness") }, - new ColorFormatParameter() { Parameter = "%Ca", Description = resourceLoader.GetString("Help_chromaticityA") }, - new ColorFormatParameter() { Parameter = "%Cb", Description = resourceLoader.GetString("Help_chromaticityB") }, + new ColorFormatParameter() { Parameter = "%Lc", Description = resourceLoader.GetString("Help_lightnessCIE") }, + new ColorFormatParameter() { Parameter = "%Ca", Description = resourceLoader.GetString("Help_chromaticityACIE") }, + new ColorFormatParameter() { Parameter = "%Cb", Description = resourceLoader.GetString("Help_chromaticityBCIE") }, + new ColorFormatParameter() { Parameter = "%Lo", Description = resourceLoader.GetString("Help_lightnessOklab") }, + new ColorFormatParameter() { Parameter = "%Oa", Description = resourceLoader.GetString("Help_chromaticityAOklab") }, + new ColorFormatParameter() { Parameter = "%Ob", Description = resourceLoader.GetString("Help_chromaticityBOklab") }, + new ColorFormatParameter() { Parameter = "%Oc", Description = resourceLoader.GetString("Help_chromaOklch") }, + new ColorFormatParameter() { Parameter = "%Oh", Description = resourceLoader.GetString("Help_hueOklch") }, new ColorFormatParameter() { Parameter = "%Xv", Description = resourceLoader.GetString("Help_X_value") }, new ColorFormatParameter() { Parameter = "%Yv", Description = resourceLoader.GetString("Help_Y_value") }, new ColorFormatParameter() { Parameter = "%Zv", Description = resourceLoader.GetString("Help_Z_value") }, diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index a95cdb86cd..694603707c 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -1660,11 +1660,11 @@ Made with 💗 by Microsoft and the PowerToys community. blackness - - chromaticityA + + chromaticity A (CIE Lab) - - chromaticityB + + chromaticity B (CIE Lab) X value @@ -4460,7 +4460,7 @@ Activate by holding the key for the character you want to add an accent to, then Commonly used variables New+ commonly used variables header in the flyout info card - + Year, represented by a full four or five digits, depending on the calendar used. New+ description of the year $YYYY variable - casing of $YYYY is important @@ -4999,4 +4999,25 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m Go to Command Palette settings to customize the activation shortcut. + + chroma (CIE LCh) + + + hue (CIE LCh) + + + lightness (Oklab/Oklch) + + + chromaticity A (Oklab) + + + chromaticity B (Oklab) + + + chroma (Oklch) + + + hue (Oklch) + \ No newline at end of file diff --git a/tools/build/cert-management.ps1 b/tools/build/cert-management.ps1 index 5f52f2611f..a085a5ca54 100644 --- a/tools/build/cert-management.ps1 +++ b/tools/build/cert-management.ps1 @@ -116,4 +116,44 @@ function EnsureCertificate { } return $cert -} \ No newline at end of file +} + +function Export-CertificateFiles { + param ( + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [string]$CerPath, + [string]$PfxPath, + [securestring]$PfxPassword + ) + + if (-not $Certificate) { + Write-Error "No certificate provided to export." + return + } + + if ($CerPath) { + try { + Export-Certificate -Cert $Certificate -FilePath $CerPath -Force | Out-Null + Write-Host "Exported CER to: $CerPath" + } catch { + Write-Warning "Failed to export CER file: $_" + } + } + + if ($PfxPath -and $PfxPassword) { + try { + Export-PfxCertificate -Cert $Certificate -FilePath $PfxPath -Password $PfxPassword -Force | Out-Null + Write-Host "Exported PFX to: $PfxPath" + } catch { + Write-Warning "Failed to export PFX file: $_" + } + } + + if (-not $CerPath -and -not $PfxPath) { + Write-Warning "No output path specified. Nothing was exported." + } +} + +$cert = EnsureCertificate +$pswd = ConvertTo-SecureString -String "MySecurePassword123!" -AsPlainText -Force +Export-CertificateFiles -Certificate $cert -CerPath "$env:TEMP\cert.cer" -PfxPath "$env:TEMP\cert.pfx" -PfxPassword $pswd