diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index d4be728886..672616c8e7 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1483,6 +1483,7 @@ rgh rgn rgs rguid +rhk RIDEV RIGHTSCROLLBAR riid @@ -1588,6 +1589,7 @@ SHGDNF SHGFI SHIL shinfo +shk shlwapi shobjidl SHORTCUTATLEAST diff --git a/.pipelines/ESRPSigning_core.json b/.pipelines/ESRPSigning_core.json index 83289fa102..f4e3e1ba38 100644 --- a/.pipelines/ESRPSigning_core.json +++ b/.pipelines/ESRPSigning_core.json @@ -60,6 +60,8 @@ "PowerToys.FancyZonesEditorCommon.dll", "PowerToys.FancyZonesModuleInterface.dll", "PowerToys.FancyZones.exe", + "FancyZonesCLI.exe", + "FancyZonesCLI.dll", "PowerToys.GcodePreviewHandler.dll", "PowerToys.GcodePreviewHandler.exe", diff --git a/PowerToys.slnx b/PowerToys.slnx index c946514fb5..1884b2d58b 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -370,6 +370,10 @@ + + + + diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs new file mode 100644 index 0000000000..7bf15dda44 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/EditorCommands.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; + +namespace FancyZonesCLI.Commands; + +/// +/// Editor and Settings commands. +/// +internal static class EditorCommands +{ + public static (int ExitCode, string Output) OpenEditor() + { + var editorExe = "PowerToys.FancyZonesEditor.exe"; + + // Check if editor-parameters.json exists + if (!FancyZonesData.EditorParametersExist()) + { + return (1, "Error: editor-parameters.json not found.\nPlease launch FancyZones Editor using Win+` (Win+Backtick) hotkey first."); + } + + // Check if editor is already running + var existingProcess = Process.GetProcessesByName("PowerToys.FancyZonesEditor").FirstOrDefault(); + if (existingProcess != null) + { + NativeMethods.SetForegroundWindow(existingProcess.MainWindowHandle); + return (0, "FancyZones Editor is already running. Brought window to foreground."); + } + + // Only check same directory as CLI + var editorPath = Path.Combine(AppContext.BaseDirectory, editorExe); + + if (File.Exists(editorPath)) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = editorPath, + UseShellExecute = true, + }); + return (0, "FancyZones Editor launched successfully."); + } + catch (Exception ex) + { + return (1, $"Failed to launch: {ex.Message}"); + } + } + + return (1, $"Error: Could not find {editorExe} in {AppContext.BaseDirectory}"); + } + + public static (int ExitCode, string Output) OpenSettings() + { + try + { + // Find PowerToys.exe in common locations + string powertoysExe = null; + + // Check in the same directory as the CLI (typical for dev builds) + var sameDirPath = Path.Combine(AppContext.BaseDirectory, "PowerToys.exe"); + if (File.Exists(sameDirPath)) + { + powertoysExe = sameDirPath; + } + + if (powertoysExe == null) + { + return (1, "Error: PowerToys.exe not found. Please ensure PowerToys is installed."); + } + + Process.Start(new ProcessStartInfo + { + FileName = powertoysExe, + Arguments = "--open-settings=FancyZones", + UseShellExecute = false, + }); + return (0, "FancyZones Settings opened successfully."); + } + catch (Exception ex) + { + return (1, $"Error: Failed to open FancyZones Settings. {ex.Message}"); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs new file mode 100644 index 0000000000..cfaf93a5d4 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/HotkeyCommands.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace FancyZonesCLI.Commands; + +/// +/// Hotkey-related commands. +/// +internal static class HotkeyCommands +{ + public static (int ExitCode, string Output) GetHotkeys() + { + var hotkeys = FancyZonesData.ReadLayoutHotkeys(); + if (hotkeys?.Hotkeys == null || hotkeys.Hotkeys.Count == 0) + { + return (0, "No hotkeys configured."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("=== Layout Hotkeys ===\n"); + sb.AppendLine("Press Win + Ctrl + Alt + to switch layouts:\n"); + + foreach (var hotkey in hotkeys.Hotkeys.OrderBy(h => h.Key)) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" [{hotkey.Key}] => {hotkey.LayoutId}"); + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) SetHotkey(int key, string layoutUuid, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) + { + if (key < 0 || key > 9) + { + return (1, "Error: Key must be between 0 and 9"); + } + + // Check if this is a custom layout UUID + var customLayouts = FancyZonesData.ReadCustomLayouts(); + var matchedLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(layoutUuid, StringComparison.OrdinalIgnoreCase)); + bool isCustomLayout = matchedLayout != null; + string layoutName = matchedLayout?.Name ?? layoutUuid; + + var hotkeys = FancyZonesData.ReadLayoutHotkeys() ?? new LayoutHotkeys(); + + hotkeys.Hotkeys ??= new List(); + + // Remove existing hotkey for this key + hotkeys.Hotkeys.RemoveAll(h => h.Key == key); + + // Add new hotkey + hotkeys.Hotkeys.Add(new LayoutHotkey { Key = key, LayoutId = layoutUuid }); + + // Save + FancyZonesData.WriteLayoutHotkeys(hotkeys); + + // Notify FancyZones + notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); + + if (isCustomLayout) + { + return (0, $"✓ Hotkey {key} assigned to custom layout '{layoutName}'\n Press Win + Ctrl + Alt + {key} to switch to this layout"); + } + else + { + return (0, $"⚠ Warning: Hotkey {key} assigned to '{layoutUuid}'\n Note: FancyZones hotkeys only work with CUSTOM layouts.\n Template layouts (focus, columns, rows, etc.) cannot be used with hotkeys.\n Create a custom layout in the FancyZones Editor to use this hotkey."); + } + } + + public static (int ExitCode, string Output) RemoveHotkey(int key, Action notifyFancyZones, uint wmPrivLayoutHotkeysFileUpdate) + { + var hotkeys = FancyZonesData.ReadLayoutHotkeys(); + if (hotkeys?.Hotkeys == null) + { + return (0, $"No hotkey assigned to key {key}"); + } + + var removed = hotkeys.Hotkeys.RemoveAll(h => h.Key == key); + if (removed == 0) + { + return (0, $"No hotkey assigned to key {key}"); + } + + // Save + FancyZonesData.WriteLayoutHotkeys(hotkeys); + + // Notify FancyZones + notifyFancyZones(wmPrivLayoutHotkeysFileUpdate); + + return (0, $"Hotkey {key} removed"); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs new file mode 100644 index 0000000000..4400b32d46 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/LayoutCommands.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; + +namespace FancyZonesCLI.Commands; + +/// +/// Layout-related commands. +/// +internal static class LayoutCommands +{ + public static (int ExitCode, string Output) GetLayouts() + { + var sb = new System.Text.StringBuilder(); + + // Print template layouts + var templatesJson = FancyZonesData.ReadLayoutTemplates(); + if (templatesJson?.Templates != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Built-in Template Layouts ({templatesJson.Templates.Count} total) ===\n"); + + for (int i = 0; i < templatesJson.Templates.Count; i++) + { + var template = templatesJson.Templates[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[T{i + 1}] {template.Type}"); + sb.Append(CultureInfo.InvariantCulture, $" Zones: {template.ZoneCount}"); + if (template.ShowSpacing && template.Spacing > 0) + { + sb.Append(CultureInfo.InvariantCulture, $", Spacing: {template.Spacing}px"); + } + + sb.AppendLine(); + sb.AppendLine(); + + // Draw visual preview + sb.Append(LayoutVisualizer.DrawTemplateLayout(template)); + + if (i < templatesJson.Templates.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\n"); + } + + // Print custom layouts + var customLayouts = FancyZonesData.ReadCustomLayouts(); + if (customLayouts?.Layouts != null) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Custom Layouts ({customLayouts.Layouts.Count} total) ==="); + + for (int i = 0; i < customLayouts.Layouts.Count; i++) + { + var layout = customLayouts.Layouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"[{i + 1}] {layout.Name}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.Uuid}"); + sb.Append(CultureInfo.InvariantCulture, $" Type: {layout.Type}"); + + bool isCanvasLayout = false; + if (layout.Info.ValueKind != JsonValueKind.Undefined && layout.Info.ValueKind != JsonValueKind.Null) + { + if (layout.Type == "grid" && layout.Info.TryGetProperty("rows", out var rows) && layout.Info.TryGetProperty("columns", out var cols)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({rows.GetInt32()}x{cols.GetInt32()} grid)"); + } + else if (layout.Type == "canvas" && layout.Info.TryGetProperty("zones", out var zones)) + { + sb.Append(CultureInfo.InvariantCulture, $" ({zones.GetArrayLength()} zones)"); + isCanvasLayout = true; + } + } + + sb.AppendLine("\n"); + + // Draw visual preview + sb.Append(LayoutVisualizer.DrawCustomLayout(layout)); + + // Add note for canvas layouts + if (isCanvasLayout) + { + sb.AppendLine("\n Note: Canvas layout preview is approximate."); + sb.AppendLine(" Open FancyZones Editor for precise zone boundaries."); + } + + if (i < customLayouts.Layouts.Count - 1) + { + sb.AppendLine(); + } + } + + sb.AppendLine("\nUse 'FancyZonesCLI.exe set-layout ' to apply a layout."); + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) GetActiveLayout() + { + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (0, "No active layouts found."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("\n=== Active FancyZones Layout(s) ===\n"); + + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + var layout = appliedLayouts.Layouts[i]; + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {i + 1}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Name: {layout.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" UUID: {layout.AppliedLayout.Uuid}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Type: {layout.AppliedLayout.Type} ({layout.AppliedLayout.ZoneCount} zones)"); + + if (layout.AppliedLayout.ShowSpacing) + { + sb.AppendLine(CultureInfo.InvariantCulture, $" Spacing: {layout.AppliedLayout.Spacing}px"); + } + + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); + + if (i < appliedLayouts.Layouts.Count - 1) + { + sb.AppendLine(); + } + } + + return (0, sb.ToString().TrimEnd()); + } + + public static (int ExitCode, string Output) SetLayout(string[] args, Action notifyFancyZones, uint wmPrivAppliedLayoutsFileUpdate) + { + Logger.LogInfo($"SetLayout called with args: [{string.Join(", ", args)}]"); + + if (args.Length == 0) + { + return (1, "Error: set-layout requires a UUID parameter"); + } + + string uuid = args[0]; + int? targetMonitor = null; + bool applyToAll = false; + + // Parse options + for (int i = 1; i < args.Length; i++) + { + if (args[i] == "--monitor" && i + 1 < args.Length) + { + if (int.TryParse(args[i + 1], out int monitorNum)) + { + targetMonitor = monitorNum; + i++; // Skip next arg + } + else + { + return (1, $"Error: Invalid monitor number: {args[i + 1]}"); + } + } + else if (args[i] == "--all") + { + applyToAll = true; + } + } + + if (targetMonitor.HasValue && applyToAll) + { + return (1, "Error: Cannot specify both --monitor and --all"); + } + + // Try to find layout in custom layouts first (by UUID) + var customLayouts = FancyZonesData.ReadCustomLayouts(); + var targetCustomLayout = customLayouts?.Layouts?.FirstOrDefault(l => l.Uuid.Equals(uuid, StringComparison.OrdinalIgnoreCase)); + + // If not found in custom layouts, try template layouts (by type name) + TemplateLayout targetTemplate = null; + if (targetCustomLayout == null) + { + var templates = FancyZonesData.ReadLayoutTemplates(); + targetTemplate = templates?.Templates?.FirstOrDefault(t => t.Type.Equals(uuid, StringComparison.OrdinalIgnoreCase)); + } + + if (targetCustomLayout == null && targetTemplate == null) + { + return (1, $"Error: Layout '{uuid}' not found\nTip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid', 'priority-grid')\n For custom layouts, use the UUID from 'get-layouts'"); + } + + // Read current applied layouts + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (1, "Error: No monitors configured"); + } + + // Determine which monitors to update + List monitorsToUpdate = new List(); + if (applyToAll) + { + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + monitorsToUpdate.Add(i); + } + } + else if (targetMonitor.HasValue) + { + int monitorIndex = targetMonitor.Value - 1; // Convert to 0-based + if (monitorIndex < 0 || monitorIndex >= appliedLayouts.Layouts.Count) + { + return (1, $"Error: Monitor {targetMonitor.Value} not found. Available monitors: 1-{appliedLayouts.Layouts.Count}"); + } + + monitorsToUpdate.Add(monitorIndex); + } + else + { + // Default: first monitor + monitorsToUpdate.Add(0); + } + + // Update selected monitors + foreach (int monitorIndex in monitorsToUpdate) + { + if (targetCustomLayout != null) + { + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = targetCustomLayout.Uuid; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetCustomLayout.Type; + } + else if (targetTemplate != null) + { + // For templates, use all-zeros UUID and the template type + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Uuid = "{00000000-0000-0000-0000-000000000000}"; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Type = targetTemplate.Type; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.ZoneCount = targetTemplate.ZoneCount; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.ShowSpacing = targetTemplate.ShowSpacing; + appliedLayouts.Layouts[monitorIndex].AppliedLayout.Spacing = targetTemplate.Spacing; + } + } + + // Write back to file + FancyZonesData.WriteAppliedLayouts(appliedLayouts); + Logger.LogInfo($"Applied layouts file updated for {monitorsToUpdate.Count} monitor(s)"); + + // Notify FancyZones to reload + notifyFancyZones(wmPrivAppliedLayoutsFileUpdate); + Logger.LogInfo("FancyZones notified of layout change"); + + string layoutName = targetCustomLayout?.Name ?? targetTemplate?.Type ?? uuid; + if (applyToAll) + { + return (0, $"Layout '{layoutName}' applied to all {monitorsToUpdate.Count} monitors"); + } + else if (targetMonitor.HasValue) + { + return (0, $"Layout '{layoutName}' applied to monitor {targetMonitor.Value}"); + } + else + { + return (0, $"Layout '{layoutName}' applied to monitor 1"); + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs new file mode 100644 index 0000000000..f542b901cc --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Commands/MonitorCommands.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; + +namespace FancyZonesCLI.Commands; + +/// +/// Monitor-related commands. +/// +internal static class MonitorCommands +{ + public static (int ExitCode, string Output) GetMonitors() + { + if (!FancyZonesData.TryReadAppliedLayouts(out var appliedLayouts, out var error)) + { + return (1, $"Error: {error}"); + } + + if (appliedLayouts.Layouts == null || appliedLayouts.Layouts.Count == 0) + { + return (0, "No monitors found."); + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"=== Monitors ({appliedLayouts.Layouts.Count} total) ==="); + sb.AppendLine(); + + for (int i = 0; i < appliedLayouts.Layouts.Count; i++) + { + var layout = appliedLayouts.Layouts[i]; + var monitorNum = i + 1; + + sb.AppendLine(CultureInfo.InvariantCulture, $"Monitor {monitorNum}:"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor: {layout.Device.Monitor}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Instance: {layout.Device.MonitorInstance}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Monitor Number: {layout.Device.MonitorNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Serial Number: {layout.Device.SerialNumber}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Virtual Desktop: {layout.Device.VirtualDesktop}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Sensitivity Radius: {layout.AppliedLayout.SensitivityRadius}px"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Active Layout: {layout.AppliedLayout.Type}"); + sb.AppendLine(CultureInfo.InvariantCulture, $" Zone Count: {layout.AppliedLayout.ZoneCount}"); + sb.AppendLine(); + } + + return (0, sb.ToString().TrimEnd()); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj new file mode 100644 index 0000000000..85c2fa30e5 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesCLI.csproj @@ -0,0 +1,32 @@ + + + + + + + + PowerToys.FancyZonesCLI + PowerToys FancyZones Command Line Interface + PowerToys FancyZones CLI + Exe + x64;ARM64 + true + true + false + false + ..\..\..\..\$(Platform)\$(Configuration) + FancyZonesCLI + $(NoWarn);SA1500;SA1402;CA1852 + + + + + + + + + + + + + diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs new file mode 100644 index 0000000000..2396c51f44 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesData.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace FancyZonesCLI; + +/// +/// Provides methods to read and write FancyZones configuration data. +/// +internal static class FancyZonesData +{ + /// + /// Try to read applied layouts configuration. + /// + public static bool TryReadAppliedLayouts(out AppliedLayouts result, out string error) + { + return TryReadJsonFile(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts, out result, out error); + } + + /// + /// Read applied layouts or return null if not found. + /// + public static AppliedLayouts ReadAppliedLayouts() + { + return ReadJsonFileOrDefault(FancyZonesPaths.AppliedLayouts, FancyZonesJsonContext.Default.AppliedLayouts); + } + + /// + /// Write applied layouts configuration. + /// + public static void WriteAppliedLayouts(AppliedLayouts layouts) + { + WriteJsonFile(FancyZonesPaths.AppliedLayouts, layouts, FancyZonesJsonContext.Default.AppliedLayouts); + } + + /// + /// Read custom layouts or return null if not found. + /// + public static CustomLayouts ReadCustomLayouts() + { + return ReadJsonFileOrDefault(FancyZonesPaths.CustomLayouts, FancyZonesJsonContext.Default.CustomLayouts); + } + + /// + /// Read layout templates or return null if not found. + /// + public static LayoutTemplates ReadLayoutTemplates() + { + return ReadJsonFileOrDefault(FancyZonesPaths.LayoutTemplates, FancyZonesJsonContext.Default.LayoutTemplates); + } + + /// + /// Read layout hotkeys or return null if not found. + /// + public static LayoutHotkeys ReadLayoutHotkeys() + { + return ReadJsonFileOrDefault(FancyZonesPaths.LayoutHotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); + } + + /// + /// Write layout hotkeys configuration. + /// + public static void WriteLayoutHotkeys(LayoutHotkeys hotkeys) + { + WriteJsonFile(FancyZonesPaths.LayoutHotkeys, hotkeys, FancyZonesJsonContext.Default.LayoutHotkeys); + } + + /// + /// Check if editor parameters file exists. + /// + public static bool EditorParametersExist() + { + return File.Exists(FancyZonesPaths.EditorParameters); + } + + private static bool TryReadJsonFile(string filePath, JsonTypeInfo jsonTypeInfo, out T result, out string error) + where T : class + { + result = null; + error = null; + + Logger.LogDebug($"Reading file: {filePath}"); + + if (!File.Exists(filePath)) + { + error = $"File not found: {Path.GetFileName(filePath)}"; + Logger.LogWarning(error); + return false; + } + + try + { + var json = File.ReadAllText(filePath); + result = JsonSerializer.Deserialize(json, jsonTypeInfo); + if (result == null) + { + error = $"Failed to parse {Path.GetFileName(filePath)}"; + Logger.LogError(error); + return false; + } + + Logger.LogDebug($"Successfully read {Path.GetFileName(filePath)}"); + return true; + } + catch (JsonException ex) + { + error = $"JSON parse error in {Path.GetFileName(filePath)}: {ex.Message}"; + Logger.LogError(error, ex); + return false; + } + catch (IOException ex) + { + error = $"Failed to read {Path.GetFileName(filePath)}: {ex.Message}"; + Logger.LogError(error, ex); + return false; + } + } + + private static T ReadJsonFileOrDefault(string filePath, JsonTypeInfo jsonTypeInfo, T defaultValue = null) + where T : class + { + if (TryReadJsonFile(filePath, jsonTypeInfo, out var result, out _)) + { + return result; + } + + return defaultValue; + } + + private static void WriteJsonFile(string filePath, T data, JsonTypeInfo jsonTypeInfo) + { + Logger.LogDebug($"Writing file: {filePath}"); + var json = JsonSerializer.Serialize(data, jsonTypeInfo); + File.WriteAllText(filePath, json); + Logger.LogInfo($"Successfully wrote {Path.GetFileName(filePath)}"); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs new file mode 100644 index 0000000000..f04d375392 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/FancyZonesPaths.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; + +namespace FancyZonesCLI; + +/// +/// Provides paths to FancyZones configuration files. +/// +internal static class FancyZonesPaths +{ + private static readonly string DataPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "Microsoft", + "PowerToys", + "FancyZones"); + + public static string AppliedLayouts => Path.Combine(DataPath, "applied-layouts.json"); + + public static string CustomLayouts => Path.Combine(DataPath, "custom-layouts.json"); + + public static string LayoutTemplates => Path.Combine(DataPath, "layout-templates.json"); + + public static string LayoutHotkeys => Path.Combine(DataPath, "layout-hotkeys.json"); + + public static string EditorParameters => Path.Combine(DataPath, "editor-parameters.json"); +} diff --git a/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs new file mode 100644 index 0000000000..fecdf33dbe --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/LayoutVisualizer.cs @@ -0,0 +1,550 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace FancyZonesCLI; + +public static class LayoutVisualizer +{ + public static string DrawTemplateLayout(TemplateLayout template) + { + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + switch (template.Type.ToLowerInvariant()) + { + case "focus": + sb.Append(RenderFocusLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "columns": + sb.Append(RenderGridLayout(1, template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "rows": + sb.Append(RenderGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3, 1)); + break; + case "grid": + // Grid layout: calculate rows and columns from zone count + // Algorithm from GridLayoutModel.InitGrid() - tries to make it close to square + // with cols >= rows preference + int zoneCount = template.ZoneCount > 0 ? template.ZoneCount : 3; + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + sb.Append(RenderGridLayoutWithZoneCount(rows, cols, zoneCount)); + break; + case "priority-grid": + sb.Append(RenderPriorityGridLayout(template.ZoneCount > 0 ? template.ZoneCount : 3)); + break; + case "blank": + sb.AppendLine(" (No zones)"); + break; + default: + sb.AppendLine(CultureInfo.InvariantCulture, $" ({template.Type} layout)"); + break; + } + + return sb.ToString(); + } + + public static string DrawCustomLayout(CustomLayout layout) + { + if (layout.Info.ValueKind == JsonValueKind.Undefined || layout.Info.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + var sb = new StringBuilder(); + sb.AppendLine(" Visual Preview:"); + + if (layout.Type == "grid" && + layout.Info.TryGetProperty("rows", out var rows) && + layout.Info.TryGetProperty("columns", out var cols)) + { + int r = rows.GetInt32(); + int c = cols.GetInt32(); + + // Check if there's a cell-child-map (merged cells) + if (layout.Info.TryGetProperty("cell-child-map", out var cellMap)) + { + sb.Append(RenderGridLayoutWithMergedCells(r, c, cellMap)); + } + else + { + int height = r >= 4 ? 12 : 8; + sb.Append(RenderGridLayout(r, c, 30, height)); + } + } + else if (layout.Type == "canvas" && + layout.Info.TryGetProperty("zones", out var zones) && + layout.Info.TryGetProperty("ref-width", out var refWidth) && + layout.Info.TryGetProperty("ref-height", out var refHeight)) + { + sb.Append(RenderCanvasLayout(zones, refWidth.GetInt32(), refHeight.GetInt32())); + } + + return sb.ToString(); + } + + private static string RenderFocusLayout(int zoneCount = 3) + { + var sb = new StringBuilder(); + + // Focus layout: overlapping zones with cascading offset + if (zoneCount == 1) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else if (zoneCount == 2) + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + else + { + sb.AppendLine(" +-------+"); + sb.AppendLine(" | |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" ..."); + sb.AppendLine(CultureInfo.InvariantCulture, $" (total: {zoneCount} zones)"); + sb.AppendLine(" ..."); + sb.AppendLine(" | +-------+"); + sb.AppendLine(" +-| |"); + sb.AppendLine(" | |"); + sb.AppendLine(" +-------+"); + } + + return sb.ToString(); + } + + private static string RenderPriorityGridLayout(int zoneCount = 3) + { + // Priority Grid has predefined layouts for zone counts 1-11 + // Data format from GridLayoutModel._priorityData + if (zoneCount >= 1 && zoneCount <= 11) + { + int[,] cellMap = GetPriorityGridCellMap(zoneCount); + return RenderGridLayoutWithCellMap(cellMap); + } + else + { + // > 11 zones: use grid layout + int rows = 1; + while (zoneCount / rows >= rows) + { + rows++; + } + + rows--; + int cols = zoneCount / rows; + if (zoneCount % rows != 0) + { + cols++; + } + + return RenderGridLayoutWithZoneCount(rows, cols, zoneCount); + } + } + + private static int[,] GetPriorityGridCellMap(int zoneCount) + { + // Parsed from Editor's _priorityData byte arrays + return zoneCount switch + { + 1 => new int[,] { { 0 } }, + 2 => new int[,] { { 0, 1 } }, + 3 => new int[,] { { 0, 1, 2 } }, + 4 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 } }, + 5 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 } }, + 6 => new int[,] { { 0, 1, 2 }, { 0, 1, 3 }, { 4, 1, 5 } }, + 7 => new int[,] { { 0, 1, 2 }, { 3, 1, 4 }, { 5, 1, 6 } }, + 8 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 2, 7 } }, + 9 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 2, 5 }, { 6, 1, 7, 8 } }, + 10 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 1, 8, 9 } }, + 11 => new int[,] { { 0, 1, 2, 3 }, { 4, 1, 5, 6 }, { 7, 8, 9, 10 } }, + _ => new int[,] { { 0 } }, + }; + } + + private static string RenderGridLayoutWithCellMap(int[,] cellMap, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int rows = cellMap.GetLength(0); + int cols = cellMap.GetLength(1); + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeTop = r > 0 && cellMap[r, c] == cellMap[r - 1, c]; + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + + if (mergeTop) + { + sb.Append(mergeLeft ? new string(' ', cellWidth) : new string(' ', cellWidth - 1) + "+"); + } + else + { + sb.Append(mergeLeft ? new string('-', cellWidth) : new string('-', cellWidth - 1) + "+"); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && cellMap[r, c] == cellMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderGridLayoutWithMergedCells(int rows, int cols, JsonElement cellMap) + { + var sb = new StringBuilder(); + const int displayWidth = 39; + const int displayHeight = 12; + + // Build zone map from cell-child-map + int[,] zoneMap = new int[rows, cols]; + for (int r = 0; r < rows; r++) + { + var rowArray = cellMap[r]; + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = rowArray[c].GetInt32(); + } + } + + int cellHeight = displayHeight / rows; + int cellWidth = displayWidth / cols; + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw rows + for (int r = 0; r < rows; r++) + { + for (int h = 0; h < cellHeight; h++) + { + sb.Append(" |"); + + for (int c = 0; c < cols; c++) + { + int currentZone = zoneMap[r, c]; + int leftZone = c > 0 ? zoneMap[r, c - 1] : -1; + bool needLeftBorder = c > 0 && currentZone != leftZone; + + bool zoneHasTopBorder = r > 0 && h == 0 && currentZone != zoneMap[r - 1, c]; + + if (needLeftBorder) + { + sb.Append('|'); + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth - 1); + } + else + { + sb.Append(zoneHasTopBorder ? '-' : ' ', cellWidth); + } + } + + sb.AppendLine("|"); + } + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + return sb.ToString(); + } + + public static string RenderGridLayoutWithZoneCount(int rows, int cols, int zoneCount, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + + // Build zone map like Editor's InitGrid + int[,] zoneMap = new int[rows, cols]; + int index = 0; + for (int r = 0; r < rows; r++) + { + for (int c = 0; c < cols; c++) + { + zoneMap[r, c] = index++; + if (index == zoneCount) + { + index--; // Remaining cells use the last zone index + } + } + } + + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append('-', mergeLeft ? cellWidth : cellWidth - 1); + if (!mergeLeft) + { + sb.Append('+'); + } + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + bool mergeLeft = c > 0 && zoneMap[r, c] == zoneMap[r, c - 1]; + sb.Append(mergeLeft ? ' ' : '|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + public static string RenderGridLayout(int rows, int cols, int width = 30, int height = 8) + { + var sb = new StringBuilder(); + int cellWidth = width / cols; + int cellHeight = height / rows; + + for (int r = 0; r < rows; r++) + { + // Top border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + + // Cell content + for (int h = 0; h < cellHeight - 1; h++) + { + sb.Append(" "); + for (int c = 0; c < cols; c++) + { + sb.Append('|'); + sb.Append(' ', cellWidth - 1); + } + + sb.AppendLine("|"); + } + } + + // Bottom border + sb.Append(" +"); + for (int c = 0; c < cols; c++) + { + sb.Append('-', cellWidth - 1); + sb.Append('+'); + } + + sb.AppendLine(); + return sb.ToString(); + } + + private static string RenderCanvasLayout(JsonElement zones, int refWidth, int refHeight) + { + var sb = new StringBuilder(); + const int displayWidth = 49; + const int displayHeight = 15; + + // Create a 2D array to track which zones occupy each position + var zoneGrid = new List[displayHeight, displayWidth]; + for (int i = 0; i < displayHeight; i++) + { + for (int j = 0; j < displayWidth; j++) + { + zoneGrid[i, j] = new List(); + } + } + + // Map each zone to the grid + int zoneId = 0; + var zoneList = new List<(int X, int Y, int Width, int Height, int Id)>(); + + foreach (var zone in zones.EnumerateArray()) + { + int x = zone.GetProperty("X").GetInt32(); + int y = zone.GetProperty("Y").GetInt32(); + int w = zone.GetProperty("width").GetInt32(); + int h = zone.GetProperty("height").GetInt32(); + + int dx = Math.Max(0, Math.Min(displayWidth - 1, x * displayWidth / refWidth)); + int dy = Math.Max(0, Math.Min(displayHeight - 1, y * displayHeight / refHeight)); + int dw = Math.Max(3, w * displayWidth / refWidth); + int dh = Math.Max(2, h * displayHeight / refHeight); + + if (dx + dw > displayWidth) + { + dw = displayWidth - dx; + } + + if (dy + dh > displayHeight) + { + dh = displayHeight - dy; + } + + zoneList.Add((dx, dy, dw, dh, zoneId)); + + for (int r = dy; r < dy + dh && r < displayHeight; r++) + { + for (int c = dx; c < dx + dw && c < displayWidth; c++) + { + zoneGrid[r, c].Add(zoneId); + } + } + + zoneId++; + } + + // Draw top border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw each row + char[] shades = { '.', ':', '░', '▒', '▓', '█', '◆', '●', '■', '▪' }; + + for (int r = 0; r < displayHeight; r++) + { + sb.Append(" |"); + for (int c = 0; c < displayWidth; c++) + { + var zonesHere = zoneGrid[r, c]; + + if (zonesHere.Count == 0) + { + sb.Append(' '); + } + else + { + int topZone = zonesHere[zonesHere.Count - 1]; + var rect = zoneList[topZone]; + + bool isTopEdge = r == rect.Y; + bool isBottomEdge = r == rect.Y + rect.Height - 1; + bool isLeftEdge = c == rect.X; + bool isRightEdge = c == rect.X + rect.Width - 1; + + if ((isTopEdge || isBottomEdge) && (isLeftEdge || isRightEdge)) + { + sb.Append('+'); + } + else if (isTopEdge || isBottomEdge) + { + sb.Append('-'); + } + else if (isLeftEdge || isRightEdge) + { + sb.Append('|'); + } + else + { + sb.Append(shades[topZone % shades.Length]); + } + } + } + + sb.AppendLine("|"); + } + + // Draw bottom border + sb.Append(" +"); + sb.Append('-', displayWidth); + sb.AppendLine("+"); + + // Draw legend + sb.AppendLine(); + sb.Append(" Legend: "); + for (int i = 0; i < Math.Min(zoneId, shades.Length); i++) + { + if (i > 0) + { + sb.Append(", "); + } + + sb.Append(CultureInfo.InvariantCulture, $"Zone {i} = {shades[i]}"); + } + + sb.AppendLine(); + return sb.ToString(); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Logger.cs b/src/modules/fancyzones/FancyZonesCLI/Logger.cs new file mode 100644 index 0000000000..3f62abf7eb --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Logger.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.IO; +using System.Runtime.CompilerServices; + +namespace FancyZonesCLI; + +/// +/// Simple logger for FancyZones CLI. +/// Logs to %LOCALAPPDATA%\Microsoft\PowerToys\FancyZones\CLI\Logs +/// +internal static class Logger +{ + private static readonly object LockObj = new(); + private static string _logFilePath = string.Empty; + private static bool _isInitialized; + + /// + /// Gets the path to the current log file. + /// + public static string LogFilePath => _logFilePath; + + /// + /// Initializes the logger. + /// + public static void InitializeLogger() + { + if (_isInitialized) + { + return; + } + + try + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var logDirectory = Path.Combine(localAppData, "Microsoft", "PowerToys", "FancyZones", "CLI", "Logs"); + + if (!Directory.Exists(logDirectory)) + { + Directory.CreateDirectory(logDirectory); + } + + var logFileName = $"FancyZonesCLI_{DateTime.Now:yyyy-MM-dd}.log"; + _logFilePath = Path.Combine(logDirectory, logFileName); + _isInitialized = true; + + LogInfo("FancyZones CLI started"); + } + catch + { + // Silently fail if logging cannot be initialized + } + } + + /// + /// Logs an error message. + /// + public static void LogError(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("ERROR", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs an error message with exception details. + /// + public static void LogError(string message, Exception ex, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + var fullMessage = ex == null + ? message + : $"{message} | Exception: {ex.GetType().Name}: {ex.Message}"; + Log("ERROR", fullMessage, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs a warning message. + /// + public static void LogWarning(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("WARN", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs an informational message. + /// + public static void LogInfo(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("INFO", message, memberName, sourceFilePath, sourceLineNumber); + } + + /// + /// Logs a debug message (only in DEBUG builds). + /// + [System.Diagnostics.Conditional("DEBUG")] + public static void LogDebug(string message, [CallerMemberName] string memberName = "", [CallerFilePath] string sourceFilePath = "", [CallerLineNumber] int sourceLineNumber = 0) + { + Log("DEBUG", message, memberName, sourceFilePath, sourceLineNumber); + } + + private static void Log(string level, string message, string memberName, string sourceFilePath, int sourceLineNumber) + { + if (!_isInitialized || string.IsNullOrEmpty(_logFilePath)) + { + return; + } + + try + { + var fileName = Path.GetFileName(sourceFilePath); + var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + var logEntry = $"[{timestamp}] [{level}] [{fileName}:{sourceLineNumber}] [{memberName}] {message}{Environment.NewLine}"; + + lock (LockObj) + { + File.AppendAllText(_logFilePath, logEntry); + } + } + catch + { + // Silently fail if logging fails + } + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/Models.cs b/src/modules/fancyzones/FancyZonesCLI/Models.cs new file mode 100644 index 0000000000..0c8bbefe54 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Models.cs @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace FancyZonesCLI; + +// JSON Source Generator for AOT compatibility +[JsonSerializable(typeof(LayoutTemplates))] +[JsonSerializable(typeof(CustomLayouts))] +[JsonSerializable(typeof(AppliedLayouts))] +[JsonSerializable(typeof(LayoutHotkeys))] +[JsonSourceGenerationOptions(WriteIndented = true)] +internal partial class FancyZonesJsonContext : JsonSerializerContext +{ +} + +// Layout Templates +public sealed class LayoutTemplates +{ + [JsonPropertyName("layout-templates")] + public List Templates { get; set; } +} + +public sealed class TemplateLayout +{ + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("zone-count")] + public int ZoneCount { get; set; } + + [JsonPropertyName("show-spacing")] + public bool ShowSpacing { get; set; } + + [JsonPropertyName("spacing")] + public int Spacing { get; set; } + + [JsonPropertyName("sensitivity-radius")] + public int SensitivityRadius { get; set; } +} + +// Custom Layouts +public sealed class CustomLayouts +{ + [JsonPropertyName("custom-layouts")] + public List Layouts { get; set; } +} + +public sealed class CustomLayout +{ + [JsonPropertyName("uuid")] + public string Uuid { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("info")] + public JsonElement Info { get; set; } +} + +// Applied Layouts +public sealed class AppliedLayouts +{ + [JsonPropertyName("applied-layouts")] + public List Layouts { get; set; } +} + +public sealed class AppliedLayoutWrapper +{ + [JsonPropertyName("device")] + public DeviceInfo Device { get; set; } = new(); + + [JsonPropertyName("applied-layout")] + public AppliedLayoutInfo AppliedLayout { get; set; } = new(); +} + +public sealed class DeviceInfo +{ + [JsonPropertyName("monitor")] + public string Monitor { get; set; } = string.Empty; + + [JsonPropertyName("monitor-instance")] + public string MonitorInstance { get; set; } = string.Empty; + + [JsonPropertyName("monitor-number")] + public int MonitorNumber { get; set; } + + [JsonPropertyName("serial-number")] + public string SerialNumber { get; set; } = string.Empty; + + [JsonPropertyName("virtual-desktop")] + public string VirtualDesktop { get; set; } = string.Empty; +} + +public sealed class AppliedLayoutInfo +{ + [JsonPropertyName("uuid")] + public string Uuid { get; set; } = string.Empty; + + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("show-spacing")] + public bool ShowSpacing { get; set; } + + [JsonPropertyName("spacing")] + public int Spacing { get; set; } + + [JsonPropertyName("zone-count")] + public int ZoneCount { get; set; } + + [JsonPropertyName("sensitivity-radius")] + public int SensitivityRadius { get; set; } +} + +// Layout Hotkeys +public sealed class LayoutHotkeys +{ + [JsonPropertyName("layout-hotkeys")] + public List Hotkeys { get; set; } +} + +public sealed class LayoutHotkey +{ + [JsonPropertyName("key")] + public int Key { get; set; } + + [JsonPropertyName("layout-id")] + public string LayoutId { get; set; } = string.Empty; +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs new file mode 100644 index 0000000000..efab0859bd --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Windows.Win32; +using Windows.Win32.Foundation; + +namespace FancyZonesCLI; + +/// +/// Native Windows API methods for FancyZones CLI. +/// +internal static class NativeMethods +{ + // Registered Windows messages for notifying FancyZones + private static uint wmPrivAppliedLayoutsFileUpdate; + private static uint wmPrivLayoutHotkeysFileUpdate; + + /// + /// Gets the Windows message ID for applied layouts file update notification. + /// + public static uint WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE => wmPrivAppliedLayoutsFileUpdate; + + /// + /// Gets the Windows message ID for layout hotkeys file update notification. + /// + public static uint WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE => wmPrivLayoutHotkeysFileUpdate; + + /// + /// Initializes the Windows messages used for FancyZones notifications. + /// + public static void InitializeWindowMessages() + { + wmPrivAppliedLayoutsFileUpdate = PInvoke.RegisterWindowMessage("{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}"); + wmPrivLayoutHotkeysFileUpdate = PInvoke.RegisterWindowMessage("{07229b7e-4f22-4357-b136-33c289be2295}"); + } + + /// + /// Broadcasts a notification message to FancyZones. + /// + /// The Windows message ID to broadcast. + public static void NotifyFancyZones(uint message) + { + PInvoke.PostMessage(HWND.HWND_BROADCAST, message, 0, 0); + } + + /// + /// Brings the specified window to the foreground. + /// + /// A handle to the window. + /// True if the window was brought to the foreground. + public static bool SetForegroundWindow(nint hWnd) + { + return PInvoke.SetForegroundWindow(new HWND(hWnd)); + } +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json new file mode 100644 index 0000000000..89cee38a92 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://aka.ms/CsWin32.schema.json", + "emitSingleFile": true, + "allowMarshaling": false +} diff --git a/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt new file mode 100644 index 0000000000..e3555c2333 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/NativeMethods.txt @@ -0,0 +1,4 @@ +PostMessage +SetForegroundWindow +RegisterWindowMessage +HWND_BROADCAST diff --git a/src/modules/fancyzones/FancyZonesCLI/Program.cs b/src/modules/fancyzones/FancyZonesCLI/Program.cs new file mode 100644 index 0000000000..1b133dfa36 --- /dev/null +++ b/src/modules/fancyzones/FancyZonesCLI/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Globalization; +using System.Linq; +using FancyZonesCLI.Commands; + +namespace FancyZonesCLI; + +internal sealed class Program +{ + private static int Main(string[] args) + { + // Initialize logger + Logger.InitializeLogger(); + Logger.LogInfo($"CLI invoked with args: [{string.Join(", ", args)}]"); + + // Initialize Windows messages + NativeMethods.InitializeWindowMessages(); + + (int ExitCode, string Output) result; + + if (args.Length == 0) + { + result = (1, GetUsageText()); + } + else + { + var command = args[0].ToLowerInvariant(); + + result = command switch + { + "open-editor" or "editor" or "e" => EditorCommands.OpenEditor(), + "get-monitors" or "monitors" or "m" => MonitorCommands.GetMonitors(), + "get-layouts" or "layouts" or "ls" => LayoutCommands.GetLayouts(), + "get-active-layout" or "active" or "get-active" or "a" => LayoutCommands.GetActiveLayout(), + "set-layout" or "set" or "s" => args.Length >= 2 + ? LayoutCommands.SetLayout(args.Skip(1).ToArray(), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_APPLIED_LAYOUTS_FILE_UPDATE) + : (1, "Error: set-layout requires a UUID parameter"), + "open-settings" or "settings" => EditorCommands.OpenSettings(), + "get-hotkeys" or "hotkeys" or "hk" => HotkeyCommands.GetHotkeys(), + "set-hotkey" or "shk" => args.Length >= 3 + ? HotkeyCommands.SetHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), args[2], NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) + : (1, "Error: set-hotkey requires "), + "remove-hotkey" or "rhk" => args.Length >= 2 + ? HotkeyCommands.RemoveHotkey(int.Parse(args[1], CultureInfo.InvariantCulture), NativeMethods.NotifyFancyZones, NativeMethods.WM_PRIV_LAYOUT_HOTKEYS_FILE_UPDATE) + : (1, "Error: remove-hotkey requires "), + "help" or "--help" or "-h" => (0, GetUsageText()), + _ => (1, $"Error: Unknown command: {command}\n\n{GetUsageText()}"), + }; + } + + // Log result + if (result.ExitCode == 0) + { + Logger.LogInfo($"Command completed successfully"); + } + else + { + Logger.LogWarning($"Command failed with exit code {result.ExitCode}: {result.Output}"); + } + + // Output result + if (!string.IsNullOrEmpty(result.Output)) + { + Console.WriteLine(result.Output); + } + + return result.ExitCode; + } + + private static string GetUsageText() + { + return """ + FancyZones CLI - Command line interface for FancyZones + ====================================================== + + Usage: FancyZonesCLI.exe [options] + + Commands: + open-editor (editor, e) Launch FancyZones layout editor + get-monitors (monitors, m) List all monitors and their properties + get-layouts (layouts, ls) List all available layouts + get-active-layout (get-active, active, a) + Show currently active layout + set-layout (set, s) [options] + Set layout by UUID + --monitor Apply to monitor N (1-based) + --all Apply to all monitors + open-settings (settings) Open FancyZones settings page + get-hotkeys (hotkeys, hk) List all layout hotkeys + set-hotkey (shk) Assign hotkey (0-9) to CUSTOM layout + Note: Only custom layouts work with hotkeys + remove-hotkey (rhk) Remove hotkey assignment + help Show this help message + + + Examples: + FancyZonesCLI.exe e # Open editor (short) + FancyZonesCLI.exe m # List monitors (short) + FancyZonesCLI.exe ls # List layouts (short) + FancyZonesCLI.exe a # Get active layout (short) + FancyZonesCLI.exe s focus --all # Set layout (short) + FancyZonesCLI.exe open-editor # Open editor (long) + FancyZonesCLI.exe get-monitors + FancyZonesCLI.exe get-layouts + FancyZonesCLI.exe set-layout {12345678-1234-1234-1234-123456789012} + FancyZonesCLI.exe set-layout focus --monitor 2 + FancyZonesCLI.exe set-layout columns --all + FancyZonesCLI.exe set-hotkey 3 {12345678-1234-1234-1234-123456789012} + """; + } +}