Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Window Registry

The window registry is the bridge between the Windows OS and flow’s internal layout model. It hooks into Win32’s SetWinEventHook system, classifies every window as Tiling, Floating, or Ignored, and maintains per-window state throughout the entire window lifecycle. All classification logic is pure Rust with no Win32 dependencies, making it fully unit-testable.

What the Registry Owns

WindowRegistry (src/registry/core.rs) is a single struct that owns:

  • A HashMap<isize, Window> — the authoritative map of HWND values (as isize for Send safety) to per-window metadata: exe name, title, class, process path, classification state, pre-manage rect, invisible bounds, and virtual-slot position.
  • A ClassificationPipeline — pre-compiled user rules, default rules, and a fallback action, used to classify every new window.
  • A focused field tracking the currently focused window.

The registry is owned directly by FlowWM on the IPC thread. The hook thread never touches it — it only sends typed HookEvents through an mpsc channel. See threading model for the full threading picture.

Window State Model

classDiagram
    class WindowState {
        <<enum>>
        Tiling
        Floating
        Ignored
    }
    class TilingState {
        <<enum>>
        Active col row
        Minimized
        Hidden
    }
    class FloatingState {
        <<enum>>
        Active rect
        Minimized
        Hidden
    }
    class IgnoredReason {
        <<enum>>
        Maximized
        Fullscreen
        ExplicitRule
    }
    class Window {
        +HWND hwnd
        +String exe
        +String title
        +String class
        +PathBuf process_path
        +WindowState state
        +Rect pre_manage_rect
        +Size last_natural_size
        +Option~VirtualSlot~ last_virtual_slot
        +Option~Rect~ tiled_rect
        +InvisibleBounds invisible_bounds
    }
    class VirtualSlot {
        +usize col
        +usize row
    }
    WindowState --> TilingState
    WindowState --> FloatingState
    WindowState --> IgnoredReason
    Window --> WindowState
    Window --> VirtualSlot

The Window struct (src/registry/types.rs) is the single record for every tracked window. Its state field determines how the layout engine and animation layer interact with it. VirtualSlot bridges the registry to the layout engine — when a tiled window is minimized, its column/row position is saved here and restored on un-minimize.

State Transitions

stateDiagram-v2
    [*] --> NotTracked

    NotTracked --> TilingActive : classification → Tile
    NotTracked --> FloatingActive : classification → Float
    NotTracked --> IgnoredExplicit : classification → Ignore

    TilingActive --> TilingMinimized : MinimizeStart
    TilingMinimized --> TilingActive : MinimizeEnd
    TilingActive --> TilingHidden : EVENT_OBJECT_HIDE
    TilingHidden --> TilingActive : EVENT_OBJECT_SHOW

    FloatingActive --> FloatingMinimized : MinimizeStart
    FloatingMinimized --> FloatingActive : MinimizeEnd
    FloatingActive --> FloatingHidden : EVENT_OBJECT_HIDE
    FloatingHidden --> FloatingActive : EVENT_OBJECT_SHOW

    NotTracked --> IgnoredMaximized : maximized at creation
    NotTracked --> IgnoredFullscreen : fullscreen at creation

    TilingActive --> Removed : EVENT_OBJECT_DESTROY
    FloatingActive --> Removed : EVENT_OBJECT_DESTROY
    IgnoredExplicit --> Removed : EVENT_OBJECT_DESTROY

    Removed --> [*]

User-driven tile ↔ float transitions are implemented via flow dispatch set-window float|tile|cycle — see Floating Space for the full transition table and animation. Two directions remain unimplemented:

  • Tiling → Ignored(Maximized) — a tiled window the user then maximizes is not yet reclassified out of the layout. The state diagram above has no TilingActive → IgnoredMaximized edge.
  • Ignored(Maximized) → tiling via a user command — only the OS-driven recovery direction works: an Ignored(Maximized) window restored by the user is re-classified into the layout via STATECHANGE (see Event Pipelines).

Ignored(Fullscreen) follows the same recovery rule.

The Classification Algorithm — Deep Dive

Classification is the central decision pipeline that runs every time a window appears. It determines whether the window participates in tiling, floats freely, or is ignored entirely. The algorithm has two phases: Win32 pre-filters (checked in the registry before any classification) and the rule pipeline (pure logic in the classification module).

Phase 1: Win32 Pre-Filters

Before the classification pipeline sees a window, the registry runs a series of cheap Win32 checks. These are ordered from cheapest to most expensive to avoid unnecessary process queries:

flowchart TB
    subgraph PreFilters["Win32 Pre-Filters (core.rs)"]
        A["EVENT_OBJECT_CREATE / init scan"] --> B{"IsWindowVisible?"}
        B -- No --> SKIP["Skip"]
        B -- Yes --> C{"IsIconic?"}
        C -- Yes --> SKIP
        C -- No --> D{"Title non-empty?"}
        D -- No --> SKIP
        D -- Yes --> E{"Alt+Tab visible?<br/>(not WS_EX_TOOLWINDOW<br/>unless WS_EX_APPWINDOW)"}
        E -- No --> SKIP
        E -- Yes --> F{"DWM Cloaked?"}
        F -- Yes --> SKIP
        F -- No --> G{"Has owner?<br/>(GW_OWNER != null)"}
        G -- Yes --> SKIP
        G -- No --> G2{"WS_CHILD?<br/>(style bit)"}
        G2 -- Yes --> SKIP
        G2 -- No --> H["get_window_info()"]
    end

