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

FlowWM

flow (FlowWM) is a tiling window manager for Windows 10/11 built around a scrolling, infinite-horizontal-canvas layout model. Unlike grid-based managers such as glazeWM or komorebi, windows occupy columns on a virtual canvas that can extend wider than any single monitor. The viewport slides left and right, bringing columns in and out of view.

The project ships as two binaries inside a single Cargo package:

BinaryRole
flowdThe daemon. Runs in the background (via flow start), owns all state, and accepts IPC commands.
flowThe CLI client. Sends commands to the daemon over a named pipe.

How to Use This Book

  • User Guide — for people who want to run flow: installation, the command reference, the configuration reference, and how to get help or report an issue.
  • Developer Guide — for contributors and anyone who wants to understand how the system works inside. Covers architecture, the layout pipeline, threading model, subsystem deep dives, design decisions, and the roadmap.

Building From Source

For the full build walkthrough — prerequisites, MSVC setup, cloning, and the contributor gates (cargo test / clippy / fmt, mdbook build) — see the dedicated Building from Source page in the Developer Guide.

User Guide

FlowWM is a scrolling tiling window manager for Windows. This guide covers the day-to-day surface: how to install it, the full command reference, and how to configure and extend it.

New here? The Installation page walks through the 60-second setup (install AutoHotkey, flow config init --ahk, flow start --ahk, and the default hotkeys). Then come back here for the complete command and config reference.

Quick start

flow config init --ahk   # write flow.toml + flow.ahk (defaults)
flow start --ahk         # start the daemon and the hotkey script
flow query all           # see what the daemon sees (JSON)
flow stop                # shut it down

Where to go next

  • Installation — prerequisites, the two install paths (crates.io or from source), and first-run setup.
  • Command Reference — every flow dispatch action, annotated, with its default hotkey.
  • Config Reference — every flow.toml and flow-rules.toml field, its default, and what it does.
  • Contributing & Reporting Issues — how to file a useful bug report, send a patch, or request a feature.
  • Hotkeysflow.ahk is yours to edit. The modifier is one ModKey line at the top; see the README’s Can I use the Win key? section.

For architecture, the layout pipeline, and subsystem deep dives, see the Developer Guide.

Installation

FlowWM is a Windows-only tiling window manager. Getting it running takes about five minutes: install the binaries, install a hotkey daemon, generate your config, and start.

Already installed and just want the command list? Jump to the Command Reference.

Prerequisites

FlowWM is a native Windows application and links against Win32, so you need:

  • Windows 10 or 11. (FlowWM does not run on Linux, macOS, or WSL — it uses Win32 APIs directly.)
  • The Rust MSVC toolchain — install via rustup and select stable-x86_64-pc-windows-msvc. The MinGW / GNU toolchains will not link.
  • Visual Studio Build Tools — the “Desktop development with C++” workload (supplies the MSVC linker and the Windows SDK). Even the cargo install path compiles locally, so this is required either way.
  • AutoHotkey v2 (recommended) — FlowWM ships no keybinder of its own. AutoHotkey v2 is the companion the --ahk flag generates a script for. Anything that can run a command on a keypress will also work (whkd, PowerToys, etc.), but you’d write that integration yourself.

Step 1 — Install the binaries

You have two paths. cargo install is recommended because it puts flow and flowd on your PATH automatically.

cargo install flow-wm

That’s it. Both binaries are installed onto your PATH.

From source

git clone https://github.com/CCpcalvin/flow-wm.git
cd flow-wm
cargo build --release

The binaries land in target\release\ — you’ll need to add that folder to your PATH (or copy the binaries somewhere already on it). For the full build walkthrough, see Building from Source in the Developer Guide.

Verify the install:

flow --version

Step 2 — Generate your config and hotkeys

flow config init --ahk

This writes two files into your config directory:

FilePurpose
%USERPROFILE%\.config\flow\flow.tomlApplication config — column counts, padding, animation, borders, etc. Sensible defaults; heavily commented.
%USERPROFILE%\.config\flow\flow.ahkA ready-to-use AutoHotkey v2 script binding every command to a Alt + <key> chord.

Existing files are never overwritten. Re-running flow config init --ahk is safe — it only writes files that don’t yet exist. To regenerate, delete the file first.

If you’d rather not use AutoHotkey, run flow config init (without --ahk) and wire up your own keybinder. The Command Reference lists every flow dispatch <command> call you’d need to bind.

The config directory resolves in this priority order: --config <dir> flag → FLOW_CONFIG_DIR env var → default %USERPROFILE%\.config\flow\. See Config & Persistence in the Developer Guide for the full resolution chain.

Step 3 — Start the daemon

flow start --ahk

This launches the flowd daemon in the background, and once it’s listening, starts your flow.ahk script alongside it. You should now be able to press Alt + T to tile the focused window.

Want this on every login? Run this once:

flow enable-autostart --ahk

It registers both the daemon and the hotkey script to launch at logon. (Use flow disable-autostart to undo.)

Step 4 — Sanity check

flow query all     # dump the daemon's view of every window/workspace (JSON)
flow dispatch focus right    # same as Alt + l
flow stop          # shut it down

If flow query all returns JSON, the daemon is up and the named-pipe IPC is working. From here, the Command Reference is the canonical list of what you can do, and the Config Reference covers customising flow.toml.

Troubleshooting

flow isn’t recognised

The binaries aren’t on your PATH. If you installed via cargo install, check that %USERPROFILE%\.cargo\bin is on your PATH (rustup usually adds it). If you built from source, see Building from Source → Putting flow on your PATH.

The daemon doesn’t respond to hotkeys

Two things have to be running: flowd (the daemon) and flow.ahk (the hotkey script). flow start --ahk starts both; flow start alone starts only the daemon. Check both processes are alive in Task Manager.

If you changed ModKey in flow.ahk, make sure the key you chose is one AutoHotkey recognises — see the AutoHotkey key list.

The daemon won’t start

Capture an isolated log and inspect it:

flow start --log-file <path>

The file is reset on each launch, so it contains only that run. To raise the verbosity, set RUST_LOG=trace first. See Contributing & Reporting Issues for how to file a useful bug report once you have a log.

Where to go next

Command Reference

This is the complete, annotated list of every flow command — what it does, what arguments it takes, and (where relevant) the default hotkey it’s bound to in flow.ahk. For the guided tour, see the README’s Getting started.

How a keypress reaches the daemon

Every hotkey in flow.ahk is a thin wrapper: it runs flow dispatch <command>, which sends a single message over a Windows named pipe to the flowd daemon. The daemon owns the whole layout pipeline (mutate → project → animate); the CLI only chooses which message to send. So anything that can run a command on a keypress — AutoHotkey, whkd, PowerToys — can drive FlowWM.

flowchart LR
  K[Keypress] -->|"flow dispatch focus right"| CLI["flow CLI"]
  CLI -->|"named pipe"| D[flowd daemon]
  D --> L[mutate → project → animate]

The modifier is configurable. The default flow.ahk holds every chord with Alt. That’s a single ModKey line at the top of the file — change it and every chord below follows. The hotkey column below assumes the default Alt; your setup may differ. (If Alt clashes with an app’s menu shortcuts, see the README’s Can I use the Win key? section.)

Default hotkey map (quick reference)

Hotkey (default)Command
Alt + h / j / k / lfocus left / down / up / right
Alt + Shift + h / j / k / lmove-window left / down / up / right
Alt + Ctrl + h / lmerge-column left / right
Alt + Ctrl + Shift + h / lpromote left / right
Alt + ,shrink-column
Alt + .expand-column
Alt + ccenter
Alt + tset-window cycle
Alt + qclose-window
Alt + 19, 0switch-workspace 110
Alt + Shift + 19, 0move-to-workspace 110
Alt + pflow stop (shuts the daemon down)

Commands with no default hotkey (run them from the terminal, or bind them yourself): swap-column, swap-workspace.


Focus & navigation

focus <direction>

Move focus to the neighbouring window.

  • focus left / focus right — move focus to the adjacent column (or to the first/last window when at the canvas edge).
  • focus up / focus down — move focus to the window above/below within the current column.
flow dispatch focus right
flow dispatch focus up

Default hotkey: Alt + h/j/k/l (vim-style: h=left, j=down, k=up, l=right).

Moving & rearranging windows

move-window <direction>

A semantic “move” — the daemon decides the concrete effect from the focused window’s state and the direction:

  • tiled, left/right → swap the focused column with its neighbour;
  • tiled, up/down → swap the focused window with the one above/below inside the same column;
  • floating → pixel nudge (deferred).
flow dispatch move-window left
flow dispatch move-window down

Default hotkey: Alt + Shift + h/j/k/l.

swap-column <direction>

Swap the entire focused column with its left or right neighbour. direction is left or right only (columns are a horizontal axis).

flow dispatch swap-column right

No default hotkey — bind it if you use it. (For single windows, prefer move-window.)

merge-column <direction>

The signature scroll move. Detach the focused window from its column and append it as a new bottom row of the adjacent column, then redistribute both columns’ row heights. This is how you stack several windows into one column — the niri / Hyprland move that makes scrolling feel better than a fixed grid.

flow dispatch merge-column right

Default hotkey: Alt + Ctrl + h (left) / Alt + Ctrl + l (right).

promote <direction>

Pull the focused window out of its column and place it as a new, single-window column on the chosen side. No-op when the window is already alone in its column.

flow dispatch promote right

Default hotkey: Alt + Ctrl + Shift + h (left) / Alt + Ctrl + Shift + l (right).

Column sizing & the viewport

expand-column

Widen the focused column to the next column_width boundary and animate. Every column to its right slides further along the canvas.

flow dispatch expand-column

Default hotkey: Alt + .

shrink-column

Narrow the focused column to the previous column_width boundary and animate.

flow dispatch shrink-column

Default hotkey: Alt + ,

center

Slide the viewport so the focused column lands at the monitor midpoint. Uses the variable-width prefix sum, so it stays correct even with expanded or shrunk columns.

flow dispatch center

Default hotkey: Alt + c

Window mode (float ↔ tile)

set-window <mode>

Change the focused window’s tiling mode. mode is one of:

  • float — detach the window from the layout and let it float freely.
  • tile — insert the window into the scrolling column layout.
  • cycle — toggle between float and tile (the most common choice).

FlowWM remembers your decision: it writes the app’s tile/float choice to history-flow-rules.toml, and the next time you open that app it tiles (or floats) automatically. See Classification & Learned Rules.

flow dispatch set-window tile
flow dispatch set-window cycle

Default hotkey: Alt + t (cycle).

Workspaces

Workspaces are niri-style virtual desktops, stacked vertically; each one holds its own horizontal scrolling canvas. swap-workspace is wired into the protocol but the animation is still being finalised, so the daemon currently returns a “not yet implemented” error for it.

switch-workspace <id>

Switch the active workspace. The requested workspace slides into the viewport while the previously active one parks above or below it, in one coordinated animation. <id> is a positive integer (1-based).

flow dispatch switch-workspace 2

Default hotkey: Alt + 19, 0 (0 = workspace 10).

swap-workspace <id>

Exchange the positions of the active workspace and workspace <id> in the vertical stack, with focus following the originally active workspace.

Status: stub. The protocol shape is locked in so keybindings can stabilise; the daemon returns “not yet implemented” until the animation lands.

flow dispatch swap-workspace 3   # not yet implemented

No default hotkey.

move-to-workspace <id>

Move the focused window to workspace <id>. The window is detached from the active workspace (with local focus succession — no OS foreground push) and re-inserted into the target workspace after its focused column. Focus stays on the source workspace.

flow dispatch move-to-workspace 4

Default hotkey: Alt + Shift + 19, 0 (0 = workspace 10).

Window & daemon lifecycle

close-window

Ask the focused window to close itself gently via Win32 WM_CLOSE — the same message Windows sends when you click the window’s ✕ button — so the application runs its normal shutdown logic (prompt to save, release resources, etc.). The window is removed from the layout automatically once Win32 reports its destruction.

flow dispatch close-window

Default hotkey: Alt + q


Beyond dispatch — the rest of the CLI

Everything above lives under flow dispatch … (the actions that change layout). A few other top-level commands are worth knowing:

CommandWhat it does
flow start [--ahk] [--config <dir>] [--log-file <path>]Launch the daemon in the background. --ahk also starts flow.ahk once the daemon is listening.
flow stop [--ahk]Stop the running daemon (and the AHK script, with --ahk).
flow config init [--ahk]Write default flow.toml (and flow.ahk) into your config dir. Never overwrites existing files.
flow config reloadTell the running daemon to re-read config from disk.
flow config editOpen the config directory in your editor.
flow config pathPrint the resolved config directory path.
flow config checkValidate flow.toml / flow-rules.toml without loading them.
flow query allDump every tracked window (state, rect, column/row, …) as JSON.
flow enable-autostart [--ahk]Create the login shortcut in shell:startup (idempotent).
flow disable-autostartRemove the login shortcut (idempotent).

Browse them any time with flow --help, and the dispatch surface with flow dispatch help.

For the daemon’s internal handling of these — the named-pipe protocol, the foreground-lock grant, crash recovery — see IPC & Watchdog.

Config Reference

FlowWM splits configuration across two TOML files in your config directory (%USERPROFILE%\.config\flow\ by default):

FileControls
flow.tomlGeometry, padding, animation, borders, floating defaults, focus reconciliation.
flow-rules.tomlWindow classification — which apps tile, which float, which are ignored entirely.

Both files are optional — flow config init writes commented defaults, but you can delete either file (or run with an empty one) and the daemon falls back to compiled-in defaults. Your flow.toml may be partial: missing fields are filled in from FlowConfig::default().

This page is the user-facing reference: every field, its default, what it does. For the internals — config directory resolution, the 4-phase load lifecycle, hot-reload semantics, validate-before-apply — see Config & Persistence in the Developer Guide.

Editing and reloading

Edit either file in any text editor. To pick up changes without restarting the daemon:

flow config reload

The daemon reloads flow.toml and flow-rules.toml from disk, validates them before touching any state, and applies the live-reloadable fields. A broken file produces an error on your terminal and leaves the running daemon untouched — there is nothing to roll back.

To validate without loading:

flow config check

Live-reloadable vs. structural fields

Not every field can change at runtime. Reload applies live fields in place; structural fields require a daemon restart.

FieldReload effect
borders.* (color / thickness / overlap)Immediate recolor/resize of all border overlays.
padding.window_gapRe-projects every workspace’s pixel layout — windows animate to the new gaps.
columns_per_screenUpdates the cached threshold (no window movement; affects future auto-center).
column_width, min_column_width_px, min_window_height_pxUpdate cached bounds; existing per-column widths preserved.
animation.*New duration / easing / enabled flag applies to the next animation.
Workspace count (10), monitor geometryStructural — not reloadable. Restart the daemon.

What is preserved across reload: the user’s arranged layout (columns, rows, window order), focus, scroll viewport, monocle state, the daemon’s window registry, learned classification rules, and float positions.

Rules reload is non-fatal

If flow-rules.toml fails to load, the daemon keeps the current rules and warns on stderr; flow.toml (if valid) still reloads. This per-file-independent policy is intentional. Reloaded rules affect only newly opened windows — existing layout is never disrupted.


flow.toml

The application config. Schema: schemas/flow-config.schema.json. A hand-written, fully-commented example lives at default-config.toml in the repo root — flow config init copies it verbatim.

Single source of truth. Default values live in code, in the Default impls in src/config/types.rs. The default-config.toml example is enforced by a test (default_config_toml_matches_compiled_defaults) to deserialise to exactly FlowConfig::default(). If you ever wonder “what’s the real default?”, trust the table below over an outdated copy you find in the wild.

Top-level fields

FieldTypeDefaultDescription
columns_per_screenu324How many columns fit side-by-side on one monitor. The pixel width of each column is derived at runtime from this, the monitor resolution, and padding.window_gap.
column_widthOption<u32>NoneOptional fixed pixel width per column. When set, ignores columns_per_screen. Useful on ultrawide monitors where you want consistent column sizes regardless of monitor count.
min_column_width_pxu32640Floor for column width in pixels. Columns cannot be resized below this.
min_window_height_pxu32100Minimum row height in pixels — the floor for any single window’s allocated height inside a column. Bounds the maximum number of rows that can stack inside one column.
check_for_updatesbooltrueWhether flow start checks GitHub for a newer release and prints a one-line notification prompting flow update when one exists. Runs after the daemon is ready, is bounded by a short timeout, and never blocks startup. The explicit flow update --check command is unaffected by this flag.

Example:

columns_per_screen = 4
# column_width = 1280          # optional fixed width; ignores columns_per_screen
min_column_width_px = 640
min_window_height_px = 100
check_for_updates = true       # set false to silence the start-time update notification

[padding] — spacing

FieldTypeDefaultDescription
window_gapu3216Uniform gap (pixels) between all elements: window-to-window, window-to-screen-edge.
upu320Reserved space (pixels) above the tiling area.
downu320Reserved space (pixels) below the tiling area — e.g. for taskbar clearance.

Example:

[padding]
window_gap = 16
up = 0
down = 0

[animation] — layout transitions

FieldTypeDefaultDescription
enabledbooltrueMaster switch. false = layout changes snap instantly.
duration_msu32240Transition duration in milliseconds.
easingConfigEasing"ease-out-expo"Easing function name. See below for accepted values.

Accepted easing values (one of the ConfigEasing enum variants):

  • "linear"
  • "ease-in", "ease-out", "ease-in-out"
  • "ease-in-quad", "ease-out-quad", "ease-in-out-quad"
  • "ease-in-cubic", "ease-out-cubic", "ease-in-out-cubic"
  • "ease-in-expo", "ease-out-expo", "ease-in-out-expo"
  • "constant" (jumps to the end value immediately — equivalent to enabled = false for the motion itself, but still goes through the animator)

Example:

[animation]
enabled = true
duration_ms = 240
easing = "ease-out-expo"

[minimize_restore] — bringing minimized windows back

FieldTypeDefaultDescription
strategyMinimizeRestoreStrategy"original_slot"Where a minimized tiling window goes when restored. See below.

Accepted strategy values:

  • "original_slot" — Restore to the exact column/row it was minimized from.
  • "right_of_focused" — Restore into a new slot immediately to the right of the currently focused window.
  • "append_right" — Append to the rightmost column.

Example:

[minimize_restore]
strategy = "original_slot"

[borders] — window border overlays

Each managed window can get a thin colored ring drawn as a separate layered overlay window that follows the target’s geometry. This produces sharp komorebi/Hyprland-style borders that the default Windows drop-shadow cannot.

FieldTypeDefaultDescription
enabledbooltrueMaster switch. false = no overlay windows are created.
thicknessu323Border ring width in pixels, applied on all four sides.
overlapu321Pixels the ring overlaps the window content. 0 = ring sits entirely in the gap; thickness = ring fills the slot edge-to-edge (closes the DWM hairline).
focused_colorString"#00AAFF"Border color for the focused/active window. Hex #RRGGBB.
unfocused_colorString"#555555"Border color for tiled-but-not-focused windows. Hex #RRGGBB.
floating_colorString"#AA00FF"Border color for floating windows. Hex #RRGGBB.

Example:

[borders]
enabled = true
thickness = 3
overlap = 1
focused_color = "#00AAFF"
unfocused_color = "#555555"
floating_color = "#AA00FF"

[floating] — default floating window size

Both fields optional. When unset, the daemon falls back to a built-in policy: 60% × 80% of the monitor work area, capped at roughly 1536 × 1152 (QHD-derived) so ultrawide / 4K monitors don’t produce absurdly large popups. An explicit pixel value below is always respected as-is — the cap applies only to the fallback path.

FieldTypeDefaultDescription
default_widthOption<u32>NoneExplicit default width (pixels) for newly floated windows.
default_heightOption<u32>NoneExplicit default height (pixels) for newly floated windows.

Example:

[floating]
default_width = 1200
default_height = 800

[focus] — foreground reconciliation

EVENT_SYSTEM_FOREGROUND is a best-effort stream from Windows: under rapid window churn (e.g. a browser closing multiple tabs), the OS can settle the foreground without emitting the final event, leaving flow’s internal focus stuck on a stale window. The daemon closes that gap by periodically reconciling its tracked focus against the authoritative GetForegroundWindow() query.

FieldTypeDefaultDescription
foreground_sync_interval_msu32250Interval (milliseconds) between reconciliation passes. The common in-sync pass is a single microsecond-scale read that no-ops.

Example:

[focus]
foreground_sync_interval_ms = 250

flow-rules.toml

Window classification rules — which windows FlowWM should tile, float, or ignore entirely. Rules are evaluated top-to-bottom, first match wins. If no rule matches, default_action is used.

Schema: schemas/flow-rules.schema.json.

FlowWM ships a set of built-in defaults for common Windows system windows (taskbar, Search UI, system dialogs, Task Manager, on-screen keyboard, Chromium legacy windows, etc.). These are embedded into the binary at compile time — they cannot be deleted or corrupted at runtime — and your flow-rules.toml is checked first, so your rules always outrank the built-in ones. See default-flow-rules.toml in the repo root for the full list of what’s ignored by default.

Top-level fields

FieldTypeDefaultDescription
default_actionWindowAction"float"Action applied when no rule matches a new window.

Accepted default_action values:

  • "tile" — New windows enter the scrolling column layout.
  • "float" — New windows float freely (FlowWM’s default — most apps are written to float).
  • "ignore" — FlowWM pretends the window doesn’t exist. No border, no layout slot, no focus tracking.

[[rules]] — individual rules

Each rule is a table in the rules array. Every rule has a match clause (fields AND’ed together) and an action.

Match fields

All match fields are optional. Multiple fields on the same rule are combined with AND logic — all must match.

FieldTypeDescription
exeStringExecutable name to match (e.g. "chrome.exe"). Case-insensitive on the filename.
titleStringWindow title substring to match.
classStringWin32 window class name to match. Often the most reliable identifier — see note below.

Prefer class over exe + title for system helpers. The class is assigned at window creation, while the title may be set a few milliseconds later. Class matching is therefore race-free; title matching can miss a window classified at creation time before its title arrives. This is why the bundled defaults match Chrome_RenderWidgetHostHWND by class — a single rule covers every Chromium host (Chrome, Edge, VS Code, Electron apps…) regardless of exe name.

Action field

Each rule’s action is one of the WindowAction values ("tile" / "float" / "ignore"), same as default_action.

Example

#:schema ./schemas/flow-rules.schema.json

default_action = "float"

# Tile my terminal and editor unconditionally
[[rules]]
match = { exe = "WindowsTerminal.exe" }
action = "tile"

[[rules]]
match = { exe = "Code.exe" }
action = "tile"

# Float the Steam friends list
[[rules]]
match = { exe = "steamwebhelper.exe", title = "Friends List" }
action = "float"

# Ignore a misbehaving helper window from some app
[[rules]]
match = { class = "SomeHelperClass" }
action = "ignore"

How classification actually works (short version)

When a new window appears, FlowWM checks, in order:

  1. User rules — your flow-rules.toml, top-to-bottom, first match wins.
  2. Learned rules — machine-written to history-flow-rules.toml. Whenever you press Alt + T on a floating window (or float a tiled one), FlowWM records your choice and applies it automatically next time you open that app.
  3. Bundled defaultsdefault-flow-rules.toml, embedded in the binary.
  4. default_action — if nothing above matched.

You never edit history-flow-rules.toml directly; it’s managed by the daemon. To “forget” a learned choice, delete the file (the daemon recreates it empty on the next launch) or add an explicit rule for that app in flow-rules.toml — user rules always outrank learned rules. For the full algorithm, see Classification & Learned Rules in the Developer Guide.


Config directory resolution

The directory FlowWM reads for flow.toml and flow-rules.toml resolves in this priority order:

  1. --config <dir> flag on the CLI (highest priority)
  2. FLOW_CONFIG_DIR environment variable
  3. Default: %USERPROFILE%\.config\flow\

The logs/ folder (where the daemon writes its date-stamped log) lives inside the config directory, so overriding the config dir moves the logs too.

For the full lifecycle — init, load, validate, use, hot-reload — see Config & Persistence in the Developer Guide.

Contributing & Reporting Issues

Contributions are welcome — bug reports, feature ideas, and pull requests all help. This page is the user-facing short version. The canonical contributor reference is the repo’s CONTRIBUTING.md, and the architecture is documented in the Developer Guide.

Reporting bugs

A good bug report is reproducible. Please include:

  • FlowWM versionflow --version.

  • Windows version (e.g. Windows 11 23H2).

  • A log file. FlowWM writes a date-stamped log by default:

    %USERPROFILE%\.config\flow\logs\flowd-YYYY-MM-DD.log

    (one file per day, appended across restarts).

    The cleanest way to capture a bug is to reproduce it with an isolated log:

    flow start --log-file <path>
    

    This redirects all logging to <path> and starts the file empty on each launch, so the file contains only that run. Attach that file to the issue.

  • Config files if relevant (flow.toml, flow-rules.toml) — but redact any personal or sensitive content first.

Raising the log level

To capture more detail, raise the log level by setting RUST_LOG before starting (default is debug in debug builds, info in release):

:: Windows cmd
set RUST_LOG=trace && flow start --log-file <path>
# PowerShell
$env:RUST_LOG='trace'; flow start --log-file <path>

A note on config location

