Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 68 additions & 5 deletions Win32/WindowCloaking.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;

namespace Bemo
{
Expand All @@ -25,6 +28,46 @@ private class VirtualDesktopManager { }

[ThreadStatic] private static IVirtualDesktopManager desktops;

private const int RPC_E_CANTCALLOUT_ININPUTSYNCCALL = unchecked((int)0x8001010D);

/// <summary>The last answer for each window asked about, for when asking is not possible.</summary>
private static readonly ConcurrentDictionary<IntPtr, bool> lastAnswer = new ConcurrentDictionary<IntPtr, bool>();

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();
}

/// <summary>
/// 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.
/// </summary>
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
Expand All @@ -36,17 +79,37 @@ public static bool IsCloaked(IntPtr hwnd)
}

/// <summary>
/// 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.
/// </summary>
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; }
Expand Down
125 changes: 120 additions & 5 deletions WtProgram/GroupPlugins/SuperBarPlugin.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<IntPtr,Bitmap>()
/// Tabs that printed blank while hidden; printing them again before they come to the front
/// would only be slow.
let blank = Collections.Generic.HashSet<IntPtr>()

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
Expand Down Expand Up @@ -315,6 +402,9 @@ type SuperBarPlugin() as this =

let mutable taskbarButton = null
let flashingTabs = Collections.Generic.HashSet<string>()
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

Expand Down Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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())
62 changes: 34 additions & 28 deletions WtProgram/TaskSwitch.fs
Original file line number Diff line number Diff line change
Expand Up @@ -124,18 +124,46 @@ type ITaskSwitchListControl =
/// A window was clicked: switch to it now.
abstract member clicked : IEvent<int>

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)

Expand Down Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion WtProgram/WindowGroup.fs
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ type WindowGroup(enableSuperBar:bool, plugins:List2<IPlugin>, 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)
Expand Down Expand Up @@ -493,6 +493,16 @@ type WindowGroup(enableSuperBar:bool, plugins:List2<IPlugin>, 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() =
Expand Down
Loading