diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt
index d4be728886..672616c8e7 100644
--- a/.github/actions/spell-check/expect.txt
+++ b/.github/actions/spell-check/expect.txt
@@ -1483,6 +1483,7 @@ rgh
rgn
rgs
rguid
+rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1588,6 +1589,7 @@ SHGDNF
SHGFI
SHIL
shinfo
+shk
shlwapi
shobjidl
SHORTCUTATLEAST
diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json
index 83289fa102..f4e3e1ba38 100644
--- a/.pipelines/ESRPSigning_core.json
+++ b/.pipelines/ESRPSigning_core.json
@@ -60,6 +60,8 @@
"PowerToys.FancyZonesEditorCommon.dll",
"PowerToys.FancyZonesModuleInterface.dll",
"PowerToys.FancyZones.exe",
+ "FancyZonesCLI.exe",
+ "FancyZonesCLI.dll",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",
diff --git a/PowerToys.slnx b/PowerToys.slnx
index c946514fb5..1884b2d58b 100644
--- a/PowerToys.slnx
+++ b/PowerToys.slnx
@@ -370,6 +370,10 @@
+
+
+
+
diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs
new file mode 100644
index 0000000000..7bf15dda44
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs
@@ -0,0 +1,90 @@
+// 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
new file mode 100644
index 0000000000..cfaf93a5d4
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs
@@ -0,0 +1,98 @@
+// 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
new file mode 100644
index 0000000000..4400b32d46
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs
@@ -0,0 +1,276 @@
+// 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
new file mode 100644
index 0000000000..f542b901cc
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs
@@ -0,0 +1,49 @@
+// 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
new file mode 100644
index 0000000000..85c2fa30e5
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ PowerToys.FancyZonesCLI
+ PowerToys FancyZones Command Line Interface
+ PowerToys FancyZones CLI
+ Exe
+ x64;ARM64
+ true
+ true
+ false
+ false
+ ..\..\..\..\$(Platform)\$(Configuration)
+ FancyZonesCLI
+ $(NoWarn);SA1500;SA1402;CA1852
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs
new file mode 100644
index 0000000000..2396c51f44
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs
@@ -0,0 +1,142 @@
+// 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/FancyZonesPaths.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs
new file mode 100644
index 0000000000..f04d375392
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs
@@ -0,0 +1,30 @@
+// 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;
+
+namespace FancyZonesCLI;
+
+///
+/// Provides paths to FancyZones configuration files.
+///
+internal static class FancyZonesPaths
+{
+ private static readonly string DataPath = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "Microsoft",
+ "PowerToys",
+ "FancyZones");
+
+ public static string AppliedLayouts => Path.Combine(DataPath, "applied-layouts.json");
+
+ public static string CustomLayouts => Path.Combine(DataPath, "custom-layouts.json");
+
+ public static string LayoutTemplates => Path.Combine(DataPath, "layout-templates.json");
+
+ public static string LayoutHotkeys => Path.Combine(DataPath, "layout-hotkeys.json");
+
+ public static string EditorParameters => Path.Combine(DataPath, "editor-parameters.json");
+}
diff --git a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs
new file mode 100644
index 0000000000..fecdf33dbe
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs
@@ -0,0 +1,550 @@
+// 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.Text;
+using System.Text.Json;
+
+namespace FancyZonesCLI;
+
+public static class LayoutVisualizer
+{
+ public static string DrawTemplateLayout(TemplateLayout template)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine(" Visual Preview:");
+
+ switch (template.Type.ToLowerInvariant())
+ {
+ case "focus":
+ sb.Append(RenderFocusLayout(template.ZoneCount > 0 ? template.ZoneCount : 3));
+ break;
+ case "columns":
+ sb.Append(RenderGridLayout(1, template.ZoneCount > 0 ? template.ZoneCount : 3));
+ break;
+ case "rows":
+ sb.Append(RenderGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3, 1));
+ break;
+ case "grid":
+ // Grid layout: calculate rows and columns from zone count
+ // Algorithm from GridLayoutModel.InitGrid() - tries to make it close to square
+ // with cols >= rows preference
+ int zoneCount = template.ZoneCount > 0 ? template.ZoneCount : 3;
+ int rows = 1;
+ while (zoneCount / rows >= rows)
+ {
+ rows++;
+ }
+
+ rows--;
+ int cols = zoneCount / rows;
+ if (zoneCount % rows != 0)
+ {
+ cols++;
+ }
+
+ sb.Append(RenderGridLayoutWithZoneCount(rows, cols, zoneCount));
+ break;
+ case "priority-grid":
+ sb.Append(RenderPriorityGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3));
+ break;
+ case "blank":
+ sb.AppendLine(" (No zones)");
+ break;
+ default:
+ sb.AppendLine(CultureInfo.InvariantCulture, $" ({template.Type} layout)");
+ break;
+ }
+
+ return sb.ToString();
+ }
+
+ public static string DrawCustomLayout(CustomLayout layout)
+ {
+ if (layout.Info.ValueKind == JsonValueKind.Undefined || layout.Info.ValueKind == JsonValueKind.Null)
+ {
+ return string.Empty;
+ }
+
+ var sb = new StringBuilder();
+ sb.AppendLine(" Visual Preview:");
+
+ if (layout.Type == "grid" &&
+ layout.Info.TryGetProperty("rows", out var rows) &&
+ layout.Info.TryGetProperty("columns", out var cols))
+ {
+ int r = rows.GetInt32();
+ int c = cols.GetInt32();
+
+ // Check if there's a cell-child-map (merged cells)
+ if (layout.Info.TryGetProperty("cell-child-map", out var cellMap))
+ {
+ sb.Append(RenderGridLayoutWithMergedCells(r, c, cellMap));
+ }
+ else
+ {
+ int height = r >= 4 ? 12 : 8;
+ sb.Append(RenderGridLayout(r, c, 30, height));
+ }
+ }
+ else if (layout.Type == "canvas" &&
+ layout.Info.TryGetProperty("zones", out var zones) &&
+ layout.Info.TryGetProperty("ref-width", out var refWidth) &&
+ layout.Info.TryGetProperty("ref-height", out var refHeight))
+ {
+ sb.Append(RenderCanvasLayout(zones, refWidth.GetInt32(), refHeight.GetInt32()));
+ }
+
+ return sb.ToString();
+ }
+
+ private static string RenderFocusLayout(int zoneCount = 3)
+ {
+ var sb = new StringBuilder();
+
+ // Focus layout: overlapping zones with cascading offset
+ if (zoneCount == 1)
+ {
+ sb.AppendLine(" +-------+");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" +-------+");
+ }
+ else if (zoneCount == 2)
+ {
+ sb.AppendLine(" +-------+");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" | +-------+");
+ sb.AppendLine(" +-| |");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" +-------+");
+ }
+ else
+ {
+ sb.AppendLine(" +-------+");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" | +-------+");
+ sb.AppendLine(" +-| |");
+ sb.AppendLine(" | +-------+");
+ sb.AppendLine(" +-| |");
+ sb.AppendLine(" ...");
+ sb.AppendLine(CultureInfo.InvariantCulture, $" (total: {zoneCount} zones)");
+ sb.AppendLine(" ...");
+ sb.AppendLine(" | +-------+");
+ sb.AppendLine(" +-| |");
+ sb.AppendLine(" | |");
+ sb.AppendLine(" +-------+");
+ }
+
+ return sb.ToString();
+ }
+
+ private static string RenderPriorityGridLayout(int zoneCount = 3)
+ {
+ // Priority Grid has predefined layouts for zone counts 1-11
+ // Data format from GridLayoutModel._priorityData
+ if (zoneCount >= 1 && zoneCount <= 11)
+ {
+ int[,] cellMap = GetPriorityGridCellMap(zoneCount);
+ return RenderGridLayoutWithCellMap(cellMap);
+ }
+ else
+ {
+ // > 11 zones: use grid layout
+ int rows = 1;
+ while (zoneCount / rows >= rows)
+ {
+ rows++;
+ }
+
+ rows--;
+ int cols = zoneCount / rows;
+ if (zoneCount % rows != 0)
+ {
+ cols++;
+ }
+
+ return RenderGridLayoutWithZoneCount(rows, cols, zoneCount);
+ }
+ }
+
+ private static int[,] GetPriorityGridCellMap(int zoneCount)
+ {
+ // Parsed from Editor's _priorityData byte arrays
+ return zoneCount switch
+ {
+ 1 => new int[,] { { 0 } },
+ 2 => new int[,] { { 0, 1 } },
+ 3 => new int[,] { { 0, 1, 2 } },
+ 4 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 } },
+ 5 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 } },
+ 6 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 }, { 4, 1, 5 } },
+ 7 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 }, { 5, 1, 6 } },
+ 8 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 2, 7 } },
+ 9 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 7, 8 } },
+ 10 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 1, 8, 9 } },
+ 11 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 8, 9, 10 } },
+ _ => new int[,] { { 0 } },
+ };
+ }
+
+ private static string RenderGridLayoutWithCellMap(int[,] cellMap, int width = 30, int height = 8)
+ {
+ var sb = new StringBuilder();
+ int rows = cellMap.GetLength(0);
+ int cols = cellMap.GetLength(1);
+
+ int cellWidth = width / cols;
+ int cellHeight = height / rows;
+
+ for (int r = 0; r < rows; r++)
+ {
+ // Top border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ bool mergeTop = r > 0 && cellMap[r, c] == cellMap[r - 1, c];
+ bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1];
+
+ if (mergeTop)
+ {
+ sb.Append(mergeLeft ? new string(' ', cellWidth) : new string(' ', cellWidth - 1) + "+");
+ }
+ else
+ {
+ sb.Append(mergeLeft ? new string('-', cellWidth) : new string('-', cellWidth - 1) + "+");
+ }
+ }
+
+ sb.AppendLine();
+
+ // Cell content
+ for (int h = 0; h < cellHeight - 1; h++)
+ {
+ sb.Append(" ");
+ for (int c = 0; c < cols; c++)
+ {
+ bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1];
+ sb.Append(mergeLeft ? ' ' : '|');
+ sb.Append(' ', cellWidth - 1);
+ }
+
+ sb.AppendLine("|");
+ }
+ }
+
+ // Bottom border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ sb.Append('-', cellWidth - 1);
+ sb.Append('+');
+ }
+
+ sb.AppendLine();
+ return sb.ToString();
+ }
+
+ private static string RenderGridLayoutWithMergedCells(int rows, int cols, JsonElement cellMap)
+ {
+ var sb = new StringBuilder();
+ const int displayWidth = 39;
+ const int displayHeight = 12;
+
+ // Build zone map from cell-child-map
+ int[,] zoneMap = new int[rows, cols];
+ for (int r = 0; r < rows; r++)
+ {
+ var rowArray = cellMap[r];
+ for (int c = 0; c < cols; c++)
+ {
+ zoneMap[r, c] = rowArray[c].GetInt32();
+ }
+ }
+
+ int cellHeight = displayHeight / rows;
+ int cellWidth = displayWidth / cols;
+
+ // Draw top border
+ sb.Append(" +");
+ sb.Append('-', displayWidth);
+ sb.AppendLine("+");
+
+ // Draw rows
+ for (int r = 0; r < rows; r++)
+ {
+ for (int h = 0; h < cellHeight; h++)
+ {
+ sb.Append(" |");
+
+ for (int c = 0; c < cols; c++)
+ {
+ int currentZone = zoneMap[r, c];
+ int leftZone = c > 0 ? zoneMap[r, c - 1] : -1;
+ bool needLeftBorder = c > 0 && currentZone != leftZone;
+
+ bool zoneHasTopBorder = r > 0 && h == 0 && currentZone != zoneMap[r - 1, c];
+
+ if (needLeftBorder)
+ {
+ sb.Append('|');
+ sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth - 1);
+ }
+ else
+ {
+ sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth);
+ }
+ }
+
+ sb.AppendLine("|");
+ }
+ }
+
+ // Draw bottom border
+ sb.Append(" +");
+ sb.Append('-', displayWidth);
+ sb.AppendLine("+");
+
+ return sb.ToString();
+ }
+
+ public static string RenderGridLayoutWithZoneCount(int rows, int cols, int zoneCount, int width = 30, int height = 8)
+ {
+ var sb = new StringBuilder();
+
+ // Build zone map like Editor's InitGrid
+ int[,] zoneMap = new int[rows, cols];
+ int index = 0;
+ for (int r = 0; r < rows; r++)
+ {
+ for (int c = 0; c < cols; c++)
+ {
+ zoneMap[r, c] = index++;
+ if (index == zoneCount)
+ {
+ index--; // Remaining cells use the last zone index
+ }
+ }
+ }
+
+ int cellWidth = width / cols;
+ int cellHeight = height / rows;
+
+ for (int r = 0; r < rows; r++)
+ {
+ // Top border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1];
+ sb.Append('-', mergeLeft ? cellWidth : cellWidth - 1);
+ if (!mergeLeft)
+ {
+ sb.Append('+');
+ }
+ }
+
+ sb.AppendLine();
+
+ // Cell content
+ for (int h = 0; h < cellHeight - 1; h++)
+ {
+ sb.Append(" ");
+ for (int c = 0; c < cols; c++)
+ {
+ bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1];
+ sb.Append(mergeLeft ? ' ' : '|');
+ sb.Append(' ', cellWidth - 1);
+ }
+
+ sb.AppendLine("|");
+ }
+ }
+
+ // Bottom border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ sb.Append('-', cellWidth - 1);
+ sb.Append('+');
+ }
+
+ sb.AppendLine();
+ return sb.ToString();
+ }
+
+ public static string RenderGridLayout(int rows, int cols, int width = 30, int height = 8)
+ {
+ var sb = new StringBuilder();
+ int cellWidth = width / cols;
+ int cellHeight = height / rows;
+
+ for (int r = 0; r < rows; r++)
+ {
+ // Top border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ sb.Append('-', cellWidth - 1);
+ sb.Append('+');
+ }
+
+ sb.AppendLine();
+
+ // Cell content
+ for (int h = 0; h < cellHeight - 1; h++)
+ {
+ sb.Append(" ");
+ for (int c = 0; c < cols; c++)
+ {
+ sb.Append('|');
+ sb.Append(' ', cellWidth - 1);
+ }
+
+ sb.AppendLine("|");
+ }
+ }
+
+ // Bottom border
+ sb.Append(" +");
+ for (int c = 0; c < cols; c++)
+ {
+ sb.Append('-', cellWidth - 1);
+ sb.Append('+');
+ }
+
+ sb.AppendLine();
+ return sb.ToString();
+ }
+
+ private static string RenderCanvasLayout(JsonElement zones, int refWidth, int refHeight)
+ {
+ var sb = new StringBuilder();
+ const int displayWidth = 49;
+ const int displayHeight = 15;
+
+ // Create a 2D array to track which zones occupy each position
+ var zoneGrid = new List[displayHeight, displayWidth];
+ for (int i = 0; i < displayHeight; i++)
+ {
+ for (int j = 0; j < displayWidth; j++)
+ {
+ zoneGrid[i, j] = new List();
+ }
+ }
+
+ // Map each zone to the grid
+ int zoneId = 0;
+ var zoneList = new List<(int X, int Y, int Width, int Height, int Id)>();
+
+ 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();
+
+ int dx = Math.Max(0, Math.Min(displayWidth - 1, x * displayWidth / refWidth));
+ int dy = Math.Max(0, Math.Min(displayHeight - 1, y * displayHeight / refHeight));
+ int dw = Math.Max(3, w * displayWidth / refWidth);
+ int dh = Math.Max(2, h * displayHeight / refHeight);
+
+ if (dx + dw > displayWidth)
+ {
+ dw = displayWidth - dx;
+ }
+
+ if (dy + dh > displayHeight)
+ {
+ dh = displayHeight - dy;
+ }
+
+ zoneList.Add((dx, dy, dw, dh, zoneId));
+
+ for (int r = dy; r < dy + dh && r < displayHeight; r++)
+ {
+ for (int c = dx; c < dx + dw && c < displayWidth; c++)
+ {
+ zoneGrid[r, c].Add(zoneId);
+ }
+ }
+
+ zoneId++;
+ }
+
+ // Draw top border
+ sb.Append(" +");
+ sb.Append('-', displayWidth);
+ sb.AppendLine("+");
+
+ // Draw each row
+ char[] shades = { '.', ':', '░', '▒', '▓', '█', '◆', '●', '■', '▪' };
+
+ for (int r = 0; r < displayHeight; r++)
+ {
+ sb.Append(" |");
+ for (int c = 0; c < displayWidth; c++)
+ {
+ var zonesHere = zoneGrid[r, c];
+
+ if (zonesHere.Count == 0)
+ {
+ sb.Append(' ');
+ }
+ else
+ {
+ int topZone = zonesHere[zonesHere.Count - 1];
+ var rect = zoneList[topZone];
+
+ bool isTopEdge = r == rect.Y;
+ bool isBottomEdge = r == rect.Y + rect.Height - 1;
+ bool isLeftEdge = c == rect.X;
+ bool isRightEdge = c == rect.X + rect.Width - 1;
+
+ if ((isTopEdge || isBottomEdge) && (isLeftEdge || isRightEdge))
+ {
+ sb.Append('+');
+ }
+ else if (isTopEdge || isBottomEdge)
+ {
+ sb.Append('-');
+ }
+ else if (isLeftEdge || isRightEdge)
+ {
+ sb.Append('|');
+ }
+ else
+ {
+ sb.Append(shades[topZone % shades.Length]);
+ }
+ }
+ }
+
+ sb.AppendLine("|");
+ }
+
+ // Draw bottom border
+ sb.Append(" +");
+ sb.Append('-', displayWidth);
+ sb.AppendLine("+");
+
+ // Draw legend
+ sb.AppendLine();
+ sb.Append(" Legend: ");
+ for (int i = 0; i < Math.Min(zoneId, shades.Length); i++)
+ {
+ if (i > 0)
+ {
+ sb.Append(", ");
+ }
+
+ sb.Append(CultureInfo.InvariantCulture, $"Zone {i} = {shades[i]}");
+ }
+
+ sb.AppendLine();
+ return sb.ToString();
+ }
+}
diff --git a/src/modules/fancyzones/FancyZonesCLI/Logger.cs b/src/modules/fancyzones/FancyZonesCLI/Logger.cs
new file mode 100644
index 0000000000..3f62abf7eb
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Logger.cs
@@ -0,0 +1,126 @@
+// 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.Globalization;
+using System.IO;
+using System.Runtime.CompilerServices;
+
+namespace FancyZonesCLI;
+
+///
+/// Simple logger for FancyZones CLI.
+/// Logs to %LOCALAPPDATA%\Microsoft\PowerToys\FancyZones\CLI\Logs
+///
+internal static class Logger
+{
+ private static readonly object LockObj = new();
+ private static string _logFilePath = string.Empty;
+ private static bool _isInitialized;
+
+ ///
+ /// Gets the path to the current log file.
+ ///
+ public static string LogFilePath => _logFilePath;
+
+ ///
+ /// Initializes the logger.
+ ///
+ public static void InitializeLogger()
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ try
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var logDirectory = Path.Combine(localAppData, "Microsoft", "PowerToys", "FancyZones", "CLI", "Logs");
+
+ if (!Directory.Exists(logDirectory))
+ {
+ Directory.CreateDirectory(logDirectory);
+ }
+
+ var logFileName = $"FancyZonesCLI_{DateTime.Now:yyyy-MM-dd}.log";
+ _logFilePath = Path.Combine(logDirectory, logFileName);
+ _isInitialized = true;
+
+ LogInfo("FancyZones CLI started");
+ }
+ catch
+ {
+ // Silently fail if logging cannot be initialized
+ }
+ }
+
+ ///
+ /// Logs an error message.
+ ///
+ public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
+ {
+ Log("ERROR", message, memberName, sourceFilePath, sourceLineNumber);
+ }
+
+ ///
+ /// Logs an error message with exception details.
+ ///
+ public static void LogError(string message, Exception ex, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
+ {
+ var fullMessage = ex == null
+ ? message
+ : $"{message} | Exception: {ex.GetType().Name}: {ex.Message}";
+ Log("ERROR", fullMessage, memberName, sourceFilePath, sourceLineNumber);
+ }
+
+ ///
+ /// Logs a warning message.
+ ///
+ public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
+ {
+ Log("WARN", message, memberName, sourceFilePath, sourceLineNumber);
+ }
+
+ ///
+ /// Logs an informational message.
+ ///
+ public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
+ {
+ Log("INFO", message, memberName, sourceFilePath, sourceLineNumber);
+ }
+
+ ///
+ /// Logs a debug message (only in DEBUG builds).
+ ///
+ [System.Diagnostics.Conditional("DEBUG")]
+ public static void LogDebug(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
+ {
+ Log("DEBUG", message, memberName, sourceFilePath, sourceLineNumber);
+ }
+
+ private static void Log(string level, string message, string memberName, string sourceFilePath, int sourceLineNumber)
+ {
+ if (!_isInitialized || string.IsNullOrEmpty(_logFilePath))
+ {
+ return;
+ }
+
+ try
+ {
+ var fileName = Path.GetFileName(sourceFilePath);
+ var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
+ var logEntry = $"[{timestamp}] [{level}] [{fileName}:{sourceLineNumber}] [{memberName}] {message}{Environment.NewLine}";
+
+ lock (LockObj)
+ {
+ File.AppendAllText(_logFilePath, logEntry);
+ }
+ }
+ catch
+ {
+ // Silently fail if logging fails
+ }
+ }
+}
diff --git a/src/modules/fancyzones/FancyZonesCLI/Models.cs b/src/modules/fancyzones/FancyZonesCLI/Models.cs
new file mode 100644
index 0000000000..0c8bbefe54
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Models.cs
@@ -0,0 +1,137 @@
+// 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
new file mode 100644
index 0000000000..efab0859bd
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs
@@ -0,0 +1,56 @@
+// 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 Windows.Win32;
+using Windows.Win32.Foundation;
+
+namespace FancyZonesCLI;
+
+///
+/// Native Windows API methods for FancyZones CLI.
+///
+internal static class NativeMethods
+{
+ // Registered Windows messages for notifying FancyZones
+ private static uint wmPrivAppliedLayoutsFileUpdate;
+ private static uint wmPrivLayoutHotkeysFileUpdate;
+
+ ///
+ /// Gets the Windows message ID for applied layouts file update notification.
+ ///
+ public static uint WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE => wmPrivAppliedLayoutsFileUpdate;
+
+ ///
+ /// Gets the Windows message ID for layout hotkeys file update notification.
+ ///
+ public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate;
+
+ ///
+ /// Initializes the Windows messages used for FancyZones notifications.
+ ///
+ public static void InitializeWindowMessages()
+ {
+ wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}");
+ wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}");
+ }
+
+ ///
+ /// Broadcasts a notification message to FancyZones.
+ ///
+ /// The Windows message ID to broadcast.
+ public static void NotifyFancyZones(uint message)
+ {
+ PInvoke.PostMessage(HWND.HWND_BROADCAST, message, 0, 0);
+ }
+
+ ///
+ /// Brings the specified window to the foreground.
+ ///
+ /// A handle to the window.
+ /// True if the window was brought to the foreground.
+ public static bool SetForegroundWindow(nint hWnd)
+ {
+ return PInvoke.SetForegroundWindow(new HWND(hWnd));
+ }
+}
diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json
new file mode 100644
index 0000000000..89cee38a92
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json
@@ -0,0 +1,5 @@
+{
+ "$schema": "https://aka.ms/CsWin32.schema.json",
+ "emitSingleFile": true,
+ "allowMarshaling": false
+}
diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt
new file mode 100644
index 0000000000..e3555c2333
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt
@@ -0,0 +1,4 @@
+PostMessage
+SetForegroundWindow
+RegisterWindowMessage
+HWND_BROADCAST
diff --git a/src/modules/fancyzones/FancyZonesCLI/Program.cs b/src/modules/fancyzones/FancyZonesCLI/Program.cs
new file mode 100644
index 0000000000..1b133dfa36
--- /dev/null
+++ b/src/modules/fancyzones/FancyZonesCLI/Program.cs
@@ -0,0 +1,115 @@
+// 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.Globalization;
+using System.Linq;
+using FancyZonesCLI.Commands;
+
+namespace FancyZonesCLI;
+
+internal sealed class Program
+{
+ private static int Main(string[] args)
+ {
+ // Initialize logger
+ Logger.InitializeLogger();
+ Logger.LogInfo($"CLI invoked with args: [{string.Join(", ", args)}]");
+
+ // Initialize Windows messages
+ NativeMethods.InitializeWindowMessages();
+
+ (int ExitCode, string Output) result;
+
+ if (args.Length == 0)
+ {
+ result = (1, GetUsageText());
+ }
+ 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()}"),
+ };
+ }
+
+ // 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}
+ """;
+ }
+}