The config directory resolves in this priority order: --config <dir> flag → FLOW_CONFIG_DIR env var → default %USERPROFILE%\.config\flow\. If you override it, the logs/ folder moves with it — mention your config dir in the report.

Where to file: open an issue at https://github.com/CCpcalvin/flow-wm/issues.

Requesting features

Feature requests are welcome too. The roadmap lives at Roadmap — check there first to see whether your idea is already planned or explicitly out of scope. If it isn’t, open an issue and describe the workflow you’re trying to enable, not just the implementation you imagine. “Here’s the friction I hit” is more useful than “add this flag”.

Pull requests

Before opening a PR, make sure the basics are green:

cargo fmt
cargo clippy --all-targets
cargo test

A few project-wide conventions:

  • Document public items. #![warn(missing_docs)] is enabled in src/lib.rs, so every public item needs at least a one-line /// doc comment.
  • Config changes must update two places. Default values live in the Default impls in src/config/types.rs (the single source of truth). If you add or change a field, also update default-config.toml — the default_config_toml_matches_compiled_defaults test enforces that they stay in sync.
  • Adding dependencies: use cargo add <crate> rather than hand-editing Cargo.toml, so versions and the lockfile stay consistent.

For the full conventions (including the 3-layer documentation model and contributor gates), see CONTRIBUTING.md in the repo root.

Understanding the codebase

For architecture, threading, the layout engine, and subsystem write-ups, see the Developer Guide. Build it locally:

mdbook build docs/

The Building from Source page covers prerequisites, MSVC setup, and the contributor gates in detail.

Developer Guide

This guide is the canonical narrative reference for flow. It explains why the system is shaped the way it is, how the pieces fit together, and where to look in the source for each concern.

Suggested Reading Order

If you haven’t built FlowWM yet, start with Building from Source.

  1. Architecture — the subsystem map and the single-orchestrator model. Start here.
  2. Threading Model — why flow uses no Arc<Mutex> and how the hook thread talks to the IPC thread.
  3. Event Pipelines — the two flows that drive every layout change: Win32 hooks and IPC commands.
  4. Layout Overview — the infinite canvas, the camera model, and the Virtual → Actual split. The heart of flow.
  5. Mutation Pipeline — how every command flows through mutate → project → animate.
  6. The rest in any order: Workspace, Window Registry, Animation, IPC & Watchdog, Config & Persistence.

Reference

  • Design Decisions — consolidated “why not X” trade-offs (single package, pixel widths, TOML, etc.).
  • Roadmap — what’s implemented, what’s stubbed, what’s next.

Repository Layout

flow/
├── Cargo.toml
├── build.rs
├── default-config.toml              # hand-written EXAMPLE (not read at runtime)
├── default-flow-rules.toml           # bundled default window rules (include_str!)
├── docs/                            # this mdBook
├── schemas/                         # generated JSON schemas for config/rules
├── src/
│   ├── main.rs                      # flowd entry point
│   ├── lib.rs                       # shared library
│   ├── bin/
│   │   └── flow.rs                   # CLI client
│   ├── common/                      # shared types: Rect, WindowId, Direction, errors
│   ├── config/                      # TOML loading, schema gen, dirs, lifecycle
│   ├── registry/                    # WindowRegistry: OS sync, classification, hooks
│   ├── layout/                      # PURE layout math: types, mutations, projection
│   ├── workspace/                   # Monitor → Workspace → Scrolling/Floating spaces
│   ├── animation/                   # embedded window-animation crate
│   ├── ipc/                         # named-pipe transport + SocketMessage types
│   └── daemon/                      # FlowWM orchestrator + submodules
└── tests/                           # integration tests

The two big ideas to internalise before reading any source file:

  1. The layout pipeline is pure. src/layout/ has zero Win32 dependencies and is fully unit-testable on any platform. src/workspace/scrolling_space.rs orchestrates mutate → project → animate and is the only thing that touches all three layers.
  2. The daemon is the single coordinator. FlowWM owns every subsystem and routes events between them. No subsystem knows about any other subsystem — they only expose methods that take inputs and return outputs.

Building from Source

FlowWM is a native Windows binary built with the MSVC toolchain. This page is for contributors, or anyone who wants to build locally instead of installing from crates.io with cargo install flow-wm.

If you only want to use FlowWM, the high-level overview lives in the User Guide. This page is the build detail.

Prerequisites

  • Rust (stable, MSVC toolchain) — install via rustup.
  • Visual Studio Build Tools — the “Desktop development with C++” workload. FlowWM links against Win32, so the MSVC linker and the Windows SDK are required. The MinGW / GNU toolchains will not work.

Set the MSVC target as your default so every cargo invocation uses it:

rustup default stable-x86_64-pc-windows-msvc

Clone and Build

git clone https://github.com/CCpcalvin/flow-wm.git
cd flow-wm
cargo build               # debug build
cargo build --release     # optimised, stripped binaries

The two binaries — flowd.exe (daemon) and flow.exe (CLI client) — are written to target\debug\ or target\release\.

Putting flow on your PATH

flow config init and the other commands assume flow.exe is reachable from your shell. Either copy the built binaries into a directory that is already on your PATH, or add the release folder to PATH:

# Add the release folder to PATH for the current PowerShell session:
$env:PATH += ";$PWD\target\release"

# (Or, for a permanent user-scoped PATH update, use:
#  [Environment]::SetEnvironmentVariable("PATH", $env:PATH, "User") )

First Run

flow config init          # writes default config to %USERPROFILE%\.config\flow\
flow config init --ahk    # also writes flow.ahk — a ready-to-use AutoHotkey v2 script
flow start                # launches the daemon in the background

The default config directory follows the XDG-style convention %USERPROFILE%\.config\flow\ (so it reads as ~/.config/flow/ on a Linux mental model). You can override it with --config <dir> on flow start or the FLOW_CONFIG_DIR environment variable. See Config & Persistence for the full resolution chain.

Once the daemon is running, start your hotkey daemon (flow start --ahk, or flow enable-autostart --ahk to auto-launch both at login) and you’re tiling. See the User Guide for everyday usage.

Contributor Gates

Before sending a patch, make sure the usual gates pass:

cargo test                       # unit + integration tests
cargo clippy -- -D warnings      # lint (warnings are errors)
cargo fmt --check                # formatting check

Documentation builds:

mdbook build docs                                  # the mdBook you are reading

When adding or changing a config field, update both the Default impl in src/config/types.rs and the example file default-config.toml in the repo root — a test (default_config_toml_matches_compiled_defaults) enforces they stay in sync. See Config & Persistence and Design Decisions for why code is the single source of truth for defaults.

Architecture

FlowWM (flow) is a tiling window manager for Windows built around an infinite-horizontal-canvas layout model. Windows occupy columns on a virtual canvas wider than any single monitor; the viewport slides left and right to bring them into view. The entire system is a single Cargo package containing two binaries that share one library crate, coordinated by a top-level orchestrator struct called FlowWM.

The two binaries — flowd (the background daemon that owns all state) and flow (the CLI client) — share the library crate rooted at src/lib.rs. The daemon entry point is src/main.rs; the CLI lives in src/bin/flow.rs.

Subsystem Map

Everything runs inside the flowd process. Two inputs feed events into one orchestrator — the FlowWM main loop — which routes each event to the right subsystem. No subsystem knows about any other; the orchestrator is the only glue. The flow CLI is an external binary that shares the library’s IPC types but holds no application state.

flowchart TB
    subgraph inputs["Inputs — where events enter the daemon"]
        IPC["IPC Server<br/>flow CLI → Windows named pipe<br/>src/ipc"]
        Hook["Win32 Hook Thread<br/>background thread:<br/>SetWinEventHook → HookEvent<br/>src/registry/hooks.rs"]
    end

    Daemon["FlowWM (orchestrator)<br/>main loop routes every event<br/>src/daemon"]

    subgraph core["Subsystems — all owned by FlowWM"]
        Registry["WindowRegistry<br/>window state"]
        Workspace["Workspace Hierarchy<br/>Monitor → Workspace →<br/>ScrollingSpace + FloatingSpace"]
        Layout["Layout Math (pure)<br/>mutation + projection"]
        Animator["WindowAnimator<br/>SetWindowPos tweens"]
        Config["Config + HistoryStore"]
    end

    IPC  -- "SocketMessage" --> Daemon
    Hook -- "HookEvent (mpsc)" --> Daemon
    Daemon --> Registry
    Daemon --> Workspace
    Daemon --> Animator
    Daemon --> Config
    Registry -- "WindowId lookup" --> Workspace
    Workspace -. "delegates pure math" .-> Layout
    Workspace -- "AppliedLayout" --> Animator

Read the diagram top-down: an event comes in from either input, the orchestrator decides which subsystem(s) to touch, the registry feeds WindowIds into the workspace, the workspace computes a layout using the pure math in src/layout, and the resulting AppliedLayout becomes the animator’s move targets.

Inputs

  • IPC Server (src/ipc) — accepts commands from the flow CLI over a Windows named pipe, parses each SocketMessage frame, and hands it to the orchestrator’s dispatch().
  • Win32 Hook Thread (src/registry/hooks.rs) — a background thread that subscribes to SetWinEventHook for window create / destroy / focus / minimize / show / location events. It never touches daemon state: it only pushes lightweight HookEvent structs through an mpsc channel that the main loop drains. See Threading Model.

Core subsystems

  • WindowRegistry (src/registry) — the authoritative source of truth for every tracked window: HWNDWindowId, title, class, the tile / float / ignore classification, and recovery state. Hook events that create or destroy a window update this first; the resulting WindowId is what every other subsystem refers to. See Window Registry.
  • Workspace Hierarchy (src/workspace) — the nested layout state: Vec<Monitor>Vec<Workspace> → one ScrollingSpace + one FloatingSpace per workspace. ScrollingSpace is the tiled half — the same thing that used to be called the LayoutEngine; it owns the list of columns and delegates the actual math to src/layout. FloatingSpace keeps non-tiled windows at literal pixel coordinates. The orchestrator always operates on the active monitor’s active workspace. See Workspace Hierarchy.
  • Layout Math (src/layout) — pure mutation and projection functions with zero Win32 dependencies. ScrollingSpace calls these to mutate the virtual layout and project it into concrete pixel rects (AppliedLayout). Being pure means it is fully unit-testable on any platform. See Layout Overview.
  • WindowAnimator (src/animation) — takes an AppliedLayout (a target rect per window) and animates each window from its current on-screen rect to that target via SetWindowPos tweens paced by DwmFlush. See Animation.

Supporting subsystems

  • Config (src/config) — loads flow.toml into FlowConfig, applies defaults (code is the single source of truth), and derives layout parameters at startup. See Config & Persistence.
  • HistoryStore (src/config/history.rs) — persists the user’s explicit set-window decisions (learned rules) to history-flow-rules.toml so the next window of the same app is classified automatically. See Classification & Learned Rules. (A separate recovery-snapshot persist module is planned but not yet implemented — see the Roadmap.)

Ownership Model

FlowWM owns every subsystem and routes events between them. This is the most important structural invariant in the codebase: no subsystem knows about any other subsystem. They only expose methods that take inputs and return outputs. The daemon is the glue.

classDiagram
    class FlowWM {
        +registry: WindowRegistry
        +monitors: Vec~Monitor~
        +active_monitor: usize
        +animator: WindowAnimator
        +server: PipeServer
        +config: FlowConfig
        +hook_receiver: Receiver~HookEvent~
        +shutting_down: bool
        +active_scrolling() ScrollingSpace
        +active_scrolling_mut() ScrollingSpace
        +active_workspace() Workspace
        +animate_layout()
    }

    class Monitor {
        +workspaces: Vec~Workspace~
        +active_workspace_index: usize
        +active_workspace() Workspace
        +active_scrolling() ScrollingSpace
    }

    class Workspace {
        +id: WorkspaceId
        +scrolling: ScrollingSpace
        +floating: FloatingSpace
    }

    class WindowRegistry {
        +windows: HashMap~HWND, Window~
        +handle_created(hwnd) Option~WindowId~
        +handle_destroyed(hwnd)
    }

    class WindowAnimator {
        +animate(layout)
    }

    FlowWM "1" --> "1..*" Monitor
    Monitor "1" --> "1..*" Workspace
    Workspace "1" --> "1" ScrollingSpace
    Workspace "1" --> "1" FloatingSpace
    FlowWM "1" --> "1" WindowRegistry
    FlowWM "1" --> "1" WindowAnimator

The daemon accesses the active scrolling space through accessor chains: self.active_scrolling() delegates to self.monitors[active_monitor] .active_workspace().scrolling. This indirection exists so that multi-monitor and multi-workspace support can be added later without changing the call sites in hook handlers and IPC dispatch — they always operate on “the active” scrolling space.

The Layout Pipeline

Layout computation follows a three-stage pipeline that runs whenever any event changes the tiling state:

flowchart LR
    A["Mutate<br/>(scrolling_space)"] --> B["Project<br/>(layout::projection)"]
    B --> C["Animate<br/>(WindowAnimator)"]
  1. Mutate — a method on ScrollingSpace mutates the virtual layout (add a window, swap columns, scroll the viewport, resize a column). This produces a LayoutDiff describing what changed.
  2. Project — a pure function in src/layout/projection.rs converts the full VirtualLayout into an ActualLayout with concrete pixel coordinates. Padding is applied here. Off-screen windows are “parked” at large negative/positive X offsets.
  3. Animate — the WindowAnimator compares each window’s target rect (from projection) against its current on-screen rect and issues smooth SetWindowPos transitions.

The layout math in src/layout/ is entirely pure — it has zero Win32 dependencies and can be unit-tested on any platform. ScrollingSpace in src/workspace/ orchestrates the three stages and is the only thing that touches all three layers.

For the full explanation, see Layout Overview and Mutation Pipeline.

Repository Layout

The repository layout is covered in the Developer Guide introduction. The two big ideas to internalise before reading any source file:

  1. The layout pipeline is pure. src/layout/ has zero Win32 dependencies and is fully unit-testable on any platform. src/workspace/scrolling_space.rs is the orchestrator that consumes it.
  2. The daemon is the single coordinator. FlowWM owns every subsystem and routes events between them. No subsystem knows about any other subsystem.

Threading Model

The flowd daemon uses exactly two threads and zero runtime locks. The hook thread runs Win32 event hooks in the background; the IPC thread owns all application state and processes both hook events and IPC commands. There is no Arc<Mutex<T>> anywhere in the daemon — the borrow checker enforces exclusive access at compile time, which is strictly safer than Mutex (runtime-only enforcement, potential deadlocks).

The Two Threads

sequenceDiagram
    participant OS as Windows OS
    participant Hook as Hook Thread
    participant Chan as mpsc Channel
    participant IPC as IPC Thread (main)
    participant Subsystems as Registry / ScrollingSpace / Animator

    Note over Hook: SetWinEventHook x6<br/>GetMessageW loop
    Note over IPC: owns FlowWM<br/>(all fields)

    OS->>Hook: WinEvent callback
    Hook->>Chan: sender.send(HookEvent)
    Hook->>IPC: SetEvent(hook_signal)
    IPC->>IPC: WaitForMultipleObjects wakes
    IPC->>IPC: ResetEvent(hook_signal)
    IPC->>Chan: try_recv() loop (drain all)
    IPC->>Subsystems: process_hook_events() via &mut self

    Note over IPC: ... later ...

    participant CLI as flow CLI
    CLI->>IPC: ConnectNamedPipe (background thread)
    Note over IPC: connected_event signaled
    IPC->>IPC: read_message() (blocking)
    IPC->>Subsystems: dispatch(msg) via &mut self
    IPC->>CLI: write_response()

Hook Thread (background)

  • Registers six SetWinEventHook callbacks covering window lifecycle (create, destroy), focus changes, minimize/restore, show/hide, state changes, and name changes. EVENT_OBJECT_LOCATIONCHANGE is deliberately excluded — it fires on every pixel of movement and would flood the channel.
  • Runs a GetMessageW message loop (required for WINEVENT_OUTOFCONTEXT).
  • On each callback: sends a typed HookEvent through the mpsc::Sender and signals a Win32 manual-reset event (SetEvent) to wake the IPC thread.
  • Never touches any daemon state. It does not hold references to FlowWM, the registry, or the layout. Its entire output is the HookEvent enum and a signal.
  • Cleaned up by dropping the HookThreadHandle, which posts WM_QUIT to the hook thread’s message loop. All hooks are unregistered before the thread exits.

IPC Thread (main)

  • Owns FlowWM and all subsystems directly — no Arc, no Mutex.
  • Runs the main event loop via WaitForMultipleObjects, waiting on two handles:
    1. hook_signal (index 0, highest priority) — wakes when any HookEvent is sent.
    2. connected_event (index 1) — wakes when a CLI client connects.
  • Calls process_hook_events() to drain the channel, then processes any IPC session if a client connected.
  • All subsystem methods are called with &mut self — the borrow checker guarantees exclusive access at compile time.

Why No Arc<Mutex>

The previous architecture (before FlowWM was introduced) used Arc<Mutex<WindowRegistry>> because the IPC loop and hook events were consumed by different parts of the code with no single coordination point. This had several problems:

  1. Runtime-only safety. Mutex only catches data races at runtime. A missed lock or a lock held too long causes undefined behavior or deadlocks — errors the compiler cannot see.
  2. No ownership clarity. When every subsystem is behind an Arc<Mutex>, it is unclear who is responsible for routing events between them.
  3. Deadlock risk. Nested locking (lock A, then B in one code path; B then A in another) is easy to introduce and hard to detect.

With FlowWM owning everything on one thread, all subsystem methods take &mut self:

  • The borrow checker rejects any code that would access a subsystem while another part of the code holds a mutable reference to it.
  • There is no possibility of deadlock because there is no locking.
  • Event routing is centralized — the daemon is the only place that calls subsystem methods, so the flow is always visible in one place.

The trade-off is that hook events are not processed instantly in the callback — they are dispatched asynchronously through the channel and drained on the next loop iteration. In practice, the signal event wakes WaitForMultipleObjects within microseconds, so the latency is imperceptible.

The Main Event Loop

The run() method in src/daemon/run.rs implements the event-driven loop:

flowchart TB
    Start["start_accept()"] --> Loop["WaitForMultipleObjects"]
    Loop -->|"hook_signal<br/>or timeout"| Reset["ResetEvent(hook_signal)"]
    Reset --> Drain["process_hook_events()"]
    Drain --> CheckIPC{"connected_event<br/>signaled?"}
    CheckIPC -->|"yes"| InnerLoop["IPC session loop"]
    CheckIPC -->|"no"| Loop
    InnerLoop --> PreRead["process_hook_events()"]
    PreRead --> Read["read_message()"]
    Read --> Dispatch["dispatch(msg)"]
    Dispatch --> Write["write_response()"]
    Write -->|"shutting_down?"| Exit["return"]
    Write -->|"more messages"| PreRead
    Write -->|"disconnect"| Disconnect["disconnect()"]
    Disconnect --> Accept["start_accept()"]
    Accept --> Loop

Key details:

  • Race-free drain. ResetEvent is called before try_recv(). Any event pushed between the reset and the drain is caught by try_recv. Any event pushed after the drain sets the signal again, so the next WaitForMultipleObjects wakes immediately. No events are lost.
  • Pending-creation retries. EVENT_OBJECT_CREATE fires before windows are fully initialized (not yet visible, no title, styles not finalized). Windows that fail classification are added to pending_creations and retried on each process_hook_events() call. When this list is non-empty, the wait timeout is 100 ms instead of infinite, ensuring retries happen even without new events.
  • IPC is one-shot. The CLI connects, sends one command, reads the response, and disconnects. The inner loop rarely runs more than once.
  • Hook events are drained between IPC reads. Inside the IPC inner loop, process_hook_events() runs before every read_message(), so layout changes from hook events are never delayed by an active IPC session.

Hook Registration

Six hooks are registered as four ranges and two single-event hooks. The single- event registration for STATECHANGE and NAMECHANGE is deliberate — there is a noisy event (EVENT_OBJECT_LOCATIONCHANGE at 0x800B) between them that would flood the channel if registered as a range.

HookEvent RangePurpose
Create / Destroy0x80000x8001Window lifecycle
Foreground0x0003Focus changes
Minimize / Restore0x00160x0017Minimize and restore
Show / Hide0x80020x8003Tray-hide, DWM cloak, re-show
State Change0x800A (single)Maximized/fullscreen recovery
Name Change0x800C (single)Late-title recovery

The recovery hooks (STATECHANGE and NAMECHANGE) handle windows that EVENT_OBJECT_CREATE misses — see Event Pipelines for the full recovery flows.

Event Pipelines

Every layout change in flow is driven by one of two flows: a Win32 hook event (from the OS reacting to window lifecycle changes) or an IPC command (from the flow CLI). Both flows converge on the same three-stage pipeline: mutate the virtual layout, project to actual pixel coordinates, and animate the result. This chapter traces each flow end-to-end and documents the per-event behavior of the hook handlers.

The Win32 Hook Pipeline

When the OS creates, destroys, minimizes, or focuses a window, the event travels through the hook thread’s mpsc channel and into the daemon’s event loop.

sequenceDiagram
    participant OS as Windows OS
    participant Hook as Hook Thread
    participant Chan as mpsc Channel
    participant flow as FlowWM
    participant Reg as WindowRegistry
    participant Layout as ScrollingSpace
    participant Anim as WindowAnimator

    OS->>Hook: EVENT_OBJECT_CREATE
    Hook->>Chan: HookEvent::Created { hwnd }
    Hook->>flow: SetEvent(hook_signal)
    flow->>Chan: try_recv() drain
    flow->>flow: on_window_created(hwnd)
    flow->>Reg: handle_created(hwnd)
    Reg-->>flow: Some(WindowId) or None
    alt Classified as tiling
        flow->>Layout: insert_window(window_id)
        Layout-->>flow: AppliedLayout
        flow->>Anim: animate_layout(applied)
    else Classified as floating
        flow->>FS: register_float(focused, centered_rect)
        FS-->>flow: ActualLayout (floats)
        flow->>Anim: animate_workspaces([(float_actual, 0)])
    else Ignored / Not ready
        Note over flow: No layout action
    end

The full pipeline for a created window:

  1. The hook callback sends HookEvent::Created { hwnd } and signals the main thread.
  2. on_window_created() calls registry.handle_created(hwnd), which runs the full classification pipeline: visibility check, title check, Alt-Tab visibility, owner check, rule evaluation. Returns Some(WindowId) if the window is classified as tiling; None otherwise (the handler then inspects the registry to distinguish a fresh float from ignored / not-ready).
  3. If tiling, ScrollingSpace::insert_window() places the new column immediately after the focused window, shifts right-side columns, and moves focus to the new window.
  4. If freshly classified as floating, the handler completes the same float setup an explicit set-window float performs — centered_rectFloatingSpace::add → registry state Floating(Active { rect })add_float_hwnd tracking → animate_workspaces → border attach — so a rule-classified float is indistinguishable from a toggled one. See Floating Space.
  5. animate_layout() / animate_workspaces() convert the result into animation targets and submit them to the WindowAnimator.
  6. reconcile_new_window_focus() pushes the OS foreground to the new window, syncs the registry focus, and refreshes borders (see Foreground on creation below).

If classification fails (window not yet visible or titled), the hwnd is added to pending_creations and retried on subsequent loop iterations until it passes or the retry budget is exhausted.

Foreground on creation

The layout engine has already moved focus to the new window by step 3, but that is flow-internal — the OS foreground (the window that receives keyboard input) is a separate quantity owned by Windows. reconcile_new_window_focus() pushes the two back into agreement.

For a normally-launched app the OS has already foregrounded it (a launch is a user activation, which grants foreground permission), so the push is a no-op: a cheap GetForegroundWindow check sees the window is already foreground and skips the SetForegroundWindow call. The registry focus is then synced from that authoritative query, which is what fixes the late-titled-app case (Windows Terminal, recovered via NAMECHANGE/SHOW) where EVENT_SYSTEM_FOREGROUND fired before the window was tracked.

The push matters for apps launched without a user-visible activation — the canonical case being an AutoHotkey launcher such as Run("chrome.exe …", , "Hide"). The Hide option starts the process with a hidden window, so the OS never grants Chrome foreground permission; when Chrome later shows itself its own SetForegroundWindow is denied by the foreground lock, and it appears with a border but no keyboard focus. set_foreground_window() defeats the lock via the AttachThreadInput trick, making the new window the real foreground regardless of how it was launched. The resulting EVENT_SYSTEM_FOREGROUND is re-consumed by on_focus_changed(), which is idempotent on this path (same workspace, and the border refresh short-circuits on an unchanged style).

Foreground reconciliation (echo backstop)

EVENT_SYSTEM_FOREGROUND is a best-effort stream, not a complete ledger. Microsoft guarantees in-order delivery but explicitly warns that events “fired very rapidly” can be dropped or degraded — exactly what happens during a close cascade (e.g. a browser tearing down multiple tabs). The OS can settle the foreground onto its final window without emitting the final EVENT_SYSTEM_FOREGROUND, leaving flow’s tracked focus (border + layout cursor) stranded on the previously-echoed window while the real keyboard foreground has already moved on. The visible symptom: the border sits on a sibling window while keystrokes land elsewhere.

