FancyZones CLI: migrate to System.CommandLine and centralize data I/O (#44344)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## 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
<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
leileizhang
2025-12-19 11:53:43 +08:00
committed by GitHub
parent 7cd201d355
commit dd138fb94b
37 changed files with 1513 additions and 899 deletions

View File

@@ -11,6 +11,7 @@
<ItemGroup>
<Compile Include="..\editor\FancyZonesEditor\Utils\ParsingResult.cs" Link="ParsingResult.cs" />
<Compile Include="..\FancyZonesEditorCommon\Data\CustomLayouts.cs" Link="CustomLayouts.cs" />
<Compile Include="..\FancyZonesEditorCommon\Data\FancyZonesPaths.cs" Link="FancyZonesPaths.cs" />
<Compile Include="..\FancyZonesEditorCommon\Data\EditorData`1.cs" Link="EditorData`1.cs" />
<Compile Include="..\FancyZonesEditorCommon\Data\LayoutDefaultSettings.cs" Link="LayoutDefaultSettings.cs" />
<Compile Include="..\FancyZonesEditorCommon\Utils\DashCaseNamingPolicy.cs" Link="DashCaseNamingPolicy.cs" />

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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 + <number> 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();
}
}

View File

@@ -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 <UUID>' to apply a layout.");
}
return sb.ToString().TrimEnd();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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<int> _key;
public RemoveHotkeyCommand()
: base("remove-hotkey", "Remove hotkey assignment")
{
AddAlias("rhk");
_key = new Argument<int>("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;
}
}

View File

@@ -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<int> _key;
private readonly Argument<string> _layout;
public SetHotkeyCommand()
: base("set-hotkey", "Assign hotkey (0-9) to a custom layout")
{
AddAlias("shk");
_key = new Argument<int>("key", "Hotkey index (0-9)");
_layout = new Argument<string>("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<LayoutHotkeys.LayoutHotkeyWrapper>();
// 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;
}
}

View File

@@ -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<string> _layoutId;
private readonly Option<int?> _monitor;
private readonly Option<bool> _all;
public SetLayoutCommand()
: base("set-layout", "Set layout by UUID or template name")
{
AddAlias("s");
_layoutId = new Argument<string>("layout", "Layout UUID or template type (e.g. focus, columns)");
AddArgument(_layoutId);
_monitor = new Option<int?>(AliasesMonitor, "Apply to monitor N (1-based)");
_monitor.AddValidator(result =>
{
if (result.Tokens.Count == 0)
{
return;
}
int? monitor = result.GetValueOrDefault<int?>();
if (monitor.HasValue && monitor.Value < 1)
{
result.ErrorMessage = "Monitor index must be >= 1.";
}
});
_all = new Option<bool>(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) : "<default>")}, all: {all}");
var (targetCustomLayout, targetTemplate) = ResolveTargetLayout(layout);
var editorParams = ReadEditorParametersWithRefresh();
var appliedLayouts = FancyZonesDataIO.ReadAppliedLayouts();
appliedLayouts.AppliedLayouts ??= new List<AppliedLayouts.AppliedLayoutWrapper>();
List<int> monitorsToUpdate = GetMonitorsToUpdate(editorParams, monitor, all);
List<AppliedLayouts.AppliedLayoutWrapper> 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<int> GetMonitorsToUpdate(EditorParameters.ParamsWrapper editorParams, int? monitor, bool all)
{
var result = new List<int>();
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<AppliedLayouts.AppliedLayoutWrapper> BuildNewLayouts(
EditorParameters.ParamsWrapper editorParams,
List<int> monitorsToUpdate,
CustomLayouts.CustomLayoutWrapper? targetCustomLayout,
LayoutTemplates.TemplateLayoutWrapper? targetTemplate)
{
var newLayouts = new List<AppliedLayouts.AppliedLayoutWrapper>();
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<int>();
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<AppliedLayouts.AppliedLayoutWrapper> newLayouts)
{
var mergedLayoutsList = new List<AppliedLayouts.AppliedLayoutWrapper>();
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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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 <args>, 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 <uuid> --monitor 1");
Console.WriteLine(" FancyZonesCLI get-hotkeys");
}
}

