Table of Contents

Window management

The Dapplo.Windows package wraps a window handle (HWND) in an IInteropWindow, which reads information about the window on demand and caches it. Static classes find windows (InteropWindowQuery, WindowsEnumerator) and report window events (WinEventHook).

dotnet add package Dapplo.Windows

Namespaces used on this page: Dapplo.Windows.Desktop, Dapplo.Windows.Enums, Dapplo.Windows.User32, Dapplo.Windows.User32.Enums, Dapplo.Windows.User32.Structs, Dapplo.Windows.Common.Structs, Dapplo.Windows.App, Dapplo.Windows.Icons, Dapplo.Windows.Software, Dapplo.Windows.Messages, Dapplo.Windows.Messages.Enums, System.Reactive.Linq, Dapplo.Windows.Input.Mouse and, for UI Automation, Dapplo.Windows.Automation.

Window information

InteropWindowFactory.CreateFor(handle) wraps a handle, nothing is read at that moment. The Get... extension methods read a value the first time and store it in the window object; later calls return the stored value unless you pass forceUpdate: true.

// Wrap a window handle, nothing is read yet
IInteropWindow window = InteropWindowFactory.CreateFor(User32Api.GetForegroundWindow());
// Or: IInteropWindow window = InteropWindowQuery.GetForegroundWindow();

// The Get... methods read the value once and cache it in the window object, pass forceUpdate: true to read it again
Console.WriteLine($"Title: {window.GetCaption()}");
Console.WriteLine($"Class: {window.GetClassname()}");
Console.WriteLine($"Bounds: {window.GetInfo().Bounds}");
Console.WriteLine($"Client bounds: {window.GetInfo().ClientBounds}");
Console.WriteLine($"Process: {window.GetProcessId()}");
Console.WriteLine($"Visible: {window.IsVisible()}, minimized: {window.IsMinimized()}, maximized: {window.IsMaximized()}");

GetInfo() returns a WindowInfo with Bounds, ClientBounds, Style, ExtendedStyle and more. For top-level windows the bounds are corrected with the DWM "extended frame bounds", so they don't include the invisible resize border that Windows 10 and 11 add around windows.

Reading several values at once

Fill() reads the values you select with InteropWindowRetrieveSettings. It respects the cache, so add ForceUpdate when you need fresh values. The cached values are also available as properties (Caption, Info, IsVisible, ...), which are null when they were not read yet.

// Read several values at once, the default is everything except the children (CacheAllAutoCorrect)
window.Fill();

// Only what you need
window.Fill(InteropWindowRetrieveSettings.Caption | InteropWindowRetrieveSettings.Info);

// ForceUpdate reads the values again, even when they were cached
window.Fill(InteropWindowRetrieveSettings.Info | InteropWindowRetrieveSettings.ForceUpdate);

// The cached values are properties, null when they were not retrieved
string caption = window.Caption;
NativeRect? bounds = window.Info?.Bounds;
Setting Reads
CacheAll caption, class name, info, maximized, minimized, parent, owner, placement, process id, text, visible, scroll info
CacheAllAutoCorrect (default of Fill) the same, with the bounds corrected (DWM frame, clipped to the parent)
CacheAllWithChildren CacheAll plus the direct children, in Z-order
ForceUpdate combine with the above to read again

Finding windows

InteropWindowQuery.GetVisibleApplicationWindows() returns the windows a user sees as application windows, from top to bottom (Z-order): top-level windows (no parent, owned windows are included) which are visible (IsWindowVisible and not cloaked, so no windows on another virtual desktop and no suspended store apps), not minimized, have a title and a size, and are not a tool window, a background store app or one of a few known system windows (the desktop Progman, Button, Dwm). Windows which render with DirectComposition (WS_EX_NOREDIRECTIONBITMAP, e.g. Chrome, Edge and other Chromium based browsers) are included. Pass includeMinimized: true to include minimized windows too, e.g. for a "capture this window" menu which restores the window first. The same test is available as window.IsVisibleApplicationWindow(); window.IsVisiblePopup() is the similar test for visible WS_POPUP windows, which also accepts tool windows and windows without a title. Change the list of ignored classes with InteropWindowQuery.AddIgnoreClass / RemoveIgnoreClass, or pass false to include them.

InteropWindowQuery.GetTopWindows() returns all top-level windows in Z-order (index 0 is the top-most window), without any filter. GetTopWindows(parent) returns the direct children of a window, also in Z-order.