Rather than fight the OS, flow treats the foreground query as the tie-breaker. The main loop folds a third deadline — last_foreground_sync + config.focus.foreground_sync_interval_ms (default 250 ms) — into its MsgWaitForMultipleObjects timeout, so it never sleeps past the next reconciliation pass. On every wake, reconcile_foreground():

  1. Queries the authoritative GetForegroundWindow() (a single microsecond-scale read).
  2. No-ops when the tracked focus already matches reality — the common case.
  3. Otherwise, if the real foreground is a tracked window in the active workspace, re-runs the on_focus_changed() path to correct the border + layout cursor.

The active-workspace restriction is deliberate: a background poll should never trigger a workspace switch. A genuine cross-workspace focus change still arrives promptly via EVENT_SYSTEM_FOREGROUND, which remains the primary driver; the poll is only a backstop for the cases where that event is dropped. The interval is hot-reloadable via flow.toml’s [focus] section — lower values catch drift sooner at the cost of more wakeups, higher values the reverse.

The IPC Command Pipeline

The flow CLI connects to the daemon’s named pipe, sends a JSON command, and reads the response.

sequenceDiagram
    participant CLI as flow CLI
    participant Pipe as PipeServer
    participant flow as FlowWM
    participant Reg as WindowRegistry
    participant Layout as ScrollingSpace
    participant Anim as WindowAnimator
    participant Win32 as Win32 API

    CLI->>Pipe: ConnectNamedPipe (background thread)
    Pipe->>flow: SetEvent(connected_event)
    flow->>Pipe: read_message()
    Note over CLI,Pipe: SocketMessage e.g. FocusLeft
    flow->>flow: dispatch(msg)
    Note over flow: Tiled-only ops first classify<br/>the OS-foreground window
    flow->>Reg: focused()
    Reg-->>flow: Option<WindowId>
    flow->>Reg: get_window(hwnd)
    Reg-->>flow: Option<&Window>
    alt foreground is Tiling(Active)
        flow->>Layout: active_scrolling_mut().focus(id, Left)
        Layout-->>flow: Some((WindowId, Option<LayoutDiff>))
        flow->>Win32: SetForegroundWindow(hwnd)
        flow->>Reg: set_focused(hwnd)
        flow->>Anim: animate_layout(diff)
    else foreground is float / ignored / absent
        Note over flow: log::debug! + return SocketResponse::Ok<br/>(silent no-op, not an error)
    end
    flow->>Pipe: write_response(Ok)
    Pipe->>CLI: SocketResponse
    CLI->>Pipe: disconnect

Every IPC command follows the same pattern:

  1. dispatch() matches on the SocketMessage variant and calls the appropriate subsystem method.
  2. Tiled-only ops (focus / swap-window / swap-column / merge / promote / expand / shrink / set-column-width / toggle-monocle / center) first resolve the OS foreground via registry.focused(), look up the window, and verify matches!(state, WindowState::Tiling(TilingState::Active { .. })). If the foreground is a float, ignored, minimized, hidden, or absent, the handler logs at debug! and returns SocketResponse::Ok silently — a deliberate no-op, not an error. This prevents the bug where a float foreground caused these ops to act on the stale last_focused_window tile cursor. See Floating Space / Focus Model.
  3. Layout commands call active_scrolling_mut() to reach the ScrollingSpace, mutate the virtual layout, and receive an AppliedLayout or Option<LayoutDiff>. The target WindowId is now passed explicitly from the dispatch layer rather than re-resolved inside ScrollingSpace from last_focused_window.
  4. Some commands require OS-level side effects (e.g. FocusLeft calls SetForegroundWindow and syncs the registry’s focus tracking).
  5. animate_layout() converts the result to animation targets.
  6. A SocketResponse is written back to the CLI.

Commands that close windows (CloseWindow) take a different path — they only queue WM_CLOSE and let the EVENT_OBJECT_DESTROY hook handle the actual layout removal, avoiding a race between the synchronous IPC response and the asynchronous window destruction.

The animate_layout Bridge

animate_layout() in src/daemon/animation.rs is the conversion point between the layout engine’s output and the animation system’s input:

Layout typeAnimation typeNotes
WindowId(isize)WindowRef(isize)Direct passthrough
Rect { x, y, width, height } positionIVec2::new(x, y)Visible rect translated to window rect
Rect { x, y, width, height } sizeIVec2::new(width, height)Visible rect translated to window rect

The layout engine produces visible rects (content area coordinates), but SetWindowPos uses window rects (which include invisible borders like shadows and resize hit-test areas). The bridge translates each entry using the window’s stored InvisibleBounds so that windows land exactly where the layout engine intended.

All windows in the actual layout are submitted as targets (not just “changed” ones). The animator compares each target against the window’s current on-screen position and drops no-ops. This ensures windows mid-flight from a previous interrupted animation are correctly retargeted.

The bridge also syncs the registry’s tiling state (column/row indices and tiled rects) from the new layout on every call, even when no pixel-level movement occurred — a logical swap can change a window’s position without triggering an animation if the columns are the same width.

Per-Event Behavior

Created (EVENT_OBJECT_CREATE)

ConditionAction
Not visible / no title / not Alt-Tab visibleDefer to pending_creations, retry later
Owned by another window (tooltip, etc.)Skip
Already tracked (de-dup)Skip
Matches an ignore rule (maximized, fullscreen, explicit)Register as Ignored, no layout
Classified as floatingRegister as Floating, add to active FloatingSpace (centered), animate, attach border
Classified as tilingRegister as Tiling::Active, insert_window() next to focused, animate

After either managed branch, reconcile_new_window_focus() actively pushes the OS foreground to the new window (no-op when the OS already foregrounded it) and syncs the registry focus. See Foreground on creation above.

Destroyed (EVENT_OBJECT_DESTROY)

ConditionAction
Not trackedSkip
Was tiling (active or minimized)remove_window() with focus successor resolution, remove_from_layout_and_registry(), animate
Was floating or ignoredregistry.remove_window() only

The successor window is chosen by next_available_window: prefer a sibling in the same column (the window directly below, else the one above), then the left column, then the right. If the removed window was focused, the successor gets OS-level foreground via SetForegroundWindow and registry focus tracking is synced.

Minimized (EVENT_SYSTEM_MINIMIZESTART)

ConditionAction
Not trackedSkip
Was Tiling::ActiveRegistry updates to Tiling::Minimized, remove_from_layout_and_refocus(), animate
Was Tiling::Minimized or not tilingRegistry state update only, no layout change

Restored (EVENT_SYSTEM_MINIMIZEEND)

ConditionAction
Not trackedSkip
Now Tiling::Activeadd_window() (appends at far right), animate
Not tilingRegistry state update only

Restored windows are appended at the far right of the canvas rather than inserted at the old position. This avoids the complexity of tracking and restoring saved virtual slots for all cases (minimize + destroy interleaving).

Foreground (EVENT_SYSTEM_FOREGROUND)

ConditionAction
Any tracked windowregistry.set_focused(hwnd) + layout.set_focus(WindowId) if tiling

Focus changes do not produce an AppliedLayout — they only update internal focus tracking. The next layout mutation uses the correct focus.

Because EVENT_SYSTEM_FOREGROUND is a best-effort stream that can drop the final settle event under rapid churn, this handler is backstopped by the periodic reconcile_foreground() poll — see Foreground reconciliation (echo backstop) above.

Hidden / Shown (EVENT_OBJECT_HIDE / EVENT_OBJECT_SHOW)

These events handle tray-hide (“close to system tray”) and DWM-cloak behaviors that EVENT_SYSTEM_MINIMIZESTART/END do not cover.

Hidden: If the window was Tiling::Active and the state actually changed to Hidden (via reconcile_visibility), the window is removed from layout with focus successor resolution — identical to the minimize path. The is_tiling_active check (not is_tiling) prevents double-removal since Win32 fires both MINIMIZESTART and HIDE for ordinary minimizes.

Shown: Two paths:

  1. Recovery — if the window is not yet tracked (created invisible, e.g. wt -p PowerShell), re-run the full creation pipeline. By this point the window is visible and titled, so classification succeeds.
  2. Re-show — if the window was Hidden and now transitions to Shown, re-add to layout via add_window().

State Change (EVENT_OBJECT_STATECHANGE)

Handles recovery for windows that launched maximized or fullscreen. When a tracked Ignored(Maximized|Fullscreen) window has its WS_MAXIMIZE bit cleared (the user restores it), reclassify_os_state() re-runs the classifier. If the window now qualifies as tiling, it is appended to the layout via add_window().

The handler only acts on windows currently in Ignored(Maximized|Fullscreen) state, so the common case (no recoverable windows) costs a single HashMap lookup.

Name Change (EVENT_OBJECT_NAMECHANGE)

Handles recovery for windows whose title arrives asynchronously (most notably Windows Terminal). If the window is not already tracked, the full creation pipeline is re-attempted — by this point the title is set, so the title-empty gate passes. Already-tracked windows are ignored to avoid layout churn on every title update (e.g. terminal prompt changes).

Layout Overview

The layout engine models a tiling window manager as an infinite horizontal canvas that the user scrolls through. Columns of stacked windows extend left and right without bound; a sliding camera determines which slice of that canvas is visible on screen. The entire layout module (src/layout/) is pure Rust with zero Win32 dependencies, making every operation fully unit-testable on any platform.

Two-layer model

The system splits cleanly into a virtual layer and an actual layer. The virtual layer describes what exists on the canvas — columns, their pixel widths, and a camera offset — but stores no absolute x-coordinates for any window. The actual layer is the projected output: concrete pixel rectangles that Windows OS renders. This separation keeps mutations simple (they operate on the virtual canvas, never on screen coordinates) and makes projection a single deterministic function.

classDiagram
    class VirtualLayout {
        +Vec~Column~ columns
        +i32 viewport_offset
        +find_window(id) Option~(usize, usize)~
        +window_count() usize
    }

    class Column {
        +i32 width_px
        +Vec~WindowId~ rows
        +new(width_px, window) Column
        +is_valid_width() bool
    }

    class ActualLayout {
        +Vec~ActualEntry~ entries
        +find(id) Option~ActualEntry~
    }

    class ActualEntry {
        +WindowId window_id
        +Rect rect
    }

    class AppliedLayout {
        +VirtualLayout virtual_layout
        +ActualLayout actual_layout
    }

    class MonitorInfo {
        +Rect work_area
    }

    class Padding {
        +i32 window_gap
        +i32 up
        +i32 down
    }

    VirtualLayout "1" *-- "*" Column : contains
    VirtualLayout --> ActualLayout : projected by project()
    ActualLayout "1" *-- "*" ActualEntry : contains
    AppliedLayout --> VirtualLayout : wraps
    AppliedLayout --> ActualLayout : wraps

VirtualLayout holds the ordered columns vector and a viewport_offset — the camera position measured in pixels from the canvas origin. Column stores only a width_px and a rows list; it carries no x-position. ActualLayout is produced by the projection step and contains one ActualEntry per window, each with a concrete Rect in screen coordinates. AppliedLayout bundles both the new virtual and actual layouts together — the animation layer diffs every window’s target rect against its real on-screen position to decide what needs tweening.

The camera model

viewport_offset on VirtualLayout acts as a camera sliding along the infinite canvas. A value of 0 means the camera is flush with the left edge of the first column. Increasing it scrolls the viewport rightward. Many operations — scrolling, focusing an off-screen window, swapping columns — are implemented by adjusting this offset rather than moving individual windows. The projection step then computes screen coordinates from the offset plus the column geometry.

graph LR
    subgraph "Infinite Canvas"
        direction LR
        C1["Column 0"]
        C2["Column 1"]
        C3["Column 2"]
        C4["Column 3"]
        C5["Column 4"]
    end

    subgraph Viewport
        direction LR
        V1["visible"]
    end

    PL["Parked Left"] -.-> C1
    C2 --> V1
    C3 --> V1
    PR["Parked Right"] -.-> C4
    C5 -.-> PR

    style PL fill:#f9f,stroke:#333,stroke-dasharray: 5 5
    style PR fill:#f9f,stroke:#333,stroke-dasharray: 5 5
    style Viewport fill:#bbf,stroke:#333,stroke-width:2px

Columns outside the viewport are parked at deterministic off-screen positions — one column-width beyond the nearest viewport edge. Windows OS does not gracefully ignore windows placed at extreme off-screen coordinates, so parking them nearby keeps the window manager well-behaved and enables smooth scroll-in/out animations. See Projection for the full parking algorithm.

Why position is implicit

Column x-positions are never stored. Instead they are computed on the fly via prefix-sum of widths: column 0 starts at window_gap, column i starts at the right edge of column i-1 plus window_gap. This design has a direct practical benefit — swapping two columns is a simple Vec::swap with zero coordinate arithmetic. Adding or removing a column shifts all downstream windows automatically because the prefix-sum recomputes from scratch on every projection.

Pixel widths, not eighths

Column widths are stored directly as pixel values (width_px). An earlier revision used eighths of the configured column_width to stay resolution-independent. That quantization had a subtle bug: expand_column computed the correct target width (column_width + window_gap), but converting back to eighths rounded the gap away (it was smaller than one eighth), so each expand step grew by exactly column_width instead of column_width + window_gap. Pixel widths make the gap observable at every step and let expand/shrink advance by the true column_shift. See Design Decisions for the full trade-off analysis.

Padding at projection time

Padding — the uniform window_gap between windows and screen edges, plus optional up/down top/bottom margins — is applied exclusively during the projection step. Neither VirtualLayout nor Column stores padding. This keeps the virtual canvas model clean: columns are pure containers of windows with widths, and the spatial realities of screen edges, gaps, and margins are deferred to the one function that converts canvas geometry to screen coordinates.

What lives where

The src/layout/ module contains only the pure math: type definitions, mutation functions, and the projection function. There is no orchestrator here. ScrollingSpace (in src/workspace/scrolling_space.rs) owns the current VirtualLayout, calls mutation functions to produce new virtual layouts, passes them through projection, and wraps the result in an AppliedLayout for the animation layer. This separation means the layout math can be tested in isolation from any workspace or Win32 state.

The full data-flow pipeline — mutation, projection, animation — is described in Pipeline. The mutation catalog and their algorithms are covered in Mutations.

The Mutation Pipeline

Every layout change in FlowWM follows the same three-stage pipeline: mutate, project, then animate. A pure mutation function transforms the current VirtualLayout into a new one; the projection function converts that virtual layout into pixel-accurate screen rectangles; and the animation layer tweens windows from their current on-screen positions to their new targets. This functional, declarative approach means there is no mutable “update position, propagate, re-render” loop — each stage is a single deterministic function with no side effects.

flowchart TB
    CMD["User command\n(e.g. swap_column Right)"]
    MUT["mutations::swap_column()\npure function\n→ new VirtualLayout"]
    APPLY["ScrollingSpace::apply_mutation()\nupdates internal state"]
    PROJ["projection::project()\nVirtualLayout → ActualLayout"]
    APPLIED["AppliedLayout {\n  virtual_layout,\n  actual_layout\n}"]
    ANIM["Animation layer\ncompares target rects vs real positions\nbuilds tweens for changed windows"]

    CMD --> MUT
    MUT --> APPLY
    APPLY --> PROJ
    PROJ --> APPLIED
    ANIM -.-> APPLIED

    style CMD fill:#ffd,stroke:#333
    style ANIM fill:#dfd,stroke:#333,stroke-dasharray: 5 5

The glue: apply_mutation

ScrollingSpace — the orchestrator in src/workspace/ — owns the current VirtualLayout and the last-projected ActualLayout. Every public method on ScrollingSpace (scroll, focus, swap, resize, add/remove) follows the same internal pattern: call a pure mutation function from src/layout/mutations.rs to get a new VirtualLayout, pass it to apply_mutation, which calls projection::project to produce the new ActualLayout, then wraps both into an AppliedLayout and returns it.

#![allow(unused)]
fn main() {
fn apply_mutation(&mut self, new_layout: VirtualLayout) -> AppliedLayout {
    let new_actual = projection::project(&new_layout, &self.monitor, &self.config.padding);
    let result = AppliedLayout {
        actual_layout: new_actual.clone(),
        virtual_layout: new_layout,
    };
    self.virtual_layout = result.virtual_layout.clone();
    self.actual = new_actual;
    result
}
}

apply_mutation is the only code path that updates ScrollingSpace’s internal state. It swaps in the new virtual and actual layouts atomically and returns the AppliedLayout for the caller (the daemon event loop) to pass to the animation layer. Because the mutation functions are pure and apply_mutation is the single state-update point, there is no risk of partial or inconsistent layout state.

Why AppliedLayout carries both virtual and actual

AppliedLayout bundles the new VirtualLayout and the new ActualLayout together. The animation layer needs the full actual layout — every window’s target rect — because it diffs each target against the window’s real on-screen position (which may be mid-flight from a previous animation). If the engine emitted only the windows whose target changed, a rapid second mutation during an in-flight animation would miss windows that are physically mid-tween, stranding them mid-screen.

By returning the full ActualLayout, the animation layer always sees every window and makes its own no-op filtering decision. The virtual layout is included because the orchestrator stores it as the new authoritative canvas state (and callers sometimes need it for query purposes).

No logical diff inside the engine

An earlier design had the layout engine compute a Vec<WindowMove> — a diff between old and new actual layouts — and hand that to the animation layer. That approach failed under rapid mutation bursts: a window animating toward its old target would be absent from the diff on the next mutation, and the animation layer would drop it. The current design pushes the diff responsibility outward. The engine produces a complete snapshot of the desired end state; the animation layer compares that snapshot against real-world positions (which include in-flight windows) and decides what to tween. This keeps the layout engine pure and stateless — it has no opinion about what is or is not currently animating.

See AppliedLayout for the full design decision rationale.

Worked example: swap_column(Right)

Tracing a column swap from user input through the full pipeline makes the data flow concrete. Consider a canvas with three columns — widths 960, 1440, 960 — with the viewport showing columns 1 and 2. Focus is on a window in column 1.

flowchart LR
    subgraph Before["Before: viewport_offset = 960"]
        direction LR
        B0["Col 0\n960px\n(parked)"]
        B1["Col 1\n1440px\n(focused)"]
        B2["Col 2\n960px"]
    end

    subgraph After["After: columns swapped, viewport adjusted"]
        direction LR
        A0["Col 0\n960px\n(parked)"]
        A1["Col 2\n960px"]
        A2["Col 1\n1440px\n(focused)"]
    end

    Before -->|"swap_column(Right)\n→ Vec::swap(1, 2)\n→ ensure_column_visible"| After

Step 1 — Mutation. The daemon receives a SwapColumn(Right) command and calls ScrollingSpace::swap_column(Direction::Right). That method calls mutations::swap_column(&virtual_layout, focused, Right, &config), which finds the focused window’s column index (1), swaps columns 1 and 2 in the Vec<Column>, then calls mutations::ensure_column_visible to adjust viewport_offset if the focused window’s new column would be off-screen. The result is a new VirtualLayout with columns reordered and the camera offset updated.

Step 2 — Projection. apply_mutation passes the new VirtualLayout to projection::project. The projection function computes prefix-sum column left edges, determines which columns overlap the viewport [offset, offset + monitor_width], computes row rects for visible columns, and parks off-screen columns just beyond the nearest viewport edge. The output is a complete ActualLayout — every window has a pixel Rect.

Step 3 — Animation. apply_mutation wraps both layouts into an AppliedLayout and returns it to the daemon. The daemon passes it to the animation layer, which iterates every ActualEntry, queries the window’s real on-screen position via Win32, and builds a tween for any window whose real position differs from the target. The swapped windows animate to their new positions; the parked window moves to its off-screen parking spot (or stays parked if already there).

The entire cycle — from mutation to animation batch — completes in a single synchronous call on the main thread. There are no background tasks, no deferred updates, no stale state windows.

Focus: a special case

The focus method on ScrollingSpace has a slight variation on the standard pipeline. It calls mutations::focus, which returns a FocusResult containing the newly focused window ID and a potentially-modified VirtualLayout (the camera may have shifted if the target was off-screen). ScrollingSpace::focus checks whether the viewport offset actually changed: if it did, it calls apply_mutation to project and return the AppliedLayout; if not, it returns None for the layout, since no windows moved and no animation is needed. This optimization avoids running projection when the user is just moving focus between two on-screen windows.

See Mutations for the catalog of all mutation functions and their algorithms.

Mutations

Every layout change in FlowWM is expressed as a pure function that takes the current VirtualLayout and returns a new one. There is no in-place mutation, no side effects, no Win32 calls — every function is #[must_use] and fully unit-testable on any platform. The src/layout/mutations.rs module contains the complete catalog of these operations, each consuming only the virtual layout and a MutationConfig struct that carries the monitor dimensions, column width, gap settings, and the precomputed expand/shrink ladder.

Mutation catalog

GroupOperationPure functionEffect
NavigationFocus left/rightfocus(layout, focused, dir, config)Moves focus to the nearest window in the given direction. Shifts the camera if the target column is off-screen (via ensure_column_visible). Vertical focus is always on-screen.
NavigationFocus up/downfocus(layout, focused, dir, config)Moves focus within the same column. Never shifts the camera.
NavigationScroll leftscroll_left(layout, config)Decrements viewport_offset by one visible column step. Clamped to 0.
NavigationScroll rightscroll_right(layout, config)Increments viewport_offset by one visible column step. Clamped to the max offset where the rightmost column edge meets the viewport right edge.
StructuralSwap columnswap_column(layout, focused, dir, config)Swaps the focused window’s entire column with its left or right neighbor. Calls ensure_column_visible to scroll if the focused column moved off-screen. Vertical directions return None.
StructuralSwap windowswap_window(layout, focused, dir, config)Swaps the focused window with a specific adjacent window. For left/right, picks the nearest row in the adjacent column and swaps the entire Row { window_id, height } (height travels with the window). For up/down, swaps rows within the same column. Row counts are preserved on both sides, so heights are not redistributed.
StructuralAdd windowadd_window(layout, window, config)Appends a new column to the right end of the canvas at column_width. The column’s single row is seeded with the n=1 distributed height. No viewport adjustment — the caller decides whether to scroll.
StructuralInsert after focusedinsert_window_after_focused(layout, focused, window, config)Inserts a new column immediately after the focused window’s column. Calls ensure_column_visible on the new column.
StructuralAdd to columnadd_window_to_column(layout, col_idx, window, config)Appends a window as a new bottom row in an existing column, then redistributes all rows in that column to equal heights via distribute_heights.
StructuralMerge columnmerge_column(layout, focused, dir, config)Detaches the focused window from its column and appends it as a new bottom row of the left/right neighbour column. Redistributes both columns to equal heights. Returns None if there is no neighbour, the direction is vertical, or min_window_height_px would be violated in the destination column.
StructuralPromote windowpromote_window(layout, focused, dir, config)Extracts the focused window into a new single-row column placed to the left/right of the source column. Redistributes the source column’s remaining rows. No-op (None) when the focused window is already alone in its column, or when the direction is vertical.
StructuralRemove windowremove_window(layout, window, config)Removes a window from its column. If the column still has rows, redistributes them to equal heights; if the column becomes empty, the column is removed entirely. Clamps viewport_offset to prevent scrolling past the new rightmost column.
StructuralInitialize windowsinitialize_windows(ids, config, focus_idx, widths)Builds the initial layout from a list of window IDs. Each becomes a single-row column. When widths is Some, each width is quantized to the nearest slot-ladder rung (clamped to [column_width, abs_max_width]); when None, all columns use column_width. Sets viewport_offset via the canvas-width fit predicate (see Center behaviors).
SizingExpand columnexpand_column(layout, focused, config)Grows the focused column by one rung on the slot ladder. Two-step top jumps to abs_max_width. No-op if already at abs_max_width.
SizingShrink columnshrink_column(layout, focused, config)Shrinks the focused column by one rung. Reverses the two-step top. No-op if already at column_width (ladder floor).
SizingSet column widthset_column_width(layout, focused, target_px, config)Sets the focused column to an explicit pixel width (free-form, not snapped to ladder). Bounded by [min_column_width_px, abs_max_width]. Calls ensure_column_visible.
SizingResize columnresize_column(layout, focused, delta_px, config)Adds a pixel delta to the current width and delegates to set_column_width. Used by drag-resize.
StateToggle monocletoggle_monocle(layout, focused, saved, config)Enters monocle by setting the focused column to abs_max_width and saving the previous width. Exits monocle by restoring the saved width (defaults to column_width).
Viewport centerCenter focused columncenter_viewport_on_focused(layout, focus_col, config)Computes a free-form viewport_offset that places the focused column’s center at the monitor midpoint, using prefix-sum canvas positions (variable-width aware). Always centers, even when all columns already fit. Exposed as the flow dispatch center command.
Viewport centerCenter canvascenter_viewport_canvas(layout, config)Computes a free-form viewport_offset that centers the entire canvas in the monitor: (canvas_width - monitor_width) / 2. May return a negative offset when the canvas is narrower than the monitor (projection handles this). Used by initialize_windows (fit case) and the move-to-workspace auto-center hook (fit case).

The F4-ladder slot model

Expand and shrink move along a discrete slot ladder rather than by arbitrary pixel increments. This ensures the window_gap is always preserved between columns as widths change.