View File

@@ -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;
/// <summary>
/// Editor and Settings commands.
/// </summary>
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}");
}
}
}

View File

@@ -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;
/// <summary>
/// Hotkey-related commands.
/// </summary>
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 + <number> 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<uint> 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<LayoutHotkey>();
// 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<uint> 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");
}
}

View File

@@ -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;
/// <summary>
/// Layout-related commands.
/// </summary>
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 <UUID>' 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<uint> 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<int> monitorsToUpdate = new List<int>();
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");
}
}
}

View File

@@ -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;
/// <summary>
/// Monitor-related commands.
/// </summary>
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());
}
}

View File

@@ -2,7 +2,6 @@
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\Common.Dotnet.CsWinRT.props" />
<Import Project="..\..\..\Common.SelfContained.props" />
<Import Project="..\..\..\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<AssemblyTitle>PowerToys.FancyZonesCLI</AssemblyTitle>
@@ -10,8 +9,6 @@
<Description>PowerToys FancyZones CLI</Description>
<OutputType>Exe</OutputType>
<Platforms>x64;ARM64</Platforms>
<PublishAot>true</PublishAot>
<DisableRuntimeMarshalling>true</DisableRuntimeMarshalling>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>..\..\..\..\$(Platform)\$(Configuration)</OutputPath>
@@ -21,9 +18,14 @@
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<PackageReference Include="System.CommandLine" />
<PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FancyZonesEditorCommon\FancyZonesEditorCommon.csproj" />
</ItemGroup>
<!-- Force using WindowsDesktop runtime to ensure consistent dll versions with other projects -->
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />

View File

@@ -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;
/// <summary>
/// Provides methods to read and write FancyZones configuration data.
/// </summary>
internal static class FancyZonesData
{
/// <summary>
/// Try to read applied layouts configuration.
/// </summary>
public static bool TryReadAppliedLayouts(out AppliedLayouts result, out string error)
{
return TryReadJsonFile(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts, out result, out error);
}
/// <summary>
/// Read applied layouts or return null if not found.
/// </summary>
public static AppliedLayouts ReadAppliedLayouts()
{
return ReadJsonFileOrDefault(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts);
}
/// <summary>
/// Write applied layouts configuration.
/// </summary>
public static void WriteAppliedLayouts(AppliedLayouts layouts)
{
WriteJsonFile(FancyZonesPaths.AppliedLayouts, layouts, FancyZonesJsonContext.Default.AppliedLayouts);
}
/// <summary>
/// Read custom layouts or return null if not found.
/// </summary>
public static CustomLayouts ReadCustomLayouts()
{
return ReadJsonFileOrDefault(FancyZonesPaths.CustomLayouts, FancyZonesJsonContext.Default.CustomLayouts);
}
/// <summary>
/// Read layout templates or return null if not found.
/// </summary>
public static LayoutTemplates ReadLayoutTemplates()
{
return ReadJsonFileOrDefault(FancyZonesPaths.LayoutTemplates, FancyZonesJsonContext.Default.LayoutTemplates);
}
/// <summary>
/// Read layout hotkeys or return null if not found.
/// </summary>
public static LayoutHotkeys ReadLayoutHotkeys()
{
return ReadJsonFileOrDefault(FancyZonesPaths.LayoutHotkeys, FancyZonesJsonContext.Default.LayoutHotkeys);
}
/// <summary>
/// Write layout hotkeys configuration.
/// </summary>
public static void WriteLayoutHotkeys(LayoutHotkeys hotkeys)
{
WriteJsonFile(FancyZonesPaths.LayoutHotkeys, hotkeys, FancyZonesJsonContext.Default.LayoutHotkeys);
}
/// <summary>
/// Check if editor parameters file exists.
/// </summary>
public static bool EditorParametersExist()
{
return File.Exists(FancyZonesPaths.EditorParameters);
}
private static bool TryReadJsonFile<T>(string filePath, JsonTypeInfo<T> 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<T>(string filePath, JsonTypeInfo<T> jsonTypeInfo, T defaultValue = null)
where T : class
{
if (TryReadJsonFile(filePath, jsonTypeInfo, out var result, out _))
{
return result;
}
return defaultValue;
}
private static void WriteJsonFile<T>(string filePath, T data, JsonTypeInfo<T> jsonTypeInfo)
{
Logger.LogDebug($"Writing file: {filePath}");
var json = JsonSerializer.Serialize(data, jsonTypeInfo);
File.WriteAllText(filePath, json);
Logger.LogInfo($"Successfully wrote {Path.GetFileName(filePath)}");
}
}

View File

@@ -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<int>[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;
}
}

