# 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`](../../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 `HookEvent`s through an
`mpsc` channel. See [threading model](./threading-model.md) for the full
threading picture.
## Window State Model
```mermaid
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`](../../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
```mermaid
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](./floating-space.md)
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](./event-pipelines.md)).
`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:
```mermaid
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`](../../src/registry/classification.rs)). The
pipeline is platform-independent — it receives a `WindowCandidate` (a plain Rust
struct with no HWND) and returns a `WindowState`.
```mermaid
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:
| `exe` | Exact | Case-insensitive |
| `exe_regex` | Regex (full string) | Case-insensitive |
| `title` | Exact | Case-sensitive |
| `title_contains` | Substring | Case-sensitive |
| `title_regex` | Regex (full string) | Case-sensitive |
| `class` | Exact | Case-sensitive |
| `class_regex` | Regex (full string) | Case-sensitive |
| `process_path` | Exact | Case-insensitive |
| `process_path_regex` | Regex (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`](../../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](./animation.md) for how this translation works.
## Hooks Used
The registry listens to eight Win32 events registered via `SetWinEventHook` on a
dedicated background thread:
| Create/Destroy range | `EVENT_OBJECT_CREATE` / `EVENT_OBJECT_DESTROY` | Window lifecycle | Classify and register, or remove from registry |
| Foreground | `EVENT_SYSTEM_FOREGROUND` | Focus changed | Update `focused` field |
| Minimize range | `EVENT_SYSTEM_MINIMIZESTART` / `EVENT_SYSTEM_MINIMIZEEND` | Minimize/restore | Transition Active to/from Minimized, save/restore virtual slot |
| Show/Hide range | `EVENT_OBJECT_SHOW` / `EVENT_OBJECT_HIDE` | Visibility | Reconcile visibility (tray-hide, DWM cloak, re-show) |
| StateChange | `EVENT_OBJECT_STATECHANGE` | State bits changed | Recovery: re-classify `Ignored(Maximized/Fullscreen)` windows when the user restores them |
| NameChange | `EVENT_OBJECT_NAMECHANGE` | Title changed | Recovery: 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](./roadmap.md) for the full design.
## Cross-References
- [Event pipelines](./event-pipelines.md) — how the daemon routes hook events
to registry handlers and coordinates with the layout engine.
- [Config and persistence](./config-and-persistence.md) — the `flow-rules.toml`
format for user-defined classification rules.
- [Animation](./animation.md) — how the `InvisibleBounds` on each `Window` are
used to translate visible rects to window rects for `SetWindowPos`.
- [Workspace hierarchy](./workspace.md) — where classified windows end up
(in the active workspace's `ScrollingSpace`).