flowchart LR
    subgraph Ladder["Expand/Shrink Slot Ladder"]
        R0["n=0\n960px\n(base)"]
        R1["n=1\n960+964=1924px"]
        R2["n=2\n960+2*964=2888px"]
        SM["slot_max\nn=max_n"]
        AM["abs_max_width\n1912px\n(monitor - 2*gap)"]
    end

    R0 -->|"column_shift = 964"| R1
    R1 -->|"column_shift"| R2
    R2 -->|"..."| SM
    SM -->|"two-step\ntop"| AM

The ladder is defined by: column_shift = column_width + window_gap (one slot step), and rungs at column_width + n * column_shift for n in [0, max_n]. The abs_max_width = monitor_width - 2 * window_gap sits above the top regular rung (slot_max) as a two-step top — the leftover pixels between slot_max and abs_max_width are smaller than one column_shift, so they get absorbed in a single jump to full width. This is the monocle width.

Expand snaps up to the next rung using floor((W - column_width) / column_shift) to find the current rung, then advancing n by 1. Shrink snaps down using the same logic in reverse with ceil. Free-form widths from drag-resize that fall between rungs are handled gracefully: expand snaps them up to the next boundary, shrink snaps them down.

Row heights: source-of-truth model

Each Row carries its own height: i32 field. Projection consumes this value verbatim as the window’s pixel height — it never recomputes, rescales, or insets. The only producer of equal-height values is distribute_heights, invoked at mutation time whenever the row membership of a column changes.

distribute_heights(n, available, gap)

Divides available pixels among n rows using the user-spec formula:

numerator  = available - (n + 1) * gap
base       = numerator / n            (integer division)
remainder  = numerator % n            (0..n-1)
heights[i] = base + (if i < remainder { 1 } else { 0 })

The (n + 1) * gap term accounts for one gap above the topmost row, one gap below the bottommost row, and n - 1 gaps between rows — totalling n + 1 gaps. The remainder pixels lost to integer division are distributed +1 to the top remainder rows, so the layout is deterministic and tile-tight.

Defensive cases: n == 0 returns an empty Vec; a non-positive numerator returns all zeros (the layout will look wrong but the function will not panic).

available is monitor_height - padding.up - padding.down (the work area vertical extent). The minimum-row budget is enforced by MutationConfig.min_window_height_px — mutations that would produce a row below this threshold reject the operation and return None.

When redistribution happens

MutationRow count changeRedistributes?
add_window_to_columncolumn n → n+1yes (destination column)
remove_windowcolumn n → n-1 (or column removed)yes (remaining rows)
merge_columnsource n → n-1, destination m → m+1yes (both columns)
promote_windowsource n → n-1, new column 0 → 1yes (source column)
swap_windowno count change on either sidenoRow { window_id, height } swaps as an opaque unit so any user-customized height travels with the window
focus / scroll / expand_column / shrink_column / set_column_widthno row membership changeno

This split preserves future drag-resize state: when a user adjusts one window’s height continuously (planned), swaps and focus changes will not stomp on that customization. Only operations that change which windows coexist in a column recompute equal shares.

Key algorithms

ensure_column_visible

This function is the camera-adjustment primitive used by focus, swap, and insert operations. Given a column index, it computes the column’s pixel range on the canvas (via prefix-sum), checks whether it overlaps the viewport [offset, offset + monitor_width], and shifts the camera if not.

The offset adjustment is free-form — it is the minimum pixel scroll that reveals the target column with a window_gap margin on the appropriate side, clamped to at least 0. It is not snapped to the column_shift grid. This produces smooth, precise scrolling behavior rather than quantized jumps.

  • Column off-screen left: new_offset = max(col_left - gap, 0).
  • Column off-screen right: new_offset = max(col_right + gap - monitor_width, 0).
  • Column already visible: no change.

expand_column / shrink_column

These are the rung-climbing functions. From a column width W:

  1. If W >= abs_max_width, return None (already at the top).
  2. If W >= slot_max, jump to abs_max_width (two-step top).
  3. Otherwise, compute n = floor((W - column_width) / column_shift), advance to n + 1, and set width_px = column_width + target_n * column_shift.

Shrink mirrors this: from abs_max_width, ceil lands back on slot_max. From between rungs, ceil snaps down. Below column_width, it is a no-op — widths in [min_column_width_px, column_width) are reachable only via drag-resize (set_column_width), never through the ladder.

add_window vs insert_window_after_focused

Two insertion strategies exist. add_window simply appends a new column at the right end of the canvas — useful for batch initialization. insert_window_after_focused places the new column immediately after the focused window’s column, so the user sees their newly opened window adjacent to where they were working. Both create the column at column_width; the insert variant also calls ensure_column_visible on the new column to scroll the viewport if needed.

remove_window

When a window is removed, it is spliced out of its column’s rows vector. If the column still has rows, they are redistributed via distribute_heights(n-1, ...) so the freed vertical space is reclaimed equally; if the column becomes empty (no remaining rows), the entire column is removed from the columns vector. The viewport_offset is then clamped to max_offset = max(total_canvas - monitor_width, 0) to prevent the viewport from scrolling past the (now shorter) canvas. Focus fallback — choosing which window to focus next after removal — is handled by ScrollingSpace using next_available_window, not by the mutation itself.

merge_column and promote_window

These two operations reshape which windows coexist in which columns; both redistribute row heights because they change row counts.

merge_column(layout, focused, dir, config) detaches the focused window from its column and appends it as a new bottom row of the left/right neighbour column. Both the source column (which loses a row) and the destination column (which gains a row) are redistributed via distribute_heights. Three rejection paths return None:

  • No neighbour in the requested direction (already at canvas edge).
  • Vertical direction (Up/Down) — merge is a horizontal-only operation.
  • Speculative min-height guard: the destination’s post-merge heights are pre-computed; if any would fall below min_window_height_px, the layout is left untouched. The pre-computed heights Vec is then reused when applying the mutation, so the check and the apply cannot diverge.

When the source column empties out and is removed, the destination’s index may shift: for direction == Right, dst_after_removal collapses to the original src_col (everything to the right shifts down by one); for direction == Left, the destination index is unaffected.

promote_window(layout, focused, dir, config) is the inverse shape: it extracts the focused window from a multi-row column into a new single-row column placed to the left or right of the source. The source column’s remaining rows are redistributed; the new column’s single row is seeded with the n=1 distributed height. No-op when the focused window is already alone in its column (the user spec defines this as a non-event, not an error). Vertical directions return None.

Both operations end with ensure_column_visible on the destination (merge) or the new column (promote), so the focused window is brought on-screen as part of the same diff.

initialize_windows

Builds the initial VirtualLayout from a list of WindowId values. Each window becomes a single-row column.

Width assignment. When widths: Some(&[u32]) is provided (the init flow collects each window’s pre_manage_rect.width from the registry scan), each width is passed through quantize_to_ladder and snapped to the nearest slot-ladder rung, clamped to [column_width, abs_max_width]. When widths: None, every column uses column_width (the original behavior, retained for tests that don’t care about variable widths). Free-form widths between rungs are reachable only via drag-resize (set_column_width), exactly as before — initialization always lands on a ladder rung.

Viewport offset. The viewport_offset is decided by the canvas-width fit predicate (see Center behaviors):

  • If canvas_width(layout, window_gap) ≤ monitor_width (all columns fit), the canvas is centered via center_viewport_canvas.
  • Else if a focus column is provided, ensure_column_visible shifts the viewport to reveal the focus column with a window_gap margin.
  • Else (overflow with no focus), viewport_offset = 0.

The old columns_per_screen config field is retained for compatibility but is not consulted by this logic — fit is computed from actual canvas width, which is the only correct approach once columns can have different widths.

Center behaviors: three modes

There are three viewport-centering behaviors, each driven by a different trigger. They all use prefix-sum canvas positions (the same math projection::project and ensure_column_visible already use) — none of them assume uniform column widths.

flowchart TD
    Trigger["Trigger: user runs `flow dispatch center`,<br/>or automated flow needs viewport adjustment"]
    Trigger --> Decision{"What should be centered?"}

    Decision -->|"Explicit user command"| Focused["**Center focused column**<br/>viewport_offset = canvas_x(focused)<br/>- (monitor_width - focused_width) / 2<br/><br/>Always centers, even when all columns fit."]
    Decision -->|"Automated flow + everything fits"| Canvas["**Center canvas**<br/>viewport_offset =<br/>(canvas_width - monitor_width) / 2<br/><br/>May be negative when canvas < monitor."]
    Decision -->|"Automated flow + overflow"| Ensure["**Ensure column visible**<br/>existing `ensure_column_visible`<br/>(free-form min-scroll, gap margin)<br/><br/>No centering — just reveal the column."]

    Focused -.->|Used by| FC["`flow dispatch center` command<br/>(dispatch_center → ScrollingSpace::center_focused_column)"]
    Canvas -.->|Used by| CC["`initialize_windows` fit case<br/>move-to-workspace fit case<br/>(ScrollingSpace::center_canvas)"]
    Ensure -.->|Used by| EC["`initialize_windows` overflow case<br/>move-to-workspace overflow case<br/>(ScrollingSpace::ensure_focused_visible)"]

center_viewport_on_focused — center the focused column

canvas_x(f) = Σ width[i] for i in 0..f + (f + 1) * window_gap
viewport_offset = canvas_x(focused) - (monitor_width - focused_width) / 2

The (f + 1) * window_gap term is the easy off-by-one trap: the canvas starts with a left-edge gap, then each column contributes its width plus a trailing gap, so column f’s left edge sits at (f + 1) gaps plus the widths of all preceding columns. focused_width is the focused column’s actual width_px (read from the layout, not assumed) — this is the fix for the original bug where the old center_viewport_grid/center_viewport_absolute functions assumed uniform widths and computed f * slot instead.

This is the only behavior that centers unconditionally — the user explicitly asked for it via flow dispatch center. The other two behaviors are policy decisions made by automated flows.

center_viewport_canvas — center the entire canvas

viewport_offset = (canvas_width(layout, window_gap) - monitor_width) / 2

Uses projection::canvas_width as the authoritative canvas math (window_gap + Σ(width_px + window_gap)). When the canvas is narrower than the monitor, this returns a negative offset — projection already supports this (the column’s left edge is shifted right of the canvas origin by the gap, which compensates). Used when an automated flow knows everything fits and wants a clean visual midpoint.

ensure_column_visible — reveal without centering (unchanged)

The existing free-form min-scroll primitive. Used by automated flows when the canvas overflows the monitor: rather than centering (which would push other useful columns off-screen), it shifts the viewport by the minimum amount needed to reveal the target column with a window_gap margin. See ensure_column_visible above.

Why the old center_viewport_grid / center_viewport_absolute were removed

Both functions took (num_columns, focus_col, config) and computed positions as f * slot / gap + N * slot. They never saw &VirtualLayout, so once any column was expanded, shrunk, or drag-resized, the math was wrong. The refactor collapses them into the two prefix-sum primitives above (center_viewport_on_focused for the user command, center_viewport_canvas for the automated fit case), with ensure_column_visible covering the overflow case. The old “grid” vs “absolute” distinction (slot-aligned vs free-form) turned out to be a red herring: the real axis is “what should be centered” (focused column vs entire canvas vs nothing), not “is the offset quantized”.

Propertycenter_viewport_on_focusedcenter_viewport_canvasensure_column_visible
TriggerExplicit user command (flow dispatch center)Automated flow, all columns fitAutomated flow, canvas overflows
CentersFocused column at monitor midpointEntire canvas at monitor midpointNothing — minimum-scroll reveal
Offset quantizationFree-formFree-formFree-form
Negative offset possibleYes (when focused col is near canvas left)Yes (when canvas < monitor)No (clamped to ≥ 0)
UnconditionalYes (always centers)No (only used when fit predicate passes)No (only used when target is off-screen)

The pure-function convention

Every mutation in the catalog follows the same signature pattern: fn(&VirtualLayout, ...) -> Option<VirtualLayout>. They never modify the input — they clone it internally and return a new value. This makes the mutation layer:

  • Composable — the output of one mutation can be fed directly into another.
  • Testable — unit tests construct a VirtualLayout, call a mutation, and assert on the result. No mocks, no Win32, no setup.
  • Deterministic — the same input always produces the same output.

The ScrollingSpace orchestrator in src/workspace/scrolling_space.rs is the only code that calls these functions and applies the results. See Pipeline for how mutations flow into projection and animation.

Projection

The projection function converts the abstract virtual canvas into concrete pixel rectangles that Windows OS can render. Given a VirtualLayout, a MonitorInfo, and a Padding, the project function computes an ActualLayout containing one entry per window with an exact screen-coordinate Rect. This is the only place in the layout module where pixel coordinates materialize — and the only place where padding is applied.

The slot model

Columns are laid out in slots on the virtual canvas. Each slot is the column’s own width_px plus a window_gap on the right. The canvas starts at window_gap (the initial left-edge gap), and the last column’s trailing gap serves as the right-edge gap. This structure means the inter-column gap emerges from the layout itself, not from insetting individual windows.

graph LR
    subgraph Canvas["Virtual Canvas"]
        direction LR
        G0["gap"]
        C0["Column 0\n960px"]
        G1["gap"]
        C1["Column 1\n1440px"]
        G2["gap"]
        C2["Column 2\n960px"]
    end

    G0 --- C0 --- G1 --- C1 --- G2 --- C2

    style G0 fill:#fdd,stroke:#999
    style G1 fill:#fdd,stroke:#999
    style G2 fill:#fdd,stroke:#999
    style Canvas fill:#fff,stroke:#333

Column x-positions are a prefix sum, not a uniform stride. The canvas accumulator starts at window_gap and advances by col.width_px + window_gap per column. This is important because columns can have different widths (after expand/shrink or drag-resize), so a uniform stride would not work.

Camera shift: canvas to screen

For each column, projection computes its canvas range [canvas_left, canvas_right] and checks whether it overlaps the viewport [viewport_offset, viewport_offset + monitor_width]. Visible columns are translated to screen coordinates by subtracting the camera offset:

screen_x = monitor_left + (canvas_col_left - viewport_offset)

This subtraction is the entire camera mechanism. The viewport_offset on the VirtualLayout shifts the slice of canvas that maps onto the physical screen. A column whose canvas x-position is less than the offset gets a negative (or off-screen-left) screen x; a column far to the right gets a screen x beyond the monitor right edge.

Visibility test

A column is visible when its canvas range partially overlaps the viewport:

visible = (canvas_col_right > viewport_left) && (canvas_col_left < viewport_right)

This is a standard interval overlap test. Columns that overlap even by one pixel are projected at their real screen coordinates; columns with no overlap are parked.

Row rects: source-of-truth heights

Each Row in a column carries its own height: i32 field, and projection consumes that value verbatim as the window’s pixel height. There is no equal-division step here, no recomputation, no rescaling, no inset — projection simply stacks the rows. Equal-height distribution is performed exactly once, at mutation time, by distribute_heights (see Mutations). Between mutations, row heights are intentionally stable so that future drag-resize or IPC-driven continuous-height adjustment can preserve user-customized heights.

The vertical stacking model: starting at y = monitor_y + padding.up + window_gap, each row occupies row.height pixels, and a single window_gap separates consecutive rows:

y_cursor = monitor_y + padding.up + window_gap
for row in column.rows:
    window.rect = (col_x, y_cursor, col_width, row.height)
    y_cursor += row.height + window_gap

This produces one window_gap between adjacent rows, one window_gap above the topmost row (after padding.up), and one trailing window_gap below the bottommost row (before padding.down). The row.height values are the sole determinant of where each window lands vertically.

The container model: from column cell to window rect

Because row.height is consumed as the literal window height, there is no inset within a row cell. Horizontally, the window fills the full col_width — the gap between columns comes from the slot model (the window_gap between slots), not from insetting the window. Vertically, each window’s height is exactly row.height, and the gaps between windows come from the + window_gap term in the stacking formula above.

graph TB
    subgraph Column["Column (vertical stack)"]
        direction TB
        TG["top gap: window_gap (after padding.up)"]
        W0["Row 0 Window\nx = col_x\ny = monitor_y + up + gap\nwidth = col_width\nheight = row.height[0]"]
        G1["inter-row gap: window_gap"]
        W1["Row 1 Window\nwidth = col_width\nheight = row.height[1]"]
        BG["bottom gap: window_gap (before padding.down)"]
    end

    TG --- W0 --- G1 --- W1 --- BG

    style TG fill:#fdd,stroke:#999
    style G1 fill:#fdd,stroke:#999
    style BG fill:#fdd,stroke:#999
    style W0 fill:#dfd,stroke:#333,stroke-width:2px
    style W1 fill:#dfd,stroke:#333,stroke-width:2px

This is a deliberate departure from the earlier “equal cell division + inset” model. The new contract is: the value on Row.height is the HWND height that Windows receives. Anything that wants to change a window’s height — equal redistribution, drag-resize, explicit IPC — must write to Row.height through the mutation layer; projection never second-guesses it.

Screen-level margins

The padding.up and padding.down fields are screen-level margins that reserve space above and below the tiling area. They reduce the available height for row calculation: available_height = monitor_height - up - down. Windows never extend into these margin zones. This is distinct from window_gap (which applies between windows and between windows and screen edges within the tiling area) — up/down are for external UI elements like custom title bars or taskbar clearance.

Parking

Columns that are not visible are parked at deterministic off-screen positions rather than left at their unreachable virtual canvas coordinates. Windows OS does not gracefully handle windows placed at extreme off-screen positions — they can cause rendering artifacts, accessibility issues, and broken animation transitions.

graph LR
    subgraph Monitor["Monitor"]
        VP["Viewport"]
    end

    PL["Left Parking\nx = monitor_left - col_width"]
    PR["Right Parking\nx = monitor_right"]

    PL -.->|"parked left"| Monitor
    Monitor -.->|"parked right"| PR

    style PL fill:#f9f,stroke:#333,stroke-dasharray: 5 5
    style PR fill:#f9f,stroke:#333,stroke-dasharray: 5 5
    style Monitor fill:#bbf,stroke:#333,stroke-width:2px

There are two parking zones:

  • Left parking: monitor_left - col_width. For columns whose canvas right edge is at or before the viewport left edge.
  • Right parking: monitor_right. For columns whose canvas left edge is at or beyond the viewport right edge.

Parked windows use the same row.height-stacking logic as visible windows, so when a column scrolls from parked to visible (or vice versa), its windows animate smoothly with consistent dimensions — there is no sudden resize or padding change at the boundary.

Why projection is the only place padding lives

Padding is applied exclusively during projection and never stored on Column, VirtualLayout, or individual windows. This design choice keeps the virtual canvas model clean: columns are pure containers of windows with widths, and the spatial realities of screen edges, gaps, and margins are deferred to the one function that converts canvas geometry to screen coordinates. The ActualEntry rects produced by projection are the final HWND rects — they can be passed directly to SetWindowPos without any further adjustment.

This separation has a practical benefit: the mutation layer operates on a padding-agnostic model. Swap, expand, shrink, and scroll never need to know about gaps or margins. Only the projection function — a single deterministic function with no side effects — handles the translation from the abstract to the physical.

See Overview for the VirtualLayout/ActualLayout type relationship and Mutations for the operations that produce the VirtualLayout inputs to projection.

Workspace Hierarchy

The workspace module models a niri-style virtual-desktop stack. A physical monitor owns one or more workspaces, and each workspace splits into a tiled half (ScrollingSpace) and a floating half (FloatingSpace). The daemon owns the top-level Vec<Monitor> and routes every tiling operation through the active monitor’s active workspace.

The Hierarchy

The tree has four levels, rooted on the daemon struct FlowWM:

graph TB
    flow["FlowWM<br/>(daemon orchestrator)"]
    flow --> monitors["monitors: Vec&lt;Monitor&gt;<br/>active_monitor: usize"]
    monitors --> M["Monitor<br/>(work_area Rect)"]
    M --> ws["workspaces: Vec&lt;Workspace&gt;<br/>active_workspace: usize"]
    ws --> W["Workspace<br/>id: WorkspaceId"]
    W --> SS["ScrollingSpace<br/>(infinite horizontal canvas)"]
    W --> FS["FloatingSpace<br/>(on-screen pixel rects)"]

FlowWM is defined in src/daemon/mod.rs. It holds monitors: Vec<Monitor> and active_monitor: usize, giving O(1) access to the active monitor through active_scrolling() and active_scrolling_mut().

Accessing the Active Scrolling Space

Every IPC command and hook-event handler ultimately calls one of two accessors on FlowWM:

  • active_scrolling() — immutable borrow of the active workspace’s ScrollingSpace.
  • active_scrolling_mut() — mutable borrow, used for mutations like add_window, swap_column, or scroll.

These accessors chain through two indirection layers: FlowWM -> active Monitor -> active Workspace -> ScrollingSpace. The daemon never exposes raw monitor or workspace indices to callers; the accessors hide the traversal. See Monitor::active_scrolling for the implementation.

Current Skeleton Invariant

At this stage the daemon creates exactly one monitor (the primary display) carrying a fixed stack of ten workspaces (id 1..=10, 1-indexed, default active = workspace 1). Multi-monitor support is future work. Workspace 1 inherits the scrolling space built from the windows that were already open at startup; workspaces 2..=10 start empty. All ten workspaces share the same layout parameters so columns size consistently when the user switches between them or moves windows across. The count is hard-coded in src/daemon/new.rs (WORKSPACE_COUNT = 10) rather than read from config.

The monitor carries two rectangles: the full physical screen_rect (taskbar included, used for parking non-active workspaces off-screen) and the taskbar-excluded work_area (used for in-workspace tiling). The work_area is also copied into each workspace’s ScrollingSpace (inside its MonitorInfo) for the projection pipeline. The duplication is benign with a single monitor and will be rationalised when multi-monitor lands.

Two Coordinate Spaces

Each workspace contains two fundamentally different spatial models:

ScrollingSpace is an infinite horizontal canvas. Windows live in virtual coordinates (columns, each with a width and vertical stack of windows). A camera/viewport selects which slice of the canvas is visible, then the projection pipeline maps those virtual coordinates to actual on-screen pixel rectangles. This is the entire tiling engine; see layout overview for the virtual-to-actual pipeline.

FloatingSpace tracks literal on-screen pixel rectangles. It does not participate in the virtual-to-actual pipeline at all — floating windows are stored as ActualEntry values (the same type the projection produces) and submitted directly to the animator. See floating space for the full architecture: the tile↔float transitions, animation batch merging, focus model, and configuration.

The key design consequence: a workspace never mixes the two spaces at the layout level. When the daemon submits an animation batch, each workspace’s scrolling layout and floating layout are merged into a single ActualLayout so that both tiles and floats ride together in the same animate_workspaces call.

Vertical Scrolling Between Workspaces

The horizontal scrolling inside a ScrollingSpace (left/right across columns) has a vertical analogue. Workspaces are stacked “above” and “below” the active one — the same packing idea used horizontally between columns but applied vertically between workspaces. Switching workspaces animates the whole stack vertically.

Three IPC commands implement this surface:

  • flow dispatch switch-workspace <id> — switch the active workspace. Implemented. Animates a vertical-packing switch: the source and destination workspaces slide between their parked y-offsets in a single coordinated animation batch.
  • flow dispatch move-to-workspace <id> — move the focused window to another workspace. Implemented. Mutates both the source and destination layouts, then switches the camera to the destination so the moved window is brought into view.
  • flow dispatch swap-workspace <id> — swap the active workspace with another. Stub. Its protocol shape is locked in, but its animation model (two workspaces exchanging positions in the packed stack) is not yet decided, so it currently returns unimplemented_command.

Switch animation: animate / teleport / skip

Every non-empty workspace on the active monitor is classified into exactly one of three buckets during a switch:

BucketWorkspacesAction
Animatesource (previously active) + destinationsubmitted to animate_workspaces as a single coordinated batch
Teleportbystanders whose parked side changed (e.g. ws 3-7 when switching 2 → 8)submitted to teleport_workspaces — instant SetWindowPos, no animator
Untouchedbystanders whose parked side stayed the sameskipped entirely

Parked workspaces sit one full physical monitor height (plus one window_gap) above or below the active workspace — the full height keeps them completely off-screen past the taskbar strip. Teleport runs before animate so the bystander “backdrop” snaps into place before the participant transition begins. Floating windows merge with tiles per workspace and ride along in the same batch. See switch_active_workspace in src/daemon/dispatch.rs for the full algorithm, and roadmap for the remaining SwapWorkspace work.

What Lives Here vs. What Doesn’t

The workspace module is deliberately thin. It owns the container types (Monitor, Workspace, WorkspaceId) and the two space types. It does not own:

  • Window metadata — HWNDs, titles, classes, and process paths live in WindowRegistry (see window registry).
  • Tile/float/ignore classification — the registry decides what state a window is in; the workspace only receives WindowIds that the daemon has already classified as tiling-eligible.
  • IPC plumbing and hooks — these are direct fields on FlowWM. The hook thread sends HookEvents over an mpsc channel; the daemon’s IPC thread processes them and routes mutations to the workspace. See event pipelines for the full flow.
  • AnimationWindowAnimator is a sibling field on the daemon, not something the workspace knows about. The daemon calls animate_layout() after every mutation that produces an AppliedLayout.

