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