From 7803b8974034140066f985e35b750b1afaf4217f Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Fri, 9 Oct 2026 22:50:21 +0800 Subject: [PATCH 1/8] Show hidden tabs in taskbar previews as they last looked in front Chromium and Electron windows stop painting while another window covers them, so a hidden tab printed black in its taskbar preview, and a minimized one showed only a grey placeholder. Each tab's last good capture, kept at most 800 pixels on its longer side, now stands in for it. A tab is captured half a second after it comes to the front, and a hidden tab that printed blank is not printed again until it has been in front. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 110 ++++++++++++++++++++++- tests/TaskbarPreview.fsx | 58 +++++++++++- 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 7112119..43b9154 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -82,6 +82,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 +401,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 @@ -373,7 +462,9 @@ type SuperBarPlugin() as this = Some(TaskbarPreview.compose previewBounds.size contentLocation (this.ts.bounds.location.sub(previewBounds.location).Point) - (fun () -> this.ts.tabInfo(tab).preview()) + (fun () -> + let window = this.os.windowFromHwnd(hwnd) + captures.preview hwnd window.bounds.size.Size window.isMinimized (this.zorder.tryHead = Some hwnd) (fun () -> this.ts.tabInfo(tab).preview())) (fun () -> this.ts.renderTs(Some(tab)))) with _ -> None activate = fun() -> this.invokeAsync <| fun() -> @@ -412,8 +503,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 +539,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 +564,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/tests/TaskbarPreview.fsx b/tests/TaskbarPreview.fsx index 3c763d3..88b3b43 100644 --- a/tests/TaskbarPreview.fsx +++ b/tests/TaskbarPreview.fsx @@ -129,6 +129,62 @@ let main() = (badged :> IDisposable).Dispose() check (not(WinUserApi.DestroyIcon(handle))) "Badged taskbar icon kept its native handle" 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 From fcc65eb7b085e97751309b852868f70d742efc3d Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 09:37:41 +0800 Subject: [PATCH 2/8] Fit tall windows' taskbar previews to their shape The taskbar lays out a preview from the thumbnails it holds, else at the largest size it would accept, too wide for a half-screen window, and it drops them when a preview closes. Each tab's thumbnail, fitted to that size, is now handed over before the next preview: soon after tabs or their size change, and a second after the taskbar last asked for one. A group's previews also failed outright while its tab strip was hidden, or when its windows had not been on screen since WindowTabs started; the preview now covers the window alone, and the window's own bounds stand in for the placement not yet saved. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 40 ++++++++++++++++++++++-- WtProgram/WindowGroup.fs | 12 ++++++- tests/GroupLifecycle.fsx | 3 ++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 43b9154..2ea7ee9 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -7,6 +7,9 @@ open System.Windows.Forms /// Preview producers transfer ownership of their bitmap to this request. module TaskbarPreview = + /// The largest thumbnail the taskbar last asked for. + let mutable thumbnailBox : Sz option = None + let relativeLocation (previewBounds:Rect) (windowBounds:Rect) = windowBounds.location.sub(previewBounds.location).Point @@ -200,6 +203,11 @@ type TaskBarButton(info) as this = let _os = OS() let taskbar = _os.getTaskbar().Value let mutable badgedIcon : TaskbarBadgedIcon option = None + /// The taskbar lays out a preview from the thumbnails it holds, else at the largest size it + /// would accept, too wide for a tall window; and it drops them when a preview closes. So + /// each tab's thumbnail is handed over before the next preview: soon after tabs or their + /// size change, and a second after the taskbar last asked for one. + let publishTimer = new System.Windows.Forms.Timer() let infoCell = Cell.create(None) let tabWindowsCell = Cell.create(Map2()) let windowsToDispose = Cell.create(Set2()) @@ -254,6 +262,9 @@ type TaskBarButton(info) as this = window) do + publishTimer.Tick.Add(fun _ -> + publishTimer.Stop() + this.publishThumbnails()) this.update info member this.os = _os @@ -263,6 +274,16 @@ type TaskBarButton(info) as this = member this.invalidate() = tabWindowsCell.value.values.iter <| fun (tab:TaskbarTab) -> tab.invalidate() + this.schedulePublish 300 + + member this.schedulePublish (delay:int) = + publishTimer.Stop() + publishTimer.Interval <- delay + publishTimer.Start() + + member private this.publishThumbnails() = + let box = TaskbarPreview.thumbnailBox |> Option.defaultWith(fun () -> Dpi.scaleSize(Sz(200,108))) + tabWindowsCell.value.values.iter <| fun (tab:TaskbarTab) -> tab.publishThumbnail box member this.update info = let findOrCreate (map:Cell>) key create = @@ -284,6 +305,7 @@ type TaskBarButton(info) as this = let prevInfo = infoCell.value infoCell.set(Some(info)) + let mutable tabAdded = false // Notify the shell before the group releases its previous icon copies. if badgedIcon |> Option.exists(fun badged -> obj.ReferenceEquals(badged.source,info.icon)) |> not then @@ -305,6 +327,7 @@ type TaskBarButton(info) as this = tabWindow.window.setAppId(appId) registerTab(tabWindow.window.hwnd) + tabAdded <- true tabWindow tabWindow.window.move(info.bounds) @@ -322,6 +345,9 @@ type TaskBarButton(info) as this = let tabOrder = tabOrder info if tabOrder.list <> prevTabOrder.list then tabOrder.iter <| fun tabWindow -> setTabOrder(tabWindow.window.hwnd, IntPtr.Zero) + + if tabAdded || prevInfo |> Option.forall(fun previous -> previous.bounds.size <> info.bounds.size) then + this.schedulePublish 300 /// A group can announce a window before its tab joins the strip, so the tab may have no preview window yet. member this.tryTabWindow key = tabWindowsCell.value.tryFind(key) |> Option.map(fun tab -> tab.window) @@ -348,6 +374,7 @@ type TaskBarButton(info) as this = interface IDisposable with member this.Dispose() = + publishTimer.Dispose() this.window.destroy() tabWindowsCell.value.items.iter <| fun (tab, tabWindow) -> tabWindow.Dispose() @@ -374,8 +401,10 @@ and TaskbarTab(parent:TaskBarButton, config,size) = msg.def() | WindowMessages.WM_DWMSENDICONICTHUMBNAIL -> let msgWindow = os.windowFromHwnd(msg.hwnd) + TaskbarPreview.thumbnailBox <- Some msg.lParam.size TaskbarPreview.send (fun () -> config().preview(true)) (TaskbarPreview.centre msg.lParam.size) msgWindow.dwmSetIconicThumbnail + parent.schedulePublish 1000 msg.def() | WindowMessages.WM_DWMSENDICONICLIVEPREVIEWBITMAP -> let msgWindow = os.windowFromHwnd(msg.hwnd) @@ -391,6 +420,9 @@ and TaskbarTab(parent:TaskBarButton, config,size) = member this.window : Window = _window member this.invalidate() = this.window.dwmInvalidateIconicBitmaps() + /// Fitted to the box, not centred in it: the slot then takes the thumbnail's own shape. + member this.publishThumbnail(box:Sz) = + TaskbarPreview.send (fun () -> config().preview(true)) (fun img -> img.resize(box)) this.window.dwmSetIconicThumbnail member this.Dispose() = _window.destroy() @@ -433,7 +465,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 = { @@ -465,7 +501,7 @@ type SuperBarPlugin() as this = (fun () -> let window = this.os.windowFromHwnd(hwnd) captures.preview hwnd window.bounds.size.Size window.isMinimized (this.zorder.tryHead = Some hwnd) (fun () -> this.ts.tabInfo(tab).preview())) - (fun () -> this.ts.renderTs(Some(tab)))) + (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) 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/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) From 4685c2f7792df629efcc10b624c8ae037807d5e6 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 10:03:41 +0800 Subject: [PATCH 3/8] Show minimized groups in taskbar previews and stop handing over thumbnails early Handing each tab's fitted thumbnail to the taskbar before it asked did not keep tall windows' previews narrow, so the taskbar asks for thumbnails again as before. A minimized window's image is now drawn where the group's windows are restored to, not far off screen, which left the thumbnail empty. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 40 +++--------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 2ea7ee9..0b5b776 100755 --- a/WtProgram/GroupPlugins/SuperBarPlugin.fs +++ b/WtProgram/GroupPlugins/SuperBarPlugin.fs @@ -7,9 +7,6 @@ open System.Windows.Forms /// Preview producers transfer ownership of their bitmap to this request. module TaskbarPreview = - /// The largest thumbnail the taskbar last asked for. - let mutable thumbnailBox : Sz option = None - let relativeLocation (previewBounds:Rect) (windowBounds:Rect) = windowBounds.location.sub(previewBounds.location).Point @@ -203,11 +200,6 @@ type TaskBarButton(info) as this = let _os = OS() let taskbar = _os.getTaskbar().Value let mutable badgedIcon : TaskbarBadgedIcon option = None - /// The taskbar lays out a preview from the thumbnails it holds, else at the largest size it - /// would accept, too wide for a tall window; and it drops them when a preview closes. So - /// each tab's thumbnail is handed over before the next preview: soon after tabs or their - /// size change, and a second after the taskbar last asked for one. - let publishTimer = new System.Windows.Forms.Timer() let infoCell = Cell.create(None) let tabWindowsCell = Cell.create(Map2()) let windowsToDispose = Cell.create(Set2()) @@ -262,9 +254,6 @@ type TaskBarButton(info) as this = window) do - publishTimer.Tick.Add(fun _ -> - publishTimer.Stop() - this.publishThumbnails()) this.update info member this.os = _os @@ -274,16 +263,6 @@ type TaskBarButton(info) as this = member this.invalidate() = tabWindowsCell.value.values.iter <| fun (tab:TaskbarTab) -> tab.invalidate() - this.schedulePublish 300 - - member this.schedulePublish (delay:int) = - publishTimer.Stop() - publishTimer.Interval <- delay - publishTimer.Start() - - member private this.publishThumbnails() = - let box = TaskbarPreview.thumbnailBox |> Option.defaultWith(fun () -> Dpi.scaleSize(Sz(200,108))) - tabWindowsCell.value.values.iter <| fun (tab:TaskbarTab) -> tab.publishThumbnail box member this.update info = let findOrCreate (map:Cell>) key create = @@ -305,7 +284,6 @@ type TaskBarButton(info) as this = let prevInfo = infoCell.value infoCell.set(Some(info)) - let mutable tabAdded = false // Notify the shell before the group releases its previous icon copies. if badgedIcon |> Option.exists(fun badged -> obj.ReferenceEquals(badged.source,info.icon)) |> not then @@ -327,7 +305,6 @@ type TaskBarButton(info) as this = tabWindow.window.setAppId(appId) registerTab(tabWindow.window.hwnd) - tabAdded <- true tabWindow tabWindow.window.move(info.bounds) @@ -345,9 +322,6 @@ type TaskBarButton(info) as this = let tabOrder = tabOrder info if tabOrder.list <> prevTabOrder.list then tabOrder.iter <| fun tabWindow -> setTabOrder(tabWindow.window.hwnd, IntPtr.Zero) - - if tabAdded || prevInfo |> Option.forall(fun previous -> previous.bounds.size <> info.bounds.size) then - this.schedulePublish 300 /// A group can announce a window before its tab joins the strip, so the tab may have no preview window yet. member this.tryTabWindow key = tabWindowsCell.value.tryFind(key) |> Option.map(fun tab -> tab.window) @@ -374,7 +348,6 @@ type TaskBarButton(info) as this = interface IDisposable with member this.Dispose() = - publishTimer.Dispose() this.window.destroy() tabWindowsCell.value.items.iter <| fun (tab, tabWindow) -> tabWindow.Dispose() @@ -401,10 +374,8 @@ and TaskbarTab(parent:TaskBarButton, config,size) = msg.def() | WindowMessages.WM_DWMSENDICONICTHUMBNAIL -> let msgWindow = os.windowFromHwnd(msg.hwnd) - TaskbarPreview.thumbnailBox <- Some msg.lParam.size TaskbarPreview.send (fun () -> config().preview(true)) (TaskbarPreview.centre msg.lParam.size) msgWindow.dwmSetIconicThumbnail - parent.schedulePublish 1000 msg.def() | WindowMessages.WM_DWMSENDICONICLIVEPREVIEWBITMAP -> let msgWindow = os.windowFromHwnd(msg.hwnd) @@ -420,9 +391,6 @@ and TaskbarTab(parent:TaskBarButton, config,size) = member this.window : Window = _window member this.invalidate() = this.window.dwmInvalidateIconicBitmaps() - /// Fitted to the box, not centred in it: the slot then takes the thumbnail's own shape. - member this.publishThumbnail(box:Sz) = - TaskbarPreview.send (fun () -> config().preview(true)) (fun img -> img.resize(box)) this.window.dwmSetIconicThumbnail member this.Dispose() = _window.destroy() @@ -494,13 +462,15 @@ 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 () -> - let window = this.os.windowFromHwnd(hwnd) - captures.preview hwnd window.bounds.size.Size window.isMinimized (this.zorder.tryHead = Some hwnd) (fun () -> this.ts.tabInfo(tab).preview())) + 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() -> From 4c64e9867dd9ae15ae226de0e3a3728735ce9524 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 10:25:21 +0800 Subject: [PATCH 4/8] Keep empty UWP frames out of Alt+Tab and draw switcher list icons sharp A UWP app closed to the background, such as Realtek Audio Console, can leave a frame that is neither minimized nor holding its app, and it reached the list. Such a frame now counts as hidden when it is on the current desktop; minimized frames let go of their app too, so they stay listed. The list switcher scaled every icon to 32 pixels and drew it larger at higher scaling. It now fetches the program's icon at the size it is drawn, as the icon switcher does. --- Win32/WindowCloaking.cs | 37 ++++++++++++++++++++++-- WtProgram/TaskSwitch.fs | 62 ++++++++++++++++++++++------------------- 2 files changed, 68 insertions(+), 31 deletions(-) diff --git a/Win32/WindowCloaking.cs b/Win32/WindowCloaking.cs index d4a487f..a472701 100644 --- a/Win32/WindowCloaking.cs +++ b/Win32/WindowCloaking.cs @@ -1,5 +1,6 @@ using System; using System.Runtime.InteropServices; +using System.Text; namespace Bemo { @@ -25,6 +26,35 @@ private class VirtualDesktopManager { } [ThreadStatic] private static IVirtualDesktopManager desktops; + 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,12 +66,13 @@ 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(); 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. From 5037253934957c1f61c1a392a2908173da6e938c Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 10:25:21 +0800 Subject: [PATCH 5/8] Record the ways tried to fit taskbar previews to tall windows --- docs/taskbar-preview-width.md | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/taskbar-preview-width.md 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. From 3cb5353e817cba48872a9d18aaf32c5af0383073 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 10:45:31 +0800 Subject: [PATCH 6/8] Keep cloaked windows out of the Alt+Tab switcher The switcher builds its list inside a low-level keyboard hook, where COM cannot call out to another process, so asking whether a cloaked window is on the current desktop failed and the window counted as shown: a UWP app closed to the background, such as Realtek Audio Console, was listed. The question is now asked from a thread pool thread when that happens. --- Win32/WindowCloaking.cs | 21 +++++++++++++++++++-- tests/Reliability.fsx | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/Win32/WindowCloaking.cs b/Win32/WindowCloaking.cs index a472701..583d80f 100644 --- a/Win32/WindowCloaking.cs +++ b/Win32/WindowCloaking.cs @@ -1,6 +1,7 @@ using System; using System.Runtime.InteropServices; using System.Text; +using System.Threading.Tasks; namespace Bemo { @@ -26,6 +27,14 @@ private class VirtualDesktopManager { } [ThreadStatic] private static IVirtualDesktopManager desktops; + private const int RPC_E_CANTCALLOUT_ININPUTSYNCCALL = unchecked((int)0x8001010D); + + 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); @@ -75,12 +84,20 @@ public static bool IsHiddenOnCurrentDesktop(IntPtr hwnd) 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 cannot call out to another process; a thread pool thread asks instead. + if (hr == RPC_E_CANTCALLOUT_ININPUTSYNCCALL) + { + var asked = Task.Run(() => { int on; int result = OnCurrentDesktop(hwnd, out on); return result == 0 && on != 0; }); + return asked.Wait(250) && asked.Result; + } + return hr == 0 && onCurrent != 0; } catch (COMException) { return false; } catch (InvalidCastException) { return false; } + catch (AggregateException) { return false; } } } } diff --git a/tests/Reliability.fsx b/tests/Reliability.fsx index e8178f5..a44af74 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,30 @@ 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. + 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 From 4242ba41b763fab4ee4b5cf4c42f6db052b02c5e Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 10:52:46 +0800 Subject: [PATCH 7/8] Answer from the last check instead of waiting inside the Alt+Tab hook Waiting for a thread pool thread inside the keyboard hook could time out, and several waits could outlast the time Windows gives a low-level hook before removing it. The answer from the window's last check stands, and a pool thread refreshes it for next time. --- Win32/WindowCloaking.cs | 23 +++++++++++++++++++---- tests/Reliability.fsx | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Win32/WindowCloaking.cs b/Win32/WindowCloaking.cs index 583d80f..aa47e8c 100644 --- a/Win32/WindowCloaking.cs +++ b/Win32/WindowCloaking.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Concurrent; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; @@ -29,6 +30,9 @@ private class VirtualDesktopManager { } 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(); @@ -87,17 +91,28 @@ public static bool IsHiddenOnCurrentDesktop(IntPtr hwnd) int onCurrent; int hr = OnCurrentDesktop(hwnd, out onCurrent); // The Alt+Tab switcher builds its list inside a low-level keyboard hook, where this - // thread cannot call out to another process; a thread pool thread asks instead. + // 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) { - var asked = Task.Run(() => { int on; int result = OnCurrentDesktop(hwnd, out on); return result == 0 && on != 0; }); - return asked.Wait(250) && asked.Result; + 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; } - catch (AggregateException) { return false; } } } } diff --git a/tests/Reliability.fsx b/tests/Reliability.fsx index a44af74..f8a26e9 100644 --- a/tests/Reliability.fsx +++ b/tests/Reliability.fsx @@ -364,6 +364,7 @@ let main() = // 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, From 1a4b958f654a94a154d33c62832b218f19d12551 Mon Sep 17 00:00:00 2001 From: leafOfTree Date: Sat, 10 Oct 2026 11:25:58 +0800 Subject: [PATCH 8/8] Keep a group's taskbar icon at most 64 pixels Some apps give a 256 pixel icon, and the badged icon made from it was drawn across the group's whole tile in the Windows Alt+Tab. --- WtProgram/GroupPlugins/SuperBarPlugin.fs | 3 ++- tests/TaskbarPreview.fsx | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/WtProgram/GroupPlugins/SuperBarPlugin.fs b/WtProgram/GroupPlugins/SuperBarPlugin.fs index 0b5b776..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 diff --git a/tests/TaskbarPreview.fsx b/tests/TaskbarPreview.fsx index 88b3b43..88954d0 100644 --- a/tests/TaskbarPreview.fsx +++ b/tests/TaskbarPreview.fsx @@ -128,6 +128,16 @@ 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 // 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.