A workspace never touches Win32. It only knows about WindowIds, Rects, and layout math. This isolation keeps the tiling engine testable without any Win32 mocking.

Floating Space

Floating windows live in literal on-screen pixel coordinates (the actual layer). Unlike ScrollingSpace, which models an infinite virtual canvas and projects tiles into screen rects, FloatingSpace stores each window’s position directly as an ActualEntry — the same type the animation layer consumes. This means floating windows never pass through the virtual-to-actual projection pipeline; they are already where they need to be.

This chapter covers the architecture of the floating-space subsystem: its data model, the tile-to-float and float-to-tile transitions, how animation batches are coordinated, and how floating windows participate in workspace switching.

Two Coordinate Spaces — Where FloatingSpace Sits

Each workspace is split between two spatial models (see layout overview for the full virtual/actual pipeline):

  • ScrollingSpace — infinite horizontal virtual canvas. Windows live in columns; the projection pipeline maps virtual geometry to on-screen pixel rectangles. This is the tiling engine.
  • FloatingSpace — direct on-screen pixel rectangles. No virtual canvas, no projection, no camera. What you store is what you animate.

The two spaces are structurally independent but share the same animation pipeline. Every mutation that touches either side produces an ActualLayout that is submitted to animate_workspaces in a single coordinated batch.

graph TB
    W["Workspace"]
    W --> SS["ScrollingSpace<br/>(virtual canvas)"]
    W --> FS["FloatingSpace<br/>(pixel rects)"]
    SS --> VP["Projection pipeline<br/>virtual → actual"]
    VP --> SA["ActualLayout<br/>(tiles)"]
    FS --> FA["ActualLayout<br/>(floats)"]
    SA --> ANIM["animate_workspaces<br/>(single batch)"]
    FA --> ANIM

Data Model

FloatingSpace (src/workspace/floating_space.rs) holds an ordered Vec<ActualEntry>. Later entries render on top (z-order). The struct is pure data and math — no Win32, no side effects.

classDiagram
    class FloatingSpace {
        +Vec~ActualEntry~ windows
        +new() FloatingSpace
        +add(window_id, rect)
        +remove(window_id) Option~Rect~
        +contains(window_id) bool
        +is_empty() bool
        +len() usize
        +windows() &[ActualEntry]
        +to_actual_layout() ActualLayout
        +centered_rect(preferred, work_area) Rect$
    }

    class ActualEntry {
        +WindowId window_id
        +Rect rect
    }

    class ActualLayout {
        +Vec~ActualEntry~ entries
    }

    FloatingSpace "1" *-- "*" ActualEntry
    FloatingSpace ..> ActualLayout : to_actual_layout()

Why ActualEntry?

FloatingSpace uses the same ActualEntry type as the projection pipeline’s output. This makes to_actual_layout() a trivial clone-and-wrap — no coordinate conversion needed. The animation layer always receives the same type regardless of whether a window is tiled or floating.

The centered_rect Algorithm

When a window is floated for the first time, it needs a default position. centered_rect is a pure function that computes this:

  1. Clamp preferred.width to [0, work_area.width] (same for height).
  2. Center horizontally: x = work_area.x + (work_area.width - w) / 2.
  3. Center vertically: y = work_area.y + (work_area.height - h) / 2.

The result is a Rect guaranteed to fit within work_area. Oversized windows are clamped rather than scaled — their dimensions are simply capped.

The preferred size comes from the window’s last_natural_size (the visible content rect measured at registration time). If that’s unavailable or zero, the config fraction fallback (FloatingConfig.default_width/height × work area dimensions) is used. See Configuration for the defaults.

Tile ↔ Float Transitions

The two transitions are the heart of the floating-space subsystem. Both operate on the OS-focused window (registry.focused()). The window that was focused before the transition is the same window that’s focused after — the transition changes the window’s space, not its focus.

Three entry points share one float-placement primitive

The float-placement core (register_float in src/daemon/dispatch.rs) — add to FloatingSpace, mirror the rect into FloatingState::Active, and arm add_float_hwnd tracking — is reached from three call sites, so a float always ends up identically wired regardless of how it became a float:

Entry pointTriggerFloat rect
set-window float / cycleexplicit user toggle of a tiled windowcentered (centered_float_rect)
on_window_created (runtime)a new window the rule pipeline classifies as Floatcentered (centered_float_rect) — identical to a toggle
startup scana Float-classified window already open when the daemon launchedits current on-screen position (adopted in place, not re-centred)

The runtime path exists because classification runs at creation time: without it, a rule-classified float would land in the registry yet stay absent from FloatingSpace (so workspace switching would strand it) and borderless until the user toggled it. The startup path adopts in place rather than animating to avoid interrupting the in-flight tiling init animation (InterruptPolicy::RetargetFromCurrent).

Tile → Float: Pop to Center

  1. ScrollingSpace::remove_window(focused) — removes the tile from the virtual canvas. This already handles right-side compression and focus succession: last_focused_window moves to next_available_window.
  2. Compute a centered float rect using last_natural_size (preferred) or config fraction fallback.
  3. FloatingSpace::add(focused, rect) — appends to the floating list (newest on top of z-order).
  4. Animate both the post-removal scrolling layout and the updated floating layout in a single batch.
sequenceDiagram
    participant CLI as flow CLI
    participant flow as Daemon
    participant SS as ScrollingSpace
    participant FS as FloatingSpace
    participant Reg as WindowRegistry
    participant Anim as Animator

    CLI->>flow: set-window float
    flow->>Reg: focused()
    Reg-->>flow: WindowId
    flow->>SS: remove_window(focused)
    Note over SS: right-side compresses left<br/>last_focused_window → successor
    SS-->>flow: AppliedLayout (scrolling post-remove)
    flow->>flow: centered_rect(preferred, work_area)
    flow->>FS: add(focused, centered_rect)
    flow->>FS: to_actual_layout()
    FS-->>flow: ActualLayout (floats)
    flow->>Reg: state = Floating(Active { rect })
    flow->>Anim: animate_workspaces([(scroll_actual, 0), (float_actual, 0)])

Key point: OS focus stays on the same window. It pops to center while the scrolling grid rearranges behind it. ScrollingSpace::last_focused_window (see Focus model) moves to the next tile, but the user’s foreground window doesn’t change.

Float → Tile: Snap to Grid

  1. FloatingSpace::remove(focused) — removes the window from the floating list, returning its old rect.
  2. ScrollingSpace::insert_window(focused) — inserts a new column immediately after last_focused_window, shifts right-side columns rightward, sets last_focused_window = focused, and calls ensure_column_visible.
  3. Animate both the post-insertion scrolling layout and the remaining floating layout in a single batch.
sequenceDiagram
    participant CLI as flow CLI
    participant flow as Daemon
    participant FS as FloatingSpace
    participant SS as ScrollingSpace
    participant Reg as WindowRegistry
    participant Anim as Animator

    CLI->>flow: set-window tile
    flow->>Reg: focused()
    Reg-->>flow: WindowId
    flow->>FS: remove(focused)
    flow->>SS: insert_window(focused)
    Note over SS: inserts right of last_focused_window<br/>shifts right side rightward<br/>sets last_focused_window = focused<br/>ensure_column_visible
    SS-->>flow: AppliedLayout (scrolling post-insert)
    flow->>FS: to_actual_layout()
    FS-->>flow: ActualLayout (floats minus one)
    flow->>Reg: update_tiling_slots + tiled_rects
    flow->>Anim: animate_workspaces([(scroll_actual, 0), (float_actual, 0)])

Key point: the window snaps from its floating rect into a tile slot. last_focused_window is set to the newly tiled window so the next insert will go to its right.

Side-by-Side Comparison

Tile → FloatFloat → Tile
Moved windowPops to centered rectSnaps into new tile slot right of last_focused_window
Scrolling layoutremove_window: right-side compresses left, last_focused_window → successorinsert_window: new column added right of last_focused_window, right side shifts right
Floating spaceadd(focused, centered_rect) — new entry appendedremove(focused) — entry removed
OS focusUnchanged (same window stays foreground)Unchanged (same window stays foreground)
last_focused_windowMoves to next_available_window via remove-window successionSet to the moved window (it’s now the most recently interacted-with tile)
Registry stateTiling(Active)Floating(Active { rect })Floating(Active)Tiling(Active { col, row }) (auto-synced by update_tiling_slots_from_layout)

Animation Batch Merging

dispatch_set_window submits a single animate_workspaces call with two (ActualLayout, y_offset) pairs at y_offset = 0 (same workspace):

[(scroll_actual, 0), (float_actual, 0)]

This is the same pattern used by dispatch_move_window_to_workspace. Both the scrolling and floating layouts are submitted together so the animator’s RetargetFromCurrent policy coordinates the entire transition in lockstep — tiles slide to fill the gap while the floated window simultaneously moves to center (or vice versa). Submitting them separately would cause a visible desynchronisation where one side animates before the other.

Workspace Switching with Floats

dispatch_switch_workspace extends naturally: for each workspace on the active monitor, the daemon merges the scrolling ActualLayout and the floating ActualLayout into one combined layout before partitioning into the animate/teleport/skip buckets.

flowchart LR
    subgraph PerWorkspace["Per workspace"]
        SA["scroll_actual"] --> MERGE["merged_entries<br/>= scroll + float"]
        FA["float_actual"] --> MERGE
    end
    MERGE --> PARTITION{"Participant?"}
    PARTITION -- Yes --> ANIM["animate_workspaces"]
    PARTITION -- "No, side changed" --> TELE["teleport_workspaces"]
    PARTITION -- "No, same side" --> SKIP["Skip"]

Why merge rather than submit separate batches? Two reasons:

  1. y-offset coherence — every window in a workspace shares the same y-offset for the workspace switch. Separate batches could place tiles and their workspace’s floating windows at different offsets mid-animation.
  2. Per-workspace stacking invariant — the animator processes windows in batch order. Merging keeps all windows from one workspace together, which matters when RetargetFromCurrent compares current against target positions.

Focus Model Clarification

Three distinct “focus” concepts exist in the codebase. Conflating them caused bugs during early development of the floating-space feature, so the naming was deliberately differentiated.

ConceptOwnerScopeUsed for
OS focus (registry.focused())WindowRegistryGlobal — the actual Win32 foreground windowThe authoritative resolver for which window a command acts on: set-window transitions, all tiled-only layout ops (focus / swap / expand / shrink / set-column-width / toggle-monocle / center / merge / promote), and SetForegroundWindow calls
ScrollingSpace::last_focused_windowScrollingSpacePer-space — most recently interacted-with tileInsert-after anchor, focus succession on removal, workspace-switch re-foreground. Not used to resolve command targets — tiled-only ops read OS focus instead (see below)
(none for floats)set-window operates on OS focus regardless of which space the window is in; tiled-only ops silently no-op when a float is foreground (future: float-aware ops such as pixel-nudge for move-window)

Why last_focused_window (not focused)

The original field was named focused, which implied OS-level foreground. This caused confusion when implementing set-window: is “the focused window” the OS-foreground window or the scrolling space’s internal cursor? Renaming to last_focused_window makes the “history cursor” semantics explicit — it tracks the most recently interacted-with tile within this space, not the global Win32 foreground.

Floating windows have no separate cursor because the OS focus is sufficient: dispatch_set_window reads registry.focused() to find the target window, then inspects its WindowState to decide what transition to apply. The scrolling space’s cursor is irrelevant for this lookup.

Tiled-only ops resolve from OS focus

All layout operations that only make sense for tiles — focus, swap-window, swap-column, merge-column, promote-window, expand-column, shrink-column, set-column-width, toggle-monocle, center — resolve their target from registry.focused() (the OS foreground), not from ScrollingSpace::last_focused_window. After resolving the foreground WindowId, the dispatch handler verifies matches!(state, WindowState::Tiling(TilingState::Active { .. })). If the foreground is a float, ignored, minimized, hidden, or absent, the handler logs at debug! and returns SocketResponse::Ok silently — a deliberate no-op, not an error. (Matching existing no-op precedent: switch-to-self, move-to-self, and set-window NoOp. There is no Warning/Info variant on SocketResponse.)

This prevents the bug where focusing a float left last_focused_window pointing at a stale tile, and a subsequent column-resize silently acted on that tile instead of being a no-op. last_focused_window is still written by the focus op (it remains the per-space tile history cursor for insert-after and succession purposes), but it is no longer read by any command handler to decide what to act on.

The classification is inline in each dispatch handler rather than wrapped in a helper, so each handler can emit a debug! message naming the specific op. When float-specific behavior arrives (e.g. move-window → pixel nudge), a second matches! arm for WindowState::Floating(FloatingState::Active { .. }) will slot in alongside the tiling arm.

Configuration

The [floating] section in flow.toml controls default floating window dimensions:

[floating]
# default_width = 1200    # explicit pixel width (optional)
# default_height = 800    # explicit pixel height (optional)

Both fields are optional explicit pixel sizes (Option<i32>). When omitted (the default), the daemon uses a built-in fallback: 60% × 80% of the monitor’s work area, capped at approximately 1536 × 1152 pixels (derived from a QHD 2560×1440 reference). The cap ensures ultrawide and 4K monitors don’t produce absurdly large popups. An explicit pixel value is always respected as-is — the cap applies only to the fallback. The fallback constants live in src/daemon/dispatch.rs.

These defaults are used as a fallback when a window has no last_natural_size. Most windows do have a natural size (measured from their DWM visible rect at registration time), so the fallback is only consulted for edge cases where the natural size is zero or unavailable.

Config-defaults rule

Code is the single source of truth. The Default impl in src/config/types.rs defines the actual runtime defaults; default-config.toml is a hand-written example synced by a compile-time test. See config and persistence for the full design rationale.

IPC + CLI

Command Surface

flow dispatch set-window float     # float the focused window
flow dispatch set-window tile      # tile the focused window
flow dispatch set-window cycle     # toggle based on current state

The IPC wire format:

{"type": "set_window", "mode": "float"}
{"type": "set_window", "mode": "tile"}
{"type": "set_window", "mode": "cycle"}

SocketMessage::ToggleFloat is aliased to dispatch_set_window(Cycle) — the legacy toggle name and the new cycle mode are semantically identical.

The Decision Function

resolve_set_window_action is a pure const fn extracted from dispatch_set_window so the full mode × state decision table is unit-testable without constructing a FlowWM (which owns Win32 handles).

flowchart TB
    REQ["SetWindow request<br/>(mode + focused window)"]
    REQ --> CHECK{"Currently tiling<br/>or floating?"}
    CHECK -- "No (ignored/minimized/hidden)" --> ERR["Err — no transition possible"]
    CHECK -- Yes --> MODE{"Requested mode?"}
    MODE -- Float --> FCHECK{"Already floating?"}
    FCHECK -- Yes --> NOP1["NoOp"]
    FCHECK -- No --> MF["MakeFloating"]
    MODE -- Tile --> TCHECK{"Already tiling?"}
    TCHECK -- Yes --> NOP2["NoOp"]
    TCHECK -- No --> MT["MakeTiling"]
    MODE -- Cycle --> CCHECK{"Currently tiling?"}
    CCHECK -- Yes --> MF2["MakeFloating"]
    CCHECK -- No --> MT2["MakeTiling"]

Full decision table:

modecurrently tilingcurrently floatingresult
FloattruefalseMakeFloating
FloatfalsetrueNoOp
TiletruefalseNoOp
TilefalsetrueMakeTiling
CycletruefalseMakeFloating
CyclefalsetrueMakeTiling
anyfalsefalseErr (ignored / minimized / hidden)

Future Work

Several enhancements are planned but not yet implemented:

  • Smart placement — cascade floating windows so they don’t fully overlap, or offset new floats by a fixed delta from the previously floated window.
  • Per-window float size memory — remember each window’s last floating rect and restore it on subsequent tile→float transitions, rather than re-centering every time.
  • Z-order raising — use place_above (via SetWindowPos with HWND_TOPMOST / restore) to bring the focused floating window to the top of the z-order, matching the expected “click to focus” behavior.
  • Floating gap management — reserve padding around floating windows so they don’t visually collide with tiled windows at workspace edges.

Cross-References

  • Workspace Hierarchy — where FloatingSpace fits in the monitor → workspace → space tree.
  • Layout Overview — the virtual/actual projection pipeline that FloatingSpace deliberately bypasses.
  • Window RegistryWindowState::Floating, focus tracking, and last_natural_size.
  • Animation — how animate_workspaces processes the merged batch.
  • IPC & Watchdog — the full SocketMessage catalog and named-pipe transport.
  • Config & Persistence — config resolution, the code-is-source-of-truth model, and the dual-edit rule.

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).

Classification & Learned Rules

flow classifies every new window as tiling, floating, or ignored. This chapter covers why the default is float, how flow learns from the user’s explicit set-window decisions, and where that learned state lives on disk. For the rule-matching algorithm itself — the Win32 pre-filters, the per-layer first-match-wins evaluation, and the regex/field semantics — see Window Registry.

The Whitelist Model: Float by Default

Most tiling window managers take a blacklist approach: every window tiles unless the user adds a rule to float it. flow inverts this. Every window floats unless the user promotes it to tiling — either with an explicit rule in flow-rules.toml or by running flow dispatch set-window tile on it once.

This whitelist model is encoded in a single value: the default_action field of WindowRulesConfig defaults to Float (see src/config/types.rs). When no rule at any layer matches a window, the pipeline returns Float rather than Tile. The bundled default-flow-rules.toml carries default_action = "float" to match.

The rationale: on a scrolling-canvas tiler like flow, most application windows (notably transients, dialogs, and small utilities) look wrong in a tile column. Tiling is most useful for a small set of “main” apps the user actively works in — and the user is the best judge of which apps those are.

The Four-Layer Priority Chain

Every classification consults four layers in priority order. The first layer to match wins; layers below it are not consulted:

flowchart TB
    W["New window<br/>(exe, title, class)"] --> L1{"User rules<br/>(flow-rules.toml)?"}
    L1 -- match --> R1["use user rule"]
    L1 -- no match --> L2{"Learned rules<br/>(history-flow-rules.toml)?"}
    L2 -- match --> R2["use learned rule"]
    L2 -- no match --> L3{"Default rules<br/>(embedded at compile time)?"}
    L3 -- match --> R3["use default rule"]
    L3 -- no match --> L4["default_action<br/>= Float"]
    R1 --> OUT["WindowState"]
    R2 --> OUT
    R3 --> OUT
    L4 --> OUT

The four layers, from highest to lowest priority:

  1. User rules — hand-written in flow-rules.toml. Always win. The escape hatch for “I want this app to behave this way regardless of what flow learned.”
  2. Learned rules — machine-written to history-flow-rules.toml. See Learned Rules below.
  3. Default rules — embedded into the binary at compile time from default-flow-rules.toml. Catch well-known system windows (taskbar, Chromium helper windows, dialogs).
  4. default_action — the unconditional fallback, now Float.

User rules outrank learned rules deliberately: if flow’s learned behavior conflicts with the user’s intent, the user’s flow-rules.toml entry wins and the learned rule is never consulted for that app.

Learned Rules

Learned rules are flow’s memory of the user’s explicit float/tile decisions. When the user runs flow dispatch set-window float or flow dispatch set-window tile on a window, flow records the decision keyed on that app’s identity. The next time a window of the same app appears, it is classified automatically — no rule writing required.

What Triggers a Recording

Only actual transitions are recorded. A set-window call that resolves to a no-op (the window was already in the requested mode) records nothing. This keeps the history file free of redundant entries and makes repeated commands idempotent.

The recording happens inside dispatch_set_window, after the transition succeeds:

sequenceDiagram
    participant CLI as flow CLI
    participant D as dispatch_set_window
    participant H as HistoryStore
    participant R as WindowRegistry
    participant FS as history-flow-rules.toml

    CLI->>D: set-window float
    D->>D: resolve action (MakeFloating / MakeTiling / NoOp)
    D->>R: execute transition
    R-->>D: Ok
    alt action != NoOp
        D->>H: record(action, exe, class)
        H-->>D: changed? (dedup-and-update)
        alt changed
            D->>FS: save (atomic: temp + rename)
            D->>R: set_learned_rules(refresh pipeline)
        end
    end
    D-->>CLI: Ok

The pipeline refresh (set_learned_rules) is what makes the new decision take effect immediately — the very next window of that app is classified using the updated learned layer, with no daemon restart.

App Identity: exe + class

The identity key for a learned rule is the app’s executable name plus its Win32 window class name (e.g. chrome.exe + Chrome_WidgetWin_1). Both fields are used when the class is non-empty; when the class is empty, the key falls back to exe alone.

Title is deliberately not part of the key. A window’s title changes constantly (the document name, the current tab, the cursor position), so keying on it would fragment one app into dozens of “different” apps. The exe + class pair is stable for the lifetime of an application version.

Dedup-and-Update, Not Append

When the user toggles an app’s mode repeatedly — float it, then tile it, then float it again — flow does not append a new rule each time. Instead, the existing learned rule for that exe + class is updated in place. This keeps the history file bounded and makes the most recent decision authoritative.

If append were used instead, first-match-wins evaluation would keep returning the first recorded (stale) decision forever, and the file would grow without bound. Dedup-and-update avoids both problems.

The History File

Learned rules persist to history-flow-rules.toml in the config directory (default %USERPROFILE%\.config\flow\, overridable via FLOW_CONFIG_DIR). The file uses the same schema as flow-rules.toml — a default_action field and a [[rules]] array — so it is human-readable and human-editable. flow writes it atomically (write to .toml.tmp, then rename) so a crash mid-write cannot corrupt the existing file.

The default_action field in the history file is ignored at load time; only the rules array is read. (The field exists only because the schema is shared with flow-rules.toml.)

Clearing or Overriding Learned State

To forget everything flow has learned:

  • Delete history-flow-rules.toml — flow recreates it (empty) on the next recorded transition. Until then, classification falls through to the default rules and default_action = Float.

  • Delete a single app’s entry — open the file in a text editor and remove the matching [[rules]] block. flow will not re-add it until the user runs set-window on that app again.

  • Override a learned rule permanently — add an explicit rule for the app in flow-rules.toml. User rules outrank learned rules, so the user’s choice always wins regardless of what the history file says.

The ForgetApp / ForgetAllApps IPC commands (declared in src/ipc/message.rs) are intended as a programmatic way to clear learned state without touching the file by hand. They are not yet implemented.

Where the Code Lives

ConcernLocation
History store (load, record, dedup, save)src/config/history.rs
History file path resolutionhistory_rules_path[_in] in src/config/dirs.rs
default_action = Float defaultDefault impl for WindowRulesConfig in src/config/types.rs
Four-layer pipeline evaluationClassificationPipeline::classify in src/registry/classification.rs
Runtime pipeline refreshset_learned_rules on ClassificationPipeline and WindowRegistry
Capture on set-window transitionrecord_learned_transition in src/daemon/dispatch.rs
Daemon-owned history storehistory field on FlowWM, loaded in new()

Cross-References

  • Window Registry — the full classification algorithm: Win32 pre-filters, the rule-pipeline flow diagram, and per-field match semantics.
  • Config & Persistence — the flow-rules.toml format for user-defined rules and the “code is the source of truth” config philosophy.
  • Floating Space — where float-classified windows live and how tile↔float transitions are animated.

Animation

The animation layer moves windows smoothly between layout positions. It is an in-tree copy of a standalone window-animation crate, embedded directly into the project rather than pulled as a dependency. This embedding strategy keeps the animation code tightly coupled to the project’s needs (specifically the InvisibleBounds translation and the AppliedLayout bridge) while retaining a clean backend abstraction for testing. See design decisions for the embedding rationale.

Public API Surface

The animation layer exposes a small set of types through src/animation/mod.rs:

  • WindowAnimator — the main entry point. Owns a background worker thread that drives the frame loop. Submit animation batches with animate().
  • WindowTarget — desired final geometry for one window (window ref + target position + target size).
  • WindowRef — opaque HWND wrapper (isize for Send safety).
  • IVec2 — 2D integer vector for positions and sizes.
  • AnimRect — the animation engine’s rectangle type, {x, y, w, h}.
  • AnimatorConfig — duration, easing curves, interrupt policy, frame pacing.

The AnimRect type uses {w, h} for its size fields. This is intentionally different from flow’s common::Rect which uses {width, height}. The two types coexist in different modules and are converted at integration boundaries. The daemon bridge handles this conversion transparently.

Data Flow

When a layout mutation produces an AppliedLayout, the daemon calls animate_layout(), which converts the layout into animation targets and submits them to the animator’s worker thread.

flowchart TB
    SUB["ScrollingSpace<br/>layout mutation"]
    AL["AppliedLayout<br/>(virtual + actual)"]
    BRIDGE["daemon::animate_layout()<br/>visible-to-window-rect<br/>translation"]
    TARGETS["Vec&lt;WindowTarget&gt;"]
    ANIM["WindowAnimator::animate()<br/>build_tweens()"]
    TWEEN["AnimationBatch<br/>per-window tweens"]
    BACKEND["WindowBackend<br/>(Win32 or Mock)"]
    SWP["SetWindowPos per window"]

    SUB --> AL
    AL --> BRIDGE
    BRIDGE --> TARGETS
    TARGETS --> ANIM
    ANIM --> TWEEN
    TWEEN --> BACKEND
    BACKEND --> SWP

The Frame Loop

