From dd138fb94baa1babbd5c3acc73b3996f98c78fa4 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Fri, 19 Dec 2025 11:53:43 +0800 Subject: [PATCH] FancyZones CLI: migrate to System.CommandLine and centralize data I/O (#44344) ## Summary of the Pull Request This PR refactors FancyZones CLI to use System.CommandLine and consolidates all FancyZones JSON config read/write through FancyZonesEditorCommon data models and FancyZonesDataIO, so CLI and Editor share the same serialization and file paths. For detailed command definitions, see PR #44078 ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../FancyZones.FuzzTests.csproj | 1 + .../Commands/FancyZonesBaseCommand.cs | 57 +++ .../Commands/GetActiveLayoutCommand.cs | 85 ++++ .../CommandLine/Commands/GetHotkeysCommand.cs | 43 ++ .../CommandLine/Commands/GetLayoutsCommand.cs | 110 ++++++ .../Commands/GetMonitorsCommand.cs | 95 +++++ .../CommandLine/Commands/OpenEditorCommand.cs | 44 +++ .../Commands/OpenSettingsCommand.cs | 50 +++ .../Commands/RemoveHotkeyCommand.cs | 55 +++ .../CommandLine/Commands/SetHotkeyCommand.cs | 89 +++++ .../CommandLine/Commands/SetLayoutCommand.cs | 374 ++++++++++++++++++ .../FancyZonesCliCommandFactory.cs | 28 ++ .../CommandLine/FancyZonesCliGuards.cs | 22 ++ .../CommandLine/FancyZonesCliUsage.cs | 62 +++ .../FancyZonesCLI/Commands/EditorCommands.cs | 90 ----- .../FancyZonesCLI/Commands/HotkeyCommands.cs | 98 ----- .../FancyZonesCLI/Commands/LayoutCommands.cs | 276 ------------- .../FancyZonesCLI/Commands/MonitorCommands.cs | 49 --- .../FancyZonesCLI/FancyZonesCLI.csproj | 8 +- .../FancyZonesCLI/FancyZonesData.cs | 142 ------- .../FancyZonesCLI/LayoutVisualizer.cs | 40 +- .../fancyzones/FancyZonesCLI/Models.cs | 137 ------- .../fancyzones/FancyZonesCLI/NativeMethods.cs | 7 + .../fancyzones/FancyZonesCLI/Program.cs | 111 +----- .../Utils/AppliedLayoutsHelper.cs | 89 +++++ .../Utils/EditorParametersRefresh.cs | 68 ++++ .../Data/AppliedLayouts.cs | 2 +- .../Data/CustomLayouts.cs | 2 +- .../Data/DefaultLayouts.cs | 2 +- .../Data/EditorParameters.cs | 2 +- .../Data}/FancyZonesPaths.cs | 6 +- .../Data/LayoutHotkeys.cs | 2 +- .../Data/LayoutTemplates.cs | 2 +- .../Utils/FancyZonesDataIO.cs | 154 ++++++++ .../fancyzones/FancyZonesLib/FancyZones.cpp | 7 + .../FancyZonesWinHookEventIDs.cpp | 2 + .../FancyZonesLib/FancyZonesWinHookEventIDs.h | 1 + 37 files changed, 1513 insertions(+), 899 deletions(-) create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs delete mode 100644 src/modules/fancyzones/FancyZonesCLI/Models.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs create mode 100644 src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs rename src/modules/fancyzones/{FancyZonesCLI => FancyZonesEditorCommon/Data}/FancyZonesPaths.cs (85%) create mode 100644 src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs diff --git a/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj b/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj index 9890b67217..9428e10608 100644 --- a/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj +++ b/src/modules/fancyzones/FancyZones.FuzzTests/FancyZones.FuzzTests.csproj @@ -11,6 +11,7 @@ + diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs new file mode 100644 index 0000000000..d47fc42cdf --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/FancyZonesBaseCommand.cs @@ -0,0 +1,57 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; + +using FancyZonesCLI; +using FancyZonesCLI.CommandLine; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal abstract class FancyZonesBaseCommand : Command +{ + protected FancyZonesBaseCommand(string name, string description) + : base(name, description) + { + this.SetHandler(InvokeInternal); + } + + protected abstract string Execute(InvocationContext context); + + private void InvokeInternal(InvocationContext context) + { + Logger.LogInfo($"Executing command '{Name}'"); + + if (!FancyZonesCliGuards.IsFancyZonesRunning()) + { + Logger.LogWarning($"Command '{Name}' blocked: FancyZones is not running"); + context.Console.Error.Write($"Error: FancyZones is not running. Start PowerToys (FancyZones) and retry.{Environment.NewLine}"); + context.ExitCode = 1; + return; + } + + try + { + string output = Execute(context); + context.ExitCode = 0; + + Logger.LogInfo($"Command '{Name}' completed successfully"); + Logger.LogDebug($"Command '{Name}' output length: {output?.Length ?? 0}"); + + if (!string.IsNullOrEmpty(output)) + { + context.Console.Out.Write(output); + context.Console.Out.Write(Environment.NewLine); + } + } + catch (Exception ex) + { + Logger.LogError($"Command '{Name}' failed", ex); + context.Console.Error.Write($"Error: {ex.Message}{Environment.NewLine}"); + context.ExitCode = 1; + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs new file mode 100644 index 0000000000..d2fc0f4f9d --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetActiveLayoutCommand.cs @@ -0,0 +1,85 @@ +// 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.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetActiveLayoutCommand : FancyZonesBaseCommand +{ + public GetActiveLayoutCommand() + : base("get-active-layout", "Show currently active layout") + { + AddAlias("active"); + } + + protected override string Execute(InvocationContext context) + { + // Trigger FancyZones to save current monitor info and read it reliably. + var editorParams = EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + + if (editorParams.Monitors == null || editorParams.Monitors.Count == 0) + { + throw new InvalidOperationException("Could not get current monitor information."); + } + + // Read applied layouts. + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + + if (appliedLayouts.AppliedLayouts == null) + { + return "No layouts configured."; + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n=== Active FancyZones Layout(s) ===\n"); + + // Show only layouts for currently connected monitors. + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + var monitor = editorParams.Monitors[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {i + 1}: {monitor.Monitor}"); + + var matchedLayout = AppliedLayoutsHelper.FindLayoutForMonitor( + appliedLayouts, + monitor.Monitor, + monitor.MonitorSerialNumber, + monitor.MonitorNumber, + monitor.VirtualDesktop); + + if (matchedLayout.HasValue) + { + var layout = matchedLayout.Value.AppliedLayout; + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout UUID: {layout.Uuid}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout Type: {layout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {layout.ZoneCount}"); + + if (layout.ShowSpacing) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Spacing: {layout.Spacing}px"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.SensitivityRadius}px"); + } + else + { + sb.AppendLine(" No layout applied"); + } + + if (i < editorParams.Monitors.Count - 1) + { + sb.AppendLine(); + } + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs new file mode 100644 index 0000000000..342598c822 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetHotkeysCommand.cs @@ -0,0 +1,43 @@ +// 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.CommandLine.Invocation; +using System.Globalization; +using System.Linq; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetHotkeysCommand : FancyZonesBaseCommand +{ + public GetHotkeysCommand() + : base("get-hotkeys", "List all layout hotkeys") + { + AddAlias("hk"); + } + + protected override string Execute(InvocationContext context) + { + var hotkeys = FancyZonesDataIO.ReadLayoutHotkeys(); + + if (hotkeys.LayoutHotkeys == null || hotkeys.LayoutHotkeys.Count == 0) + { + return "No hotkeys configured."; + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("=== Layout Hotkeys ===\n"); + sb.AppendLine("Press Win + Ctrl + Alt + to switch layouts:\n"); + + foreach (var hotkey in hotkeys.LayoutHotkeys.OrderBy(h => h.Key)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" [{hotkey.Key}] => {hotkey.LayoutId}"); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs new file mode 100644 index 0000000000..fc754fb95b --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetLayoutsCommand.cs @@ -0,0 +1,110 @@ +// 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.CommandLine.Invocation; +using System.Globalization; +using System.Text.Json; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetLayoutsCommand : FancyZonesBaseCommand +{ + public GetLayoutsCommand() + : base("get-layouts", "List available layouts") + { + AddAlias("ls"); + } + + protected override string Execute(InvocationContext context) + { + var sb = new System.Text.StringBuilder(); + + // Print template layouts. + var templatesJson = FancyZonesDataIO.ReadLayoutTemplates(); + + if (templatesJson.LayoutTemplates != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Built-in Template Layouts ({templatesJson.LayoutTemplates.Count} total) ===\n"); + + for (int i = 0; i < templatesJson.LayoutTemplates.Count; i++) + { + var template = templatesJson.LayoutTemplates[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[T{i + 1}] {template.Type}"); + sb.Append(CultureInfo.InvariantCulture, $" Zones: {template.ZoneCount}"); + if (template.ShowSpacing && template.Spacing > 0) + { + sb.Append(CultureInfo.InvariantCulture, $", Spacing: {template.Spacing}px"); + } + + sb.AppendLine(); + sb.AppendLine(); + + // Draw visual preview. + sb.Append(LayoutVisualizer.DrawTemplateLayout(template)); + + if (i < templatesJson.LayoutTemplates.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\n"); + } + + // Print custom layouts. + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + + if (customLayouts.CustomLayouts != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Custom Layouts ({customLayouts.CustomLayouts.Count} total) ==="); + + for (int i = 0; i < customLayouts.CustomLayouts.Count; i++) + { + var layout = customLayouts.CustomLayouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[{i + 1}] {layout.Name}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.Uuid}"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {layout.Type}"); + + bool isCanvasLayout = false; + if (layout.Info.ValueKind != JsonValueKind.Undefined && layout.Info.ValueKind != JsonValueKind.Null) + { + if (layout.Type == "grid" && layout.Info.TryGetProperty("rows", out var rows) && layout.Info.TryGetProperty("columns", out var cols)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({rows.GetInt32()}x{cols.GetInt32()} grid)"); + } + else if (layout.Type == "canvas" && layout.Info.TryGetProperty("zones", out var zones)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({zones.GetArrayLength()} zones)"); + isCanvasLayout = true; + } + } + + sb.AppendLine("\n"); + + // Draw visual preview. + sb.Append(LayoutVisualizer.DrawCustomLayout(layout)); + + // Add note for canvas layouts. + if (isCanvasLayout) + { + sb.AppendLine("\n Note: Canvas layout preview is approximate."); + sb.AppendLine(" Open FancyZones Editor for precise zone boundaries."); + } + + if (i < customLayouts.CustomLayouts.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\nUse 'FancyZonesCLI.exe set-layout ' to apply a layout."); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs new file mode 100644 index 0000000000..f8e8ebb189 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/GetMonitorsCommand.cs @@ -0,0 +1,95 @@ +// 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.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class GetMonitorsCommand : FancyZonesBaseCommand +{ + public GetMonitorsCommand() + : base("get-monitors", "List monitors and FancyZones metadata") + { + AddAlias("m"); + } + + protected override string Execute(InvocationContext context) + { + // Request FancyZones to save current monitor configuration and read it reliably. + EditorParameters.ParamsWrapper editorParams; + try + { + editorParams = EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to read monitor information. {ex.Message}{Environment.NewLine}Note: Ensure FancyZones is running to get current monitor information.", ex); + } + + if (editorParams.Monitors == null || editorParams.Monitors.Count == 0) + { + return "No monitors found."; + } + + // Also read applied layouts to show which layout is active on each monitor. + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Monitors ({editorParams.Monitors.Count} total) ==="); + sb.AppendLine(); + + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + var monitor = editorParams.Monitors[i]; + var monitorNum = i + 1; + + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {monitorNum}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor: {monitor.Monitor}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Instance: {monitor.MonitorInstanceId}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Number: {monitor.MonitorNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Serial Number: {monitor.MonitorSerialNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Virtual Desktop: {monitor.VirtualDesktop}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" DPI: {monitor.Dpi}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Resolution: {monitor.MonitorWidth}x{monitor.MonitorHeight}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Work Area: {monitor.WorkAreaWidth}x{monitor.WorkAreaHeight}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Position: ({monitor.LeftCoordinate}, {monitor.TopCoordinate})"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Selected: {monitor.IsSelected}"); + + // Find matching applied layout for this monitor using EditorCommon's matching logic. + if (appliedLayouts.AppliedLayouts != null) + { + var matchedLayout = AppliedLayoutsHelper.FindLayoutForMonitor( + appliedLayouts, + monitor.Monitor, + monitor.MonitorSerialNumber, + monitor.MonitorNumber, + monitor.VirtualDesktop); + + if (matchedLayout != null && matchedLayout.Value.AppliedLayout.Type != null) + { + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $" Active Layout: {matchedLayout.Value.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {matchedLayout.Value.AppliedLayout.ZoneCount}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {matchedLayout.Value.AppliedLayout.SensitivityRadius}px"); + if (!string.IsNullOrEmpty(matchedLayout.Value.AppliedLayout.Uuid) && + matchedLayout.Value.AppliedLayout.Uuid != "{00000000-0000-0000-0000-000000000000}") + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Layout UUID: {matchedLayout.Value.AppliedLayout.Uuid}"); + } + } + } + + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs new file mode 100644 index 0000000000..122d3a000f --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenEditorCommand.cs @@ -0,0 +1,44 @@ +// 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.CommandLine.Invocation; +using System.Diagnostics; +using System.Linq; +using System.Threading; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class OpenEditorCommand : FancyZonesBaseCommand +{ + public OpenEditorCommand() + : base("open-editor", "Launch FancyZones layout editor") + { + AddAlias("e"); + } + + protected override string Execute(InvocationContext context) + { + const string FancyZonesEditorToggleEventName = "Local\\FancyZones-ToggleEditorEvent-1e174338-06a3-472b-874d-073b21c62f14"; + + // Check if editor is already running + var existingProcess = Process.GetProcessesByName("PowerToys.FancyZonesEditor").FirstOrDefault(); + if (existingProcess != null) + { + NativeMethods.SetForegroundWindow(existingProcess.MainWindowHandle); + return string.Empty; + } + + try + { + using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, FancyZonesEditorToggleEventName); + eventHandle.Set(); + return string.Empty; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to request FancyZones Editor launch. {ex.Message}", ex); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs new file mode 100644 index 0000000000..84698bb9b6 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/OpenSettingsCommand.cs @@ -0,0 +1,50 @@ +// 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.CommandLine.Invocation; +using System.Diagnostics; +using System.IO; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class OpenSettingsCommand : FancyZonesBaseCommand +{ + public OpenSettingsCommand() + : base("open-settings", "Open FancyZones settings page") + { + AddAlias("settings"); + } + + protected override string Execute(InvocationContext context) + { + // Check in the same directory as the CLI (typical for dev builds) + var powertoysExe = Path.Combine(AppContext.BaseDirectory, "PowerToys.exe"); + if (!File.Exists(powertoysExe)) + { + throw new FileNotFoundException("PowerToys.exe not found. Ensure PowerToys is installed, or run the CLI from the same folder as PowerToys.exe.", powertoysExe); + } + + try + { + var process = Process.Start(new ProcessStartInfo + { + FileName = powertoysExe, + Arguments = "--open-settings=FancyZones", + UseShellExecute = false, + }); + + if (process == null) + { + throw new InvalidOperationException("PowerToys.exe failed to start."); + } + + return string.Empty; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to open FancyZones Settings. {ex.Message}", ex); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs new file mode 100644 index 0000000000..4f88e268c5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/RemoveHotkeyCommand.cs @@ -0,0 +1,55 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class RemoveHotkeyCommand : FancyZonesBaseCommand +{ + private readonly Argument _key; + + public RemoveHotkeyCommand() + : base("remove-hotkey", "Remove hotkey assignment") + { + AddAlias("rhk"); + + _key = new Argument("key", "Hotkey index (0-9)"); + AddArgument(_key); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + int key = context.ParseResult.GetValueForArgument(_key); + + var hotkeysWrapper = FancyZonesDataIO.ReadLayoutHotkeys(); + + if (hotkeysWrapper.LayoutHotkeys == null) + { + return "No hotkeys configured."; + } + + var hotkeysList = hotkeysWrapper.LayoutHotkeys; + var removed = hotkeysList.RemoveAll(h => h.Key == key); + if (removed == 0) + { + return $"No hotkey assigned to key {key}"; + } + + // Save. + var newWrapper = new LayoutHotkeys.LayoutHotkeysWrapper { LayoutHotkeys = hotkeysList }; + FancyZonesDataIO.WriteLayoutHotkeys(newWrapper); + + // Notify FancyZones. + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE); + + return string.Empty; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs new file mode 100644 index 0000000000..4982be284c --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetHotkeyCommand.cs @@ -0,0 +1,89 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; +using System.Linq; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class SetHotkeyCommand : FancyZonesBaseCommand +{ + private readonly Argument _key; + private readonly Argument _layout; + + public SetHotkeyCommand() + : base("set-hotkey", "Assign hotkey (0-9) to a custom layout") + { + AddAlias("shk"); + + _key = new Argument("key", "Hotkey index (0-9)"); + _layout = new Argument("layout", "Custom layout UUID"); + + AddArgument(_key); + AddArgument(_layout); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + int key = context.ParseResult.GetValueForArgument(_key); + string layout = context.ParseResult.GetValueForArgument(_layout); + + if (key < 0 || key > 9) + { + throw new InvalidOperationException("Key must be between 0 and 9."); + } + + // Editor only allows assigning hotkeys to existing custom layouts. + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + + CustomLayouts.CustomLayoutWrapper? matchedLayout = null; + if (customLayouts.CustomLayouts != null) + { + foreach (var candidate in customLayouts.CustomLayouts) + { + if (candidate.Uuid.Equals(layout, StringComparison.OrdinalIgnoreCase)) + { + matchedLayout = candidate; + break; + } + } + } + + if (!matchedLayout.HasValue) + { + throw new InvalidOperationException($"Layout '{layout}' is not a custom layout UUID."); + } + + string layoutName = matchedLayout.Value.Name; + + var hotkeysWrapper = FancyZonesDataIO.ReadLayoutHotkeys(); + + var hotkeysList = hotkeysWrapper.LayoutHotkeys ?? new List(); + + // Match editor behavior: + // - One key maps to one layout + // - One layout maps to at most one key + hotkeysList.RemoveAll(h => h.Key == key); + hotkeysList.RemoveAll(h => string.Equals(h.LayoutId, layout, StringComparison.OrdinalIgnoreCase)); + + // Add new hotkey. + hotkeysList.Add(new LayoutHotkeys.LayoutHotkeyWrapper { Key = key, LayoutId = layout }); + + // Save. + var newWrapper = new LayoutHotkeys.LayoutHotkeysWrapper { LayoutHotkeys = hotkeysList }; + FancyZonesDataIO.WriteLayoutHotkeys(newWrapper); + + // Notify FancyZones. + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE); + + return string.Empty; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs new file mode 100644 index 0000000000..3d2a204af9 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/Commands/SetLayoutCommand.cs @@ -0,0 +1,374 @@ +// 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.CommandLine; +using System.CommandLine.Invocation; +using System.Globalization; + +using FancyZonesCLI.Utils; +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.CommandLine.Commands; + +internal sealed partial class SetLayoutCommand : FancyZonesBaseCommand +{ + private static readonly string[] AliasesMonitor = ["--monitor", "-m"]; + private static readonly string[] AliasesAll = ["--all", "-a"]; + + private const string DefaultLayoutUuid = "{00000000-0000-0000-0000-000000000000}"; + + private readonly Argument _layoutId; + private readonly Option _monitor; + private readonly Option _all; + + public SetLayoutCommand() + : base("set-layout", "Set layout by UUID or template name") + { + AddAlias("s"); + + _layoutId = new Argument("layout", "Layout UUID or template type (e.g. focus, columns)"); + AddArgument(_layoutId); + + _monitor = new Option(AliasesMonitor, "Apply to monitor N (1-based)"); + _monitor.AddValidator(result => + { + if (result.Tokens.Count == 0) + { + return; + } + + int? monitor = result.GetValueOrDefault(); + if (monitor.HasValue && monitor.Value < 1) + { + result.ErrorMessage = "Monitor index must be >= 1."; + } + }); + + _all = new Option(AliasesAll, "Apply to all monitors"); + + AddOption(_monitor); + AddOption(_all); + + AddValidator(commandResult => + { + int? monitor = commandResult.GetValueForOption(_monitor); + bool all = commandResult.GetValueForOption(_all); + + if (monitor.HasValue && all) + { + commandResult.ErrorMessage = "Cannot specify both --monitor and --all."; + } + }); + } + + protected override string Execute(InvocationContext context) + { + // FancyZones running guard is handled by FancyZonesBaseCommand. + string layout = context.ParseResult.GetValueForArgument(_layoutId); + int? monitor = context.ParseResult.GetValueForOption(_monitor); + bool all = context.ParseResult.GetValueForOption(_all); + Logger.LogInfo($"SetLayout called with layout: '{layout}', monitor: {(monitor.HasValue ? monitor.Value.ToString(CultureInfo.InvariantCulture) : "")}, all: {all}"); + + var (targetCustomLayout, targetTemplate) = ResolveTargetLayout(layout); + + var editorParams = ReadEditorParametersWithRefresh(); + var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts(); + appliedLayouts.AppliedLayouts ??= new List(); + + List monitorsToUpdate = GetMonitorsToUpdate(editorParams, monitor, all); + List newLayouts = BuildNewLayouts(editorParams, monitorsToUpdate, targetCustomLayout, targetTemplate); + var updatedLayouts = MergeWithHistoricalLayouts(appliedLayouts, newLayouts); + + Logger.LogInfo($"Writing {updatedLayouts.AppliedLayouts?.Count ?? 0} layouts to file"); + FancyZonesDataIO.WriteAppliedLayouts(updatedLayouts); + Logger.LogInfo($"Applied layouts file updated for {monitorsToUpdate.Count} monitor(s)"); + + NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE); + Logger.LogInfo("FancyZones notified of layout change"); + + return BuildSuccessMessage(layout, monitor, all); + } + + private static string BuildSuccessMessage(string layout, int? monitor, bool all) + { + if (all) + { + return string.Format(CultureInfo.InvariantCulture, "Layout '{0}' applied to all monitors.", layout); + } + + if (monitor.HasValue) + { + return string.Format(CultureInfo.InvariantCulture, "Layout '{0}' applied to monitor {1}.", layout, monitor.Value); + } + + return string.Format(CultureInfo.InvariantCulture, "Layout '{0}' applied to monitor 1.", layout); + } + + private static (CustomLayouts.CustomLayoutWrapper? TargetCustomLayout, LayoutTemplates.TemplateLayoutWrapper? TargetTemplate) ResolveTargetLayout(string layout) + { + var customLayouts = FancyZonesDataIO.ReadCustomLayouts(); + CustomLayouts.CustomLayoutWrapper? targetCustomLayout = FindCustomLayout(customLayouts, layout); + + LayoutTemplates.TemplateLayoutWrapper? targetTemplate = null; + if (!targetCustomLayout.HasValue || string.IsNullOrEmpty(targetCustomLayout.Value.Uuid)) + { + var templates = FancyZonesDataIO.ReadLayoutTemplates(); + targetTemplate = FindTemplate(templates, layout); + + if (targetCustomLayout.HasValue && string.IsNullOrEmpty(targetCustomLayout.Value.Uuid)) + { + targetCustomLayout = null; + } + } + + if (!targetCustomLayout.HasValue && !targetTemplate.HasValue) + { + throw new InvalidOperationException( + $"Layout '{layout}' not found{Environment.NewLine}" + + "Tip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid', 'priority-grid')" + + $"{Environment.NewLine} For custom layouts, use the UUID from 'get-layouts'"); + } + + return (targetCustomLayout, targetTemplate); + } + + private static CustomLayouts.CustomLayoutWrapper? FindCustomLayout(CustomLayouts.CustomLayoutListWrapper customLayouts, string layout) + { + if (customLayouts.CustomLayouts == null) + { + return null; + } + + foreach (var customLayout in customLayouts.CustomLayouts) + { + if (customLayout.Uuid.Equals(layout, StringComparison.OrdinalIgnoreCase)) + { + return customLayout; + } + } + + return null; + } + + private static LayoutTemplates.TemplateLayoutWrapper? FindTemplate(LayoutTemplates.TemplateLayoutsListWrapper templates, string layout) + { + if (templates.LayoutTemplates == null) + { + return null; + } + + foreach (var template in templates.LayoutTemplates) + { + if (template.Type.Equals(layout, StringComparison.OrdinalIgnoreCase)) + { + return template; + } + } + + return null; + } + + private static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh() + { + return EditorParametersRefresh.ReadEditorParametersWithRefresh( + () => NativeMethods.NotifyFancyZones(NativeMethods.WM_PRIV_SAVE_EDITOR_PARAMETERS)); + } + + private static List GetMonitorsToUpdate(EditorParameters.ParamsWrapper editorParams, int? monitor, bool all) + { + var result = new List(); + + if (all) + { + for (int i = 0; i < editorParams.Monitors.Count; i++) + { + result.Add(i); + } + + return result; + } + + if (monitor.HasValue) + { + int monitorIndex = monitor.Value - 1; // Convert to 0-based. + if (monitorIndex < 0 || monitorIndex >= editorParams.Monitors.Count) + { + throw new InvalidOperationException($"Monitor {monitor.Value} not found. Available monitors: 1-{editorParams.Monitors.Count}"); + } + + result.Add(monitorIndex); + return result; + } + + // Default: first monitor. + result.Add(0); + return result; + } + + private static List BuildNewLayouts( + EditorParameters.ParamsWrapper editorParams, + List monitorsToUpdate, + CustomLayouts.CustomLayoutWrapper? targetCustomLayout, + LayoutTemplates.TemplateLayoutWrapper? targetTemplate) + { + var newLayouts = new List(); + + foreach (int monitorIndex in monitorsToUpdate) + { + var currentMonitor = editorParams.Monitors[monitorIndex]; + + var (layoutUuid, layoutType, showSpacing, spacing, zoneCount, sensitivityRadius) = + GetLayoutSettings(targetCustomLayout, targetTemplate); + + var deviceId = new AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper + { + Monitor = currentMonitor.Monitor, + MonitorInstance = currentMonitor.MonitorInstanceId, + MonitorNumber = currentMonitor.MonitorNumber, + SerialNumber = currentMonitor.MonitorSerialNumber, + VirtualDesktop = currentMonitor.VirtualDesktop, + }; + + newLayouts.Add(new AppliedLayouts.AppliedLayoutWrapper + { + Device = deviceId, + AppliedLayout = new AppliedLayouts.AppliedLayoutWrapper.LayoutWrapper + { + Uuid = layoutUuid, + Type = layoutType, + ShowSpacing = showSpacing, + Spacing = spacing, + ZoneCount = zoneCount, + SensitivityRadius = sensitivityRadius, + }, + }); + } + + if (newLayouts.Count == 0) + { + throw new InvalidOperationException("Internal error - no monitors to update."); + } + + return newLayouts; + } + + private static (string LayoutUuid, string LayoutType, bool ShowSpacing, int Spacing, int ZoneCount, int SensitivityRadius) GetLayoutSettings( + CustomLayouts.CustomLayoutWrapper? targetCustomLayout, + LayoutTemplates.TemplateLayoutWrapper? targetTemplate) + { + if (targetCustomLayout.HasValue) + { + var customLayoutsSerializer = new CustomLayouts(); + string type = targetCustomLayout.Value.Type?.ToLowerInvariant() ?? string.Empty; + + bool showSpacing = false; + int spacing = 0; + int zoneCount = 0; + int sensitivityRadius = 20; + + if (type == "canvas") + { + var info = customLayoutsSerializer.CanvasFromJsonElement(targetCustomLayout.Value.Info.GetRawText()); + zoneCount = info.Zones?.Count ?? 0; + sensitivityRadius = info.SensitivityRadius; + } + else if (type == "grid") + { + var info = customLayoutsSerializer.GridFromJsonElement(targetCustomLayout.Value.Info.GetRawText()); + showSpacing = info.ShowSpacing; + spacing = info.Spacing; + sensitivityRadius = info.SensitivityRadius; + + if (info.CellChildMap != null) + { + var uniqueZoneIds = new HashSet(); + + for (int r = 0; r < info.CellChildMap.Length; r++) + { + int[] row = info.CellChildMap[r]; + if (row == null) + { + continue; + } + + for (int c = 0; c < row.Length; c++) + { + uniqueZoneIds.Add(row[c]); + } + } + + zoneCount = uniqueZoneIds.Count; + } + } + else + { + throw new InvalidOperationException($"Unsupported custom layout type '{targetCustomLayout.Value.Type}'."); + } + + return ( + targetCustomLayout.Value.Uuid, + Constants.CustomLayoutJsonTag, + ShowSpacing: showSpacing, + Spacing: spacing, + ZoneCount: zoneCount, + SensitivityRadius: sensitivityRadius); + } + + if (targetTemplate.HasValue) + { + return ( + DefaultLayoutUuid, + targetTemplate.Value.Type, + targetTemplate.Value.ShowSpacing, + targetTemplate.Value.Spacing, + targetTemplate.Value.ZoneCount, + targetTemplate.Value.SensitivityRadius); + } + + throw new InvalidOperationException("Internal error - no layout selected."); + } + + private static AppliedLayouts.AppliedLayoutsListWrapper MergeWithHistoricalLayouts( + AppliedLayouts.AppliedLayoutsListWrapper existingLayouts, + List newLayouts) + { + var mergedLayoutsList = new List(); + mergedLayoutsList.AddRange(newLayouts); + + if (existingLayouts.AppliedLayouts != null) + { + foreach (var existingLayout in existingLayouts.AppliedLayouts) + { + bool isUpdated = false; + + foreach (var newLayout in newLayouts) + { + if (AppliedLayoutsHelper.MatchesDevice( + existingLayout.Device, + newLayout.Device.Monitor, + newLayout.Device.SerialNumber, + newLayout.Device.MonitorNumber, + newLayout.Device.VirtualDesktop)) + { + isUpdated = true; + break; + } + } + + if (!isUpdated) + { + mergedLayoutsList.Add(existingLayout); + } + } + } + + return new AppliedLayouts.AppliedLayoutsListWrapper + { + AppliedLayouts = mergedLayoutsList, + }; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs new file mode 100644 index 0000000000..864e221b34 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliCommandFactory.cs @@ -0,0 +1,28 @@ +// 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.CommandLine; +using FancyZonesCLI.CommandLine.Commands; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliCommandFactory +{ + public static RootCommand CreateRootCommand() + { + var root = new RootCommand("FancyZones CLI - Command line interface for FancyZones"); + + root.AddCommand(new OpenEditorCommand()); + root.AddCommand(new GetMonitorsCommand()); + root.AddCommand(new GetLayoutsCommand()); + root.AddCommand(new GetActiveLayoutCommand()); + root.AddCommand(new SetLayoutCommand()); + root.AddCommand(new OpenSettingsCommand()); + root.AddCommand(new GetHotkeysCommand()); + root.AddCommand(new SetHotkeyCommand()); + root.AddCommand(new RemoveHotkeyCommand()); + + return root; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.cs new file mode 100644 index 0000000000..e80af07c15 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliGuards.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.Diagnostics; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliGuards +{ + public static bool IsFancyZonesRunning() + { + try + { + return Process.GetProcessesByName("PowerToys.FancyZones").Length != 0; + } + catch + { + return false; + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs new file mode 100644 index 0000000000..2d18624507 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/CommandLine/FancyZonesCliUsage.cs @@ -0,0 +1,62 @@ +// 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.CommandLine; +using System.Linq; + +namespace FancyZonesCLI.CommandLine; + +internal static class FancyZonesCliUsage +{ + public static void PrintUsage() + { + Console.OutputEncoding = System.Text.Encoding.UTF8; + Console.WriteLine("FancyZones CLI - Command line interface for FancyZones"); + Console.WriteLine(); + + var cmd = FancyZonesCliCommandFactory.CreateRootCommand(); + + Console.WriteLine("Usage: FancyZonesCLI [command] [options]"); + Console.WriteLine(); + + Console.WriteLine("Options:"); + foreach (var option in cmd.Options) + { + var aliases = string.Join(", ", option.Aliases); + var description = option.Description ?? string.Empty; + Console.WriteLine($" {aliases,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine("Commands:"); + foreach (var command in cmd.Subcommands) + { + if (command.IsHidden) + { + continue; + } + + // Format: "command-name , alias" + string argsLabel = string.Join(" ", command.Arguments.Select(a => $"<{a.Name}>")); + string baseLabel = string.IsNullOrEmpty(argsLabel) ? command.Name : $"{command.Name} {argsLabel}"; + + // Find first alias (Aliases includes Name) + string alias = command.Aliases.FirstOrDefault(a => !string.Equals(a, command.Name, StringComparison.OrdinalIgnoreCase)); + string label = string.IsNullOrEmpty(alias) ? baseLabel : $"{baseLabel}, {alias}"; + + var description = command.Description ?? string.Empty; + Console.WriteLine($" {label,-30} {description}"); + } + + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" FancyZonesCLI --help"); + Console.WriteLine(" FancyZonesCLI --version"); + Console.WriteLine(" FancyZonesCLI get-monitors"); + Console.WriteLine(" FancyZonesCLI set-layout focus"); + Console.WriteLine(" FancyZonesCLI set-layout --monitor 1"); + Console.WriteLine(" FancyZonesCLI get-hotkeys"); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs deleted file mode 100644 index 7bf15dda44..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs +++ /dev/null @@ -1,90 +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.Diagnostics; -using System.IO; -using System.Linq; - -namespace FancyZonesCLI.Commands; - -/// -/// Editor and Settings commands. -/// -internal static class EditorCommands -{ - public static (int ExitCode, string Output) OpenEditor() - { - var editorExe = "PowerToys.FancyZonesEditor.exe"; - - // Check if editor-parameters.json exists - if (!FancyZonesData.EditorParametersExist()) - { - return (1, "Error: editor-parameters.json not found.\nPlease launch FancyZones Editor using Win+` (Win+Backtick) hotkey first."); - } - - // Check if editor is already running - var existingProcess = Process.GetProcessesByName("PowerToys.FancyZonesEditor").FirstOrDefault(); - if (existingProcess != null) - { - NativeMethods.SetForegroundWindow(existingProcess.MainWindowHandle); - return (0, "FancyZones Editor is already running. Brought window to foreground."); - } - - // Only check same directory as CLI - var editorPath = Path.Combine(AppContext.BaseDirectory, editorExe); - - if (File.Exists(editorPath)) - { - try - { - Process.Start(new ProcessStartInfo - { - FileName = editorPath, - UseShellExecute = true, - }); - return (0, "FancyZones Editor launched successfully."); - } - catch (Exception ex) - { - return (1, $"Failed to launch: {ex.Message}"); - } - } - - return (1, $"Error: Could not find {editorExe} in {AppContext.BaseDirectory}"); - } - - public static (int ExitCode, string Output) OpenSettings() - { - try - { - // Find PowerToys.exe in common locations - string powertoysExe = null; - - // Check in the same directory as the CLI (typical for dev builds) - var sameDirPath = Path.Combine(AppContext.BaseDirectory, "PowerToys.exe"); - if (File.Exists(sameDirPath)) - { - powertoysExe = sameDirPath; - } - - if (powertoysExe == null) - { - return (1, "Error: PowerToys.exe not found. Please ensure PowerToys is installed."); - } - - Process.Start(new ProcessStartInfo - { - FileName = powertoysExe, - Arguments = "--open-settings=FancyZones", - UseShellExecute = false, - }); - return (0, "FancyZones Settings opened successfully."); - } - catch (Exception ex) - { - return (1, $"Error: Failed to open FancyZones Settings. {ex.Message}"); - } - } -} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs deleted file mode 100644 index cfaf93a5d4..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs +++ /dev/null @@ -1,98 +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.Globalization; -using System.Linq; - -namespace FancyZonesCLI.Commands; - -/// -/// Hotkey-related commands. -/// -internal static class HotkeyCommands -{ - public static (int ExitCode, string Output) GetHotkeys() - { - var hotkeys = FancyZonesData.ReadLayoutHotkeys(); - if (hotkeys?.Hotkeys == null || hotkeys.Hotkeys.Count == 0) - { - return (0, "No hotkeys configured."); - } - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("=== Layout Hotkeys ===\n"); - sb.AppendLine("Press Win + Ctrl + Alt + to switch layouts:\n"); - - foreach (var hotkey in hotkeys.Hotkeys.OrderBy(h => h.Key)) - { - sb.AppendLine(CultureInfo.InvariantCulture, $" [{hotkey.Key}] => {hotkey.LayoutId}"); - } - - return (0, sb.ToString().TrimEnd()); - } - - public static (int ExitCode, string Output) SetHotkey(int key, string layoutUuid, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) - { - if (key < 0 || key > 9) - { - return (1, "Error: Key must be between 0 and 9"); - } - - // Check if this is a custom layout UUID - var customLayouts = FancyZonesData.ReadCustomLayouts(); - var matchedLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(layoutUuid, StringComparison.OrdinalIgnoreCase)); - bool isCustomLayout = matchedLayout != null; - string layoutName = matchedLayout?.Name ?? layoutUuid; - - var hotkeys = FancyZonesData.ReadLayoutHotkeys() ?? new LayoutHotkeys(); - - hotkeys.Hotkeys ??= new List(); - - // Remove existing hotkey for this key - hotkeys.Hotkeys.RemoveAll(h => h.Key == key); - - // Add new hotkey - hotkeys.Hotkeys.Add(new LayoutHotkey { Key = key, LayoutId = layoutUuid }); - - // Save - FancyZonesData.WriteLayoutHotkeys(hotkeys); - - // Notify FancyZones - notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); - - if (isCustomLayout) - { - return (0, $"✓ Hotkey {key} assigned to custom layout '{layoutName}'\n Press Win + Ctrl + Alt + {key} to switch to this layout"); - } - else - { - return (0, $"⚠ Warning: Hotkey {key} assigned to '{layoutUuid}'\n Note: FancyZones hotkeys only work with CUSTOM layouts.\n Template layouts (focus, columns, rows, etc.) cannot be used with hotkeys.\n Create a custom layout in the FancyZones Editor to use this hotkey."); - } - } - - public static (int ExitCode, string Output) RemoveHotkey(int key, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) - { - var hotkeys = FancyZonesData.ReadLayoutHotkeys(); - if (hotkeys?.Hotkeys == null) - { - return (0, $"No hotkey assigned to key {key}"); - } - - var removed = hotkeys.Hotkeys.RemoveAll(h => h.Key == key); - if (removed == 0) - { - return (0, $"No hotkey assigned to key {key}"); - } - - // Save - FancyZonesData.WriteLayoutHotkeys(hotkeys); - - // Notify FancyZones - notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); - - return (0, $"Hotkey {key} removed"); - } -} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs deleted file mode 100644 index 4400b32d46..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs +++ /dev/null @@ -1,276 +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.Globalization; -using System.Linq; -using System.Text.Json; - -namespace FancyZonesCLI.Commands; - -/// -/// Layout-related commands. -/// -internal static class LayoutCommands -{ - public static (int ExitCode, string Output) GetLayouts() - { - var sb = new System.Text.StringBuilder(); - - // Print template layouts - var templatesJson = FancyZonesData.ReadLayoutTemplates(); - if (templatesJson?.Templates != null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"=== Built-in Template Layouts ({templatesJson.Templates.Count} total) ===\n"); - - for (int i = 0; i < templatesJson.Templates.Count; i++) - { - var template = templatesJson.Templates[i]; - sb.AppendLine(CultureInfo.InvariantCulture, $"[T{i + 1}] {template.Type}"); - sb.Append(CultureInfo.InvariantCulture, $" Zones: {template.ZoneCount}"); - if (template.ShowSpacing && template.Spacing > 0) - { - sb.Append(CultureInfo.InvariantCulture, $", Spacing: {template.Spacing}px"); - } - - sb.AppendLine(); - sb.AppendLine(); - - // Draw visual preview - sb.Append(LayoutVisualizer.DrawTemplateLayout(template)); - - if (i < templatesJson.Templates.Count - 1) - { - sb.AppendLine(); - } - } - - sb.AppendLine("\n"); - } - - // Print custom layouts - var customLayouts = FancyZonesData.ReadCustomLayouts(); - if (customLayouts?.Layouts != null) - { - sb.AppendLine(CultureInfo.InvariantCulture, $"=== Custom Layouts ({customLayouts.Layouts.Count} total) ==="); - - for (int i = 0; i < customLayouts.Layouts.Count; i++) - { - var layout = customLayouts.Layouts[i]; - sb.AppendLine(CultureInfo.InvariantCulture, $"[{i + 1}] {layout.Name}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.Uuid}"); - sb.Append(CultureInfo.InvariantCulture, $" Type: {layout.Type}"); - - bool isCanvasLayout = false; - if (layout.Info.ValueKind != JsonValueKind.Undefined && layout.Info.ValueKind != JsonValueKind.Null) - { - if (layout.Type == "grid" && layout.Info.TryGetProperty("rows", out var rows) && layout.Info.TryGetProperty("columns", out var cols)) - { - sb.Append(CultureInfo.InvariantCulture, $" ({rows.GetInt32()}x{cols.GetInt32()} grid)"); - } - else if (layout.Type == "canvas" && layout.Info.TryGetProperty("zones", out var zones)) - { - sb.Append(CultureInfo.InvariantCulture, $" ({zones.GetArrayLength()} zones)"); - isCanvasLayout = true; - } - } - - sb.AppendLine("\n"); - - // Draw visual preview - sb.Append(LayoutVisualizer.DrawCustomLayout(layout)); - - // Add note for canvas layouts - if (isCanvasLayout) - { - sb.AppendLine("\n Note: Canvas layout preview is approximate."); - sb.AppendLine(" Open FancyZones Editor for precise zone boundaries."); - } - - if (i < customLayouts.Layouts.Count - 1) - { - sb.AppendLine(); - } - } - - sb.AppendLine("\nUse 'FancyZonesCLI.exe set-layout ' to apply a layout."); - } - - return (0, sb.ToString().TrimEnd()); - } - - public static (int ExitCode, string Output) GetActiveLayout() - { - if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) - { - return (1, $"Error: {error}"); - } - - if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) - { - return (0, "No active layouts found."); - } - - var sb = new System.Text.StringBuilder(); - sb.AppendLine("\n=== Active FancyZones Layout(s) ===\n"); - - for (int i = 0; i < appliedLayouts.Layouts.Count; i++) - { - var layout = appliedLayouts.Layouts[i]; - sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {i + 1}:"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Name: {layout.AppliedLayout.Type}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.AppliedLayout.Uuid}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Type: {layout.AppliedLayout.Type} ({layout.AppliedLayout.ZoneCount} zones)"); - - if (layout.AppliedLayout.ShowSpacing) - { - sb.AppendLine(CultureInfo.InvariantCulture, $" Spacing: {layout.AppliedLayout.Spacing}px"); - } - - sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); - - if (i < appliedLayouts.Layouts.Count - 1) - { - sb.AppendLine(); - } - } - - return (0, sb.ToString().TrimEnd()); - } - - public static (int ExitCode, string Output) SetLayout(string[] args, Action notifyFancyZones, uint wmPrivAppliedLayoutsFileUpdate) - { - Logger.LogInfo($"SetLayout called with args: [{string.Join(", ", args)}]"); - - if (args.Length == 0) - { - return (1, "Error: set-layout requires a UUID parameter"); - } - - string uuid = args[0]; - int? targetMonitor = null; - bool applyToAll = false; - - // Parse options - for (int i = 1; i < args.Length; i++) - { - if (args[i] == "--monitor" && i + 1 < args.Length) - { - if (int.TryParse(args[i + 1], out int monitorNum)) - { - targetMonitor = monitorNum; - i++; // Skip next arg - } - else - { - return (1, $"Error: Invalid monitor number: {args[i + 1]}"); - } - } - else if (args[i] == "--all") - { - applyToAll = true; - } - } - - if (targetMonitor.HasValue && applyToAll) - { - return (1, "Error: Cannot specify both --monitor and --all"); - } - - // Try to find layout in custom layouts first (by UUID) - var customLayouts = FancyZonesData.ReadCustomLayouts(); - var targetCustomLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(uuid, StringComparison.OrdinalIgnoreCase)); - - // If not found in custom layouts, try template layouts (by type name) - TemplateLayout targetTemplate = null; - if (targetCustomLayout == null) - { - var templates = FancyZonesData.ReadLayoutTemplates(); - targetTemplate = templates?.Templates?.FirstOrDefault(t => t.Type.Equals(uuid, StringComparison.OrdinalIgnoreCase)); - } - - if (targetCustomLayout == null && targetTemplate == null) - { - return (1, $"Error: Layout '{uuid}' not found\nTip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid', 'priority-grid')\n For custom layouts, use the UUID from 'get-layouts'"); - } - - // Read current applied layouts - if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) - { - return (1, $"Error: {error}"); - } - - if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) - { - return (1, "Error: No monitors configured"); - } - - // Determine which monitors to update - List monitorsToUpdate = new List(); - if (applyToAll) - { - for (int i = 0; i < appliedLayouts.Layouts.Count; i++) - { - monitorsToUpdate.Add(i); - } - } - else if (targetMonitor.HasValue) - { - int monitorIndex = targetMonitor.Value - 1; // Convert to 0-based - if (monitorIndex < 0 || monitorIndex >= appliedLayouts.Layouts.Count) - { - return (1, $"Error: Monitor {targetMonitor.Value} not found. Available monitors: 1-{appliedLayouts.Layouts.Count}"); - } - - monitorsToUpdate.Add(monitorIndex); - } - else - { - // Default: first monitor - monitorsToUpdate.Add(0); - } - - // Update selected monitors - foreach (int monitorIndex in monitorsToUpdate) - { - if (targetCustomLayout != null) - { - appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = targetCustomLayout.Uuid; - appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetCustomLayout.Type; - } - else if (targetTemplate != null) - { - // For templates, use all-zeros UUID and the template type - appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = "{00000000-0000-0000-0000-000000000000}"; - appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetTemplate.Type; - appliedLayouts.Layouts[monitorIndex].AppliedLayout.ZoneCount = targetTemplate.ZoneCount; - appliedLayouts.Layouts[monitorIndex].AppliedLayout.ShowSpacing = targetTemplate.ShowSpacing; - appliedLayouts.Layouts[monitorIndex].AppliedLayout.Spacing = targetTemplate.Spacing; - } - } - - // Write back to file - FancyZonesData.WriteAppliedLayouts(appliedLayouts); - Logger.LogInfo($"Applied layouts file updated for {monitorsToUpdate.Count} monitor(s)"); - - // Notify FancyZones to reload - notifyFancyZones(wmPrivAppliedLayoutsFileUpdate); - Logger.LogInfo("FancyZones notified of layout change"); - - string layoutName = targetCustomLayout?.Name ?? targetTemplate?.Type ?? uuid; - if (applyToAll) - { - return (0, $"Layout '{layoutName}' applied to all {monitorsToUpdate.Count} monitors"); - } - else if (targetMonitor.HasValue) - { - return (0, $"Layout '{layoutName}' applied to monitor {targetMonitor.Value}"); - } - else - { - return (0, $"Layout '{layoutName}' applied to monitor 1"); - } - } -} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs deleted file mode 100644 index f542b901cc..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs +++ /dev/null @@ -1,49 +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.Globalization; - -namespace FancyZonesCLI.Commands; - -/// -/// Monitor-related commands. -/// -internal static class MonitorCommands -{ - public static (int ExitCode, string Output) GetMonitors() - { - if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) - { - return (1, $"Error: {error}"); - } - - if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) - { - return (0, "No monitors found."); - } - - var sb = new System.Text.StringBuilder(); - sb.AppendLine(CultureInfo.InvariantCulture, $"=== Monitors ({appliedLayouts.Layouts.Count} total) ==="); - sb.AppendLine(); - - for (int i = 0; i < appliedLayouts.Layouts.Count; i++) - { - var layout = appliedLayouts.Layouts[i]; - var monitorNum = i + 1; - - sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {monitorNum}:"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor: {layout.Device.Monitor}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Instance: {layout.Device.MonitorInstance}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Number: {layout.Device.MonitorNumber}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Serial Number: {layout.Device.SerialNumber}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Virtual Desktop: {layout.Device.VirtualDesktop}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Active Layout: {layout.AppliedLayout.Type}"); - sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {layout.AppliedLayout.ZoneCount}"); - sb.AppendLine(); - } - - return (0, sb.ToString().TrimEnd()); - } -} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj index 85c2fa30e5..36274c95ef 100644 --- a/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj @@ -2,7 +2,6 @@ - PowerToys.FancyZonesCLI @@ -10,8 +9,6 @@ PowerToys FancyZones CLI Exe x64;ARM64 - true - true false false ..\..\..\..\$(Platform)\$(Configuration) @@ -21,9 +18,14 @@ + + + + + diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs deleted file mode 100644 index 2396c51f44..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs +++ /dev/null @@ -1,142 +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.IO; -using System.Text.Json; -using System.Text.Json.Serialization.Metadata; - -namespace FancyZonesCLI; - -/// -/// Provides methods to read and write FancyZones configuration data. -/// -internal static class FancyZonesData -{ - /// - /// Try to read applied layouts configuration. - /// - public static bool TryReadAppliedLayouts(out AppliedLayouts result, out string error) - { - return TryReadJsonFile(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts, out result, out error); - } - - /// - /// Read applied layouts or return null if not found. - /// - public static AppliedLayouts ReadAppliedLayouts() - { - return ReadJsonFileOrDefault(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts); - } - - /// - /// Write applied layouts configuration. - /// - public static void WriteAppliedLayouts(AppliedLayouts layouts) - { - WriteJsonFile(FancyZonesPaths.AppliedLayouts, layouts, FancyZonesJsonContext.Default.AppliedLayouts); - } - - /// - /// Read custom layouts or return null if not found. - /// - public static CustomLayouts ReadCustomLayouts() - { - return ReadJsonFileOrDefault(FancyZonesPaths.CustomLayouts, FancyZonesJsonContext.Default.CustomLayouts); - } - - /// - /// Read layout templates or return null if not found. - /// - public static LayoutTemplates ReadLayoutTemplates() - { - return ReadJsonFileOrDefault(FancyZonesPaths.LayoutTemplates, FancyZonesJsonContext.Default.LayoutTemplates); - } - - /// - /// Read layout hotkeys or return null if not found. - /// - public static LayoutHotkeys ReadLayoutHotkeys() - { - return ReadJsonFileOrDefault(FancyZonesPaths.LayoutHotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); - } - - /// - /// Write layout hotkeys configuration. - /// - public static void WriteLayoutHotkeys(LayoutHotkeys hotkeys) - { - WriteJsonFile(FancyZonesPaths.LayoutHotkeys, hotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); - } - - /// - /// Check if editor parameters file exists. - /// - public static bool EditorParametersExist() - { - return File.Exists(FancyZonesPaths.EditorParameters); - } - - private static bool TryReadJsonFile(string filePath, JsonTypeInfo jsonTypeInfo, out T result, out string error) - where T : class - { - result = null; - error = null; - - Logger.LogDebug($"Reading file: {filePath}"); - - if (!File.Exists(filePath)) - { - error = $"File not found: {Path.GetFileName(filePath)}"; - Logger.LogWarning(error); - return false; - } - - try - { - var json = File.ReadAllText(filePath); - result = JsonSerializer.Deserialize(json, jsonTypeInfo); - if (result == null) - { - error = $"Failed to parse {Path.GetFileName(filePath)}"; - Logger.LogError(error); - return false; - } - - Logger.LogDebug($"Successfully read {Path.GetFileName(filePath)}"); - return true; - } - catch (JsonException ex) - { - error = $"JSON parse error in {Path.GetFileName(filePath)}: {ex.Message}"; - Logger.LogError(error, ex); - return false; - } - catch (IOException ex) - { - error = $"Failed to read {Path.GetFileName(filePath)}: {ex.Message}"; - Logger.LogError(error, ex); - return false; - } - } - - private static T ReadJsonFileOrDefault(string filePath, JsonTypeInfo jsonTypeInfo, T defaultValue = null) - where T : class - { - if (TryReadJsonFile(filePath, jsonTypeInfo, out var result, out _)) - { - return result; - } - - return defaultValue; - } - - private static void WriteJsonFile(string filePath, T data, JsonTypeInfo jsonTypeInfo) - { - Logger.LogDebug($"Writing file: {filePath}"); - var json = JsonSerializer.Serialize(data, jsonTypeInfo); - File.WriteAllText(filePath, json); - Logger.LogInfo($"Successfully wrote {Path.GetFileName(filePath)}"); - } -} diff --git a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs index fecdf33dbe..bf4c658119 100644 --- a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs +++ b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs @@ -7,12 +7,13 @@ using System.Collections.Generic; using System.Globalization; using System.Text; using System.Text.Json; +using FancyZonesEditorCommon.Data; namespace FancyZonesCLI; public static class LayoutVisualizer { - public static string DrawTemplateLayout(TemplateLayout template) + public static string DrawTemplateLayout(LayoutTemplates.TemplateLayoutWrapper template) { var sb = new StringBuilder(); sb.AppendLine(" Visual Preview:"); @@ -62,7 +63,7 @@ public static class LayoutVisualizer return sb.ToString(); } - public static string DrawCustomLayout(CustomLayout layout) + public static string DrawCustomLayout(CustomLayouts.CustomLayoutWrapper layout) { if (layout.Info.ValueKind == JsonValueKind.Undefined || layout.Info.ValueKind == JsonValueKind.Null) { @@ -426,6 +427,11 @@ public static class LayoutVisualizer const int displayWidth = 49; const int displayHeight = 15; + if (refWidth <= 0 || refHeight <= 0) + { + return string.Empty; + } + // Create a 2D array to track which zones occupy each position var zoneGrid = new List[displayHeight, displayWidth]; for (int i = 0; i < displayHeight; i++) @@ -442,10 +448,13 @@ public static class LayoutVisualizer foreach (var zone in zones.EnumerateArray()) { - int x = zone.GetProperty("X").GetInt32(); - int y = zone.GetProperty("Y").GetInt32(); - int w = zone.GetProperty("width").GetInt32(); - int h = zone.GetProperty("height").GetInt32(); + if (!TryGetInt32Property(zone, "x", "X", out int x) || + !TryGetInt32Property(zone, "y", "Y", out int y) || + !TryGetInt32Property(zone, "width", "Width", out int w) || + !TryGetInt32Property(zone, "height", "Height", out int h)) + { + continue; + } int dx = Math.Max(0, Math.Min(displayWidth - 1, x * displayWidth / refWidth)); int dy = Math.Max(0, Math.Min(displayHeight - 1, y * displayHeight / refHeight)); @@ -547,4 +556,23 @@ public static class LayoutVisualizer sb.AppendLine(); return sb.ToString(); } + + private static bool TryGetInt32Property(JsonElement element, string primaryName, string fallbackName, out int value) + { + if (element.TryGetProperty(primaryName, out var property) || element.TryGetProperty(fallbackName, out property)) + { + if (property.ValueKind == JsonValueKind.Number) + { + return property.TryGetInt32(out value); + } + + if (property.ValueKind == JsonValueKind.String) + { + return int.TryParse(property.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + } + + value = default; + return false; + } } diff --git a/src/modules/fancyzones/FancyZonesCLI/Models.cs b/src/modules/fancyzones/FancyZonesCLI/Models.cs deleted file mode 100644 index 0c8bbefe54..0000000000 --- a/src/modules/fancyzones/FancyZonesCLI/Models.cs +++ /dev/null @@ -1,137 +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.Collections.Generic; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace FancyZonesCLI; - -// JSON Source Generator for AOT compatibility -[JsonSerializable(typeof(LayoutTemplates))] -[JsonSerializable(typeof(CustomLayouts))] -[JsonSerializable(typeof(AppliedLayouts))] -[JsonSerializable(typeof(LayoutHotkeys))] -[JsonSourceGenerationOptions(WriteIndented = true)] -internal partial class FancyZonesJsonContext : JsonSerializerContext -{ -} - -// Layout Templates -public sealed class LayoutTemplates -{ - [JsonPropertyName("layout-templates")] - public List Templates { get; set; } -} - -public sealed class TemplateLayout -{ - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; - - [JsonPropertyName("zone-count")] - public int ZoneCount { get; set; } - - [JsonPropertyName("show-spacing")] - public bool ShowSpacing { get; set; } - - [JsonPropertyName("spacing")] - public int Spacing { get; set; } - - [JsonPropertyName("sensitivity-radius")] - public int SensitivityRadius { get; set; } -} - -// Custom Layouts -public sealed class CustomLayouts -{ - [JsonPropertyName("custom-layouts")] - public List Layouts { get; set; } -} - -public sealed class CustomLayout -{ - [JsonPropertyName("uuid")] - public string Uuid { get; set; } = string.Empty; - - [JsonPropertyName("name")] - public string Name { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; - - [JsonPropertyName("info")] - public JsonElement Info { get; set; } -} - -// Applied Layouts -public sealed class AppliedLayouts -{ - [JsonPropertyName("applied-layouts")] - public List Layouts { get; set; } -} - -public sealed class AppliedLayoutWrapper -{ - [JsonPropertyName("device")] - public DeviceInfo Device { get; set; } = new(); - - [JsonPropertyName("applied-layout")] - public AppliedLayoutInfo AppliedLayout { get; set; } = new(); -} - -public sealed class DeviceInfo -{ - [JsonPropertyName("monitor")] - public string Monitor { get; set; } = string.Empty; - - [JsonPropertyName("monitor-instance")] - public string MonitorInstance { get; set; } = string.Empty; - - [JsonPropertyName("monitor-number")] - public int MonitorNumber { get; set; } - - [JsonPropertyName("serial-number")] - public string SerialNumber { get; set; } = string.Empty; - - [JsonPropertyName("virtual-desktop")] - public string VirtualDesktop { get; set; } = string.Empty; -} - -public sealed class AppliedLayoutInfo -{ - [JsonPropertyName("uuid")] - public string Uuid { get; set; } = string.Empty; - - [JsonPropertyName("type")] - public string Type { get; set; } = string.Empty; - - [JsonPropertyName("show-spacing")] - public bool ShowSpacing { get; set; } - - [JsonPropertyName("spacing")] - public int Spacing { get; set; } - - [JsonPropertyName("zone-count")] - public int ZoneCount { get; set; } - - [JsonPropertyName("sensitivity-radius")] - public int SensitivityRadius { get; set; } -} - -// Layout Hotkeys -public sealed class LayoutHotkeys -{ - [JsonPropertyName("layout-hotkeys")] - public List Hotkeys { get; set; } -} - -public sealed class LayoutHotkey -{ - [JsonPropertyName("key")] - public int Key { get; set; } - - [JsonPropertyName("layout-id")] - public string LayoutId { get; set; } = string.Empty; -} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs index efab0859bd..4d3f14db0c 100644 --- a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs @@ -15,6 +15,7 @@ internal static class NativeMethods // Registered Windows messages for notifying FancyZones private static uint wmPrivAppliedLayoutsFileUpdate; private static uint wmPrivLayoutHotkeysFileUpdate; + private static uint wmPrivSaveEditorParameters; /// /// Gets the Windows message ID for applied layouts file update notification. @@ -26,6 +27,11 @@ internal static class NativeMethods /// public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate; + /// + /// Gets the Windows message ID used to request saving editor-parameters.json. + /// + public static uint WM_PRIV_SAVE_EDITOR_PARAMETERS => wmPrivSaveEditorParameters; + /// /// Initializes the Windows messages used for FancyZones notifications. /// @@ -33,6 +39,7 @@ internal static class NativeMethods { wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}"); wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}"); + wmPrivSaveEditorParameters = PInvoke.RegisterWindowMessage("{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}"); } /// diff --git a/src/modules/fancyzones/FancyZonesCLI/Program.cs b/src/modules/fancyzones/FancyZonesCLI/Program.cs index 1b133dfa36..6c44653c79 100644 --- a/src/modules/fancyzones/FancyZonesCLI/Program.cs +++ b/src/modules/fancyzones/FancyZonesCLI/Program.cs @@ -3,113 +3,44 @@ // See the LICENSE file in the project root for more information. using System; -using System.Globalization; +using System.CommandLine; using System.Linq; -using FancyZonesCLI.Commands; +using System.Threading.Tasks; +using FancyZonesCLI.CommandLine; namespace FancyZonesCLI; internal sealed class Program { - private static int Main(string[] args) + private static async Task Main(string[] args) { - // Initialize logger Logger.InitializeLogger(); Logger.LogInfo($"CLI invoked with args: [{string.Join(", ", args)}]"); - // Initialize Windows messages + // Initialize Windows messages used to notify FancyZones. NativeMethods.InitializeWindowMessages(); - (int ExitCode, string Output) result; - - if (args.Length == 0) + // Intercept help requests early and print custom usage. + if (args.Any(a => string.Equals(a, "--help", StringComparison.OrdinalIgnoreCase) || + string.Equals(a, "-h", StringComparison.OrdinalIgnoreCase) || + string.Equals(a, "-?", StringComparison.OrdinalIgnoreCase))) { - result = (1, GetUsageText()); + FancyZonesCliUsage.PrintUsage(); + return 0; + } + + RootCommand rootCommand = FancyZonesCliCommandFactory.CreateRootCommand(); + int exitCode = await rootCommand.InvokeAsync(args); + + if (exitCode == 0) + { + Logger.LogInfo("Command completed successfully"); } else { - var command = args[0].ToLowerInvariant(); - - result = command switch - { - "open-editor" or "editor" or "e" => EditorCommands.OpenEditor(), - "get-monitors" or "monitors" or "m" => MonitorCommands.GetMonitors(), - "get-layouts" or "layouts" or "ls" => LayoutCommands.GetLayouts(), - "get-active-layout" or "active" or "get-active" or "a" => LayoutCommands.GetActiveLayout(), - "set-layout" or "set" or "s" => args.Length >= 2 - ? LayoutCommands.SetLayout(args.Skip(1).ToArray(), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE) - : (1, "Error: set-layout requires a UUID parameter"), - "open-settings" or "settings" => EditorCommands.OpenSettings(), - "get-hotkeys" or "hotkeys" or "hk" => HotkeyCommands.GetHotkeys(), - "set-hotkey" or "shk" => args.Length >= 3 - ? HotkeyCommands.SetHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), args[2], NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) - : (1, "Error: set-hotkey requires "), - "remove-hotkey" or "rhk" => args.Length >= 2 - ? HotkeyCommands.RemoveHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) - : (1, "Error: remove-hotkey requires "), - "help" or "--help" or "-h" => (0, GetUsageText()), - _ => (1, $"Error: Unknown command: {command}\n\n{GetUsageText()}"), - }; + Logger.LogWarning($"Command failed with exit code {exitCode}"); } - // Log result - if (result.ExitCode == 0) - { - Logger.LogInfo($"Command completed successfully"); - } - else - { - Logger.LogWarning($"Command failed with exit code {result.ExitCode}: {result.Output}"); - } - - // Output result - if (!string.IsNullOrEmpty(result.Output)) - { - Console.WriteLine(result.Output); - } - - return result.ExitCode; - } - - private static string GetUsageText() - { - return """ - FancyZones CLI - Command line interface for FancyZones - ====================================================== - - Usage: FancyZonesCLI.exe [options] - - Commands: - open-editor (editor, e) Launch FancyZones layout editor - get-monitors (monitors, m) List all monitors and their properties - get-layouts (layouts, ls) List all available layouts - get-active-layout (get-active, active, a) - Show currently active layout - set-layout (set, s) [options] - Set layout by UUID - --monitor Apply to monitor N (1-based) - --all Apply to all monitors - open-settings (settings) Open FancyZones settings page - get-hotkeys (hotkeys, hk) List all layout hotkeys - set-hotkey (shk) Assign hotkey (0-9) to CUSTOM layout - Note: Only custom layouts work with hotkeys - remove-hotkey (rhk) Remove hotkey assignment - help Show this help message - - - Examples: - FancyZonesCLI.exe e # Open editor (short) - FancyZonesCLI.exe m # List monitors (short) - FancyZonesCLI.exe ls # List layouts (short) - FancyZonesCLI.exe a # Get active layout (short) - FancyZonesCLI.exe s focus --all # Set layout (short) - FancyZonesCLI.exe open-editor # Open editor (long) - FancyZonesCLI.exe get-monitors - FancyZonesCLI.exe get-layouts - FancyZonesCLI.exe set-layout {12345678-1234-1234-1234-123456789012} - FancyZonesCLI.exe set-layout focus --monitor 2 - FancyZonesCLI.exe set-layout columns --all - FancyZonesCLI.exe set-hotkey 3 {12345678-1234-1234-1234-123456789012} - """; + return exitCode; } } diff --git a/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs b/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs new file mode 100644 index 0000000000..f403ee0de5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Utils/AppliedLayoutsHelper.cs @@ -0,0 +1,89 @@ +// 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 FancyZonesEditorCommon.Data; + +namespace FancyZonesCLI.Utils; + +/// +/// Helper for managing applied layouts across monitors. +/// CLI-only business logic for matching, finding, and updating applied layouts. +/// +internal static class AppliedLayoutsHelper +{ + public const string DefaultVirtualDesktopGuid = "{00000000-0000-0000-0000-000000000000}"; + + public static bool MatchesDevice( + AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper device, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + // Must match monitor name + if (device.Monitor != monitorName) + { + return false; + } + + // Must match virtual desktop + if (device.VirtualDesktop != virtualDesktop) + { + return false; + } + + // If serial numbers are both available, they must match + if (!string.IsNullOrEmpty(device.SerialNumber) && !string.IsNullOrEmpty(serialNumber)) + { + if (device.SerialNumber != serialNumber) + { + return false; + } + } + + // If we reach here: Monitor name, VirtualDesktop, and SerialNumber (if available) all match + // MonitorInstance and MonitorNumber can vary, so we accept any value + return true; + } + + public static bool MatchesDeviceWithDefaultVirtualDesktop( + AppliedLayouts.AppliedLayoutWrapper.DeviceIdWrapper device, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + if (device.VirtualDesktop == DefaultVirtualDesktopGuid) + { + // For this one layout record only, match any virtual desktop. + return device.Monitor == monitorName; + } + + return MatchesDevice(device, monitorName, serialNumber, monitorNumber, virtualDesktop); + } + + public static AppliedLayouts.AppliedLayoutWrapper? FindLayoutForMonitor( + AppliedLayouts.AppliedLayoutsListWrapper layouts, + string monitorName, + string serialNumber, + int monitorNumber, + string virtualDesktop) + { + if (layouts.AppliedLayouts == null) + { + return null; + } + + foreach (var layout in layouts.AppliedLayouts) + { + if (MatchesDevice(layout.Device, monitorName, serialNumber, monitorNumber, virtualDesktop)) + { + return layout; + } + } + + return null; + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs b/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs new file mode 100644 index 0000000000..898d4b2f9e --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Utils/EditorParametersRefresh.cs @@ -0,0 +1,68 @@ +// 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.IO; +using System.Text.Json; +using System.Threading; + +using FancyZonesEditorCommon.Data; +using FancyZonesEditorCommon.Utils; + +namespace FancyZonesCLI.Utils; + +/// +/// Helper for requesting FancyZones to save editor-parameters.json and reading it reliably. +/// +internal static class EditorParametersRefresh +{ + public static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh(Action requestSave) + { + const int maxWaitMilliseconds = 500; + const int pollIntervalMilliseconds = 50; + + string filePath = FancyZonesPaths.EditorParameters; + DateTime lastWriteBefore = File.Exists(filePath) ? File.GetLastWriteTimeUtc(filePath) : DateTime.MinValue; + + requestSave(); + + int elapsedMilliseconds = 0; + while (elapsedMilliseconds < maxWaitMilliseconds) + { + try + { + if (File.Exists(filePath)) + { + DateTime lastWriteNow = File.GetLastWriteTimeUtc(filePath); + + // Prefer reading after the file is updated, but don't block forever if the + // timestamp resolution is coarse or FancyZones rewrites identical content. + if (lastWriteNow >= lastWriteBefore || elapsedMilliseconds > 100) + { + var editorParams = FancyZonesDataIO.ReadEditorParameters(); + if (editorParams.Monitors != null && editorParams.Monitors.Count > 0) + { + return editorParams; + } + } + } + } + catch (Exception ex) when (ex is FileNotFoundException || ex is IOException || ex is UnauthorizedAccessException || ex is JsonException) + { + // File may be mid-write/locked or temporarily invalid JSON; retry. + } + + Thread.Sleep(pollIntervalMilliseconds); + elapsedMilliseconds += pollIntervalMilliseconds; + } + + var finalParams = FancyZonesDataIO.ReadEditorParameters(); + if (finalParams.Monitors == null || finalParams.Monitors.Count == 0) + { + throw new InvalidOperationException($"Could not get current monitor information (timed out after {maxWaitMilliseconds}ms waiting for '{Path.GetFileName(filePath)}')."); + } + + return finalParams; + } +} diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs index 188e834b37..2a5545d83b 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/AppliedLayouts.cs @@ -12,7 +12,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\applied-layouts.json"; + return FancyZonesPaths.AppliedLayouts; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs index 110250ce02..7be8746964 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/CustomLayouts.cs @@ -15,7 +15,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\custom-layouts.json"; + return FancyZonesPaths.CustomLayouts; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs index 0a916559dc..03020c184f 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/DefaultLayouts.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\default-layouts.json"; + return FancyZonesPaths.DefaultLayouts; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs index e973c6070f..fe1f023e0a 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/EditorParameters.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\editor-parameters.json"; + return FancyZonesPaths.EditorParameters; } } diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.cs similarity index 85% rename from src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs rename to src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.cs index f04d375392..3d47f9e4c1 100644 --- a/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/FancyZonesPaths.cs @@ -5,12 +5,12 @@ using System; using System.IO; -namespace FancyZonesCLI; +namespace FancyZonesEditorCommon.Data; /// /// Provides paths to FancyZones configuration files. /// -internal static class FancyZonesPaths +public static class FancyZonesPaths { private static readonly string DataPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @@ -27,4 +27,6 @@ internal static class FancyZonesPaths public static string LayoutHotkeys => Path.Combine(DataPath, "layout-hotkeys.json"); public static string EditorParameters => Path.Combine(DataPath, "editor-parameters.json"); + + public static string DefaultLayouts => Path.Combine(DataPath, "default-layouts.json"); } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs index 9b4fc2661f..1f4dfabf7f 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutHotkeys.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-hotkeys.json"; + return FancyZonesPaths.LayoutHotkeys; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs index 4cb932571b..8dae1fbb3b 100644 --- a/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Data/LayoutTemplates.cs @@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data { get { - return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-templates.json"; + return FancyZonesPaths.LayoutTemplates; } } diff --git a/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs b/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs new file mode 100644 index 0000000000..e07fbde0f6 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesEditorCommon/Utils/FancyZonesDataIO.cs @@ -0,0 +1,154 @@ +// 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.IO; +using FancyZonesEditorCommon.Data; + +namespace FancyZonesEditorCommon.Utils +{ + /// + /// Unified helper for all FancyZones data file I/O operations. + /// Centralizes reading and writing of all JSON configuration files. + /// + public static class FancyZonesDataIO + { + private static TWrapper ReadData( + Func createInstance, + Func fileSelector, + Func readFunc) + { + var instance = createInstance(); + string filePath = fileSelector(instance); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"File not found: {Path.GetFileName(filePath)}", filePath); + } + + return readFunc(instance, filePath); + } + + private static void WriteData( + Func createInstance, + Func fileSelector, + Func serializeFunc, + TWrapper data) + { + var instance = createInstance(); + var filePath = fileSelector(instance); + + IOUtils ioUtils = new IOUtils(); + ioUtils.WriteFile(filePath, serializeFunc(instance, data)); + } + + // AppliedLayouts operations + public static AppliedLayouts.AppliedLayoutsListWrapper ReadAppliedLayouts() + { + return ReadData( + () => new AppliedLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteAppliedLayouts(AppliedLayouts.AppliedLayoutsListWrapper data) + { + WriteData( + () => new AppliedLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // CustomLayouts operations + public static CustomLayouts.CustomLayoutListWrapper ReadCustomLayouts() + { + return ReadData( + () => new CustomLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteCustomLayouts(CustomLayouts.CustomLayoutListWrapper data) + { + WriteData( + () => new CustomLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // LayoutTemplates operations + public static LayoutTemplates.TemplateLayoutsListWrapper ReadLayoutTemplates() + { + return ReadData( + () => new LayoutTemplates(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteLayoutTemplates(LayoutTemplates.TemplateLayoutsListWrapper data) + { + WriteData( + () => new LayoutTemplates(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // LayoutHotkeys operations + public static LayoutHotkeys.LayoutHotkeysWrapper ReadLayoutHotkeys() + { + return ReadData( + () => new LayoutHotkeys(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteLayoutHotkeys(LayoutHotkeys.LayoutHotkeysWrapper data) + { + WriteData( + () => new LayoutHotkeys(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // EditorParameters operations + public static EditorParameters.ParamsWrapper ReadEditorParameters() + { + return ReadData( + () => new EditorParameters(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteEditorParameters(EditorParameters.ParamsWrapper data) + { + WriteData( + () => new EditorParameters(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + + // DefaultLayouts operations + public static DefaultLayouts.DefaultLayoutsListWrapper ReadDefaultLayouts() + { + return ReadData( + () => new DefaultLayouts(), + instance => instance.File, + (instance, file) => instance.Read(file)); + } + + public static void WriteDefaultLayouts(DefaultLayouts.DefaultLayoutsListWrapper data) + { + WriteData( + () => new DefaultLayouts(), + instance => instance.File, + (instance, wrapper) => instance.Serialize(wrapper), + data); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp b/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp index 1956c57a97..68bd08c6af 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp +++ b/src/modules/fancyzones/FancyZonesLib/FancyZones.cpp @@ -728,6 +728,13 @@ LRESULT FancyZones::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lpa { FancyZonesSettings::instance().LoadSettings(); } + else if (message == WM_PRIV_SAVE_EDITOR_PARAMETERS) + { + if (!EditorParameters::Save(m_workAreaConfiguration, m_dpiUnawareThread)) + { + Logger::warn(L"Failed to save editor-parameters.json"); + } + } else { return DefWindowProc(window, message, wparam, lparam); diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp index 404486797a..94c07ebd24 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp +++ b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.cpp @@ -18,6 +18,7 @@ UINT WM_PRIV_DEFAULT_LAYOUTS_FILE_UPDATE; UINT WM_PRIV_SNAP_HOTKEY; UINT WM_PRIV_QUICK_LAYOUT_KEY; UINT WM_PRIV_SETTINGS_CHANGED; +UINT WM_PRIV_SAVE_EDITOR_PARAMETERS; std::once_flag init_flag; @@ -40,5 +41,6 @@ void InitializeWinhookEventIds() WM_PRIV_SNAP_HOTKEY = RegisterWindowMessage(L"{72f4fd8e-23f1-43ab-bbbc-029363df9a84}"); WM_PRIV_QUICK_LAYOUT_KEY = RegisterWindowMessage(L"{15baab3d-c67b-4a15-aFF0-13610e05e947}"); WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{89ca3Daa-bf2d-4e73-9f3f-c60716364e27}"); + WM_PRIV_SAVE_EDITOR_PARAMETERS = RegisterWindowMessage(L"{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}"); }); } diff --git a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h index b00c8c1f8f..214a5b1f75 100644 --- a/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h +++ b/src/modules/fancyzones/FancyZonesLib/FancyZonesWinHookEventIDs.h @@ -16,5 +16,6 @@ extern UINT WM_PRIV_DEFAULT_LAYOUTS_FILE_UPDATE; // Scheduled when the watched d extern UINT WM_PRIV_SNAP_HOTKEY; // Scheduled when we receive a snap hotkey key down press extern UINT WM_PRIV_QUICK_LAYOUT_KEY; // Scheduled when we receive a key down press to quickly apply a layout extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when a watched settings file is updated +extern UINT WM_PRIV_SAVE_EDITOR_PARAMETERS; // Scheduled to request saving editor-parameters.json void InitializeWinhookEventIds();