Both are a snapshot: the list is taken at once, with EnumWindows / EnumChildWindows, when you call the method. Windows builds the list before it reports the first window, so the result can't loop, skip or repeat windows when windows are activated, created or destroyed in the meantime (a GetWindow(GW_HWNDNEXT) walk can). The windows in the snapshot can of course still change or disappear afterwards; GetVisibleApplicationWindows() takes the snapshot when it is called and applies its filter while you enumerate the result.

// The application windows the user sees (visible, with a title, not minimized), from top to bottom
foreach (var window in InteropWindowQuery.GetVisibleApplicationWindows())
{
    Console.WriteLine($"{window.GetCaption()} ({window.GetClassname()})");
}

Filter with LINQ, or let WindowsEnumerator filter while it enumerates:

// All visible Notepad windows
var notepads = InteropWindowQuery.GetVisibleApplicationWindows()
    .Where(window => window.GetClassname() == "Notepad")
    .ToList();

// All top-level windows of a process
var ownWindows = InteropWindowQuery.GetWindowsForProcess(Process.GetCurrentProcess().Id);

// Enumerate with a predicate and stop early: the first window with "Dapplo" in the title
var firstMatch = WindowsEnumerator.EnumerateWindows(
        wherePredicate: window => window.GetCaption().Contains("Dapplo"),
        takeWhileFunc: (window, count) => count < 1)
    .FirstOrDefault();
IInteropWindow FindWindowByTitle(string title) =>
    InteropWindowQuery.GetVisibleApplicationWindows()
        .FirstOrDefault(window => window.GetCaption().IndexOf(title, StringComparison.OrdinalIgnoreCase) >= 0);

var calculator = FindWindowByTitle("Calculator");

Children, parent and owner

A child window lives inside its parent (a button in a dialog). A top-level window can have an owner (a dialog belongs to the main window of the application), but it has no parent. Dapplo.Windows keeps both apart: GetParent() never returns the owner, use GetOwner() for that.

// The direct children, from top to bottom (Z-order)
foreach (var child in window.GetChildren())
{
    Console.WriteLine($"Child {child.GetClassname()}");
}

// Children, grandchildren, ...
var descendants = window.GetDescendants();
// The parent is the window a child window lives in, top-level windows have none (IntPtr.Zero)
IInteropWindow parent = window.GetParentWindow();

// The owner is the window a dialog or tool window belongs to, e.g. the main window of the application
IInteropWindow owner = window.GetOwnerWindow();

// All other top-level windows of the same process
var linked = window.GetLinkedWindows();

GetChildren() returns the direct children in Z-order (a snapshot, see above) and stores them in Children; pass forceUpdate: true to read them again. GetChildren(allLevels: true) fills the whole tree below the window from one enumeration: every descendant gets its Children (empty for a window without children), Parent and ParentWindow, so GetInfo() clips to the parents without more lookups. GetDescendants() returns all levels as a flat list, depth-first (a child is followed by its own descendants), and doesn't store them.