WindowAnimator spawns a dedicated worker thread at construction time. The worker drives a continuous loop:

  1. When idle, it blocks on a crossbeam_channel waiting for commands.
  2. When an Animate command arrives, it builds per-window tweens (from rect, to rect) and enters the frame loop.
  3. Each frame, it samples the normalised progress t from wall-clock elapsed time, computes the interpolated rect for each tween, and calls backend.apply_batch() to reposition the windows.
  4. After applying, it calls backend.dwm_flush(), which blocks until the Desktop Window Manager completes its current composition cycle (~16ms at 60Hz). This is the frame-pacing mechanism: rather than a fixed sleep interval, DwmFlush synchronises the animation with the display refresh rate.
  5. The batch is cleared only when t reaches exactly 1.0 — the frame that was applied at the precise target rect. This prevents the “resizing column width comes out wrong” bug where progress() ticks past 1.0 during the SetWindowPos round-trip and the batch is retired without ever applying the final frame.

Progress is clamped to [0.0, 1.0] and never exceeds 1.0, even if wall-clock time far exceeds the animation duration. This invariant makes t >= 1.0 a precise and safe completion signal.

Easing Functions

Position and size channels are eased independently. The animation module (src/animation/easing.rs) provides a comprehensive catalog of 31 named easing curves plus arbitrary CSS cubic-bezier curves (solved via Newton-Raphson iteration):

CategoryVariants
LinearLinear
SineEaseInSine, EaseOutSine, EaseInOutSine
QuadraticEaseInQuad, EaseOutQuad, EaseInOutQuad
CubicEaseInCubic, EaseOutCubic, EaseInOutCubic
QuarticEaseInQuart, EaseOutQuart, EaseInOutQuart
QuinticEaseInQuint, EaseOutQuint, EaseInOutQuint
ExponentialEaseInExpo, EaseOutExpo, EaseInOutExpo
CircularEaseInCirc, EaseOutCirc, EaseInOutCirc
BackEaseInBack, EaseOutBack, EaseInOutBack
ElasticEaseInElastic, EaseOutElastic, EaseInOutElastic
BounceEaseInBounce, EaseOutBounce, EaseInOutBounce
CustomCubicBezier(x1, y1, x2, y2)

The default config uses EaseInOut (maps to EaseInOutCubic) for position and Linear for size. Position curves can be more expressive because resize-heavy transitions look worse on Windows — window content must reflow each frame.

Size animation supports a DisabledIfUnchanged mode: when a window’s width and height already match the target, the size channels are skipped and the move is treated as a pure translation. This avoids unnecessary content reflow.

Batch Scheduling

The animation layer uses AnimationBatch (src/animation/batch.rs) to manage all per-window tweens for one animation run. Key design points:

  • No-op filtering: windows whose current rect already matches the target are silently excluded from the batch. This avoids wasting frames on windows that don’t need to move.
  • Independent easing per channel: position (x, y) and size (w, h) are eased with separate curves, allowing a fast position move combined with a slower size transition.
  • Wall-clock timing: progress is computed from Instant::now() at batch start, not from frame counting. This makes animation duration independent of frame rate.

Interrupt Policies

When a new animate() call arrives while an animation is already playing, the configurable interrupt policy decides what happens:

PolicyBehaviour
RetargetFromCurrent (default)Sample each window’s current interpolated position, cancel the active batch, and start a new batch from those positions toward the new targets. Preserves visual continuity.
QueueAfterCurrentFinish the current animation, then start the queued one.
DropNewSilently discard the incoming request.

The default RetargetFromCurrent policy is what enables smooth rapid-fire operations (e.g., holding the scroll key). Each successive layout mutation retargets windows from wherever they currently appear on screen, not from their original position.

The Two Backends

The WindowBackend trait (src/animation/backend/mod.rs) decouples the animation engine from Win32:

classDiagram
    class WindowBackend {
        <<trait>>
        +get_window_rect(WindowRef) Rect
        +apply_batch(updates) Result
        +dwm_flush() Result
    }
    class Win32Backend {
        Individual SetWindowPos calls
        + DwmFlush for vsync pacing
    }
    class MockBackend {
        In-memory HashMap of rects
        Records call history for assertions
    }
    WindowBackend <|-- Win32Backend
    WindowBackend <|-- MockBackend

Win32Backend (src/animation/backend/win32.rs) uses individual SetWindowPos calls rather than the batch DeferWindowPos API. This is a deliberate resilience choice: DeferWindowPos is atomic, so a single elevated admin window (protected by UIPI) causes the entire batch to fail with ERROR_ACCESS_DENIED. Individual calls mean one failure logs a warning but does not block the remaining windows.

MockBackend (src/animation/backend/mock.rs) stores rects in a HashMap and records call history. Always compiled (no feature gate) so both unit tests and integration tests can use it freely.

The Bridge: daemon::animation

The daemon’s animation bridge (src/daemon/animation.rs) is the integration point between the layout engine and the animation system. Its responsibilities:

  1. Registry sync: always calls update_tiling_slots_from_layout() and update_tiled_rects() on the registry, even when no windows physically moved. Logical positions (col/row) may change without pixel-level moves.

  2. Visible-rect to window-rect translation: the layout engine computes visible rects (what the user sees), but SetWindowPos uses window rects (which include invisible borders). The bridge looks up each window’s InvisibleBounds from the registry and calls visible_to_window() to translate. Without this, windows would appear with gaps larger than configured.

  3. Type conversion: maps flow’s WindowId(isize) to WindowRef(isize), and the visible rect’s {width, height} to IVec2::new(width, height).

  4. Submit all windows: every entry in actual_layout becomes a target, not just the ones that changed. The animator’s build_tweens drops no-ops internally, but passing all windows ensures that mid-flight retargeting works correctly even when a window’s target rect didn’t change.

A standalone animate_layout_raw() function performs the same conversion but takes &mut WindowAnimator directly. This is used during daemon construction when FlowWM doesn’t exist yet but the animator needs to snap windows to their initial positions (with Duration::ZERO for an instant snap).

Mid-Flight Retargeting

If a new layout arrives while a previous animation is still running, the RetargetFromCurrent policy ensures smooth transitions. The worker samples each window’s current interpolated position from the active batch (using the batch’s frozen config for consistency) and starts a new batch from those positions toward the new targets. This means rapid-fire operations like scrolling or swapping produce continuous motion with no visible restarts.

Cross-References

  • Layout pipeline — how AppliedLayout is produced by the mutate-then-project pipeline.
  • Event pipelines — when animate_layout is called in response to hook events and IPC commands.
  • Window registry — how InvisibleBounds is measured and stored per window.

Borders

flow draws komorebi/Hyprland-style colored borders around managed windows using click-through, layered overlay windows. Each border is seated just above its target window in z-order (not globally topmost), so overlapping windows — floats and ignored windows — correctly cover it. Each managed window can own a [Border] (Option<Border> on the registry’s [Window] struct) that renders a thin colored ring just inside the window’s visible content edge.

This chapter covers the border subsystem’s architecture: the positioning model, the coordinate-space fix, the lifecycle, and how borders participate in animation without a dedicated hook thread.

The Positioning Principle: Daemon Commands, Border Obeys

The border overlay never queries the OS for its target’s position. Instead, the daemon commands the border’s geometry. This is the central design decision and the defining difference from the previous architecture.

flowchart LR
    subgraph Old["Previous design"]
        direction TB
        OH["Private EVENT_OBJECT_LOCATIONCHANGE hook"]
        OH -->|"GetWindowRect(target)"| OVL["BorderOverlay"]
    end
    subgraph New["Current design"]
        direction TB
        D["Daemon IPC thread"]
        D -->|"set_geometry(visible_rect)"| B["Border"]
        D -->|"flatten into animator targets"| B
    end

The daemon already knows where every window should be — it computed the layout, it issued the SetWindowPos, and it tracks floating rects via its own EVENT_OBJECT_LOCATIONCHANGE subscription. Having the border re-derive this information from the OS is both redundant (a second hook) and incorrect (the OS rect includes invisible borders — see Coordinate Spaces).

Why Not a Private Hook?

The previous design gave BorderManager its own background thread (flow-borders-hook) that subscribed to EVENT_OBJECT_LOCATIONCHANGE for all desktop windows. Three problems motivated its removal:

  1. Wasted work. Two SetWinEventHook registrations for the same event in one process means the OS fires the callback twice per location change. The border’s hook was global (not filtered to managed windows), so it did work for every window on the desktop.

  2. Process-global state. SetWinEventHook callbacks take no user data, so the hook reached the border state through a static OnceLock<Arc<Inner>>. This indirection is gone — the border now lives directly on Window.

  3. The misalignment bug. The hook called GetWindowRect(target), which returns the full window rect including the invisible DWM resize border. The colored ring was drawn at the HWND’s outer edge, not at the visible content edge. See Coordinate Spaces for the fix.

Coordinate Spaces

This is the subtle part. Two distinct rectangles describe each window:

RectWhat it representsUsed for
Window HWND rectThe full GetWindowRect — includes invisible DWM resize bordersSetWindowPos (positions the actual window)
Visible content rectWhat the user sees as the window’s content areaLayout engine output, border positioning

The layout engine computes visible content rects (entry.rect in ActualLayout). The daemon’s animation bridge translates these into window HWND rects before issuing SetWindowPos, using each window’s measured InvisibleBounds (see Window Registry).

The border overlay is positioned at the visible content rect directly — no translation, no invisible-bounds expansion:

   ┌─── window HWND rect (GetWindowRect) ───┐
   │ ▒ invisible DWM border (resize frame) ▒ │
   │ ┌─── border overlay (entry.rect) ─────┐ │   ← positioned at visible rect
   │ │█                                  █│ │      ring = thickness px
   │ │█  ┌── visible content ──────────┐ █│ │
   │ │█  │ (inset by thickness-overlap)│ █│ │      window content lives here
   │ │█  │                             │ █│ │
   │ │█  └─────────────────────────────┘ █│ │
   │ │█                                  █│ │
   │ └───────────────────────────────────┘ │
   │ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │
   └───────────────────────────────────────┘

The window target rect is computed as:

visible_to_window(entry.rect).inset(border_thickness - border_overlap)

This expands the visible rect out to the HWND rect, then shrinks it inward by (thickness - overlap) on every side. The ring is the outer thickness px of entry.rect, so it overlaps the visible content by overlap px on each edge. With the default overlap = 1, this single pixel closes the 1px DWM client-edge hairline that otherwise shows between an unfocused ring and the window content. overlap = 0 leaves a gap exactly thickness px wide (ring flush against the content edge — the pre-overlap behavior); overlap = thickness fills the whole layout slot with content sitting under the ring.

The old design drew the ring at the HWND rect’s outer edge (the invisible border), producing a visible gap between the colored ring and the window content.

Lifecycle

A Border is created, recolored, repositioned, and destroyed entirely from the daemon’s IPC thread. No background thread, no callbacks.

stateDiagram-v2
    [*] --> None: Window::new
    None --> Active: refresh_border_for (create)
    Active --> Active: set_style (recolor on focus change)
    Active --> Active: set_geometry / animator (reposition)
    Active --> None: refresh_border_for (minimize/hide/destroy)
    None --> [*]: Window::drop → Border::drop → DestroyWindow

Creation: refresh_border_for

daemon::borders::refresh_border_for is the single entry point for border lifecycle changes. It resolves the desired BorderStyle from the window’s registry state, then:

  • None (minimized / hidden / ignored) → sets window.border = None, dropping the Border and triggering DestroyWindow via Drop.
  • Some(style) + border exists → re-seats the overlay just above its target (seat_above_target), then calls set_style(style). set_style compares the new style against the current one and short-circuits when they are equal — no UpdateLayeredWindow, no repaint. This makes no-op recolors free.
  • Some(style) + no border → creates the overlay via Border::create(style, target_hwnd), immediately calls set_geometry(rect) so it doesn’t flash at (0,0) before the next animation frame, and assigns it to window.border. create seeds the z-order by seating the overlay just above target_hwnd.

That rect is read from the window’s state: tiled_rect for active tiles; for active floats the stored rect is outset by (thickness − overlap) via float_border_rect, so a float border’s ring sits in the surrounding gap like a tiled border’s, even before the first drag.

Focus changes are O(1)

A focus switch repaints only the two affected overlays — the window losing focus and the window gaining it — not every managed window. on_focus_changed captures the previous focused HWND, updates the registry’s focus, then calls refresh_border_for on just those two HWNDs. Combined with the set_style short-circuit, a focus change that lands on an already-correctly-colored window costs nothing. (The full O(N) refresh_all_border_styles survives only for the one-time init pass.)

Init highlight

At daemon startup, new queries the OS foreground window and calls registry.set_focused(fg) before the init refresh_all_border_styles. This ensures the window that was already foreground when the daemon launched is colored Focused from the first paint, rather than waiting for the first EVENT_SYSTEM_FOREGROUND.

Late-detected windows (Windows Terminal)

Windows Terminal (and other late-titling apps) is not caught by EVENT_OBJECT_CREATE: its title arrives later, so classification defers to the NAMECHANGE / SHOW recovery path, which re-runs on_window_created (see Event Pipelines). The problem: by the time the window is finally tracked, its EVENT_SYSTEM_FOREGROUND has already fired, so the registry’s focused HWND is stale and the freshly-created border paints Unfocused. on_window_created now reconciles: when a newly tracked window is the live OS foreground (GetForegroundWindow()), it calls registry.set_focused(hwnd) before refresh_border_for, so the recovery path paints Focused immediately.

Destruction: Drop

Border is Arc<BorderInner>. The overlay HWND is destroyed exactly once, when the last Arc clone drops (BorderInner::dropDestroyWindow). This happens eagerly when window.border = None, or lazily when the Window itself leaves the registry.

Setting window.border = None is the canonical “detach” operation — there is no separate detach method. The old BorderManager::detach(HWND) is gone.

Three Movement Paths

Borders move via three different mechanisms depending on the window’s state. All three share the same repaint mechanism: when SetWindowPos changes the overlay’s size, Win32 sends WM_SIZE, whose handler rebuilds the bitmap. Move-only SetWindowPos calls (same size) do not trigger WM_SIZE — the compositor simply translates the cached bitmap.

PathWhenHowBitmap rebuild?
AnimatorTiled window animatesBorder flattened into Vec<WindowTarget> alongside the window; SetWindowPos moves both in lockstepYes if size changes (WM_SIZEon_wm_size); no for move-only frames
Float hookFloating window draggedstore_float_rect calls set_geometry(visible_rect) after updating the registrySame: set_geometry calls SetWindowPos, which triggers WM_SIZE if resized
TeleportBystander workspace switchteleport_workspaces calls set_geometry(visible_rect) directly (instant, no animation)Same

The overlay is self-sufficient: WM_SIZE drives repaint

The overlay’s window procedure (overlay_wnd_proc) handles WM_SIZE by retrieving the BorderInner back-pointer from GWLP_USERDATA and calling on_wm_size, which queries the overlay’s current rect via GetWindowRect and calls paint. Because the size changed (the precondition for WM_SIZE), paint rebuilds the bitmap via CachedSurface::build and re-uploads it via UpdateLayeredWindow.

This eliminates the stale-bitmap bug that previously occurred during resize animations (expand-column, shrink-column). Before the WM_SIZE handler existed, SetWindowPos updated the overlay’s outer rect but left the cached bitmap at the old size — the new edge area had no pixels, so the border edge disappeared mid-animation. The author worked around this for teleport_workspaces by calling set_geometry explicitly (which called paint directly), but the animator path was missed. With WM_SIZE handling, both paths — and any future caller — automatically get a correct bitmap.

Float hook integration

The daemon’s existing EVENT_OBJECT_LOCATIONCHANGE subscription (filtered to active-workspace floats — see Event Pipelines) already tracks floating window positions. store_float_rect computes the visible rect and mirrors it into FloatingState::Active { rect }. After that update, it seats the overlay via float_border_rect(visible_rect) — the visible rect outset by (thickness − overlap) so the ring lands in the surrounding gap, matching the ring geometry of a tiled border (whose content the animator insets by the same amount). The same helper is used at border creation in refresh_border_for, so a freshly created float border matches one mid-drag.

This means float borders follow the window in real time during drags, driven by the same hook that already tracks the float rect. No second hook, no extra traffic.

Threading Model

Borders live entirely on the daemon’s single IPC thread. There is no border hook thread.

Border’s methods take &self and mutate through Mutex (interior mutability). In practice the mutexes are uncontended — all access happens on one thread. They exist because Border is reached via Arc clones held by the registry’s Window, and &self methods are more ergonomic than &mut self when the daemon holds a &mut Window but wants to call multiple border methods.

The animator is the one exception: border HWNDs are flattened into WindowTargets and sent to the animator’s worker thread as WindowRef(isize). But the animator only calls SetWindowPos on them — it never touches the Border struct itself. The overlay HWND is a real window, so the animator’s SetWindowPos-based backend treats it like any other.

When a cross-thread SetWindowPos resizes the overlay, Win32 dispatches WM_SIZE via SendMessage to the thread that created the overlay (the IPC thread). The IPC thread’s message pump (run.rs::pump_messages) drains the queue on every loop wake, so on_wm_sizepaint runs on the IPC thread even though the animator triggered it. The animator blocks inside SetWindowPos until the IPC thread processes the message. This adds a small per-frame cost during resize animations (~0.5-1 ms per border for the repaint), well within the ~14 ms headroom the animator has at 60 Hz.

The Border Type

classDiagram
    class Border {
        +create(style, target_hwnd) Result~Border~
        +hwnd() isize
        +seat_above_target()
        +set_geometry(visible_rect)
        +set_style(style)
        +set_visible(bool)
    }
    class BorderInner {
        -overlay: Mutex~isize~
        -target: isize
        -style: Mutex~BorderStyle~
    }
    class BorderStyle {
        +color: Color
        +width_px: u32
        +corner_preference: CornerPreference
    }
    Border --> BorderInner : Arc
    BorderInner --> BorderStyle

Border is Arc<BorderInner>Clone is a cheap refcount bump. This keeps Window: Clone sound (the registry derives Clone for snapshots and queries) while guaranteeing DestroyWindow runs exactly once.

When recoloring via set_style (on focus changes), the border queries its own overlay position with GetWindowRect rather than remembering a commanded rect. This is essential because the animator moves overlays via SetWindowPos without going through set_geometry — so the overlay’s actual position is the only source of truth at repaint time. (UpdateLayeredWindow both rebuilds the bitmap and repositions the layered window, so feeding it a stale rect would snap the border back to its pre-animation location.) The border never queries the target window — only its own overlay.

Z-order: seated above the target

The overlay is not WS_EX_TOPMOST. Instead, each Border remembers its target HWND (the target field on BorderInner) and seats itself just above that sibling via SetWindowPos(overlay, hwndInsertAfter = target, …) — see seat_above_target. hwndInsertAfter places the overlay immediately above the named sibling in z-order, which is exactly the relationship we want: the border rides on top of the one window it decorates.

This matters because the border ring wraps the outside of the window — there is no overlap with the target itself — but other windows do overlap the ring region. With WS_EX_TOPMOST every border floated above float windows and ignored windows; a float dragged over a tiled border covered nothing. Seated above the target, a float (which is itself above the tiled window) correctly covers the tiled window’s border.

Z-order is established once at create / re-asserted at set_geometry (seat_above_target), and the animator preserves it: the animator’s SetWindowPos uses SWP_NOZORDER, so it only translates the overlay without disturbing its place in the z-stack. set_geometry deliberately drops SWP_NOZORDER so it can re-assert the seat on every size/position command.

Rendering Pipeline

Each border overlay is a WS_EX_LAYERED window painted via UpdateLayeredWindow (the ULW_ALPHA mode). The render path:

  1. Build a 32-bit ARGB DIB section sized to the current rect.
  2. Fill the border ringfill_border_ring (a pure, unit-tested function) writes the configured Color × alpha into the outer thickness-px ring of the pixel buffer, leaving the interior transparent. On Windows 11 the ring is rounded to match the target window’s corner preference (queried via DwmGetWindowAttribute(DWMWA_WINDOW_CORNER_PREFERENCE)), so the border hugs the window’s rounded corners instead of drawing a square halo. See Corner preference.
  3. Upload via UpdateLayeredWindow with ULW_ALPHA + a BLENDFUNCTION that uses AC_SRC_ALPHA per-pixel alpha. AC_SRC_ALPHA requires the source bitmap to carry premultiplied ARGB: each RGB channel must already be scaled by its pixel’s alpha/255, because the compositor’s over-operator is result = src.RGB + dst.RGB·(1 − src.α) and does not re-scale the source channels. Every pixel writer in the pipeline (fill_border_ring, blit_corner, recolor_pixel) encodes via pack_premultiplied, so partial-coverage pixels at corner arcs blend to the correct perceptual colour rather than producing a bright fringe where the unscaled RGB is added at full intensity. Opaque fills (α=255) are identity under premultiplication and use the simpler pack_bgra.

The overlay’s extended style makes it click-through (WS_EX_TRANSPARENT) and invisible to the taskbar/Alt+Tab (WS_EX_TOOLWINDOW). It never takes focus (WS_EX_NOACTIVATE) and is a plain WS_POPUP — it is not WS_EX_TOPMOST; its z-order comes from being seated above its target, not from a global topmost flag.

Corner preference

BorderStyle carries a corner_preference: CornerPreference (Default / Square / Rounded / RoundedSmall). Rather than expose this as a config knob, the daemon auto-detects it per window by reading the target’s live DWM corner preference (DwmGetWindowAttribute with DWMWA_WINDOW_CORNER_PREFERENCE) inside border_style_for. This is intentionally “rendering-only” (Option A): the border matches whatever corner shape the window already has; it never forces a window square or rounded.

corner_radius_px turns that preference into a pixel radius for the outer edge of the ring, then subtracts thickness for the inner edge so the ring stays a uniform thickness wide around a concentric arc:

PreferenceWindow radiusRing outer radius
Square00 (square fast-path)
Rounded8 px8 + thickness
RoundedSmall4 px4 + thickness
Defaulttreated as 8 px (Win11 default)8 + thickness

fill_border_ring has a square fast-path (radius 0: a slice-fill — exact, because the edges are pixel-aligned) and a rounded path. The rounded path anti-aliases the corner arcs — the only edges that aren’t pixel-aligned — using exact pixel-circle area integration rather than stochastic supersampling. For each pixel in the [0, r] × [0, r] corner tile, coverage is computed analytically as area(pixel ∩ outer circle) − area(pixel ∩ inner circle), both circles concentric at the arc centre (r, r) with radii r and r − thickness respectively. The area formula is closed-form, built on the antiderivative G(y) = (y·√(R²−y²) + R²·asin(y/R)) / 2; quick-reject and quick-accept tests on the pixel’s nearest and farthest corners short-circuit the common fully-inside and fully-outside cases. This yields a continuous 256-level gradient at the fringe (versus the 17 discrete levels a 4×4 supersample grid would produce), avoiding banding on long arcs without pulling in a Direct2D/DirectComposition dependency.

The per-tile cost is paid once per (radius, thickness) pair and cached. Three of the four corners are reproduced by reflection, because the annulus is symmetric about the arc centre. composite_ring (the production hot path) blits the cached tiles plus solid straight-band fills; fill_border_ring (the test reference oracle) recomputes the same coverage per pixel via the shared corner_pixel_alpha helper, keeping the two paths byte-identical. Recolors swap the RGB channels in place via recolor_pixel while preserving each pixel’s coverage alpha. The DWM read fails open (returns Default) if the attribute can’t be read. Microsoft does not document the exact pixel radii; 8 px / 4 px are the observed Win11 values.

Configuration

Borders are configured under the [borders] section in flow.toml:

[borders]
enabled = true
thickness = 3
overlap = 1                    # px the ring overlaps content per edge (closes the DWM hairline)
focused_color = "#00AAFF"      # the focused/active window
unfocused_color = "#555555"    # tiled but not focused
floating_color = "#AA00FF"     # floating windows
FieldTypeDefaultNotes
enabledbooltrueMaster switch. false prevents overlay creation and detaches existing overlays.
thicknessu323Ring width in px, uniform on all sides. Capped at 50 by validate().
overlapu321Pixels the ring overlaps the visible content per edge. 0 = ring entirely in the reserved gap (window shrinks by the full thickness); thickness = content fills the layout slot under the ring. Capped at thickness by validate().
focused_colorColor#00AAFFColor for the OS-foreground window.
unfocused_colorColor#555555Color for tiled-but-not-focused windows.
floating_colorColor#AA00FFColor for floating windows (komorebi convention: floats always use this regardless of focus).

No corner_preference field. Corner shape is auto-detected per window from DWM (see Corner preference), not set in config. The border always matches the window’s existing corner shape.

The daemon resolves which color applies via style_for_state(cfg, state), mapping its internal WindowState onto the three-bucket BorderState enum (Focused / Unfocused / Floating). Minimized, hidden, and ignored windows produce no border at all.

Config-defaults rule

Code is the single source of truth. The Default impl in src/config/types.rs defines the actual runtime defaults; default-config.toml is a hand-written example synced by a compile-time test. See config and persistence.