Each filter exists for a specific reason:

  1. Visibility (IsWindowVisible) — invisible windows have no place in the layout. Note that minimized windows pass this check (WS_VISIBLE stays set), which is why the iconic check exists separately.

  2. Not iconic (IsIconic) — minimized windows should not be tiled. They are caught later by MinimizeStart events instead.

  3. Non-empty title (GetWindowTextLengthW) — titleless windows are typically internal containers or splash screens. This is the gate that causes late-titling apps (Windows Terminal) to be dropped on creation and recovered later via NAMECHANGE.

  4. Alt+Tab visibility — a two-part check. First, the WS_EX_TOOLWINDOW extended style: windows with this style are hidden from Alt+Tab (tool windows, tray icons, floating toolbars). However, WS_EX_APPWINDOW forces visibility even if WS_EX_TOOLWINDOW is set. Second, a DWM cloak check (DWMWA_CLOAKED) filters out suspended UWP background frames like ApplicationFrameHost.exe that are technically “visible” to Win32 but not rendered on screen.

  5. No owner (GetWindow(GW_OWNER)) — owned windows are dialogs or popups that belong to their owner. They should not be independently tiled.

  6. Not a child window (WS_CHILD style bit via GetWindowLongW(GWL_STYLE)) — child windows are embedded controls (buttons, labels, the Inno Setup TNew* family, the Win32 Button/Static controls) that live inside another window’s client area. They have no independent frame and cannot be tiled. This check catches them structurally, without needing per-class rules in default-flow-rules.toml.

    The check is deliberately narrow: it does not catch reparented popups (WS_POPUP + SetParent) or owned top-level windows, both of which may be legitimate tiling candidates (e.g. a DAW’s MIDI editor). Reparented popups are rare and better handled by WS_EX_TOOLWINDOW (via is_alt_tab_visible) or explicit rules.

    Why not GetAncestor(GA_PARENT)? That API returns the desktop window handle for top-level windows, not null — so a naive “parent != null” check would flag every window. Comparing against GetDesktopWindow() works but is strictly broader than the WS_CHILD bit: it also catches reparented popups, which risks false positives on legitimate application windows. The WS_CHILD style bit is the precise Win32 definition of “child window.”

Phase 2: Rule Pipeline

Windows that survive the pre-filters enter the classification pipeline (src/registry/classification.rs). The pipeline is platform-independent — it receives a WindowCandidate (a plain Rust struct with no HWND) and returns a WindowState.

flowchart TB
    C["WindowCandidate<br/>(exe, title, class, process_path)"] --> MAX{"IsZoomed?<br/>(maximized)"}
    MAX -- Yes --> IGMAX["Ignored(Maximized)<br/>ALWAYS wins"]
    MAX -- No --> FS{"Fullscreen?<br/>(rect == screen,<br/>no caption/thickframe)"}
    FS -- Yes --> IGFS["Ignored(Fullscreen)<br/>ALWAYS wins"]
    FS -- No --> PIPE["ClassificationPipeline"]

    subgraph PIPE["Multi-layer rule pipeline"]
        UR["User rules<br/>(from flow-rules.toml)<br/>first match wins"]
        LR["Learned rules<br/>(history-flow-rules.toml)<br/>first match wins"]
        DR["Default rules<br/>(embedded at compile time)<br/>first match wins"]
        FALL["Default action<br/>(fallback)"]
        UR -- no match --> LR
        LR -- no match --> DR
        DR -- no match --> FALL
    end

    PIPE --> T["Tiling(Active)"]
    PIPE --> FL["Floating(Active)"]
    PIPE --> IGX["Ignored(ExplicitRule)"]

Maximized and fullscreen overrides always take precedence over rules. A window that is maximized will always be Ignored(Maximized) even if a config rule says to tile it — maximized and fullscreen windows have their own management behavior that conflicts with tiling.

Rule Matching Details

Within each rule layer, rules are evaluated top-to-bottom with first-match-wins semantics. Each rule has a match section with zero or more fields:

FieldMatch modeCase sensitivity
exeExactCase-insensitive
exe_regexRegex (full string)Case-insensitive
titleExactCase-sensitive
title_containsSubstringCase-sensitive
title_regexRegex (full string)Case-sensitive
classExactCase-sensitive
class_regexRegex (full string)Case-sensitive
process_pathExactCase-insensitive
process_path_regexRegex (full string)Case-insensitive

AND logic applies: every specified (non-None) field in a rule must match. If a regex pattern fails to compile, it logs a warning and treats the field as non-matching rather than crashing the daemon. Inline regex flags like (?i) and (?-i) let users override the default case sensitivity for specific patterns.

