diff --git a/Win32/WindowCloaking.cs b/Win32/WindowCloaking.cs index d4a487f..aa47e8c 100644 --- a/Win32/WindowCloaking.cs +++ b/Win32/WindowCloaking.cs @@ -1,5 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; namespace Bemo { @@ -25,6 +28,46 @@ private class VirtualDesktopManager { } [ThreadStatic] private static IVirtualDesktopManager desktops; + private const int RPC_E_CANTCALLOUT_ININPUTSYNCCALL = unchecked((int)0x8001010D); + + /// The last answer for each window asked about, for when asking is not possible. + private static readonly ConcurrentDictionary lastAnswer = new ConcurrentDictionary(); + + private static int OnCurrentDesktop(IntPtr hwnd, out int onCurrent) + { + if (desktops == null) desktops = (IVirtualDesktopManager)new VirtualDesktopManager(); + return desktops.IsWindowOnCurrentVirtualDesktop(hwnd, out onCurrent); + } + + private delegate bool EnumChildProc(IntPtr hwnd, IntPtr lParam); + [DllImport("user32.dll")] private static extern bool EnumChildWindows(IntPtr parent, EnumChildProc callback, IntPtr lParam); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(IntPtr hwnd, StringBuilder name, int size); + [DllImport("user32.dll")] private static extern bool IsIconic(IntPtr hwnd); + + private static string ClassName(IntPtr hwnd) + { + var name = new StringBuilder(256); + GetClassName(hwnd, name, name.Capacity); + return name.ToString(); + } + + /// + /// A UWP frame shows its app in a CoreWindow child. A frame left behind by an app closed to + /// the background no longer holds it, although it is not minimized; a minimized frame lets + /// go of it too, so minimized frames do not count. + /// + public static bool IsEmptyAppFrame(IntPtr hwnd) + { + if (ClassName(hwnd) != "ApplicationFrameWindow" || IsIconic(hwnd)) return false; + bool holdsApp = false; + EnumChildWindows(hwnd, (child, _) => + { + if (ClassName(child) == "Windows.UI.Core.CoreWindow") { holdsApp = true; return false; } + return true; + }, IntPtr.Zero); + return !holdsApp; + } + public static bool IsCloaked(IntPtr hwnd) { try @@ -36,17 +79,37 @@ public static bool IsCloaked(IntPtr hwnd) } /// - /// True when the window is cloaked although it is on the current virtual desktop, i.e. the - /// app has hidden it. False when that cannot be determined, so no window is dropped by mistake. + /// True when the window is cloaked, or an empty UWP frame, although it is on the current + /// virtual desktop, i.e. the app has hidden it. False when that cannot be determined, so no + /// window is dropped by mistake. /// public static bool IsHiddenOnCurrentDesktop(IntPtr hwnd) { - if (!IsCloaked(hwnd)) return false; + if (!IsCloaked(hwnd) && !IsEmptyAppFrame(hwnd)) return false; try { - if (desktops == null) desktops = (IVirtualDesktopManager)new VirtualDesktopManager(); int onCurrent; - return desktops.IsWindowOnCurrentVirtualDesktop(hwnd, out onCurrent) == 0 && onCurrent != 0; + int hr = OnCurrentDesktop(hwnd, out onCurrent); + // The Alt+Tab switcher builds its list inside a low-level keyboard hook, where this + // thread can neither call out to another process nor wait long. The answer from the + // last time the window was checked stands, and a pool thread asks again for next time. + if (hr == RPC_E_CANTCALLOUT_ININPUTSYNCCALL) + { + Task.Run(() => + { + try + { + int on; + if (OnCurrentDesktop(hwnd, out on) == 0) lastAnswer[hwnd] = on != 0; + } + catch (COMException) { } + catch (InvalidCastException) { } + }); + bool known; + return lastAnswer.TryGetValue(hwnd, out known) && known; + } + if (hr == 0) lastAnswer[hwnd] = onCurrent != 0; + return hr == 0 && onCurrent != 0; } catch (COMException) { return false; } catch (InvalidCastException) { return false; } diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 7112119..9eec97e 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -60,7 +60,8 @@ module TaskbarProxies = /// A group's taskbar icon: the app's icon with the WindowTabs badge at the bottom right, as in /// the window switcher. Windows 11 draws an overlay icon at the top right instead. type TaskbarBadgedIcon(icon:Icon) = - let size = max 32 icon.Width + // Some apps give a 256 pixel icon, which the Windows Alt+Tab drew across a group's whole tile. + let size = min 64 (max 32 icon.Width) let handle = use canvas = new Bitmap(size,size,PixelFormat.Format32bppArgb) do @@ -82,6 +83,92 @@ type TaskbarBadgedIcon(icon:Icon) = badged.Dispose() WinUserApi.DestroyIcon(handle) |> ignore +/// Chromium and Electron windows stop painting while another window covers them, so a tab +/// behind the active one prints blank, and a minimized one prints only a placeholder. Each +/// tab's last good capture stands in for it, kept small: thumbnails are a few hundred pixels +/// and a peek at a hidden tab may be soft. +type TabCaptures() = + let captures = Collections.Generic.Dictionary() + /// Tabs that printed blank while hidden; printing them again before they come to the front + /// would only be slow. + let blank = Collections.Generic.HashSet() + + static member maxSide = 800 + + /// A window that drew nothing prints one colour across its middle. + static member isBlank (bitmap:Bitmap) = + let width,height = bitmap.Width,bitmap.Height + if width < 8 || height < 8 then true + else + let first = bitmap.GetPixel(width/2,height/2) + seq { for i in 1..8 do + for j in 1..8 -> bitmap.GetPixel(width*i/9,height*(1+j)/10) } + |> Seq.forall(fun pixel -> pixel = first) + + static member private scaled (source:Bitmap) (size:Size) = + let result = new Bitmap(size.Width,size.Height,PixelFormat.Format32bppArgb) + try + use g = Graphics.FromImage(result) + g.InterpolationMode <- Drawing2D.InterpolationMode.HighQualityBilinear + g.DrawImage(source,Rectangle(Point.Empty,size)) + result + with _ -> + result.Dispose() + reraise() + + member this.count = captures.Count + + /// Keeps a copy of a capture that shows the window. + member this.remember (hwnd:IntPtr) (image:Bitmap) = + if not(TabCaptures.isBlank image) then + blank.Remove hwnd |> ignore + let ratio = min 1.0 (float TabCaptures.maxSide / float (max image.Width image.Height)) + let size = Size(max 1 (int(float image.Width * ratio)),max 1 (int(float image.Height * ratio))) + let copy = TabCaptures.scaled image size + match captures.TryGetValue hwnd with + | true,previous -> previous.Dispose() + | _ -> () + captures.[hwnd] <- copy + + /// The capture to show for a window of the given size: a live one when it shows the window, + /// else the last that did. A hidden tab known to print blank is not printed again. + member this.preview (hwnd:IntPtr) (size:Size) (minimized:bool) (inFront:bool) (capture:unit -> Img) : Img = + let saved = match captures.TryGetValue hwnd with | true,saved -> Some saved | _ -> None + let fromSaved (saved:Bitmap) (size:Size) = Img(TabCaptures.scaled saved size) + match saved with + | Some saved when not minimized && not inFront && blank.Contains hwnd -> fromSaved saved size + | _ -> + let live = capture() + let shows = not minimized && not(TabCaptures.isBlank live.bitmap) + if shows then + this.remember hwnd live.bitmap + live + else + if not minimized then blank.Add hwnd |> ignore + match saved with + | Some saved -> + let result = fromSaved saved live.bitmap.Size + live.bitmap.Dispose() + result + | None -> live + + /// The tab is in front again and paints, so its next capture is worth taking. + member this.shown (hwnd:IntPtr) = blank.Remove hwnd |> ignore + + member this.forget (hwnd:IntPtr) = + blank.Remove hwnd |> ignore + match captures.TryGetValue hwnd with + | true,saved -> + saved.Dispose() + captures.Remove hwnd |> ignore + | _ -> () + + interface IDisposable with + member this.Dispose() = + for saved in captures.Values do saved.Dispose() + captures.Clear() + blank.Clear() + type TbButton = { icon : Icon text : string @@ -315,6 +402,9 @@ type SuperBarPlugin() as this = let mutable taskbarButton = null let flashingTabs = Collections.Generic.HashSet() + let captures = new TabCaptures() + /// A tab that comes to the front is captured once it has painted, for when it is hidden again. + let captureTimer = new System.Windows.Forms.Timer(Interval=500) member this.os = _os @@ -344,7 +434,11 @@ type SuperBarPlugin() as this = (button :> IDisposable).Dispose() taskbarButton <- null elif this.zorder.isEmpty.not then - let previewBounds = this.ts.bounds.union(this.wtGroup.placementBounds) + // A hidden strip has no bounds; the preview is then the window alone. + let stripShown = this.ts.visible && not(this.ts.bounds.size.isEmptyArea) + let previewBounds = + if stripShown then this.ts.bounds.union(this.wtGroup.previewArea) + else this.wtGroup.previewArea let top = this.zorder.head let topTab = Tab(top) let tbButtonInfo = { @@ -369,12 +463,16 @@ type SuperBarPlugin() as this = preview = fun(isThumbnail) -> try let (Tab(hwnd)) = tab - let contentLocation = TaskbarPreview.relativeLocation previewBounds (this.os.windowFromHwnd(hwnd).bounds) + let window = this.os.windowFromHwnd(hwnd) + // A minimized window sits far off screen; it shows where the group's windows are restored to. + let area = if window.isMinimized then this.wtGroup.previewArea else window.bounds + let contentLocation = TaskbarPreview.relativeLocation previewBounds area Some(TaskbarPreview.compose previewBounds.size contentLocation (this.ts.bounds.location.sub(previewBounds.location).Point) - (fun () -> this.ts.tabInfo(tab).preview()) - (fun () -> this.ts.renderTs(Some(tab)))) + (fun () -> + captures.preview hwnd area.size.Size window.isMinimized (this.zorder.tryHead = Some hwnd) (fun () -> this.ts.tabInfo(tab).preview())) + (fun () -> if stripShown then this.ts.renderTs(Some(tab)) else Img(Sz(1,1)))) with _ -> None activate = fun() -> this.invokeAsync <| fun() -> this.tabActivate(tab, false) @@ -412,8 +510,19 @@ type SuperBarPlugin() as this = this.updateTaskbar() member this.onRemoved(hwnd) = + captures.forget hwnd this.updateTaskbar() this.showInTaskbar(hwnd, true) + + member private this.captureTop() = + captureTimer.Stop() + if taskbarButton <> null && this.zorder.isEmpty.not then + let top = this.zorder.head + captures.shown top + if not(this.os.windowFromHwnd(top).isMinimized) then + let image = this.ts.tabInfo(Tab(top)).preview() + try captures.remember top image.bitmap + finally image.bitmap.Dispose() /// A tab stops flashing when its window redraws, which it also does when printed for a /// preview. Invalidating then would make the taskbar print it again, over and over. @@ -437,11 +546,16 @@ type SuperBarPlugin() as this = else this.zorder.iter <| fun hwnd -> this.showInTaskbar(hwnd, false) this.updateTaskbar() + if this.zorder.count > 1 then + captureTimer.Stop() + captureTimer.Start() member this.onTabMoved(tab, index) = this.updateTaskbar() member this.onGroupExited() = + captureTimer.Dispose() + (captures :> IDisposable).Dispose() if taskbarButton <> null then (taskbarButton :> IDisposable).Dispose() taskbarButton <- null @@ -457,3 +571,4 @@ type SuperBarPlugin() as this = this.wtGroup.zorder.changed.Add this.onZorderChanged this.ts.tabMoved.Add this.onTabMoved this.wtGroup.exited.Add this.onGroupExited + captureTimer.Tick.Add(fun _ -> this.captureTop()) diff --git a/WtProgram/TaskSwitch.fs b/WtProgram/TaskSwitch.fs index 72bed90..741233b 100755 --- a/WtProgram/TaskSwitch.fs +++ b/WtProgram/TaskSwitch.fs @@ -124,18 +124,46 @@ type ITaskSwitchListControl = /// A window was clicked: switch to it now. abstract member clicked : IEvent +module private TaskWindowIcons = + /// A window's app icon at the given size: the shell's icon for its program, which comes in + /// large sizes, or the window's own when the program has no icon of its own. + let large (window:Window) (size:int) : Bitmap option = + let fromShell() = + try + if window.className="ApplicationFrameWindow" then AppIcons.GetAppIcon(AppIcons.GetHostedAppId(window.hwnd),size) + else + let path = window.pid.processPath + if AppIcons.HasOwnIcon(path) then AppIcons.GetFileIcon(path,size) else null + with _ -> null + let fromWindow() = + match ImgHelper.windowIcon window with + | Some icon -> + let bitmap = new Bitmap(icon) + icon.Dispose() + Some bitmap + | None -> None + match fromShell() with + | null -> fromWindow() + | icon -> Some icon + module private TaskWindowItems = let create (TaskWindowItem(hwnd,isGroup)) = let window = OS().windowFromHwnd(hwnd) - match ImgHelper.windowIcon window with + // Fetched at the size the list draws it in screen pixels; scaled up from 32 it blurs. + let size = Dpi.scale 32 + match TaskWindowIcons.large window size with | Some icon -> - let image = Img(new Bitmap(icon)).resize(Sz(32,32)) - icon.Dispose() + let image = + if icon.Width = size && icon.Height = size then Img(icon) + else + let resized = Img(icon).resize(Sz(size,size)) + icon.Dispose() + resized if isGroup then use badgeIcon = Services.openIcon("Bemo.ico") - let badge = Img(badgeIcon.ToBitmap()).resize(Sz(16,16)).bitmap - let g = image.graphics - g.DrawImage(badge, Point(16,16)) + use badge = Img(badgeIcon.ToBitmap()).resize(Sz(size/2,size/2)).bitmap + use g = image.graphics + g.DrawImage(badge, Point(size-badge.Width,size-badge.Height)) TreeListItem(window.text,Icon=image.bitmap) | None -> TreeListItem(window.text,Glyph=WindowGlyph) @@ -301,28 +329,6 @@ type TaskSwitchForm(control:ITaskSwitchListControl) = member this.hovered = control.hovered member this.clicked = control.clicked -module private TaskWindowIcons = - /// A window's app icon at the given size: the shell's icon for its program, which comes in - /// large sizes, or the window's own when the program has no icon of its own. - let large (window:Window) (size:int) : Bitmap option = - let fromShell() = - try - if window.className="ApplicationFrameWindow" then AppIcons.GetAppIcon(AppIcons.GetHostedAppId(window.hwnd),size) - else - let path = window.pid.processPath - if AppIcons.HasOwnIcon(path) then AppIcons.GetFileIcon(path,size) else null - with _ -> null - let fromWindow() = - match ImgHelper.windowIcon window with - | Some icon -> - let bitmap = new Bitmap(icon) - icon.Dispose() - Some bitmap - | None -> None - match fromShell() with - | null -> fromWindow() - | icon -> Some icon - /// The horizontal switcher: large app icons in a row, wrapping onto more rows when there are /// many, with the chosen window's title beneath them. The panel matches the vertical list: /// the same surface, border, rounded corners and shadow. diff --git a/WtProgram/WindowGroup.fs b/WtProgram/WindowGroup.fs index 4bb3101..6aaa35f 100755 --- a/WtProgram/WindowGroup.fs +++ b/WtProgram/WindowGroup.fs @@ -405,7 +405,7 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: preview = fun() -> try if window.isMinimized then - let size = this.placementBounds.size + let size = this.previewArea.size let _,icon = iconCache.get(hwnd) let iconSize = icon.Size.Sz let img = Img(size) @@ -493,6 +493,16 @@ type WindowGroup(enableSuperBar:bool, plugins:List2, initialAppearance: member this.placementBounds : Rect = placement.value.map(fun(bounds,_,_) -> bounds).def(Rect()) + /// Where the group's windows are, for previews: nothing is saved for a group that has not + /// been on screen since WindowTabs started, minimized from the start, say. + member this.previewArea : Rect = + let saved = this.placementBounds + match zorderCell.value.tryHead with + | Some top when saved.size.isEmptyArea -> + let window = this.os.windowFromHwnd(top) + if window.isMinimized then window.placement.rcNormalPosition else window.bounds + | _ -> saved + member private this.isTop(hwnd) = zorderCell.value.where(isMinimized >> not).tryHead = Some(hwnd) member private this.saveTopWindowPlacement() = diff --git a/docs/taskbar-preview-width.md b/docs/taskbar-preview-width.md new file mode 100644 index 0000000..20ae918 --- /dev/null +++ b/docs/taskbar-preview-width.md @@ -0,0 +1,61 @@ +# Taskbar previews: getting the width right + +With "Combine taskbar icons per group", a group's windows share one stand-in taskbar button +(`GroupPlugins/SuperBarPlugin.fs`). Each tab has a hidden proxy window registered with +`ITaskbarList3::RegisterTab`, and WindowTabs supplies its thumbnail through +`WM_DWMSENDICONICTHUMBNAIL` / `DwmSetIconicThumbnail`. Native previews fit each window's +shape; ours often show a half-screen (tall) window in a slot twice its width. + +## What the taskbar does + +Measured on Windows 11 at 125% with a request/push log: + +- **Default slot size:** without a thumbnail for a tab, the taskbar lays its slot out at the + largest size it asks for (`250x135` here), then requests the thumbnail. It does not lay the + slot out again when the thumbnail arrives, so a `125x135` thumbnail leaves half the slot empty. +- **Cached thumbnails:** with a cached thumbnail, the slot takes that thumbnail's shape. That is + why a quick second hover often looks right. +- **Cache drops:** the taskbar drops cached thumbnails on its own, for example when a preview + closes. Several tabs were asked for again on every hover, though nothing in WindowTabs had + invalidated them. +- **Proxy size ignored:** the proxy window's size has no effect. The proxies stay hidden, and the + slot is the same whether a proxy is sized before or after `RegisterTab`. + +## Tried + +| Approach | Result | +|---|---| +| Centre the image in a thumbnail of the full requested size (current) | Reliable: the empty space is split evenly on both sides of a tall window, and every hover looks the same | +| Size the proxy before `RegisterTab` | No effect | +| Push fitted thumbnails before the first hover | Worked for most tabs on the first hover, but the taskbar dropped them later | +| `DwmInvalidateIconicBitmaps` then push | The taskbar then asks again on every hover; the push is ignored | +| Push again 250 ms after a request, while the preview is open | No layout change while open | +| Push early, plus re-push a second after each request burst | Fitted slots in three rounds of scripted hovers, but in real use the first preview after a while was still wide; reverted | +| Native grouping by a shared AppUserModelID (branch `native-taskbar-grouping`) | Native, live, correctly shaped previews. But clicking the button needs a mouse-hook intercept, reordering and icon changes flicker, other apps' window IDs are rewritten, and UWP and administrator windows can't join. See [native-taskbar-grouping.md](native-taskbar-grouping.md) | + +## Not tried yet + +1. **Live DWM thumbnail in a visible proxy.** Each tab's proxy becomes a real, visible window the + size of the real window, placed off screen, showing the real window through + `DwmRegisterThumbnail`, and registered without `DWMWA_FORCE_ICONIC_REPRESENTATION`. The + taskbar would treat it like a normal window: its own shape and live content, with no + printing. Risks: + - visible off-screen windows may appear in Task View or the Windows Alt+Tab; + - DWM may skip rendering a window that is fully off screen; + - a covered Chromium window may still show an old frame. +2. **Visible but transparent proxy** (layered, alpha 0, click-through) that keeps the iconic + thumbnails. This tests whether the taskbar sizes slots from a visible proxy's rectangle. It is + close to the "proxy size ignored" finding above, so it is less promising. +3. **Track what drops the cache** with a request log over a day of normal use. A pattern such as + a timeout or another button's preview could be met with a targeted re-push. + +## Related fixes found on the way + +- **Hidden tab strip:** a group's previews failed outright while its strip was hidden (0x0 strip + bitmap). The preview is now the window alone. +- **Group never on screen:** with no saved placement since WindowTabs started, the preview area is + now the window's own bounds. +- **Minimized windows:** they sit at -32000,-32000, so their image was drawn off the thumbnail, + leaving it black. It is now drawn where the group's windows are restored to. +- **Black hidden tabs:** a hidden Chromium/Electron tab prints black. Its last capture from the + front stands in for it. diff --git a/tests/GroupLifecycle.fsx b/tests/GroupLifecycle.fsx index fd01ec2..589cf5f 100644 --- a/tests/GroupLifecycle.fsx +++ b/tests/GroupLifecycle.fsx @@ -120,6 +120,9 @@ let main() = let d = joining.Handle for hwnd in [a;b] do (combined :> IGroup).addWindow(hwnd,false) pumpUntil "Combined group did not acquire two windows" (fun () -> nativeOrder combined = [a;b]) + // Off screen, the group saves no placement; its previews still need the window's area. + let area,top = onGroup combined (fun group -> group.previewArea, group.topWindow) + check (not area.size.isEmptyArea && area = OS().windowFromHwnd(top).bounds) (sprintf "A group never on screen has no preview area: %A" area) // The window standing in for the group on the taskbar is an Alt+Tab window the switcher must skip. let proxies() = OS().windowsInZorder.where(fun w -> TaskbarProxies.contains w.hwnd).map(fun w -> w.hwnd).list let proxy = proxies() |> List.filter(fun hwnd -> OS().windowFromHwnd(hwnd).isAltTabWindow) diff --git a/tests/Reliability.fsx b/tests/Reliability.fsx index e8178f5..f8a26e9 100644 --- a/tests/Reliability.fsx +++ b/tests/Reliability.fsx @@ -10,6 +10,19 @@ open Bemo open Newtonsoft.Json.Linq let check condition message = if not condition then failwith message + +module CloakProbe = + [] + extern int DwmSetWindowAttribute(nativeint hwnd, int attribute, int& value, int size) + [] + extern nativeint SendMessage(nativeint hwnd, int msg, nativeint wParam, nativeint lParam) + +/// Runs a check while handling a message sent from another thread, an input-synchronous call. +type SyncCallWindow(onMessage:unit -> unit) = + inherit System.Windows.Forms.NativeWindow() + override this.WndProc(m:System.Windows.Forms.Message byref) = + if m.Msg = 0x8001 then onMessage() else base.WndProc(&m) + let main() = // A system theme change deadlocked when SystemEvents ran on the main thread while a tab // group thread waited on it for the appearance. @@ -348,6 +361,31 @@ let main() = check (logical.scaled.tabHeight=Dpi.scaleAt dpi logical.tabHeight) "DPI geometry accumulated scaling" check (logical.scaled.tabHeight=logical.tabHeight) "DPI round trip changed logical dimensions" finally Dpi.set originalDpi - printfn "PASS: startup ownership, singleton, transactional shortcuts, workspace recovery and regex timeout, private diagnostics, single-flight cancellation and DPI geometry." + // The Alt+Tab switcher builds its list inside a low-level keyboard hook, an input-synchronous + // call in which COM cannot reach another process. Asking there whether a cloaked window is on + // this desktop failed, so a UWP app closed to the background (Realtek Audio Console) was listed. + // The answer from the window's last check outside the hook now stands. + do + use cloaked = new System.Windows.Forms.Form(ShowInTaskbar=false, Text="Cloaked probe", + StartPosition=System.Windows.Forms.FormStartPosition.Manual, + Location=System.Drawing.Point(-20000,-20000)) + cloaked.Show() + let mutable cloak = 1 + CloakProbe.DwmSetWindowAttribute(cloaked.Handle, 13, &cloak, 4) |> ignore + let target = cloaked.Handle + check (WindowCloaking.IsHiddenOnCurrentDesktop target) "A cloaked window on this desktop is not hidden" + let insideCall = ref None + let probe = SyncCallWindow(fun () -> insideCall.Value <- Some(WindowCloaking.IsHiddenOnCurrentDesktop target)) + probe.CreateHandle(System.Windows.Forms.CreateParams()) + try + let handle = probe.Handle + let sender = Thread(fun () -> CloakProbe.SendMessage(handle, 0x8001, 0n, 0n) |> ignore) + sender.Start() + while sender.IsAlive do + System.Windows.Forms.Application.DoEvents() + Thread.Sleep(5) + finally probe.DestroyHandle() + check (insideCall.Value = Some true) (sprintf "A cloaked window counted as shown inside an input-synchronous call: %A" insideCall.Value) + printfn "PASS: startup ownership, singleton, transactional shortcuts, workspace recovery and regex timeout, private diagnostics, single-flight cancellation, DPI geometry and cloaked windows inside an input-synchronous call." TestInit.run main diff --git a/tests/TaskbarPreview.fsx b/tests/TaskbarPreview.fsx index 3c763d3..88954d0 100644 --- a/tests/TaskbarPreview.fsx +++ b/tests/TaskbarPreview.fsx @@ -128,7 +128,73 @@ let main() = let handle = badged.icon.Handle (badged :> IDisposable).Dispose() check (not(WinUserApi.DestroyIcon(handle))) "Badged taskbar icon kept its native handle" + // A 256 pixel app icon made a badged icon the Windows Alt+Tab drew across a group's whole tile. + do + use huge = new Bitmap(256,256) + let hugeHandle = huge.GetHicon() + use hugeIcon = Icon.FromHandle(hugeHandle) + let capped = new TaskbarBadgedIcon(hugeIcon) + let width = capped.icon.Width + (capped :> IDisposable).Dispose() + WinUserApi.DestroyIcon(hugeHandle) |> ignore + check (width = 64) (sprintf "A 256 pixel app icon made a %d pixel taskbar icon" width) WinUserApi.DestroyIcon(appHandle) |> ignore - printfn "PASS: preview ownership on success/failure, crop identity, composition, 400 native bitmap publications without forced GC, centred thumbnails and the badged taskbar icon." + // A hidden Chromium tab prints blank: its last capture from the front stands in for it, + // and it is not printed again until it has been in front. + let solidImage (size:Sz) (color:Color) = + let image = Img(size) + do + use g = image.graphics + g.Clear(color) + image + let drawn (size:Sz) = + let image = solidImage size Color.White + do + use g = image.graphics + use brush = new SolidBrush(Color.SeaGreen) + g.FillRectangle(brush, size.width/4, size.height/4, size.width/2, size.height/2) + image + do + use blankImage = (solidImage (Sz(64,48)) Color.Black).bitmap + use drawnImage = (drawn (Sz(64,48))).bitmap + check (TabCaptures.isBlank blankImage && not(TabCaptures.isBlank drawnImage)) "Blank captures are not told from drawn ones" + let captures = new TabCaptures() + let hwnd = IntPtr(0x1234) + let size = Size(1600,1000) + let mutable printed = 0 + let print (image:unit -> Img) = fun () -> printed <- printed + 1; image() + // In front, the live capture is shown and kept, smaller than the window. + let front = drawn (Sz(1600,1000)) + let shown = captures.preview hwnd size false true (print (fun () -> front)) + check (obj.ReferenceEquals(shown.bitmap, front.bitmap) && captures.count = 1) "A capture from the front was not shown and kept" + shown.bitmap.Dispose() + // Hidden and printing black, the kept capture comes back at the window's size. + let black = solidImage (Sz(1600,1000)) Color.Black + let standIn = captures.preview hwnd size false false (print (fun () -> black)) + check (disposed black.bitmap) "A blank capture was kept instead of released" + check (standIn.bitmap.Width = 1600 && standIn.bitmap.GetPixel(800,500).ToArgb() = Color.SeaGreen.ToArgb()) "A blank hidden tab did not show its last capture" + standIn.bitmap.Dispose() + // Known to print blank, it is not printed again while hidden. + let before = printed + let again = captures.preview hwnd size false false (print (fun () -> failwith "printed a tab known to print blank")) + check (printed = before && again.bitmap.Width = 1600) "A tab known to print blank was printed again" + again.bitmap.Dispose() + // Minimized, the last capture stands in for the placeholder. + let placeholder = drawn (Sz(400,250)) + let minimized = captures.preview hwnd (Size(400,250)) true false (fun () -> placeholder) + check (disposed placeholder.bitmap && minimized.bitmap.GetPixel(200,125).ToArgb() = Color.SeaGreen.ToArgb()) "A minimized tab did not show its last capture" + minimized.bitmap.Dispose() + // Back in front, it is printed again; a window that left takes its capture with it. + captures.shown hwnd + let printedAgain = captures.preview hwnd size false false (print (fun () -> drawn (Sz(1600,1000)))) + check (printed = before + 1) "A tab that was in front again was not printed" + printedAgain.bitmap.Dispose() + captures.forget hwnd + let unknown = solidImage (Sz(64,48)) Color.Black + let withoutCapture = captures.preview hwnd (Size(64,48)) false false (fun () -> unknown) + check (obj.ReferenceEquals(withoutCapture.bitmap, unknown.bitmap) && captures.count = 0) "A forgotten tab kept its capture" + withoutCapture.bitmap.Dispose() + (captures :> IDisposable).Dispose() + printfn "PASS: preview ownership on success/failure, crop identity, composition, 400 native bitmap publications without forced GC, centred thumbnails, the badged taskbar icon and stand-in captures for hidden tabs." TestInit.run main