# 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](./layout/overview.md) 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.
```mermaid
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`](../../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.
```mermaid
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](#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`](../../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:
| **`set-window float` / `cycle`** | explicit user toggle of a tiled window | centered (`centered_float_rect`) |
| **`on_window_created` (runtime)** | a new window the rule pipeline classifies as `Float` | centered (`centered_float_rect`) — identical to a toggle |
| **startup scan** | a `Float`-classified window already open when the daemon launched | its **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.
```mermaid
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](#focus-model-clarification)) 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.
```mermaid
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
| **Moved window** | Pops to centered rect | Snaps into new tile slot right of `last_focused_window` |
| **Scrolling layout** | `remove_window`: right-side compresses left, `last_focused_window` → successor | `insert_window`: new column added right of `last_focused_window`, right side shifts right |
| **Floating space** | `add(focused, centered_rect)` — new entry appended | `remove(focused)` — entry removed |
| **OS focus** | Unchanged (same window stays foreground) | Unchanged (same window stays foreground) |
| **`last_focused_window`** | Moves to `next_available_window` via remove-window succession | Set to the moved window (it's now the most recently interacted-with tile) |
| **Registry state** | `Tiling(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.
```mermaid
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.
| **OS focus** (`registry.focused()`) | `WindowRegistry` | Global — the actual Win32 foreground window | The 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_window`** | `ScrollingSpace` | Per-space — most recently interacted-with **tile** | Insert-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](#tiled-only-ops-resolve-from-os-focus)) |
| **(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:
```toml
[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`](../../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`](../../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](./config-and-persistence.md)
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:
```json
{"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).
```mermaid
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:
| Float | true | false | MakeFloating |
| Float | false | true | NoOp |
| Tile | true | false | NoOp |
| Tile | false | true | MakeTiling |
| Cycle | true | false | MakeFloating |
| Cycle | false | true | MakeTiling |
| *any* | false | false | Err (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](./workspace.md) — where `FloatingSpace` fits in the
monitor → workspace → space tree.
- [Layout Overview](./layout/overview.md) — the virtual/actual projection
pipeline that `FloatingSpace` deliberately bypasses.
- [Window Registry](./window-registry.md) — `WindowState::Floating`, focus
tracking, and `last_natural_size`.
- [Animation](./animation.md) — how `animate_workspaces` processes the merged
batch.
- [IPC & Watchdog](./ipc-and-watchdog.md) — the full `SocketMessage` catalog
and named-pipe transport.
- [Config & Persistence](./config-and-persistence.md) — config resolution,
the code-is-source-of-truth model, and the dual-edit rule.