All regex patterns are pre-compiled at pipeline construction time into CompiledRule structs, so the per-classification cost is pure matching with zero allocations.

Notable Default Rules

The embedded default rules (from default-flow-rules.toml) catch several well-known Windows edge cases:

Chromium Legacy Window filtering. Every Chromium-based application (Chrome, Edge, VS Code/Electron, Slack, Discord) spawns an invisible helper window with class Chrome_RenderWidgetHostHWND and title “Chrome Legacy Window”. This window passes all Win32 pre-filters — it is “visible”, titled, not tool-window, and not cloaked — yet it is never shown to the user. Without an explicit ignore rule, it would enter the tiling layout and cause layout churn every time Chromium opens or closes a tab. The default rule matches on class (identical across all Chromium hosts) rather than exe (which differs per application), so a single rule covers every Chromium-based app. Class matching is also race-free — the class is assigned at creation time, while the title may arrive milliseconds later.

Taskbar, search, and system UI. Windows like Shell_TrayWnd (the taskbar), Windows.UI.Core.CoreWindow (Settings), and various system overlay windows are ignored by default rules. These windows are either always-on-top or interact with the shell in ways that conflict with tiling.

The InvisibleBounds Concept

GetWindowRect returns the full window rectangle, but on Windows 10/11 this includes invisible borders — typically ~7px on the left, right, and bottom edges — used for drop shadows and resize hit-testing. This means GetWindowRect is not the visual rect of the window.

The registry measures each window’s invisible borders once at registration time by comparing GetWindowRect against DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS), which returns the rect that the user actually sees. The difference is stored as an InvisibleBounds struct ({left, top, right, bottom}) in the Window. If either query fails (DWM unavailable, window destroyed mid-query), the bounds default to zero (fail-open — the window may have slightly larger gaps, but this is preferable to crashing).

The animation bridge later uses InvisibleBounds to translate between the layout engine’s visible-rect coordinates (what the user sees) and Win32’s window-rect coordinates (what SetWindowPos expects). See animation for how this translation works.

Hooks Used

The registry listens to eight Win32 events registered via SetWinEventHook on a dedicated background thread:

HookWin32 eventMeaningAction
Create/Destroy rangeEVENT_OBJECT_CREATE / EVENT_OBJECT_DESTROYWindow lifecycleClassify and register, or remove from registry
ForegroundEVENT_SYSTEM_FOREGROUNDFocus changedUpdate focused field
Minimize rangeEVENT_SYSTEM_MINIMIZESTART / EVENT_SYSTEM_MINIMIZEENDMinimize/restoreTransition Active to/from Minimized, save/restore virtual slot
Show/Hide rangeEVENT_OBJECT_SHOW / EVENT_OBJECT_HIDEVisibilityReconcile visibility (tray-hide, DWM cloak, re-show)
StateChangeEVENT_OBJECT_STATECHANGEState bits changedRecovery: re-classify Ignored(Maximized/Fullscreen) windows when the user restores them
NameChangeEVENT_OBJECT_NAMECHANGETitle changedRecovery: re-attempt registration for windows not yet tracked (late-titling apps like Windows Terminal)

EVENT_OBJECT_LOCATIONCHANGE is deliberately not hooked — it fires on every pixel of window movement and would flood the event channel. Maximize/restore is already covered by STATECHANGE.

STATECHANGE and NAMECHANGE are registered as single-event hooks (min == max) so the intervening LOCATIONCHANGE (which sits between them numerically) is excluded from the range.

Recovery Hooks

Two hooks exist specifically to recover windows that EVENT_OBJECT_CREATE misses. CREATE fires very early in the Win32 lifecycle — before a window is visible, titled, or has finalized its styles. The recovery hooks give flow a second chance:

  • NAMECHANGE recovery: apps like Windows Terminal set their title asynchronously, often more than 500ms after creation. When the title finally lands, NAMECHANGE fires and the daemon re-attempts registration — but only for windows not already tracked, to avoid re-classifying every window on every title change.

  • STATECHANGE recovery: a window that starts maximized is classified Ignored(Maximized). When the user restores it, WS_MAXIMIZE flips and STATECHANGE fires. The daemon re-classifies the window (now not maximized) and adds it to the layout. Only windows currently Ignored(Maximized|Fullscreen) are re-evaluated, so the common-case cost is a single HashMap lookup.

Recovery Snapshot (Planned)

A separate watchdog process is planned to restore windows if the daemon crashes, reading a flow-recovery.json snapshot written by the daemon on every state mutation and calling SetWindowPos for each entry to put windows back at their pre-manage positions. This is not yet implemented and is gated on flow/flowd being feature-complete; the Window struct already carries pre_manage_rect for this purpose. See the Roadmap for the full design.

Cross-References

  • Event pipelines — how the daemon routes hook events to registry handlers and coordinates with the layout engine.
  • Config and persistence — the flow-rules.toml format for user-defined classification rules.
  • Animation — how the InvisibleBounds on each Window are used to translate visible rects to window rects for SetWindowPos.
  • Workspace hierarchy — where classified windows end up (in the active workspace’s ScrollingSpace).