Cross-References

  • Threading Model — why all border mutations happen on the single IPC thread.
  • Animation — how the animator’s WindowBackend moves border overlays as WindowTargets (the animator doesn’t know an HWND is an overlay).
  • Event Pipelines — the EVENT_OBJECT_LOCATIONCHANGE hook path that drives float borders.
  • Floating Spacestore_float_rect, where float borders get their geometry.
  • Window RegistryInvisibleBounds, the Window struct, and WindowState.
  • Config & PersistenceBorderConfig defaults and the dual-edit rule.

IPC

The flow CLI communicates with the flowd daemon over a single Windows named pipe using newline-delimited JSON. The daemon listens for one client at a time on a background accept thread, while the main IPC thread processes hook events and dispatches commands.

Named-Pipe Protocol

The IPC transport uses the Windows named pipe \\.\pipe\flow (configurable via the FLOW_PIPE_NAME environment variable for integration tests). The wire format is newline-delimited JSON: each message is a single JSON object terminated by \n, serialised by serde_json. The encoding and decoding helpers live in src/ipc/message.rs.

The pipe uses byte mode (PIPE_TYPE_BYTE | PIPE_READMODE_BYTE) with duplex access. The server accepts one client at a time (sequential, not concurrent). After processing a request, the server disconnects and waits for the next connection. The server is created with FILE_FLAG_FIRST_PIPE_INSTANCE so that a second daemon instance fails fast with an address-in-use error instead of silently replacing the first.

sequenceDiagram
    participant CLI as flow CLI
    participant Pipe as named pipe
    participant Accept as Accept Thread
    participant IPC as IPC Thread
    participant flow as FlowWM

    CLI->>Pipe: CreateFileW (overlapped)
    Accept->>Accept: ConnectNamedPipe (blocking)
    Accept->>IPC: SetEvent(connected_event)
    IPC->>IPC: WaitForMultipleObjects wakes
    IPC->>Pipe: read_message() -- blocking ReadFile
    Pipe-->>IPC: {"type":"focus_left"}\n
    IPC->>flow: dispatch(SocketMessage::FocusLeft)
    flow-->>IPC: SocketResponse::Ok
    IPC->>Pipe: write_response() -- WriteFile
    Pipe-->>CLI: {"status":"ok"}\n
    IPC->>Pipe: DisconnectNamedPipe
    IPC->>Accept: start_accept() -- next client

Why a Named Pipe

A named pipe was chosen over TCP or Unix-domain sockets for three reasons:

  • Local-only – named pipes are inherently local to the machine. No network exposure, no firewall rules, no port conflicts.
  • Windows-nativeCreateNamedPipeW / ConnectNamedPipe are the standard Win32 IPC primitives. No third-party dependency, no compatibility shim.
  • ACL-capable – the pipe can (and will, in a future hardening pass) accept a SECURITY_ATTRIBUTES structure to restrict access to the current user session. Currently any local process can connect (acceptable for the single-user MVP).

Client-Side Timeout

The client opens the pipe with FILE_FLAG_OVERLAPPED so every read/write is bounded by a 30-second deadline (IPC_TIMEOUT in src/ipc/transport.rs). If the daemon accepts the connection but never replies, CancelIo fires and the CLI returns a TimedOut error instead of hanging forever. Each overlapped operation uses a Win32 manual- reset event and WaitForSingleObject to await completion or cancellation. This prevents integration tests from stalling when the daemon is stuck in layout computation.

Server-Side Architecture

The server side in src/ipc/transport.rs uses synchronous (blocking) I/O – appropriate because the daemon’s clients are one- shot flow invocations that always send immediately. The PipeServer struct manages a background accept thread: start_accept() spawns a short-lived thread that blocks in ConnectNamedPipe, then signals a Win32 event to wake the main thread’s WaitForMultipleObjects. See threading model for how the connected event coexists with the hook signal in the main loop.

All pipe handles are wrapped in PipeHandle (RAII, closes on drop) and a similar EventHandle wrapper manages Win32 event handles, preventing kernel handle leaks on early returns.

Message Surface

SocketMessage (CLI -> Daemon)

Commands are grouped by purpose. All variants use serde’s externally-tagged enum format with a "type" field and snake_case variant names.

GroupVariantsDescription
ControlStop, ReloadConfig, CheckConfigDaemon lifecycle and config
FocusFocusLeft/Right/Up/DownMove focus between windows
Swap (per-window)SwapLeft/Right/Up/DownSwap focused window with neighbour
Swap (column)SwapColumn { direction }Swap entire focused column
Semantic moveMoveWindow { direction }Daemon resolves concrete action by state
ScrollScrollLeft, ScrollRightSlide the viewport
Column resizeExpandColumn, ShrinkColumn, SetColumnWidth { width_px }Adjust column widths
Window stateToggleFloat, ToggleMonocle, PlaceAbove, Promote, CloseWindowPer-window operations
WorkspaceSwitchWorkspace { workspace_id }, SwapWorkspace { workspace_id }, MoveWindowToWorkspace { workspace_id }niri-style virtual desktops (Switch/Move implemented; Swap stub)
QueryQueryWindowsAll, QueryLayoutVirtual, QueryLayoutActual, QueryStateRead-only introspection
Config mutationSetConfigValue { key, value }Runtime config change
App preferencesForgetApp { exe }, ForgetAllAppsClear per-app learned state

The MoveWindow variant is deliberately semantic: for tiled windows moving left or right it delegates to SwapColumn, but the daemon owns the translation so keybindings stay stable as floating support lands. Vertical movement (within-column swap) and floating-window nudging are deferred — only the tiled left/right path is wired today.

Of the three workspace variants, SwitchWorkspace and MoveWindowToWorkspace are fully implemented (see Workspace Hierarchy for the vertical-packing switch animation). Only SwapWorkspace returns unimplemented_command — its protocol shape is locked in, but its animation model (two workspaces exchanging positions in the packed stack) is still undecided.

SocketResponse (Daemon -> CLI)

VariantFieldsWhen
OkCommand succeeded
Errormessage: StringCommand failed
Datapayload: serde_json::ValueResponse to a query command

Responses use a "status" tag ("ok", "error", "data"), distinct from the message "type" tag. A response-shaped JSON will not parse as a SocketMessage and vice versa – the tests in src/ipc/message.rs enforce this explicitly.

The flow CLI Client

The CLI (src/bin/flow.rs) is built with clap and structured into four top-level command groups. Every dispatch command sends a single SocketMessage and prints a one-line success or error message. The CLI does no layout computation – it is a thin transport layer. See event pipelines for what happens on the daemon side after a command arrives.

Lifecycle

CommandAction
flow start [--config <dir>] [--log-file <path>] [--ahk]Spawn flowd.exe detached, poll until pipe is ready; with --ahk, also launch flow.ahk via its shell association
flow stop [--ahk]Send Stop via named pipe; with --ahk, also terminate the AutoHotkey process launched by start --ahk

flow start locates flowd.exe next to the current executable, spawns it with CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW (falling back to plain spawn() under WSL interop), then polls the pipe at 200 ms intervals for up to 5 seconds. The --config flag sets FLOW_CONFIG_DIR in the current process before spawning so the child inherits it; the --log-file flag is forwarded as a CLI argument.

The --ahk flag launches the user’s flow.ahk after the daemon is ready, so the first hotkey always reaches a listening daemon. AutoHotkey is resolved via ShellExecuteExW on the .ahk file (Windows’ shell association handles interpreter lookup); the launched PID is written to flow.ahk.pid in the config directory so flow stop --ahk can terminate exactly that process. stop --ahk runs after the daemon has stopped, so a live daemon never loses its keybindings mid-dispatch.

Autostart

CommandAction
flow enable-autostart [--ahk]Create a single flow.lnk in shell:startup that runs flow.exe start [--ahk]
flow disable-autostartRemove the flow.lnk shortcut (idempotent)

The autostart shortcut targets flow.exe start [--ahk] rather than flowd.exe directly, so login reuses the interactive startup path’s pre-flight config check and double-start guard – there is no parallel startup code path. The .lnk uses SW_HIDE to suppress the brief console window flow.exe allocates before it spawns the detached daemon and exits. disable-autostart takes no --ahk flag because there is only ever one shortcut.

Config

CommandAction
flow config initCreate config dir + write default files (no daemon contact)
flow config reloadSend ReloadConfig to daemon
flow config editOpen config dir in $EDITOR / VISUAL / notepad.exe
flow config pathPrint resolved config directory path
flow config checkValidate config files locally

All config commands except reload operate on local files without contacting the daemon. The config directory resolution chain is documented in config and persistence.

Query

CommandAction
flow query allSend QueryWindowsAll, pretty-print JSON response

Dispatch

CommandMaps to
flow dispatch focus left/right/up/downFocusLeft/Right/Up/Down
flow dispatch swap-column left/rightSwapColumn
flow dispatch move-window left/rightMoveWindow
flow dispatch expand-columnExpandColumn
flow dispatch shrink-columnShrinkColumn
flow dispatch close-windowCloseWindow
flow dispatch switch-workspace <id>SwitchWorkspace
flow dispatch swap-workspace <id>SwapWorkspace (stub)
flow dispatch move-to-workspace <id>MoveWindowToWorkspace

The dispatch commands that change layout flow through the daemon’s 3-stage pipeline (mutate, project, animate) as described in architecture. The CloseWindow command is a special case: it only queues WM_CLOSE and lets the EVENT_OBJECT_DESTROY hook handle the actual layout removal, avoiding a race between the synchronous IPC response and the asynchronous window destruction.

Graceful-Shutdown Window Rescue

flow stop sends the Stop command, the event loop exits, and then – before the daemon process tears down – [FlowWM::rescue_stranded_windows] runs once. Its job is to bring windows that became physically inaccessible during this flow session back onto the screen, without disturbing anything the user can currently see.

Why rescue is needed

flow parks non-active workspaces off-screen (one monitor height above or below the active one) and scrolls windows horizontally across the infinite tiling canvas. If the daemon simply exited, every window on a non-active workspace – plus any window scrolled into the off-screen packing region of the active workspace – would be left stranded where flow put it: visible to Win32 (so a future flow run would re-classify them as already-tiled) but unreachable to the user, who has no way to scroll to a workspace that no longer exists.

The visibility rule

The rescue pass treats the operating system as ground truth, not flow’s own bookkeeping. For each controlled window it calls GetWindowRect to find the window’s current rect, then asks: does the window’s visible content overlap the active monitor’s work_area?

  • Yes (visible) – the user can see this window right now. Leave it exactly where it is. The user has almost certainly rearranged things by hand since flow started; rewinding their work would be hostile.
  • No (stranded) – the window is off-screen purely because flow moved it. Bring it back.

The visible-content rect is the raw GetWindowRect rect with the window’s invisible borders stripped (InvisibleBounds::window_to_visible). This step is not cosmetic: GetWindowRect includes the ~7px invisible borders Windows 10/11 draw for drop shadows, while the layout engine parks columns flush against the work_area edge in visible coordinates. Without the conversion, a window parked exactly at the edge would bleed a few pixels of invisible border into the work_area and read as “visible” – so the rescue pass would leave it stranded in the packing region of the active workspace. Stripping the borders recovers the true visible rect, which only touches the edge and is correctly treated as stranded. See roadmap.md (“GetWindowRect Includes Invisible Borders”) for background.

Touching edges do not count as overlapping, matching Win32 IntersectRect semantics (see Rect::overlaps).

flowchart TD
    A[For each controlled window] --> B[GetWindowRect]
    B --> G[Strip invisible borders to visible-content rect]
    G --> C{Visible content overlaps work_area?}
    C -- yes: visible --> D[Leave it untouched]
    C -- no: stranded --> E[Clamp pre_manage_rect into work_area]
    E --> F[SetWindowPos to clamped anchor]

The rescue target: pre_manage_rect

Each controlled window remembers the rect it had when flow first noticed it (stored as pre_manage_rect at registration time). The rescue moves stranded windows back to that anchor, then clamps the anchor into the work_area in case the anchor itself is off-screen (e.g. the window came from a now-detached monitor, or the work area changed because the taskbar moved).

Clamping shrinks-and-shifts rather than discards: a window larger than the work area collapses to the work area’s size; otherwise the original size is preserved and only the position is nudged on-screen.

Tiles and floats are unified

There is no special-casing between tiling and floating: every window flow is actively positioning – a Tiling(Active) or Floating(Active) window – goes through the same loop. Minimized, Hidden, and Ignored windows never reach it. flow does not actively place minimized/hidden windows, so their position is the OS’s and the user’s responsibility; leaving them alone on shutdown avoids surprising the user by moving windows they intentionally hid.

The “pre-this-run” contract

pre_manage_rect is the position the window held before this flow run, not before the very first flow run in history. Consequences:

  • If the previous flow session was killed uncleanly (crash, taskkill /f, Ctrl-C), windows it had tiled remain at their tiled positions. The next run captures those as the new baseline. The desktop self-heals after one clean flow stop.
  • Minimized/hidden windows are out of scope entirely (see above); float windows lose any in-run drag history (acceptable for v1), restoring to their registration-time anchor.

The unclean-shutdown case – where the daemon process is gone and cannot run the rescue pass – is what the planned watchdog process is for: restoring from an on-disk snapshot without needing the daemon alive (see the Roadmap).

Code path

StepLocation
Wired after the event loop exitssrc/main.rsflow.run() then flow.rescue_stranded_windows()
The rescue loopsrc/daemon/shutdown.rs
Which windows are controlledWindowRegistry::restorable_windows in src/registry/core.rs
Clamp geometryRect::clamped_into in src/common/types.rs

Watchdog

A separate watchdog process is planned for crash recovery — restoring windows from an on-disk snapshot when the daemon dies unexpectedly. It is not yet implemented and is deliberately deferred until flow/flowd are feature-complete. See the Roadmap for the full design, including why it is a separate process rather than a Windows service.

Cross-References

  • Threading model – the IPC thread’s WaitForMultipleObjects loop and how pipe connections coexist with hook events
  • Event pipelines – the IPC command dispatch path through the 3-stage layout pipeline
  • Architecture – subsystem overview showing where the IPC server fits inside the daemon

Config and Persistence

How flow resolves, loads, validates, and applies user configuration – with code as the single source of truth for all default values.

Design Philosophy: Code Is the Source of Truth

flow’s configuration model makes a deliberate choice: compiled Rust Default impls are the canonical default values, not any TOML file. Every config struct in src/config/types.rs carries #[serde(default)] at the container level, so serde starts with a full Default instance and then overlays only the fields present in the user’s TOML. This means a user’s flow.toml may be partial, empty, or even nested-partial – serde fills every gap automatically.

A previous design used a two-layer TOML merge (shipped defaults + user overlay) at the toml::Value level before deserialization. That model silently fell back to stale compiled-in defaults when the shipped file was absent during development – the build never copied it next to flowd.exe. Moving defaults into code eliminated that failure mode entirely.

flowchart LR
    subgraph SourceOfTruth ["Single Source of Truth"]
        D["Default impls<br/>src/config/types.rs"]
    end

    subgraph Runtime ["Runtime (serde)"]
        TOML["User's flow.toml<br/>(partial / empty / full)"]
        MERGE["serde(default)<br/>Start with Default,<br/>overlay user fields"]
        CFG["FlowConfig"]
    end

    subgraph Example ["Example File (NOT runtime)"]
        EX["default-config.toml<br/>(hand-written starter)"]
        SYNC["Sync test:<br/>default_config_toml<br/>_matches_compiled_defaults"]
    end

    D --> MERGE
    TOML --> MERGE
    MERGE --> CFG
    EX -.->|enforced by| SYNC
    SYNC -.->|must equal| D

The Dual-Edit Rule

When adding or changing a config field, you must update both:

  1. The Default impl in src/config/types.rs – this is the actual runtime default.
  2. default-config.toml in the repo root – this is the human-readable example copied to users by flow config init.

The default_config_toml_matches_compiled_defaults test (in src/config/types.rs) enforces they stay in sync: it deserializes the example file and asserts it equals FlowConfig::default(). If you change a default in code and forget the TOML, CI fails.

The example file is never read at runtime. It exists solely as a commented starter template so first-time users understand what fields are available. Users can trim it, add comments, or even empty it entirely – serde will still produce a fully valid config from code defaults.

Config File Split

Configuration lives in two separate TOML files inside the config directory:

FileStructPurpose
flow.tomlFlowConfigApplication settings: column sizing, padding, animation, minimize-restore behavior
flow-rules.tomlWindowRulesConfigWindow classification rules and default action

This separation lets users edit rules frequently (adding ignore patterns for new apps) without risking their application settings, and vice-versa. Both files are documented at src/config/types.rs.

Config Directory Resolution

The config directory is resolved by src/config/dirs.rs using a three-level priority chain:

flowchart TD
    A["CLI --config flag"] -->|highest priority| D["Resolved config dir"]
    B["FLOW_CONFIG_DIR env var"] -->|second priority| D
    C["%USERPROFILE%\\.config\\flow\\"] -->|default| D
    C -->|"fallback: %APPDATA%-derived"| E["<user>\\.config\\flow\\"]
    E -->|"fallback: CWD"| F[".\\flow\\"]

The default path %USERPROFILE%\.config\flow\ follows the XDG Base Directory convention ($XDG_CONFIG_HOME/appname/), which is well-known to developers and increasingly expected on all platforms. The older %APPDATA% path was rejected because it hides configs inside a Roaming directory that most users never browse.

If %USERPROFILE% is unset (broken service account), the module falls back to %APPDATA% with the \AppData\Roaming suffix stripped, then ultimately to the current working directory. All fallbacks are logged. The directory is created on first resolution if it does not exist.

The Four-Phase Lifecycle

Defined in src/config/lifecycle.rs, the config system follows four phases:

1. Init – init_config_dir

Creates the config directory and writes default files if they do not already exist:

  • flow.toml – the fully-commented example template (default-config.toml), including its own #:schema header.
  • flow-rules.toml – default WindowRulesConfig as TOML with a #:schema header prepended.
  • schemas/flow-config.schema.json – JSON Schema for FlowConfig.
  • schemas/flow-rules.schema.json – JSON Schema for WindowRulesConfig.

Existing files are never overwritten. This makes init_config_dir safe to call on every daemon startup.

2. Load – load_app_config / load_rules_config

Reads TOML from disk and deserializes into the respective config struct. Load functions distinguish three failure modes and propagate parse/I/O errors rather than hiding them: a missing file is benign (fresh install), but a broken file is surfaced so the user learns their config is unreadable instead of silently running on defaults.

ConditionBehavior
File not foundOk(Default) – benign (fresh install), logged at debug
Parse / I/O errorErr(FlowError::Config) carrying <path>: <reason> – caller decides policy
SuccessOk(parsed); for flow.toml, validate() logs warnings but still returns the config

A private generic core (load_toml) and a ConfigLoadError { Missing, Io, Parse } enum sit behind both loaders, so the file-backed read/parse path is shared rather than duplicated.

Load-error policy (daemon vs. client)

The two processes apply different policy because the detached daemon’s stdout/stderr are discarded – it cannot tell the user anything. So flow start runs a pre-flight check on the user’s terminal before spawning flowd, and the daemon performs its own authoritative load as a backstop:

flow.toml failureflow-rules.toml failure
flow client (pre-flight, user terminal)fatal: refuse to spawnwarning on stderr + continue
flowd daemon (authoritative load)fatal: run() Err -> exit(1)non-fatal: warn + default rules

Both call the same compiled load_app_config/load_rules_config (one crate), so the pre-flight verdict matches the daemon’s – no desync. Rules failures are non-fatal everywhere because rules are advisory and the daemon is usable with defaults. The daemon’s fatal load catches a config edit landing in the spawn race window (the pipe is never created, so flow reports a startup timeout rather than silently starting on defaults).

flowchart TD
    START["flow start"] --> PRE["preflight_config_check<br/>on user terminal"]
    PRE -->|"flow.toml Err"| REFUSE["refuse: print why + where"]
    PRE -->|"flow-rules.toml Err"| WARN["warn on stderr, continue"]
    PRE -->|"flow.toml OK"| SPAWN["spawn flowd"]
    WARN --> SPAWN
    SPAWN --> AUTH["flowd authoritative load<br/>(same compiled loaders)"]
    AUTH -->|"flow.toml OK"| RUN["daemon runs"]
    AUTH -->|"flow.toml Err in race window"| DIE["exit 1: pipe never created"]

flow config reload reuses these same loaders to hot-reload a running daemon (see Hot-reload below).

After loading flow.toml, the daemon calls FlowConfig::validate() to catch semantically invalid values (negative padding, min_column_width_px exceeding column_width). Validation failures produce warnings but do not prevent the daemon from running.

3. Validate – check_config

Used by flow config check to validate config files without loading them into the daemon. Missing files are not errors – they simply mean defaults will be used. Logs nothing (designed for pure CLI output).

4. Use – daemon subsystems

The daemon extracts fields from FlowConfig into the layout engine. The ConfigEasing enum is converted to the animation engine’s EasingStyle in the daemon/ layer (see src/config/types.rs for the design rationale on module dependency ordering).

flowchart TD
    INIT["init_config_dir<br/>Write starter files & schemas"] --> LOAD["load_app_config<br/>load_rules_config"]
    LOAD --> VALID["check_config<br/>(CLI validation pass)"]
    LOAD --> USE["Daemon subsystems<br/>Layout engine, animation, registry"]

Hot-reload – flow config reload

flow config reload sends a ReloadConfig IPC message to the running daemon, which reloads flow.toml (and flow-rules.toml) from disk and applies the live-reloadable fields to the running daemon without disturbing runtime window/workspace state.

Validate-before-apply

The handler loads + validates the new config before touching any state. A broken file or a validation failure (e.g. columns_per_screen = 0) returns a SocketResponse::Error immediately – the running daemon keeps its current config untouched, so there is nothing to roll back. The error message surfaces on the user’s terminal via flow’s send_command.

Live-reloadable vs. structural fields

Not every field can change at runtime. Reload applies live fields in place; structural fields require a daemon restart.

FieldReload effect
borders.* (color/thickness/overlap)immediate recolor/resize of all overlays (refresh_all_border_styles)
padding.window_gapre-projects every workspace’s pixel layout from its existing virtual layout – windows animate to new gaps
columns_per_screenupdates the cached threshold (no window movement; affects future auto-center)
column_width (base), min_column_width_px, min_window_height_pxupdate cached bounds; existing per-column widths preserved
animation.*WindowAnimator::update_config for the new duration/easing/enabled
Workspace count (10), monitor geometrystructural – not reloadable

What is preserved

Reload touches only the geometry constants + borders + the animation config. The user’s arranged layout is untouched: per-ScrollingSpace virtual layout (columns, rows, window order), focus, scroll viewport, monocle state; plus the daemon’s registry (window slots/states), learned history rules, and float positions. Concretely, ScrollingSpace::reconfigure rebuilds the cached MutationConfig and re-projects actual from the same virtual_layout – windows keep their column/row assignment and slide to their new pixel positions.

Rules reload is non-fatal

flow-rules.toml (classification) is reloaded in the same operation via registry.set_user_rules. It affects only new windows, so it never disrupts existing layout. Consistent with startup policy, a rules failure is non-fatal: the daemon warns and keeps the current rules; flow.toml (if valid) still reloads. This is the per-file-independent policy chosen at design time.

sequenceDiagram
    participant U as User
    participant C as flow (client)
    participant D as flowd (daemon)
    U->>C: flow config reload
    C->>D: ReloadConfig (IPC)
    D->>D: load_app_config + validate (no state touched)
    alt load/validate Err
        D-->>C: SocketResponse::Error(message)
        C-->>U: print error (nothing applied)
    else Ok
        D->>D: store config<br/>reconfigure all workspaces<br/>animate active<br/>refresh borders<br/>reload rules (non-fatal)
        D-->>C: SocketResponse::Ok
        C-->>U: "configuration reloaded"
    end

Application Config Fields

All defaults are from FlowConfig::default() in src/config/types.rs:

Field / GroupTypeDefaultDescription
columns_per_screenu324Number of columns per monitor; daemon computes pixel width at runtime
column_widthOption<u32>NoneFixed pixel-width override; skips auto-computation when set
min_column_width_pxu32640Minimum allowed column width
padding.window_gapi3216Uniform gap between all elements
padding.upi3216Top screen margin
padding.downi3216Bottom screen margin
animation.enabledbooltrueEnable layout transition animations
animation.duration_msu32240Animation duration in milliseconds
animation.easingConfigEasingease-out-expoEasing curve (31 named variants)
minimize_restore.strategyenumoriginal_slotWhere restored windows are placed

Column Sizing Model

The primary sizing mode uses columns_per_screen: the daemon computes the actual pixel width at startup as base_content_width = (monitor_width - (N+1) * window_gap) / N where N = columns_per_screen. Setting column_width to a fixed pixel value overrides this computation entirely.

Window Classification Rules

Rules are defined in flow-rules.toml and evaluated top-to-bottom, first match wins against new windows. If no rule matches, default_action (default: float) is used. See window registry for how rules feed the classification pipeline, and classification & learned rules for the whitelist model and the machine-written history-flow-rules.toml.

Match Criteria

All fields in a match clause use AND logic – unspecified fields are ignored. Three matching modes are supported:

ModeFieldsCase Sensitivity
Exactexe, title, class, process_pathexe/process_path: insensitive; title/class: sensitive
Substringtitle_containsSensitive
Regexexe_regex, title_regex, class_regex, process_path_regexexe/process_path: insensitive; title/class: sensitive (use (?i) to override)