View File

@@ -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<TemplateLayout> 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<CustomLayout> 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<AppliedLayoutWrapper> 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<LayoutHotkey> Hotkeys { get; set; }
}
public sealed class LayoutHotkey
{
[JsonPropertyName("key")]
public int Key { get; set; }
[JsonPropertyName("layout-id")]
public string LayoutId { get; set; } = string.Empty;
}

View File

@@ -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;
/// <summary>
/// Gets the Windows message ID for applied layouts file update notification.
@@ -26,6 +27,11 @@ internal static class NativeMethods
/// </summary>
public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate;
/// <summary>
/// Gets the Windows message ID used to request saving editor-parameters.json.
/// </summary>
public static uint WM_PRIV_SAVE_EDITOR_PARAMETERS => wmPrivSaveEditorParameters;
/// <summary>
/// Initializes the Windows messages used for FancyZones notifications.
/// </summary>
@@ -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}");
}
/// <summary>

View File

@@ -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<int> 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 <key> <uuid>"),
"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 <key>"),
"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 <command> [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) <uuid> [options]
Set layout by UUID
--monitor <n> 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) <key> <uuid> Assign hotkey (0-9) to CUSTOM layout
Note: Only custom layouts work with hotkeys
remove-hotkey (rhk) <key> 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;
}
}

View File

@@ -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;
/// <summary>
/// Helper for managing applied layouts across monitors.
/// CLI-only business logic for matching, finding, and updating applied layouts.
/// </summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// Helper for requesting FancyZones to save editor-parameters.json and reading it reliably.
/// </summary>
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;
}
}

View File

@@ -12,7 +12,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\applied-layouts.json";
return FancyZonesPaths.AppliedLayouts;
}
}

View File

@@ -15,7 +15,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\custom-layouts.json";
return FancyZonesPaths.CustomLayouts;
}
}

View File

@@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\default-layouts.json";
return FancyZonesPaths.DefaultLayouts;
}
}

View File

@@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\editor-parameters.json";
return FancyZonesPaths.EditorParameters;
}
}

View File

@@ -5,12 +5,12 @@
using System;
using System.IO;
namespace FancyZonesCLI;
namespace FancyZonesEditorCommon.Data;
/// <summary>
/// Provides paths to FancyZones configuration files.
/// </summary>
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");
}

View File

@@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-hotkeys.json";
return FancyZonesPaths.LayoutHotkeys;
}
}

View File

@@ -14,7 +14,7 @@ namespace FancyZonesEditorCommon.Data
{
get
{
return GetDataFolder() + "\\Microsoft\\PowerToys\\FancyZones\\layout-templates.json";
return FancyZonesPaths.LayoutTemplates;
}
}

View File

@@ -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
{
/// <summary>
/// Unified helper for all FancyZones data file I/O operations.
/// Centralizes reading and writing of all JSON configuration files.
/// </summary>
public static class FancyZonesDataIO
{
private static TWrapper ReadData<TData, TWrapper>(
Func<TData> createInstance,
Func<TData, string> fileSelector,
Func<TData, string, TWrapper> 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<TData, TWrapper>(
Func<TData> createInstance,
Func<TData, string> fileSelector,
Func<TData, TWrapper, string> 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);
}
}
}

View File

@@ -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);

View File

@@ -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}");
});
}

View File

@@ -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();