# Place the desktop icons into the zones drawn on the wallpaper. # # Geometry comes from dashboard\layout.json - the same file the dashboard page # draws its zone panels and seats from - so an icon always lands on the seat # painted for it. Change the layout there and both follow. # # pwsh scripts\icon-layout.ps1 # place everything # pwsh scripts\icon-layout.ps1 -WhatIf # show the moves, change nothing # pwsh scripts\icon-layout.ps1 -Save # write current positions to a backup # pwsh scripts\icon-layout.ps1 -Restore # put them back where they were # # The desktop icon coordinate space is the raw screen: this display is 3000x2000 # with no scaling, so an icon at (60,410) sits exactly over the wallpaper pixel # (60,410). On a scaled display that would no longer hold. param( [switch]$WhatIf, [switch]$Save, [switch]$Restore, [switch]$Quiet ) $ErrorActionPreference = 'Stop' $root = Split-Path $PSScriptRoot -Parent $layoutFile = Join-Path $root 'dashboard\layout.json' $backupFile = Join-Path $root 'baseline\icon-positions.json' Add-Type -TypeDefinition @' using System; using System.Text; using System.Runtime.InteropServices; public static class Icons { [DllImport("user32.dll", SetLastError=true)] static extern IntPtr FindWindow(string c, string w); [DllImport("user32.dll", SetLastError=true)] static extern IntPtr FindWindowEx(IntPtr p, IntPtr c, string cls, string win); [DllImport("user32.dll")] static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp); [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr l); delegate bool EnumWindowsProc(IntPtr h, IntPtr l); [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr OpenProcess(uint a, bool inh, uint pid); [DllImport("kernel32.dll", SetLastError=true)] static extern IntPtr VirtualAllocEx(IntPtr h, IntPtr a, uint sz, uint t, uint p); [DllImport("kernel32.dll", SetLastError=true)] static extern bool VirtualFreeEx(IntPtr h, IntPtr a, uint sz, uint t); [DllImport("kernel32.dll", SetLastError=true)] static extern bool ReadProcessMemory(IntPtr h, IntPtr a, byte[] b, int sz, out IntPtr read); [DllImport("kernel32.dll", SetLastError=true)] static extern bool WriteProcessMemory(IntPtr h, IntPtr a, byte[] b, int sz, out IntPtr wrote); [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr h); [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); const uint LVM_FIRST = 0x1000; const uint LVM_GETITEMCOUNT = LVM_FIRST + 4; const uint LVM_SETITEMPOSITION = LVM_FIRST + 15; const uint LVM_GETITEMPOSITION = LVM_FIRST + 16; const uint LVM_GETITEMTEXTW = LVM_FIRST + 115; const uint LVM_REDRAWITEMS = LVM_FIRST + 21; [StructLayout(LayoutKind.Sequential)] struct LVITEMW { public uint mask; public int iItem, iSubItem; public uint state, stateMask; public IntPtr pszText; public int cchTextMax, iImage; public IntPtr lParam; public int iIndent, iGroupId; public uint cColumns; public IntPtr puColumns; public IntPtr piColFmt; public int iGroup; } // The icon list is normally Progman > SHELLDLL_DefView > SysListView32, but // when a wallpaper host has split the desktop it hangs off a WorkerW instead. public static IntPtr FindListView() { IntPtr dv = FindWindowEx(FindWindow("Progman", null), IntPtr.Zero, "SHELLDLL_DefView", null); if (dv != IntPtr.Zero) return FindWindowEx(dv, IntPtr.Zero, "SysListView32", null); IntPtr found = IntPtr.Zero; EnumWindows((h, l) => { IntPtr d = FindWindowEx(h, IntPtr.Zero, "SHELLDLL_DefView", null); if (d != IntPtr.Zero) { found = FindWindowEx(d, IntPtr.Zero, "SysListView32", null); return false; } return true; }, IntPtr.Zero); return found; } public static int Count(IntPtr lv) { return (int)SendMessage(lv, LVM_GETITEMCOUNT, IntPtr.Zero, IntPtr.Zero); } const uint PROCESS_VM = 0x0008 | 0x0010 | 0x0020 | 0x0400; const uint MEM_COMMIT = 0x1000, MEM_RESERVE = 0x2000, MEM_RELEASE = 0x8000, PAGE_RW = 0x04; // LVM_GETITEMTEXT wants the LVITEM *and* its text buffer in the list view's // own address space, so both are allocated inside explorer and read back. // (Setting a position needs none of this - the point packs into the LPARAM.) public static string[] Names(IntPtr lv, int count) { uint pid; GetWindowThreadProcessId(lv, out pid); IntPtr proc = OpenProcess(PROCESS_VM, false, pid); if (proc == IntPtr.Zero) throw new Exception("OpenProcess failed: " + Marshal.GetLastWin32Error()); int itemSz = Marshal.SizeOf(typeof(LVITEMW)), textBytes = 520; IntPtr remote = VirtualAllocEx(proc, IntPtr.Zero, (uint)(itemSz + textBytes), MEM_COMMIT | MEM_RESERVE, PAGE_RW); if (remote == IntPtr.Zero) { CloseHandle(proc); throw new Exception("VirtualAllocEx failed"); } IntPtr remoteText = (IntPtr)(remote.ToInt64() + itemSz); string[] outp = new string[count]; try { for (int i = 0; i < count; i++) { LVITEMW it = new LVITEMW { iItem = i, iSubItem = 0, pszText = remoteText, cchTextMax = textBytes / 2 }; byte[] buf = new byte[itemSz]; IntPtr tmp = Marshal.AllocHGlobal(itemSz); Marshal.StructureToPtr(it, tmp, false); Marshal.Copy(tmp, buf, 0, itemSz); Marshal.FreeHGlobal(tmp); IntPtr w; WriteProcessMemory(proc, remote, buf, itemSz, out w); SendMessage(lv, LVM_GETITEMTEXTW, (IntPtr)i, remote); byte[] tb = new byte[textBytes]; IntPtr r; ReadProcessMemory(proc, remoteText, tb, textBytes, out r); string s = Encoding.Unicode.GetString(tb); int z = s.IndexOf('\0'); outp[i] = z >= 0 ? s.Substring(0, z) : s; } } finally { VirtualFreeEx(proc, remote, 0, MEM_RELEASE); CloseHandle(proc); } return outp; } public static int[][] Positions(IntPtr lv, int count) { uint pid; GetWindowThreadProcessId(lv, out pid); IntPtr proc = OpenProcess(PROCESS_VM, false, pid); IntPtr remote = VirtualAllocEx(proc, IntPtr.Zero, 8, MEM_COMMIT | MEM_RESERVE, PAGE_RW); int[][] outp = new int[count][]; try { for (int i = 0; i < count; i++) { SendMessage(lv, LVM_GETITEMPOSITION, (IntPtr)i, remote); byte[] b = new byte[8]; IntPtr r; ReadProcessMemory(proc, remote, b, 8, out r); outp[i] = new int[] { BitConverter.ToInt32(b, 0), BitConverter.ToInt32(b, 4) }; } } finally { VirtualFreeEx(proc, remote, 0, MEM_RELEASE); CloseHandle(proc); } return outp; } public static void SetPosition(IntPtr lv, int index, int x, int y) { SendMessage(lv, LVM_SETITEMPOSITION, (IntPtr)index, (IntPtr)((y << 16) | (x & 0xFFFF))); } // Where an item actually DRAWS, which is not where SetPosition puts it: // SetPosition addresses the icon image, while the visible cell is the image // centred in one icon-spacing width, so bounds.left sits (spacing - image)/2 // to the left. Read it instead of assuming it - it changes with the desktop // icon size (View > Large/Medium/Small). const uint LVM_GETITEMRECT = LVM_FIRST + 14; public static int[] Bounds(IntPtr lv, int index) { uint pid; GetWindowThreadProcessId(lv, out pid); IntPtr proc = OpenProcess(PROCESS_VM, false, pid); IntPtr remote = VirtualAllocEx(proc, IntPtr.Zero, 16, MEM_COMMIT | MEM_RESERVE, PAGE_RW); try { byte[] pre = new byte[16]; // rect.left carries LVIR_BOUNDS (0) IntPtr n; WriteProcessMemory(proc, remote, pre, 16, out n); SendMessage(lv, LVM_GETITEMRECT, (IntPtr)index, remote); byte[] b = new byte[16]; ReadProcessMemory(proc, remote, b, 16, out n); return new int[] { BitConverter.ToInt32(b,0), BitConverter.ToInt32(b,4), BitConverter.ToInt32(b,8), BitConverter.ToInt32(b,12) }; } finally { VirtualFreeEx(proc, remote, 0, MEM_RELEASE); CloseHandle(proc); } } public static void Redraw(IntPtr lv, int count) { SendMessage(lv, LVM_REDRAWITEMS, IntPtr.Zero, (IntPtr)(count - 1)); } } '@ [Icons]::SetProcessDPIAware() | Out-Null $lv = [Icons]::FindListView() if ($lv -eq [IntPtr]::Zero) { throw 'desktop icon list (SysListView32) not found - is explorer running?' } $count = [Icons]::Count($lv) if ($count -le 0) { throw 'the desktop reports no icons' } $names = [Icons]::Names($lv, $count) $pos = [Icons]::Positions($lv, $count) # ---------------------------------------------------------------- save / restore if ($Save) { New-Item -ItemType Directory -Force -Path (Split-Path $backupFile) | Out-Null $map = @{} for ($i = 0; $i -lt $count; $i++) { $map[$names[$i]] = @($pos[$i][0], $pos[$i][1]) } $map | ConvertTo-Json -Depth 4 | Set-Content $backupFile -Encoding utf8 "saved $count icon positions -> $backupFile" return } if ($Restore) { if (-not (Test-Path $backupFile)) { throw "no saved positions at $backupFile" } $map = Get-Content $backupFile -Raw | ConvertFrom-Json $n = 0 for ($i = 0; $i -lt $count; $i++) { $p = $map.($names[$i]) if ($p) { [Icons]::SetPosition($lv, $i, [int]$p[0], [int]$p[1]); $n++ } } [Icons]::Redraw($lv, $count) "restored $n of $count icons" return } # ---------------------------------------------------------------- plan the moves $layout = Get-Content $layoutFile -Raw | ConvertFrom-Json $cell = $layout.cell $titleH = $layout.titleH # Index the desktop by name once; names are unique on a desktop because the file # system already guarantees it. $byName = @{} for ($i = 0; $i -lt $count; $i++) { if (-not $byName.ContainsKey($names[$i])) { $byName[$names[$i]] = $i } } $claimed = New-Object 'System.Collections.Generic.HashSet[string]' $moves = [System.Collections.Generic.List[object]]::new() $missing = [System.Collections.Generic.List[string]]::new() # Measure, once, how far an item's drawn cell sits from the position we set, and # cancel it. Without this every icon renders ~43px left of its painted seat. $probe = [Icons]::Bounds($lv, 0) $offX = $probe[0] - $pos[0][0] $offY = $probe[1] - $pos[0][1] if (-not $Quiet) { "icon draw offset: dx=$offX dy=$offY (cancelled when placing)" } function Add-Move($zone, $slot, $name) { # (X,Y) is the seat's top-left on the wallpaper; Set/Get positions are shifted # by the draw offset so the *drawn* cell lands exactly on the seat. $x = $zone.rect[0] + $zone.padX + ($slot % $zone.cols) * $cell.w $y = $zone.rect[1] + $titleH + [math]::Floor($slot / $zone.cols) * $cell.h $script:moves.Add([pscustomobject]@{ Name = $name; Index = $byName[$name]; Zone = $zone.id; Slot = $slot X = [int]$x; Y = [int]$y # where it should DRAW SetX = [int]($x - $script:offX); SetY = [int]($y - $script:offY) }) } foreach ($zone in $layout.iconZones) { $slot = 0 foreach ($name in $zone.items) { if (-not $byName.ContainsKey($name)) { $missing.Add("$name (zone $($zone.id))"); continue } if (-not $claimed.Add($name)) { continue } Add-Move $zone $slot $name $slot++ } # Remember where the named items stopped, so the overflow zone appends after # them rather than writing over them. $zone | Add-Member -NotePropertyName _next -NotePropertyValue $slot -Force } # Anything on the desktop that no zone named goes to the overflow zone, so a file # saved to the desktop tomorrow lands in a seat instead of on top of a panel. $overflow = $layout.iconZones | Where-Object { $_.overflow } | Select-Object -First 1 $unplaced = [System.Collections.Generic.List[string]]::new() if ($overflow) { $slot = $overflow._next $cap = $overflow.cols * $overflow.rows foreach ($name in ($names | Sort-Object)) { if ($claimed.Contains($name)) { continue } if ($slot -ge $cap) { $unplaced.Add($name); continue } $claimed.Add($name) | Out-Null Add-Move $overflow $slot $name $slot++ } } # ---------------------------------------------------------------- apply if (-not $Quiet -or $WhatIf) { foreach ($m in $moves) { $cur = $pos[$m.Index] $same = ($cur[0] -eq $m.SetX -and $cur[1] -eq $m.SetY) "{0,-26} {1,-9} slot {2,2} seat ({3},{4}) <- set ({5},{6}){7}" -f $m.Name, $m.Zone, $m.Slot, $m.X, $m.Y, $m.SetX, $m.SetY, $(if ($same) { ' = already there' } else { '' }) } } foreach ($n in $missing) { Write-Warning "layout names an icon the desktop does not have: $n" } foreach ($n in $unplaced) { Write-Warning "no seat left for '$n' - left where it was (grow the inbox zone)" } if ($WhatIf) { "`n-WhatIf: nothing moved ($($moves.Count) icons would move)"; return } foreach ($m in $moves) { [Icons]::SetPosition($lv, $m.Index, $m.SetX, $m.SetY) } [Icons]::Redraw($lv, $count) # Verify against where each icon actually DRAWS, not against the position we set. # Comparing set-vs-read is worthless - it always matches - and hid a 43px offset. # A mismatch here means either the draw offset moved (icon size changed) or # "Align icons to grid" is on and Explorer snapped the icons to its own cells. $snapped = @($moves | Where-Object { $bb = [Icons]::Bounds($lv, $_.Index) $bb[0] -ne $_.X -or $bb[1] -ne $_.Y }) if ($snapped.Count) { Write-Warning ("$($snapped.Count) of $($moves.Count) icons did not land exactly - " + "'Align icons to grid' is probably ON (right-click desktop > View)") foreach ($m in ($snapped | Select-Object -First 5)) { $bb = [Icons]::Bounds($lv, $m.Index) " {0,-24} seat ({1},{2}) drawn at ({3},{4})" -f $m.Name, $m.X, $m.Y, $bb[0], $bb[1] } } "placed $($moves.Count) of $count desktop icons" ` + $(if ($snapped.Count) { ", $($snapped.Count) SNAPPED off-seat" } else { ", all pixel-exact" }) ` + $(if ($missing.Count) { ", $($missing.Count) named but absent" }) ` + $(if ($unplaced.Count) { ", $($unplaced.Count) with no seat" })