The exe and process_path fields are case-insensitive because Windows paths are case-insensitive. Window class names and titles are application-controlled strings and matched case-sensitively.

Actions

ActionBehavior
tileManaged by the layout engine
floatFree-floating, user-positioned
ignoreExcluded from tiling entirely

Default Rules

default-flow-rules.toml in the project root ships sensible defaults for common Windows system windows (taskbar, Search UI, system dialogs, Task Manager, on-screen keyboard, Chromium legacy windows, etc.). This file is embedded into the binary at compile time via include_str! in src/config/lifecycle.rs – it is never read from disk at runtime, so it cannot be accidentally deleted or corrupted by end users. Users override these defaults through their own flow-rules.toml, which the classification pipeline checks first.

JSON Schema Generation

src/config/schema.rs generates JSON Schemas from the Rust type definitions using the schemars crate. Two schemas are produced:

  • schemas/flow-config.schema.json – for FlowConfig
  • schemas/flow-rules.schema.json – for WindowRulesConfig

These are written into a schemas/ subdirectory inside the config directory during init_config_dir. Each TOML config file carries a #:schema comment header pointing to its schema, enabling autocomplete and validation in editors that support the taplo TOML language server (VS Code, Neovim).

The schemas are generated at init time (not build time), so they always reflect the exact types compiled into the running binary. The schemas in the repo root (schemas/) are checked-in artifacts for development convenience.

Dependency Management

When adding dependencies to the config module (or anywhere in the project), use cargo add to handle versions and Cargo.toml edits. Do not hand-edit Cargo.toml for dependency changes.

Self-Update (flow update)

flow update pulls the latest release from GitHub, verifies it, and replaces the running flow.exe and flowd.exe in place. It is a single command with one optional flag:

CommandAction
flow updateDownload, verify, stage, and swap in the latest release
flow update --checkPrint whether an update is available; exit non-zero if so. Safe while the daemon runs.

The install path is self-located: flow updates whichever copy of itself is running, via std::env::current_exe().parent(). No registry key, config file, or PATH entry is consulted. This matches the existing find_daemon_exe convention — flowd.exe is assumed to live next to flow.exe, and both are replaced together.

The Windows File-Lock Problem

The central design constraint is that Windows refuses to open a running .exe for write. While flow.exe is executing, its image file is locked by the kernel; the same is true for flowd.exe while the daemon is running. A naive updater that downloads a new binary and overwrites the old one in place fails with ERROR_SHARING_VIOLATION on the very file it is trying to replace.

There is a narrow escape: Windows does allow a running executable to be renamed (moved within the same volume) but not overwritten or deleted. This is the primitive the updater exploits. The flow is:

  1. Download the new archive into memory.
  2. Extract the two binaries to a .stage/ directory next to the install dir.
  3. Spawn a detached helper process (the shim) that outlives flow.exe.
  4. flow.exe exits.
  5. The shim waits for flow.exe’s PID to disappear, then for each binary:
    • renames the running file to <name>.old (rename is allowed),
    • renames the staged file into place,
    • deletes <name>.old.
  6. The shim removes .stage/ and deletes itself.

By the time the shim renames anything, flow.exe has exited and flowd.exe is guaranteed absent (see Refuse-if-running below), so the rename window is contention-free.

sequenceDiagram
    participant User
    participant flow as flow.exe
    participant Stage as .stage/
    participant Shim as powershell shim
    participant FS as filesystem

    User->>flow: flow update
    flow->>FS: extract flow.exe + flowd.exe into .stage/
    flow->>Shim: spawn detached, pass flow PID
    flow->>User: "updated to vX.Y.Z — run flow start"
    flow->>flow: process exits
    Shim->>FS: wait until flow.exe PID gone
    Shim->>FS: rename flow.exe  -> flow.exe.old
    Shim->>FS: rename .stage\flow.exe -> flow.exe
    Shim->>FS: delete flow.exe.old
    Shim->>FS: (repeat for flowd.exe)
    Shim->>FS: rmdir .stage, delete self

Refuse-if-running

The updater refuses to proceed if transport::is_daemon_running() reports a live daemon, telling the user to run flow stop first. This is a deliberate UX choice rather than a technical limitation:

  • It avoids a surprise window shuffle — flow stop runs graceful-shutdown window rescue, which the user should see happen explicitly, not as a side effect of an update.
  • It simplifies the shim: with flowd.exe guaranteed absent, the shim only needs to wait for one PID (flow.exe itself) before swapping.

flow update --check is exempt from this guard because it only queries the GitHub releases API and never touches local files — it is safe to run any time, including while the daemon is actively tiling.

Start-Time Notification

flow start can optionally check GitHub for a newer release on each launch and print a one-line notification prompting flow update when one exists. It is on by default and controlled by the top-level check_for_updates field in flow.toml (see Config Reference):

check_for_updates = true   # set to false to silence the start-time notification

The notification is deliberately non-intrusive:

  • Non-blocking — the check runs after the daemon reports ready, so a slow network can never delay tiling. flow start prints flow: daemon started first, then the notification if one applies.
  • Bounded — the network call is capped by a short timeout, so a dead or unresponsive network resolves in seconds rather than hanging the command. The same bound also applies to the metadata fetch in flow update; only the multi-MB archive download is left unbounded.
  • Best-effort — every error (offline, parse failure, GitHub outage) is silenced. The daemon always starts regardless of the outcome.
  • Print-only — it only echoes flow: update available (<tag>) — run "flow update" to install; it never downloads or installs anything.

This automatic check reuses check_for_update — the same call behind flow update --check. That explicit command is unaffected by the check_for_updates flag: a user who disables the automatic notification can still ask for a version check on demand.

Version Comparison

Versions come from two sources:

  • The running binary’s version: env!("CARGO_PKG_VERSION") (e.g. 0.1.0).
  • The latest release’s version: the tag_name field of the GitHub releases API response (e.g. v0.1.1).

Both are parsed into a (u32, u32, u32) tuple and compared with tuple ordering. The leading v on the tag is stripped before parsing. This deliberately avoids pulling in a semver crate — FlowWM uses plain MAJOR.MINOR.PATCH integers with no pre-release suffixes, so a three-tuple is the exact model. --check returns “up to date” when the tuples are equal; the install path returns early (a no-op success) when not strictly newer, so re-running flow update after a successful update is idempotent.

Integrity Verification

Every release ships a SHA-256 sidecar (<zipname>.sha256) alongside the zip, produced by Get-FileHash in the release workflow (see .github/workflows/release.yml). The updater:

  1. Downloads the sidecar first and parses the 64-hex-character hash out of its GNU-coreutils-style <hash> <filename>\n format.
  2. Downloads the zip into memory and computes SHA256 over the bytes via the sha2 crate.
  3. Compares the computed lowercase-hex digest against the sidecar hash (case-insensitively — Get-FileHash emits uppercase, the Rust digest is lowercase).
  4. Aborts with UpdateError::ShaMismatch if they disagree.

The zip is never extracted or written to disk before the hash check passes.

Edge-Case Guards

Two environment checks run before any download:

  • Running-from-zip: if current_exe() resolves to a path containing \TempN_ (Windows’ temporary folder for opened-in-place zip extraction), the updater aborts with UpdateError::RunningFromZip. Updating a binary inside a transient extraction folder would “succeed” but be discarded the moment the user closes the zip viewer.
  • Read-only install directory: a writability probe (create and delete a .flow-write-probe temp file) catches Program Files installs that need elevation. The updater reports UpdateError::ReadOnlyDir rather than failing mysteriously halfway through the swap.

Code Path

StepLocation
Public entry pointscheck_for_update, perform_update in src/updater/mod.rs
CLI wiringcmd_update in src/bin/flow.rs
Daemon-running guardtransport::is_daemon_running in src/ipc/transport.rs
Shim script builder + spawnersrc/updater/shim.rs

The updater module is a sibling of the other top-level modules under src/ (registered in src/lib.rs). It depends only on flow_wm::ipc::transport for the daemon-running guard — it does not touch the layout engine, window registry, or config system.

Cross-References

  • IPC – the named-pipe transport used for the daemon-running guard and for flow stop
  • Architecture – subsystem overview
  • Roadmap – future work (e.g. delta updates, package-manager integration)

Design Decisions

This chapter collects the major “why not X” trade-offs in one place. Each decision is presented with the chosen approach, the main alternative considered, and the rationale for the choice.

Single Cargo Package (Not a Workspace)

flow is a single Cargo package with two binaries (flowd, flow) sharing one library crate (src/lib.rs). Rust supports this layout natively — one package can contain a library crate, a default binary, and additional binaries.

The alternative was a multi-crate workspace from the start. That was rejected because the internal subsystem boundaries are still evolving. Module boundaries within a single package are sufficient for the current scale, and keeping everything in one package avoids Cargo configuration overhead and makes refactoring across module boundaries trivial.

Extraction to a separate crate will happen only when a concrete reason appears: the code is reusable outside flow, compile times become a real bottleneck, or the API has stabilized and deserves a stronger boundary. Likely future extraction candidates are src/animation (if the animation system becomes reusable elsewhere) and src/ipc (if external tools need a standalone client crate). Internal modules like registry, layout, and workspace should stay where they are until a strong reason to move them emerges.

Pixel Widths (Not Proportional Eighths)

Column widths in the virtual layout are stored as absolute pixel values (width_px: i32), not as proportional units (e.g. eighths of a base column width).

An earlier revision used eighths to stay resolution-independent. This caused a gap-loss bug: expand_column computed the correct pixel target (column_width + window_gap), then pixels_to_eighths re-quantized onto the base grid, discarding the gap because it was smaller than one eighth and rounded away. Each expand step grew by exactly column_width instead of column_width + window_gap.

Pixel widths make the gap observable at every step and fix the bug. The cost is dependence on the configured column width and monitor resolution — accepted because window_gap is already pixel-based everywhere else and resolution independence was never a real requirement (the target is Windows desktop, not cross-platform).

No Arc<Mutex> (Single-Thread Ownership)

The daemon uses single-thread ownership: FlowWM lives entirely on the IPC thread, and all subsystem methods take &mut self. There is no Arc<Mutex<T>> anywhere in the daemon.

The previous architecture used Arc<Mutex<WindowRegistry>> because hook events and IPC commands were consumed by different parts of the code with no single coordination point. The refactor to a single orchestrator eliminated that need. The borrow checker now enforces exclusive access at compile time, which is strictly safer than Mutex (runtime-only enforcement, potential deadlocks). The hook thread communicates exclusively through an mpsc channel and never touches daemon state.

See Threading Model for the full model.

TOML Config (Not YAML, JSON, or Lua)

Configuration uses TOML. The alternatives were YAML (indentation-sensitive, complex spec), JSON (no comments, verbose), and Lua (fully programmable but requires a runtime and steep learning curve).

TOML’s clean syntax, comment support, and good fit for flat/nested config made it the clear choice. Lua remains a future option if users need conditional logic (e.g. per-monitor layouts), but TOML covers all current use cases.

The config system uses a two-layer merge model: compiled-in Default impls provide the base, and the user’s flow.toml overlays on top. Serde’s #[serde(default)] at the container level means a user’s config can be partial, empty, or nested-partial — serde fills the gaps from the Default impl.

Config Defaults: Code Is the Single Source of Truth

Default values for every config field live in the Default impls of each config struct in src/config/types.rs. Each struct carries #[serde(default)] at the container level, so a user’s flow.toml may be partial or empty.

default-config.toml in the repo root is a hand-written example file, copied to users by flow config init. It is not read at runtime — the compiled defaults are authoritative. When a developer adds or changes a config field, they must update both the Default impl and default-config.toml. A test (default_config_toml_matches_compiled_defaults) enforces they stay in sync.

This avoids the fragility of “file A is the source of truth except when it’s missing” and the complexity of a two-layer TOML merge with a shipped file at runtime.

Separate WindowRegistry and ScrollingSpace

The window registry (src/registry) and the tiling engine (ScrollingSpace in src/workspace) are separate components with a clear purity boundary.

The registry owns all window metadata: HWND-to-WindowId mapping, titles, classes, classification state (tile/float/ignore), and invisible bounds. It bridges the layout engine to Win32 — it is the only place that holds HWND references.

ScrollingSpace owns the layout math: virtual layout, focus state, column widths, viewport offset. It operates exclusively on WindowId values and never sees HWNDs. The pure layout computation in src/layout/ is even more isolated — it has zero Win32 dependencies.

This separation means the layout math is fully unit-testable on any platform and can be reasoned about without considering Win32 quirks. The daemon is the only code that shuttles data between the two subsystems.

SetWindowPos Over DeferWindowPos

Window positioning uses SetWindowPos rather than the batched DeferWindowPos API.

DeferWindowPos batches multiple position changes and triggers a single repaint at the end — useful for UI frameworks that own all the windows they move. In flow’s case, not all windows are deferrable (some apps reject deferred positioning), and each application owns its own rendering pipeline. SetWindowPos applies the position change immediately, which is what the animation system expects when tweening individual window rects frame by frame.

WindowId as the Platform-Independent Bridge Type

WindowId (currently pub struct WindowId(pub isize), wrapping the raw HWND value) is the bridge type between the registry and the layout engine.

The layout engine only ever sees WindowId — it never knows about HWNDs. This keeps the layout math platform-independent and unit-testable. The registry is the only component that holds the HWND-to-WindowId mapping and performs Win32 calls.

The isize wrapping exists because HWND is !Send in windows-rs (it wraps a raw pointer), but WindowId must be Send to cross thread boundaries (e.g. the hook callback sends HookEvent { hwnd: isize } through the mpsc channel). The raw integer value is just a kernel handle — it is safe to share across threads.

Keybindings Removed, Delegated to External Tools

Keybindings were removed from flow. The daemon accepts IPC commands over a named pipe, and users are expected to configure external tools (AutoHotkey, PowerToys, etc.) to send those commands.

The original design had a built-in InputInterceptor with Super-key capture and configurable hotkey bindings. This was removed because:

  • It required intercepting global input, which conflicts with other tools the user may already have.
  • A dedicated keybinding tool gives users more flexibility (per-application rules, macros, chords) than any tiling manager’s built-in binding system.
  • It keeps flow focused on what it does well: window management and layout.

The IPC protocol surface (focus, swap, scroll, resize, etc.) is fully defined and stable — any external tool that can write JSON to a named pipe can drive flow.

Roadmap and Future Work

Where flow is headed. The core tiling engine, the scrolling canvas, the floating space, and niri-style workspace switching are all implemented and exercised end-to-end. What remains is polish, the long tail of IPC commands, multi-monitor support, crash recovery, and the aspirational input-driven features.

Status at a Glance

AreaStatusNote
Tiling engine + scrolling canvas✅ Donemutate → project → animate pipeline
Window lifecycle hooks✅ Donecreate/destroy/minimize/restore/show-hide/foreground + STATECHANGE/NAMECHANGE
Dispatch surface✅ Mostlyfocus, swap, scroll, widths, center, monocle, close — see Remaining IPC stubs for the gaps
Floating space✅ Donetile↔float, centered placement, merged scroll+float batches
Workspaces✅ Done10 per monitor; switch + move-to animate; SwapWorkspace pending
Classification + learned rules✅ DoneDWM-cloak/iconic/Alt-Tab/owner filters + 4-layer rule pipeline
Animation✅ Done31 easing curves + CubicBezier, RetargetFromCurrent, MockBackend
Graceful-shutdown rescue✅ Donerescue_stranded_windows restores off-screen windows on clean exit
Mouse-drag follow on tiled windows🜂 Near-termtiled windows don’t react to user drags today — looks broken
SwapWorkspace animation🜂 Near-termonly workspace command still stubbed
Remaining IPC stubs🜂 Near-termPlaceAbove, QueryState, CheckConfig, SetConfigValue, ForgetApp/ForgetAllApps
Floating enhancements🜂 Near-termsmart placement, size memory, z-order, gap mgmt
Real cross-column MoveWindow🜂 Near-termLeft/Right aliased to swap-column; row-preserving move + floating nudge pending
Multi-monitor support◷ Mid-termVec<Monitor> modeled; constructor hard-codes one
Cloak parked windows (DWMWA_CLOAK)◷ Mid-termread side exists; write side missing
Watchdog + recovery snapshot◷ Mid-termnot started; gated on flow/flowd being complete
Input hooks (DragSession etc.)◷ Aspirationalsrc/input/ does not exist
Coexistence warning◷ Aspirationaldetect komorebi / GlazeWM at startup

Implemented Baseline

The mutate → project → animate pipeline, the window registry, and the Win32 hook thread are all in place and driving real window management:

  • Window lifecycle: create, destroy, minimize, restore, show/hide, foreground, plus the STATECHANGE (maximize/fullscreen recovery) and NAMECHANGE (late-title recovery) hooks. See Event Pipelines.
  • Dispatch surface: focus, per-window swap, column swap, semantic move, scroll, expand/shrink/set column width, center, monocle toggle, close window. See IPC & Watchdog.
  • Floating windows: tile↔float transitions (set-window float|tile|cycle), centered default placement, and merged scroll+float animation batches. See Floating Space.
  • Workspaces: ten workspaces per monitor; switch-workspace and move-to-workspace animate a vertical-packing switch (animate / teleport / skip partitioning). See Workspace Hierarchy.
  • Classification: DWM-cloak, iconic, Alt-Tab visibility, and owner pre-filters, plus the four-layer user/learned/default/default_action rule pipeline. See Classification & Learned Rules.
  • Animation: 31 named easing curves plus arbitrary CSS cubic-bezier, RetargetFromCurrent mid-flight retargeting, and a MockBackend for tests. See Animation.
  • Graceful-shutdown rescue: on clean exit the daemon runs rescue_stranded_windows, moving any off-screen window back onto the active monitor using its pre_manage_rect as the anchor. See IPC & Watchdog.

Near-term: Polish and Completeness

The wiring-heavy work is finished. The remaining near-term items close gaps in the IPC surface, make tiled windows feel responsive to direct manipulation, and round out the floating and workspace feature sets.

React to Mouse Dragging of Tiled Windows

Today the daemon does not react when the user drags a tiled window with the mouse: the window is moved by the OS/application, but flow neither follows the drag live nor re-applies the tiled geometry, so the window visually detaches from its column until the next layout-changing command snaps it back. This looks broken. The fix is to detect the drag (via the existing WinEvent surface — EVENT_OBJECT_LOCATIONCHANGE on a managed tiled window) and either re-assert the tiled position or transition the window to floating for the duration of the drag.

Workspace: SwapWorkspace

SwapWorkspace is the only workspace command still routed to unimplemented_command. SwitchWorkspace and MoveWindowToWorkspace are fully implemented with a vertical-packing animation model; SwapWorkspace needs its own animation decision because it exchanges two workspaces’ positions in the packed stack rather than sliding between them. Its protocol shape (SwapWorkspace { workspace_id }) is already locked in.

Remaining IPC Stubs

Several SocketMessage variants are declared and parsed by the CLI but return unimplemented_command on the daemon side. Their wire formats are stable so external tooling and keybindings can target them now:

CommandPurpose
PlaceAboveRaise the focused floating window’s z-order (SetWindowPos with HWND_TOPMOST/restore)
QueryStateRead-only introspection of daemon/registry state beyond QueryWindowsAll
CheckConfig / SetConfigValueRuntime config validation / mutation without a daemon restart (ReloadConfig is already implemented)
ForgetApp / ForgetAllAppsProgrammatic clearing of learned rules (today this requires hand-editing history-flow-rules.toml)

See Classification & Learned Rules for the learned-rules model that ForgetApp would expose programmatically.

Floating Window Enhancements

The floating space is functional; the open work is quality-of-life. See the “Future Work” section of Floating Space for the full list, summarised here:

  • Smart placement — cascade or offset new floats so they don’t fully overlap.
  • Per-window float size memory — remember each window’s last floating rect and restore it on subsequent tile→float transitions.
  • Z-order raisingPlaceAbove to bring the focused float to the top of the z-order (depends on the IPC stub above).
  • Floating gap management — reserve padding around floats at workspace edges.

MoveWindow: Real Cross-Column Move and Floating Nudge

MoveWindow’s up/down path is implemented (it delegates to dispatch_swap_window for an in-column swap). Two paths remain deferred:

  • Tiled left/right, row-preservingLeft/Right are currently aliased to SwapColumn (a column swap), not a true move that preserves row membership. The alias is intentional as a placeholder; a real cross-column extract-and-insert is the missing piece.
  • Floating, any direction — a pixel nudge by a configurable shift.

The branching structure already lives behind a single delegation point in dispatch_move_window, so these land without changing the IPC protocol or keybindings.

Mid-term

Multi-Monitor Support

The workspace hierarchy already models Vec<Monitor> with active_monitor: usize, and every IPC command routes through active_scrolling() so multi-monitor can land without touching call sites. The current constructor hard-codes a single monitor derived from get_primary_monitor_info() (src/daemon/new.rs). Expanding to multiple monitors requires iterating EnumDisplayMonitors / MonitorFromPoint + GetMonitorInfoW, building a Monitor per display, and adding flow dispatch focusmonitor / move-to-workspace <id> <monitor> plumbing.

Performance: Cloaking Off-Screen Windows

Parked (off-screen) tiled windows are kept one column-width beyond the nearest viewport edge so they animate smoothly when scrolled into view. They are, however, still rendered. A future optimisation can apply DWMWA_CLOAK (SetWindowCompositionAttribute) to parked windows so the DWM skips compositing them, reducing GPU work on large canvases. The classification pipeline already inspects DWMWA_CLOAKED; the write side is the new work.

Watchdog and Recovery-Snapshot Persistence

Not started. This is deliberately deferred until flow and flowd are feature-complete; it is tracked here as the intended design.

The planned design: flowd spawns a separate flow-watchdog helper process with --parent-pid and --recovery-path arguments. The watchdog polls the parent PID and, on unexpected exit, reads a flow-recovery.json snapshot and calls SetWindowPos to restore each window to its pre-manage geometry. The Window struct already carries pre_manage_rect for this purpose; the atomic write-to-temp-then-rename persistence path and the watchdog binary itself are what is missing. The watchdog must survive even if the daemon is corrupted, hung, or crashed, so it owns a separate process lifetime — the OS reaps the daemon while the watchdog keeps running, with no dependency on daemon state (it reads a file and calls Win32 APIs directly) and a minimal code path focused only on recovery. A Windows service was considered and rejected — it requires admin privileges for installation and adds a service-control-manager dependency; a child process is simpler and sufficient for a single-user desktop tool.

On graceful shutdown, no watchdog is needed: the daemon performs the equivalent restore inline via rescue_stranded_windows (already implemented — see Implemented Baseline). The watchdog only covers the crash case.

See IPC & Watchdog.

Aspirational: Deliberately Deferred

These features are acknowledged as valuable but not planned for the current development cycle.

InputInterceptor, DragSession, ResizeSession

Full mouse-driven tiling where Super + Left Mouse Button initiates a drag or resize session with layout snapping. This was described in the original spec but has no active implementation work — the src/input/ module does not exist. The daemon’s input surface today is the IPC pipe; mouse-driven tiling would add an in-process global input hook alongside the existing WinEvent hooks.

Super+LMB Mouse Gestures

Mouse gestures (drag to tile, drag to edge to snap, etc.) build on the DragSession infrastructure above and share its dependency on an unimplemented input hook.

Coexistence Warning

If komorebi, GlazeWM, or another tiling window manager is detected as running, flow should display a warning and ask the user to close the conflicting manager before using flow. Coexistence with another WM that also moves windows will produce unpredictable results.

Design Records

These sections record timeless constraints and deliberate design decisions, not pending work.

Keybindings Intentionally Removed

Keybinding handling was intentionally removed from both the config and the codebase. The rationale: external tools like AutoHotkey, PowerToys, or Komorebi’s keybinding layer are better at translating physical keypresses into IPC commands than a re-implemented keyboard hook. flow’s role is the layout engine and window manager — not the input layer. See Design Decisions for more on this separation of concerns.

Users map their preferred hotkeys to flow dispatch CLI calls via their chosen keybinding tool. This keeps flow’s attack surface small and avoids duplicating well-tested input infrastructure.

Known Win32 Limitations

These are not bugs — they are inherent properties of the Windows rendering model.

SetWindowPos vs DeferWindowPos

flow uses SetWindowPos (immediate positioning) rather than DeferWindowPos (batch positioning). DeferWindowPos batches multiple repositions into a single repaint, but it is atomic: a single elevated admin window (protected by UIPI) fails the entire batch with ERROR_ACCESS_DENIED. Individual SetWindowPos calls mean one failure logs a warning but does not block the remaining windows. See Animation for the per-backend rationale.

GetWindowRect Includes Invisible Borders

GetWindowRect returns a rect that includes a hidden ~7px border on the left, right, and bottom edges. This is not the visual rect of the window. flow works around this via the InvisibleBounds tracking in the registry. See the Window Registry chapter for how invisible bounds are measured and how visible_to_window() compensates.

Applications Own Their Render

flow can request a window position via SetWindowPos, but the application controls its own rendering. Some apps (especially UWP and Electron-based) may not immediately respect position changes or may reposition themselves autonomously. This is a fundamental constraint of the Windows windowing model.