Add FancyZones CLI for command-line layout management (#44078)

<!-- 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
Adds a new command-line interface (CLI) tool for FancyZones, enabling
users and automation scripts to manage window layouts without the GUI.

**Commands:**
| Command | Aliases | Description |
|---------|---------|-------------|
| `help` | | Displays general help information for all 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 with
ASCII art preview |
| `get-active-layout` | `active`, `a` | Show currently active layout |
| `set-layout <uuid>` | `set`, `s` | Apply layout by UUID or template
name |
| `open-settings` | `settings` | Open FancyZones settings page |
| `get-hotkeys` | `hotkeys`, `hk` | List all layout hotkeys |
| `set-hotkey <key> <uuid>` | `shk` | Assign hotkey (0-9) to custom
layout |
| `remove-hotkey <key>` | `rhk` | Remove hotkey assignment |

**Key Capabilities:**
- ASCII art visualization of layouts (grid, focus, priority-grid,
canvas)
- Support for both template layouts and custom layouts
- Monitor-specific layout targeting (`--monitor N` or `--all`)
- Real-time notification to FancyZones via Windows messages
- Native AOT compilation support for fast startup

### Example Usage

```bash
# List all layouts with visual previews
FancyZonesCLI.exe ls

# Apply "columns" template to all monitors
FancyZonesCLI.exe s columns --all

# Set custom layout on monitor 2
FancyZonesCLI.exe s {uuid} --monitor 2

# Assign hotkey Win+Ctrl+Alt+3 to a layout
FancyZonesCLI.exe shk 3 {uuid}
```



https://github.com/user-attachments/assets/2b141399-a4ca-4f64-8750-f123b7e0fea7



<!-- 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-09 10:13:48 +08:00
committed by GitHub
parent 4710b816b4
commit 73e379238b
17 changed files with 1718 additions and 0 deletions

View File

@@ -1483,6 +1483,7 @@ rgh
rgn
rgs
rguid
rhk
RIDEV
RIGHTSCROLLBAR
riid
@@ -1588,6 +1589,7 @@ SHGDNF
SHGFI
SHIL
shinfo
shk
shlwapi
shobjidl
SHORTCUTATLEAST

View File

@@ -60,6 +60,8 @@
"PowerToys.FancyZonesEditorCommon.dll",
"PowerToys.FancyZonesModuleInterface.dll",
"PowerToys.FancyZones.exe",
"FancyZonesCLI.exe",
"FancyZonesCLI.dll",
"PowerToys.GcodePreviewHandler.dll",
"PowerToys.GcodePreviewHandler.exe",

View File

@@ -370,6 +370,10 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/fancyzones/FancyZones/FancyZones.vcxproj" Id="ff1d7936-842a-4bbb-8bea-e9fe796de700" />
<Project Path="src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/fancyzones/FancyZonesEditorCommon/FancyZonesEditorCommon.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

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

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

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

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

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- 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>
<AssemblyDescription>PowerToys FancyZones Command Line Interface</AssemblyDescription>
<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>
<AssemblyName>FancyZonesCLI</AssemblyName>
<NoWarn>$(NoWarn);SA1500;SA1402;CA1852</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" />
<PackageReference Include="Microsoft.Windows.CsWin32" PrivateAssets="all" />
</ItemGroup>
<!-- Force using WindowsDesktop runtime to ensure consistent dll versions with other projects -->
<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App" />
</ItemGroup>
</Project>

View File

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

@@ -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;
/// <summary>
/// Provides paths to FancyZones configuration files.
/// </summary>
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");
}

View File

@@ -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<int>[displayHeight, displayWidth];
for (int i = 0; i < displayHeight; i++)
{
for (int j = 0; j < displayWidth; j++)
{
zoneGrid[i, j] = new List<int>();
}
}
// 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();
}
}

View File

@@ -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;
/// <summary>
/// Simple logger for FancyZones CLI.
/// Logs to %LOCALAPPDATA%\Microsoft\PowerToys\FancyZones\CLI\Logs
/// </summary>
internal static class Logger
{
private static readonly object LockObj = new();
private static string _logFilePath = string.Empty;
private static bool _isInitialized;
/// <summary>
/// Gets the path to the current log file.
/// </summary>
public static string LogFilePath => _logFilePath;
/// <summary>
/// Initializes the logger.
/// </summary>
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
}
}
/// <summary>
/// Logs an error message.
/// </summary>
public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log("ERROR", message, memberName, sourceFilePath, sourceLineNumber);
}
/// <summary>
/// Logs an error message with exception details.
/// </summary>
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);
}
/// <summary>
/// Logs a warning message.
/// </summary>
public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log("WARN", message, memberName, sourceFilePath, sourceLineNumber);
}
/// <summary>
/// Logs an informational message.
/// </summary>
public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0)
{
Log("INFO", message, memberName, sourceFilePath, sourceLineNumber);
}
/// <summary>
/// Logs a debug message (only in DEBUG builds).
/// </summary>
[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
}
}
}

View File

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

@@ -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;
/// <summary>
/// Native Windows API methods for FancyZones CLI.
/// </summary>
internal static class NativeMethods
{
// Registered Windows messages for notifying FancyZones
private static uint wmPrivAppliedLayoutsFileUpdate;
private static uint wmPrivLayoutHotkeysFileUpdate;
/// <summary>
/// Gets the Windows message ID for applied layouts file update notification.
/// </summary>
public static uint WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE => wmPrivAppliedLayoutsFileUpdate;
/// <summary>
/// Gets the Windows message ID for layout hotkeys file update notification.
/// </summary>
public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate;
/// <summary>
/// Initializes the Windows messages used for FancyZones notifications.
/// </summary>
public static void InitializeWindowMessages()
{
wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}");
wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}");
}
/// <summary>
/// Broadcasts a notification message to FancyZones.
/// </summary>
/// <param name="message">The Windows message ID to broadcast.</param>
public static void NotifyFancyZones(uint message)
{
PInvoke.PostMessage(HWND.HWND_BROADCAST, message, 0, 0);
}
/// <summary>
/// Brings the specified window to the foreground.
/// </summary>
/// <param name="hWnd">A handle to the window.</param>
/// <returns>True if the window was brought to the foreground.</returns>
public static bool SetForegroundWindow(nint hWnd)
{
return PInvoke.SetForegroundWindow(new HWND(hWnd));
}
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://aka.ms/CsWin32.schema.json",
"emitSingleFile": true,
"allowMarshaling": false
}

View File

@@ -0,0 +1,4 @@
PostMessage
SetForegroundWindow
RegisterWindowMessage
HWND_BROADCAST

View File

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