stacks/dotnet/hooks/dev/DevLayout.psm1
|
# DevLayout.psm1 # Window layout management for development environments # Adapted from DevLayout project - Windows-only functionality # Platform check - Win32 APIs only work on Windows $script:IsWindowsPlatform = $IsWindows -or ($env:OS -eq "Windows_NT") # Internal status writer (avoids dependency on Common.ps1) function Write-LayoutStatus { param( [string]$Message, [ValidateSet("Success", "Info", "Warning", "Error", "Neutral")] [string]$Type = "Info" ) $prefix = switch ($Type) { "Success" { "[OK]" } "Info" { "[--]" } "Warning" { "[!!]" } "Error" { "[XX]" } "Neutral" { "[ ]" } } $color = switch ($Type) { "Success" { "Green" } "Info" { "Cyan" } "Warning" { "Yellow" } "Error" { "Red" } "Neutral" { "Gray" } } Write-Host "$prefix $Message" -ForegroundColor $color } # Win32 type definitions (only load on Windows) if ($script:IsWindowsPlatform) { if (-not ([System.Management.Automation.PSTypeName]'Win32DevLayout').Type) { Add-Type @" using System; using System.Runtime.InteropServices; public class Win32DevLayout { [DllImport("user32.dll")] public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int W, int H, bool repaint); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); public const uint WM_CLOSE = 0x0010; public const uint WM_KEYDOWN = 0x0100; public const uint WM_KEYUP = 0x0101; public const int VK_F5 = 0x74; } "@ } # Separate type for DPI detection (avoids caching issues with main type) if (-not ([System.Management.Automation.PSTypeName]'Win32DpiHelper').Type) { Add-Type @" using System; using System.Runtime.InteropServices; public class Win32DpiHelper { [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int awareness); [DllImport("shcore.dll")] public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY); [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags); [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } public const int PROCESS_PER_MONITOR_DPI_AWARE = 2; } "@ } # Set process DPI awareness to get accurate monitor DPI values try { [Win32DpiHelper]::SetProcessDpiAwareness([Win32DpiHelper]::PROCESS_PER_MONITOR_DPI_AWARE) | Out-Null } catch { Write-Verbose "Platform API call failed: $_" } Add-Type -AssemblyName System.Windows.Forms } # Get DPI scale factor for a screen function Get-ScreenDpiScale { param($Screen) try { # Get monitor handle from a point on this screen $pt = New-Object Win32DpiHelper+POINT $pt.X = $Screen.Bounds.X + 10 $pt.Y = $Screen.Bounds.Y + 10 $hMonitor = [Win32DpiHelper]::MonitorFromPoint($pt, 0) $dpiX = [uint32]0 $dpiY = [uint32]0 $result = [Win32DpiHelper]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY) if ($result -eq 0 -and $dpiX -gt 0) { return [double]$dpiX / 96.0 } } catch { # Fall back to 1.0 if DPI detection fails } return 1.0 } # Layout definitions: [x%, y%, w%, h%] $script:Layouts = @{ "1L-1R" = @{ terminals = ,@(0, 0, 50, 100) browser = @(50, 0, 50, 100) } "2L-1R" = @{ terminals = @(@(0, 0, 50, 50), @(0, 50, 50, 50)) browser = @(50, 0, 50, 100) } "3L-1R" = @{ terminals = @(@(0, 0, 50, 33), @(0, 33, 50, 33), @(0, 66, 50, 34)) browser = @(50, 0, 50, 100) } "1T-1B" = @{ terminals = ,@(0, 0, 100, 50) browser = @(0, 50, 100, 50) } "2T-2B" = @{ terminals = @(@(0, 0, 50, 50), @(50, 0, 50, 50)) browser = @(0, 50, 100, 50) } } function Get-LayoutRect { param($Zone, $Screen) @{ X = $Screen.X + [int]($Screen.Width * $Zone[0] / 100) Y = $Screen.Y + [int]($Screen.Height * $Zone[1] / 100) W = [int]($Screen.Width * $Zone[2] / 100) H = [int]($Screen.Height * $Zone[3] / 100) } } function New-TerminalWindow { param($Command, $Zone, $Screen) $rect = Get-LayoutRect -Zone $Zone -Screen $Screen # Track by handle - WT reuses process $beforeHandles = @(Get-Process WindowsTerminal -EA SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -Exp MainWindowHandle) # Write command to temp script to avoid wt escaping issues $tempScript = Join-Path $env:TEMP "devlayout-$(New-Guid).ps1" # Prepend PATH setup to ensure dotnet and other tools are available $scriptContent = @" # Set up PATH for dev tools `$env:PATH = '$env:PATH' # Run the actual command $Command "@ Set-Content -Path $tempScript -Value $scriptContent -Encoding UTF8 # -w new forces a new window instead of a tab in existing window # -NoProfile skips profile loading for faster startup and no oh-my-posh errors Start-Process wt -ArgumentList "-w new pwsh -NoProfile -NoExit -File `"$tempScript`"" Start-Sleep -Milliseconds 1500 $wt = Get-Process WindowsTerminal -EA SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowHandle -notin $beforeHandles } | Select-Object -First 1 if ($wt) { # Wait a bit more for window to settle Start-Sleep -Milliseconds 500 [Win32DevLayout]::ShowWindow($wt.MainWindowHandle, 9) | Out-Null [Win32DevLayout]::MoveWindow($wt.MainWindowHandle, $rect.X, $rect.Y, $rect.W, $rect.H, $true) | Out-Null return @{ handle = $wt.MainWindowHandle.ToInt64(); cmd = $Command } } return $null } function New-BrowserWindow { param($Url, $Zone, $Screen, $DpiScale = 1.0) $rect = Get-LayoutRect -Zone $Zone -Screen $Screen # MoveWindow behavior with DPI-aware processes is complex: # - Position (X/Y) uses physical pixels # - Size (W/H) needs logical (scaled) coordinates for Chrome $moveX = $rect.X $moveY = $rect.Y $moveW = [int]($rect.W / $DpiScale) $moveH = [int]($rect.H / $DpiScale) $beforeHandles = @(Get-Process chrome -EA SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -Exp MainWindowHandle) Start-Process "chrome.exe" -ArgumentList "--new-window", $Url Start-Sleep -Milliseconds 1500 $chrome = Get-Process chrome -EA SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowHandle -notin $beforeHandles } | Select-Object -First 1 if ($chrome) { [Win32DevLayout]::ShowWindow($chrome.MainWindowHandle, 9) | Out-Null [Win32DevLayout]::MoveWindow($chrome.MainWindowHandle, $moveX, $moveY, $moveW, $moveH, $true) | Out-Null return @{ handle = $chrome.MainWindowHandle.ToInt64(); url = $Url } } return $null } function Open-DevLayout { <# .SYNOPSIS Opens a dev environment with split window layout .PARAMETER Monitor Monitor index (0-based) .PARAMETER Layout Layout preset: 1L-1R, 2L-1R, 3L-1R, 1T-1B, 2T-2B .PARAMETER Terminals Array of commands to run in terminal windows .PARAMETER Urls Array of URLs to open in browser windows .PARAMETER SessionName Name for the session (used for cleanup) .PARAMETER Quiet Suppress console output #> param( [int]$Monitor = 0, [ValidateSet("1L-1R", "2L-1R", "3L-1R", "1T-1B", "2T-2B")] [string]$Layout = "2L-1R", [string[]]$Terminals = @(), [string[]]$Urls = @(), [string]$SessionName = "default", [switch]$Quiet ) # Skip on non-Windows if (-not $script:IsWindowsPlatform) { if (-not $Quiet) { Write-LayoutStatus "DevLayout skipped (Windows-only feature)" -Type Neutral } return @{ session = $SessionName; status = "skipped"; reason = "non-windows" } } $sessionFile = Join-Path $env:TEMP "devlayout-$SessionName.json" # Check for existing session if (Test-Path $sessionFile) { if (-not $Quiet) { Write-LayoutStatus "Session '$SessionName' already running" -Type Warning } return @{ session = $SessionName; status = "already_running" } } # Get target monitor $screens = [System.Windows.Forms.Screen]::AllScreens if ($Monitor -ge $screens.Count) { if (-not $Quiet) { Write-LayoutStatus "Monitor $Monitor not found, using 0" -Type Warning } $Monitor = 0 } # After SetProcessDpiAwareness, WorkingArea returns actual pixel dimensions # so we can use them directly with MoveWindow $workArea = $screens[$Monitor].WorkingArea $dpiScale = Get-ScreenDpiScale -Screen $screens[$Monitor] $scr = @{ X = $workArea.X Y = $workArea.Y Width = $workArea.Width Height = $workArea.Height } if (-not $Quiet) { Write-LayoutStatus "Layout: $Layout on monitor $Monitor ($($scr.Width)x$($scr.Height))" -Type Info } # Track opened windows $session = @{ name = $SessionName started_at = (Get-Date).ToString("o") terminals = [System.Collections.ArrayList]@() browsers = [System.Collections.ArrayList]@() } # Launch terminals $layoutDef = $script:Layouts[$Layout] $termZones = $layoutDef.terminals for ($i = 0; $i -lt $termZones.Count -and $i -lt $Terminals.Count; $i++) { $info = New-TerminalWindow -Command $Terminals[$i] -Zone $termZones[$i] -Screen $scr if ($info) { [void]$session.terminals.Add($info) if (-not $Quiet) { Write-LayoutStatus "Terminal opened" -Type Success } } } # Launch browsers foreach ($url in $Urls) { $info = New-BrowserWindow -Url $url -Zone $layoutDef.browser -Screen $scr -DpiScale $dpiScale if ($info) { [void]$session.browsers.Add($info) if (-not $Quiet) { Write-LayoutStatus "Browser opened: $url" -Type Success } } } # Save session $session | ConvertTo-Json -Depth 3 | Set-Content $sessionFile -Encoding UTF8 return @{ session = $SessionName status = "running" terminals = $session.terminals.Count browsers = $session.browsers.Count } } function Close-DevLayout { <# .SYNOPSIS Closes windows opened by Open-DevLayout .PARAMETER SessionName Name of the session to close .PARAMETER Quiet Suppress console output #> param( [string]$SessionName = "default", [switch]$Quiet ) # Skip on non-Windows if (-not $script:IsWindowsPlatform) { return @{ session = $SessionName; status = "skipped"; reason = "non-windows" } } $sessionFile = Join-Path $env:TEMP "devlayout-$SessionName.json" if (-not (Test-Path $sessionFile)) { return @{ session = $SessionName; status = "not_found" } } $session = Get-Content $sessionFile | ConvertFrom-Json $closedTerminals = 0 $closedBrowsers = 0 # Close terminals by handle foreach ($t in $session.terminals) { $handle = [IntPtr]::new($t.handle) if ([Win32DevLayout]::IsWindow($handle)) { [Win32DevLayout]::PostMessage($handle, [Win32DevLayout]::WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null $closedTerminals++ if (-not $Quiet) { Write-LayoutStatus "Closed terminal" -Type Success } } } # Close browsers by handle foreach ($b in $session.browsers) { $handle = [IntPtr]::new($b.handle) if ([Win32DevLayout]::IsWindow($handle)) { [Win32DevLayout]::PostMessage($handle, [Win32DevLayout]::WM_CLOSE, [IntPtr]::Zero, [IntPtr]::Zero) | Out-Null $closedBrowsers++ if (-not $Quiet) { Write-LayoutStatus "Closed browser" -Type Success } } } # Remove session file Remove-Item $sessionFile -Force return @{ session = $SessionName status = "closed" closed_terminals = $closedTerminals closed_browsers = $closedBrowsers } } function Get-DevLayoutMonitors { <# .SYNOPSIS Lists available monitors #> if (-not $script:IsWindowsPlatform) { Write-Host "Monitor listing only available on Windows" -ForegroundColor Yellow return @() } $monitors = @() $i = 0 [System.Windows.Forms.Screen]::AllScreens | ForEach-Object { $monitors += @{ index = $i primary = $_.Primary width = $_.Bounds.Width height = $_.Bounds.Height x = $_.Bounds.X y = $_.Bounds.Y } $i++ } return $monitors } function Send-BrowserRefresh { <# .SYNOPSIS Sends F5 (refresh) to browser windows in a session .PARAMETER SessionName Name of the session containing browser windows .PARAMETER Quiet Suppress console output #> param( [string]$SessionName = "default", [switch]$Quiet ) # Skip on non-Windows if (-not $script:IsWindowsPlatform) { return @{ status = "skipped"; reason = "non-windows" } } $sessionFile = Join-Path $env:TEMP "devlayout-$SessionName.json" if (-not (Test-Path $sessionFile)) { return @{ status = "not_found" } } $session = Get-Content $sessionFile | ConvertFrom-Json $refreshed = 0 foreach ($b in $session.browsers) { $handle = [IntPtr]::new($b.handle) if ([Win32DevLayout]::IsWindow($handle)) { # Bring window to foreground and send F5 [Win32DevLayout]::SetForegroundWindow($handle) | Out-Null Start-Sleep -Milliseconds 100 [Win32DevLayout]::PostMessage($handle, [Win32DevLayout]::WM_KEYDOWN, [IntPtr]::new([Win32DevLayout]::VK_F5), [IntPtr]::Zero) | Out-Null [Win32DevLayout]::PostMessage($handle, [Win32DevLayout]::WM_KEYUP, [IntPtr]::new([Win32DevLayout]::VK_F5), [IntPtr]::Zero) | Out-Null $refreshed++ if (-not $Quiet) { Write-LayoutStatus "Refreshed browser" -Type Success } } } return @{ status = "refreshed" count = $refreshed } } Export-ModuleMember -Function Open-DevLayout, Close-DevLayout, Get-DevLayoutMonitors, Send-BrowserRefresh |