window.FindChildAt(point) returns the deepest visible child window at a screen point (the window itself when no child contains the point, null when the window doesn't): it walks the stored tree (filling it with allLevels when needed) and uses the cached GetInfo() values, so it also works on a snapshot of a screen which has changed since, e.g. under a full-screen overlay.

Changing windows

window.Minimize();
window.Maximize();
window.Restore();

// Anything else ShowWindow supports
User32Api.ShowWindow(window.Handle, ShowWindowCommands.Hide);
User32Api.ShowWindow(window.Handle, ShowWindowCommands.ShowNoActivation);
// Move, keeping the size
window.MoveTo(new NativePoint(100, 100));

// Move and resize
User32Api.SetWindowPos(window.Handle, IntPtr.Zero, 100, 100, 800, 600, WindowPos.SWP_NOZORDER | WindowPos.SWP_NOACTIVATE);

// Placement: the normal (restored) bounds and the show state, e.g. to save and restore a layout
WindowPlacement placement = window.GetPlacement();
window.SetPlacement(placement);

After a monitor was removed, windows can end up where nobody sees them. GetVisibleLocation finds a place on one of the current displays:

// Move a window, e.g. after a monitor was disconnected, to a place where it can be seen
if (!window.GetVisibleLocation(out var visibleLocation) || visibleLocation != window.GetInfo().Bounds.Location)
{
    window.MoveTo(visibleLocation);
}

Bringing a window to the front

ToForegroundAsync() restores a minimized window, waits (up to 2 seconds) until it's restored and makes it the foreground window. Windows has rules about which process may change the foreground window; when it refuses, the taskbar button flashes instead. ToForegroundAsync(cancellationToken) stops waiting for the restore when the token is cancelled. Most calls complete without waiting; after a wait the foreground switch continues on a thread-pool thread, not on the context of the caller.

// Restores a minimized window and makes it the foreground window. Windows may still refuse,
// e.g. when the user is working in another application: then the taskbar button flashes.
await window.ToForegroundAsync();

Z-order and always on top

SetWindowPos changes the Z-order. WindowHandles has the special handles for its hWndInsertAfter argument: HWND_TOP, HWND_BOTTOM, HWND_TOPMOST and HWND_NOTOPMOST (and HWND_MESSAGE and HWND_BROADCAST for other APIs).

bool isTopmost = (window.GetInfo(forceUpdate: true).ExtendedStyle & ExtendedWindowStyleFlags.WS_EX_TOPMOST) != 0;
User32Api.SetWindowPos(window.Handle, isTopmost ? WindowHandles.HWND_NOTOPMOST : WindowHandles.HWND_TOPMOST, 0, 0, 0, 0,
    WindowPos.SWP_NOMOVE | WindowPos.SWP_NOSIZE | WindowPos.SWP_NOACTIVATE);
const WindowPos zOrderOnly = WindowPos.SWP_NOMOVE | WindowPos.SWP_NOSIZE | WindowPos.SWP_NOACTIVATE;

// Send a window behind all other windows, or bring it to the top without activating it
User32Api.SetWindowPos(window.Handle, WindowHandles.HWND_BOTTOM, 0, 0, 0, 0, zOrderOnly);
User32Api.SetWindowPos(window.Handle, WindowHandles.HWND_TOP, 0, 0, 0, 0, zOrderOnly);

// Place a window directly below another one
User32Api.SetWindowPos(window.Handle, other.Handle, 0, 0, 0, 0, zOrderOnly);

// A snapshot of the Z-order, index 0 is the top-most window
var zOrder = InteropWindowQuery.GetTopWindows().Select(w => w.Handle).ToList();
bool isAboveOther = zOrder.IndexOf(window.Handle) < zOrder.IndexOf(other.Handle);

Posting messages

window.PostMessage(...) (or User32Api.PostMessage for a handle) puts a message in the message queue of the window's thread and returns immediately, SendMessage waits until the message was processed. It returns false when the message couldn't be posted, Marshal.GetLastWin32Error() tells why (e.g. the window is gone, or UIPI blocks messages from a process with a lower integrity level). Only post messages with values in wParam / lParam, not with pointers to your memory. User32Api.PostThreadMessage posts to a thread (its message loop) instead of a window.

// Ask a window to close, without waiting: an application which asks "Save changes?" doesn't block the caller
if (!window.PostMessage(WindowsMessages.WM_CLOSE))
{
    Console.WriteLine($"Posting failed, error {Marshal.GetLastWin32Error()}");
}

// Post a registered message to all top-level windows, e.g. to the other instances of your application
uint showMessage = RegisteredWindowMessages.Register("MyApp.ShowMainWindow");
User32Api.PostMessage(WindowHandles.HWND_BROADCAST, showMessage, IntPtr.Zero, IntPtr.Zero);

Window state

Window handles are reused by Windows after a window is destroyed. Check Exists() before you use a handle you kept for a while.

// Handles are recycled, check that the window still exists before working with it
if (!window.Exists())
{
    return;
}
bool isApplicationWindow = window.IsVisibleApplicationWindow();
bool isOwnWindow = window.IsOwnedByCurrentProcess();
// A Windows Store (UWP) app window
bool isApp = window.IsApp();

Screenshots, icons and scrolling

PrintWindow() asks the window to render itself into a Bitmap, so it also works for windows which are covered by other windows. It uses PW_RENDERFULLCONTENT on Windows 8.1 and later, which captures DirectComposition content (browsers, UWP apps) as well. Minimized windows can't be captured. For WPF use PrintWindowAsBitmapSource() from Dapplo.Windows.Wpf.

// Renders the window, also when it's covered by other windows (not when it's minimized).
// The result is cropped to the visible frame, without the invisible resize borders.
using Bitmap bitmap = window.PrintWindow();
bitmap?.Save("window.png", ImageFormat.Png);
// The icon of a window, as Bitmap or Icon
using var smallIcon = window.GetIcon<Bitmap>();
using var largeIcon = window.GetIcon<Icon>(useLargeIcons: true);

Scrolling

GetWindowScroller() returns a WindowScroller for a window with a Win32 scroll bar, for example to capture a long page in parts. It returns null when the window can't be scrolled that way.

// null when the window has no scroll bar
WindowScroller scroller = window.GetWindowScroller();
if (scroller == null)
{
    return;
}
scroller.Start();                 // scroll to the top
while (!scroller.IsAtEnd)
{
    // e.g. capture the visible part here
    if (!scroller.Next())         // one page down
    {
        break;
    }
}
scroller.Reset();                 // back to the original position

Scrolling capture

A scrolling capture stitches frames, so consecutive frames must overlap: set StepFraction (greater than 0, at most 1, default 1.0 = a page) to scroll part of a page per Next() / Previous().

ScrollMode A step of StepFraction
AbsoluteWindowMessage The position moves by max(1, PageSize * StepFraction), clamped to the range
WindowsMessage Below 1.0, SB_LINEDOWN / SB_LINEUP until the position moved by max(1, PageSize * StepFraction) scroll units (checked after every message, so windows which scroll in pixels work too); 1.0 sends SB_PAGEDOWN / SB_PAGEUP
MouseWheel WheelDelta (a page) times the fraction, rounded to whole notches (at least one); set UseFractionalWheelDelta for applications which handle high-resolution wheel deltas. A horizontal scroll bar gets horizontal wheel input
KeyboardPageUpDown The fraction is ignored, a key press scrolls a page

In MouseWheel mode the wheel input goes to the middle of ScrollingWindow. For an area inside a window (the page of a browser, a list in a dialog) set WheelLocation (screen coordinates). The system delivers wheel input to the window under the cursor, so the cursor moves there; RestoreCursorAfterWheel = true moves it back after every wheel movement (in the same SendInput call). MouseInputGenerator.MoveMouseWheelAt(delta, location, restoreCursor, horizontal) does the same for your own wheel input.

ViewportBounds is the scrolling area in screen coordinates, to crop the frames.

Windows without a Win32 scroll bar: UI Automation

Chromium / Electron, Firefox, WPF, WinUI, Office and the Explorer file list draw their own scroll bars, so GetWindowScroller() returns null for them. The Dapplo.Windows.Automation package scrolls them with the UI Automation ScrollPattern, using the native UI Automation COM API (no WPF needed):

dotnet add package Dapplo.Windows.Automation
  • UiAutomationScroller.FromPoint(screenPoint, horizontal) takes the element under the point and walks up to the first one with a ScrollPattern which can scroll in that direction; FromWindow(window, horizontal) uses the window, the element in its middle or the first scrollable descendant.
  • ViewportBounds is the bounding rectangle of that element, ScrollPercent the position (0 to 100), VisibleFraction the visible part of the content; IsAtStart / IsAtEnd allow PercentTolerance (0.01 percentage points).
  • Start() / End() / Next() / Previous() / Reset() use SetScrollPercent, or Scroll with small or large increments when setting the percentage is not supported. A step of StepFraction moves the content by that part of the viewport.
  • ScrollMode = UiAutomationScrollModes.MouseWheel wheels at the viewport centre (or WheelLocation) one notch at a time until the position moved far enough, for controls which report the pattern but ignore it.
  • Use it on a background thread, never on the UI thread which owns the target window; an STA thread is not needed. When the element is gone (navigation, closed window) IsAvailable is false, IsAtEnd is true so loops end, and Refresh() finds it again. Next() returns false when the position didn't change, so a loop can't run forever. Chromium builds its accessibility tree on the first request, so retry a null from FromPoint once after a short delay. Dispose it to release the COM objects.

Controls without a ScrollPattern

Some controls scroll by themselves and expose no ScrollPattern anywhere, but do expose their scroll bar as an element with control type ScrollBar (e.g. the Visual Studio editor and Output pane). When no element on the way up from the point has a ScrollPattern, FromPoint takes the first one which has a visible child scroll bar in the direction; FromWindow does the same after its ScrollPattern lookups found nothing. Such a scroller has IsScrollBarFallback true and always scrolls with the mouse wheel (ScrollMode is MouseWheel, setting ScrollPattern throws), so the area has to be visible on the screen; ViewportBounds is the control without its scroll bar when the scroll bar is at an edge (and without a scroll bar of the other orientation at an edge, e.g. a horizontal one at the bottom), so the frames of a capture don't show it; WheelLocation and RestoreCursorAfterWheel work as usual.

The position comes from the scroll bar:

  • its RangeValue pattern: the position from Value between Minimum and Maximum, the visible part from LargeChange; Start() and End() set the value when it's writable and confirm with one wheel notch towards the target that the content followed (the Visual Studio editor only follows the scroll bar's Scroll events, setting the value moves just the scroll bar), else they continue with the wheel; Reset() uses the wheel;
  • else the position of its thumb between the line buttons. That is to a pixel of the thumb, which on long content stands for a lot of content: IsAtEnd can be true a little early (compare the captured frames when that matters), Start() and End() wheel a bit further when the thumb says they got there;
  • else it is unknown: IsPositionKnown is false, ScrollPercent is -1, IsAtStart / IsAtEnd are only true when the line button in that direction is disabled and the other one enabled (WPF disables both while the mouse isn't over the scroll bar), Next() / Previous() move one wheel notch per step and return true, and Start(), End() and Reset() return false. The caller detects the end itself, e.g. when the captured content stops changing.

With a known position a step wheels one notch at a time until about StepFraction of a page moved. Start() and End() set a writable RangeValue and check the position read back; otherwise, like Reset(), they wheel several pages per input (independent of StepFraction, more notches when one input didn't move the thumb) until the start or end is reached or the position stops moving, with a limit from the remaining percentage. They return false when the start or end wasn't reached.

Both scrollers implement IScroller, so one piece of code handles both:

// Windows with a Win32 scroll bar: WindowScroller; browsers, Electron, WPF, WinUI, Office: UI Automation.
// Both implement IScroller. Run this on a background thread, not on the UI thread of the window.
IScroller scroller = window.GetWindowScroller();
scroller ??= UiAutomationScroller.FromPoint(clickedPoint);   // or UiAutomationScroller.FromWindow(window)
// FromPoint also finds controls without a ScrollPattern which expose a scroll bar (e.g. the Visual Studio editor):
// IsScrollBarFallback is true, they are scrolled with the mouse wheel, so the area must be visible on the screen.
if (scroller == null)
{
    return;                       // nothing to scroll here
}
try
{
    scroller.StepFraction = 0.5;  // half a page per step, so consecutive frames overlap for stitching
    scroller.Start();
    captureFrame(scroller.ViewportBounds);
    while (!scroller.IsAtEnd)
    {
        if (!scroller.Next())
        {
            break;
        }
        // Give the application time to paint, then capture the visible part.
        // When UiAutomationScroller.IsPositionKnown is false, IsAtEnd can't tell the end: stop when the frame didn't change.
        captureFrame(scroller.ViewportBounds);
    }
    scroller.Reset();             // back to where the user was
}
finally
{
    (scroller as IDisposable)?.Dispose();   // releases the UI Automation COM objects
}

Finding the scrollable areas of a window

One window can hold several scrollable areas: in Visual Studio the editor, the Output pane and the Solution Explorer are all in one WPF window. UiAutomationScroller.FindScrollableAreas(window, horizontal) lists the screen bounds (pixels) of the window element and every descendant which can scroll in that direction, and the parent of every scroll bar element in that direction (the controls without a ScrollPattern above; without the scroll bars at its edges, like ViewportBounds), in tree order, without hit testing on the screen, so it works while another window (a selection window) covers it. It is one FindAll with a cache request for the bounding rectangle, plus one call per scroll bar for its parent; empty and offscreen elements are left out, duplicates (a ScrollPattern element and the parent of its own scroll bar) are returned once, an outer area comes before the areas inside it, and the result is an empty list (never null) when nothing scrolls or UI Automation isn't available. FindScrollableAreas(window, horizontal, timeout, includeScrollBarAreas: false) returns the ScrollPattern elements only. The areas can stick out of the window (e.g. a ScrollViewer larger than its pane), clip them yourself when needed.

The call blocks: large trees (Visual Studio, Office, browsers) take a while. Run it on a background thread and cache the result per window. The UI Automation connection and transaction timeouts are set to timeout (default UiAutomationScroller.DefaultFindTimeout, 2 seconds; Windows 8 and later), so a hanging provider can't block for the default 20 seconds.

Chromium based browsers (Edge, Chrome) and Electron apps build their accessibility tree when a UI Automation client first asks, so the first search after the browser started or a page loaded finds no area for the page. FindScrollableAreas(window, horizontal, timeout, includeScrollBarAreas, contentWait, cancellationToken) searches again in that case: when nothing is found and a shallow read of the control view shows an element without content covering at least a quarter of the window (the check of UiAutomationAreas.FindAreasAsync), or the window's own element has no content (only for Chromium's render widget window, Chrome_RenderWidgetHostHWND, before its tree is built; other windows without children are complete), it searches again with a pause of a quarter of a second between the attempts, until an area is found, the tree is complete or contentWait has passed. The search itself makes Chromium build the tree, so a tree which looks complete after a search which found nothing is searched once more (null for UiAutomationAreas.DefaultContentWait, 3 seconds; TimeSpan.Zero searches once, like the overloads without it), and returns the last result. A window with nothing to scroll and a complete tree never waits; one with an element which really is large and empty waits the whole contentWait, so cache the result. The token is checked during every pause and before every search.

// Greenshot-style: list the scrollable areas of the window under the mouse once (it blocks, so not on the UI thread;
// works while your own window covers the screen), hit test them on every mouse move, scroll the chosen one later.
IReadOnlyList<NativeRect> areas = await Task.Run(() => UiAutomationScroller.FindScrollableAreas(window));
// The smallest area containing the cursor wins: in Visual Studio the editor, not the whole window
NativeRect? chosen = areas
    .Where(area => area.Contains(mouseLocation))
    .OrderBy(area => area.Width * area.Height)
    .Select(area => (NativeRect?)area)
    .FirstOrDefault();
if (chosen is { } area)
{
    // After your own window is gone, FromPoint finds the same element under the middle of the area
    using var scroller = UiAutomationScroller.FromPoint(new NativePoint(area.X + area.Width / 2, area.Y + area.Height / 2));
    // ... the scrolling capture loop from the sample above
}

The parts of a window: UI Automation areas

Windows which draw their own content (a browser page, Electron, WPF or UWP apps, an Office ribbon) have no child windows to snap a selection to, but UI Automation knows their parts. UiAutomationAreas.FindAreasAsync(window, maxDepth = 3, minimumSize = 0, timeout, contentWait, cancellationToken) reads the element tree of the control view of a window and returns it as immutable UiAutomationAreas (Bounds, ControlType, Name, Children), the window's own element is the root; null when UI Automation isn't available or the window is gone. It works on a window handle, so also while your own window covers the screen. For snapping the tree is cleaned up: offscreen and empty elements are left out, elements smaller than minimumSize with their children, every rectangle is clipped to its parent's, and an element with the same rectangle as its parent is replaced by its children. root.GetAreasAt(point) returns the areas containing a point, deepest first (the top-most sibling wins on overlap), as pure geometry on the snapshot.

The calls run on a background (MTA) thread. The tree is read level by level, one request to the application per area whose children are read (only the children which aren't offscreen), so maxDepth limits the work; it counts the levels of the result, after merging elements with their parent's rectangle. The cancellation token is checked before every request, a single request can't be interrupted. The UI Automation timeouts are set to timeout (default 2 seconds).

Chromium based browsers build their accessibility tree when a UI Automation client first asks, the first answer has only the frame of the window: when an element whose children were read has no content (no children which aren't offscreen, only children with an empty rectangle, or children with its own rectangle and no content) and its area covers at least a quarter of the window, FindAreasAsync reads the tree again, with a pause of a quarter of a second between the attempts, until the tree is complete or contentWait has passed (default UiAutomationAreas.DefaultContentWait, 3 seconds), and returns the last result. TimeSpan.Zero reads only once. A complete tree never waits; the cancellation token is checked during every pause, so a caller which moved on stops the attempts at once. An area whose children were all left out (smaller than minimumSize, clipped away) has content and doesn't count, neither does an empty overlay next to a sibling with the same bounds and content. A large element which really is empty (an empty pane) makes every call wait the whole contentWait, pass a shorter one when that matters.

Measured on a CI runner with Edge and a page of 3000 paragraphs, 300 links and a 300-row table: the page document is on level 3 and its first content (heading, links, table) on level 4, so a browser page needs maxDepth: 4 or more.

maxDepth Time Areas Content
3 ~35 ms 18 browser frame, the document without its content
4 ~0.35 s 29 the first level of the page
5 ~0.6 s 637 most of the visible page
7 ~0.75 s 669 everything

The first request to a new Edge (with the reads again) took about 1 to 1.2 seconds with maxDepth: 4; right after Edge started, with Gmail still loading, it can take longer, up to contentWait.

// Snap a region selection to the parts of a window which has no child windows there (a web page, a WPF app, a ribbon):
// read the UI Automation tree of the window under the mouse once (on a background thread, cancel it when the mouse moves on),
// then hit test the snapshot
UiAutomationArea root = await UiAutomationAreas.FindAreasAsync(window, maxDepth: 4, minimumSize: 8, cancellationToken: cancellationToken);
if (root != null)
{
    // Deepest area first, the window last: snap to the first, let the user step up the chain with the keys
    IReadOnlyList<UiAutomationArea> chain = root.GetAreasAt(mouseLocation);
    NativeRect? snapTo = chain.Count > 0 ? chain[0].Bounds : null;
}

The text of a window: UI Automation text

UiAutomationText.ReadVisibleTextAsync(window, area, connectionTimeout, transactionTimeout, maxDuration, cancellationToken) reads the text a window shows inside area (screen pixels, NativeRect.Empty for the whole window) and returns an immutable UiAutomationTextSnapshot: Lines from top to bottom, each with its Bounds and Words (Bounds, Text, and LinkTarget when the word is part of a link). It is meant for windows whose text should not go through OCR (browsers, Electron, WPF, UWP, Office) and has nothing specific to an application. Like the areas it works on a window handle, also while your own window covers the screen, and the calls run on a background (MTA) thread.

  • An element with a TextPattern (the document of a browser page, an edit field, a WPF RichTextBox, Word) is read with one request for its whole subtree. When the subtree has Text elements (browsers, Electron and WebView2 expose every run of text as one) the text comes from them, a few requests for a whole page: Chromium's text ranges are slow, 5 to 30 ms per request, so reading a page through them took tens of seconds. Else the TextPattern is read line by line (the text and the rectangles of each line); a line with a link (a hyperlink inside the element) is read word by word, so its words get the hyperlink's value (its ValuePattern, the URL in a browser) as LinkTarget.
  • Elements: a Text element is a run of text with its name and bounds (a hyperlink inside it is read too and replaces it when it covers the whole text, e.g. a WPF TextBlock with only a Hyperlink), the Text elements inside a Hyperlink get its value as target, a Hyperlink without them is a run with its name, a single-line Edit element a run with its value.
  • A run (or a line of a TextPattern) is split into words which get their share of its bounds by characters, an estimate good for selections and labels. A run higher than one and a half lines wraps: inside a document its line rectangles come from RangeFromChild, else it is cut into bands of a line. Words which overlap vertically are one line.
  • Password elements are left out with their children, so are the words of a TextPattern which lie in one; offscreen elements are left out, and everything is clipped to its element, the window and area.
  • While nothing is found and the tree looks incomplete (Chromium builds it on the first request) the text is read again, like UiAutomationAreas.FindAreasAsync does, for up to UiAutomationAreas.DefaultContentWait. IsComplete is false when it gave up, a request timed out, a limit was reached, the window is gone or UI Automation isn't available.
  • A window of a remote display client (Citrix, Remote Desktop, Horizon, see below) is read once, IsComplete is false and RemoteDisplay says which client it is: its content is a picture, use OCR.
  • The connection and transaction timeouts (default 2 seconds each) are set where CUIAutomation8 is available. After maxDuration the reading stops and the snapshot has what was read until then, with TimedOut true and IsComplete false; the cancellation token throws instead. The verbose log has the time to the first word, the number of requests, elements, runs and lines, and the time per phase, so a slow provider is visible.
  • Text and link targets are never logged, ToString() of the snapshot types leaves them out too.
// Read the text of the part of a window the user selected, e.g. to copy it or to find the links, without OCR (a browser,
// an Electron or WPF app, Office); on a background thread. After maxDuration it returns what it has (TimedOut), the token throws.
UiAutomationTextSnapshot snapshot = await UiAutomationText.ReadVisibleTextAsync(window, selection, maxDuration: TimeSpan.FromSeconds(2),
    cancellationToken: cancellationToken);
string text = snapshot.Text;
// The links in the selection, with the bounds to draw them
var links = snapshot.Words.Where(word => word.IsLink).Select(word => (word.Bounds, word.LinkTarget)).ToList();
if (snapshot.RemoteDisplay != RemoteDisplayKinds.None)
{
    // A Citrix, Remote Desktop, Horizon ... window: only a picture for Windows, the text has to come from OCR
}
else if (!snapshot.IsComplete)
{
    // Not all text could be read (the tree wasn't ready, a timeout): fall back to OCR, or tell the user
}

Remote display windows: Citrix, Remote Desktop, Horizon

A window of a remote display client is the picture of a session on another machine: for Windows and UI Automation it is a bitmap, there is no text or element for what it shows. RemoteDisplayWindow.GetKind(handle) (and IsRemoteDisplay) tells which client draws a window: Citrix (Citrix Workspace app: wfica32 / CDViewer, seamless windows of the class "Transparent Windows Client"), RemoteDesktop (mstsc session and RemoteApp windows, msrdc for Azure Virtual Desktop and Windows App), Horizon (vmware-remotemks), HyperV (vmconnect) or Vnc. It only looks at the executable of the process and the class names of the window and its top-level window, so it is cheap and needs nothing of the client. RemoteDisplayWindow.Classify(processName, className) has the same rules without a window.

UiAutomationText.ReadVisibleTextAsync uses it: for a remote display window it doesn't wait for the tree, IsComplete is false and UiAutomationTextSnapshot.RemoteDisplay says which client it is. This is the opposite question of Dapplo.Windows.Citrix, which tells if your own process runs inside a Citrix session.

// Is the window the picture of a session on another machine (Citrix Workspace app, Remote Desktop, Horizon, Hyper-V, VNC)?
RemoteDisplayKinds kind = RemoteDisplayWindow.GetKind(windowHandle);
if (RemoteDisplayWindow.IsRemoteDisplay(windowHandle))
{
    Console.WriteLine($"The window is drawn by a remote display client: {kind}, use OCR for its text");
}

Window events

WinEventHook turns WinEvents into observables. The hook is installed when you subscribe and removed when the last subscription is disposed. The events are delivered on the thread of the SharedMessageWindow: keep OnNext short and use ObserveOn for slow work.

The events are also raised for objects inside windows (menus, the caret, list items ...). Check ObjectIdentifier == ObjectIdentifiers.Window (and IsSelf) when you only want windows.

// Created and destroyed top-level and child windows, events arrive on the SharedMessageWindow thread
IDisposable subscription = WinEventHook.WindowCreateDestroyObservable()
    .Subscribe(info =>
    {
        if (info.WinEvent == WinEvents.EVENT_OBJECT_CREATE)
        {
            Console.WriteLine($"Created {info.Handle}");
        }
        else
        {
            // The window is gone, only the handle is left
            Console.WriteLine($"Destroyed {info.Handle}");
        }
    });

// Removes the hook
subscription.Dispose();
var subscription = WinEventHook.WindowTitleChangeObservable()
    .Select(info => InteropWindowFactory.CreateFor(info.Handle))
    .Where(window => window.IsVisibleApplicationWindow())
    .Subscribe(window => Console.WriteLine($"New title: {window.GetCaption(forceUpdate: true)}"));
// The user switched to another window
var subscription = WinEventHook.Create(WinEvents.EVENT_SYSTEM_FOREGROUND)
    .Subscribe(info => Console.WriteLine($"Active: {InteropWindowFactory.CreateFor(info.Handle).GetCaption()}"));
// Moved or resized windows, this also fires for the caret and the cursor: filter on the window itself
var subscription = WinEventHook.Create(WinEvents.EVENT_OBJECT_LOCATIONCHANGE)
    .Where(info => info.ObjectIdentifier == ObjectIdentifiers.Window && info.IsSelf)
    // Many events arrive while dragging, only take the last one
    .Throttle(TimeSpan.FromMilliseconds(100))
    .Subscribe(info => Console.WriteLine($"Moved: {InteropWindowFactory.CreateFor(info.Handle).GetInfo(forceUpdate: true).Bounds}"));

Limit a hook to one process (or thread) when you can, Windows then only calls you for that process:

// Only the events of one process: less work for Windows and for you
var subscription = WinEventHook.Create(WinEvents.EVENT_OBJECT_CREATE, WinEvents.EVENT_OBJECT_DESTROY, process: process.Id)
    .Where(info => info.ObjectIdentifier == ObjectIdentifiers.Window)
    .Subscribe(info => Console.WriteLine($"{process.ProcessName}: {info.WinEvent}"));

Displays

DisplayInfo.AllDisplayInfos lists the monitors; it's updated when displays, resolutions or the work area change.

foreach (var display in DisplayInfo.AllDisplayInfos)
{
    Console.WriteLine($"{display.DeviceName}: {display.Bounds}, work area {display.WorkingArea}, primary: {display.IsPrimary}");
}
// The bounds of all displays together
NativeRect desktop = DisplayInfo.ScreenBounds;

Installed software

// Reads the uninstall information of the registry (64 and 32 bit, machine and user)
foreach (var software in InstallationInformation.InstalledSoftware().Where(s => s.Publisher == "Microsoft Corporation"))
{
    Console.WriteLine($"{software.DisplayName} {software.DisplayVersion}");
}

Tips

  • An IInteropWindow is a snapshot. Values which change (title, bounds, state) need forceUpdate: true when you read them again later.
  • GetTopWindows(), GetVisibleApplicationWindows(), GetChildren() and WindowsEnumerator return a snapshot of the windows at the moment of the call; call them again for the current state.
  • Reading the text of a window which belongs to a hung application times out after 500 ms instead of blocking.
  • Keep WinEvent subscriptions narrow (event range, process) and throttle location events, they are frequent.

See also