Skip to content

feat: bind workspaces to displays - #545

Open
sj-cstar wants to merge 11 commits into
acsandmann:mainfrom
sj-cstar:sjoshi/workspace-display-bindings
Open

sj-cstar wants to merge 11 commits into
acsandmann:mainfrom
sj-cstar:sjoshi/workspace-display-bindings

Conversation

@sj-cstar

@sj-cstar sj-cstar commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Implements per-display workspaces from #124: a workspace rule can bind its workspace to a display. Usability wise, It is built such that displays feel like a natural extension workspace-working area rather than introducing new set of commands. You can also re-arrange the displays, both focus and moves will adapt to it.

[virtual_workspaces]
default_workspace_count = 9
workspace_names = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]
workspace_rules = [
  { workspace = "1", display = "37D8832A-2D66-02CA-B9F7-8F30A301B230" }, # by UUID
  { workspace = "6", display = 1 },                                      # by index
]

display is a DisplaySelector, the same type the display commands take, so it can be a UUID or an index in physical order. While a bound workspace's display is connected, the workspace lives there:

  • Each display starts on its own workspace.
  • switch_to_workspace and the overview focus the owning display and switch there.
  • move_window_to_workspace sends the window there.
  • move_workspace_to_display won't take a bound workspace off its display.
  • Cycling, relative moves and back-and-forth on a display skip workspaces that live elsewhere.

When the display is unplugged, its workspaces fall back to the remaining displays and keep their windows. When it comes back, those windows move home. None of this runs while the spaces actor has the topology invalidated.

How it is built

The feature leans on existing primitives rather than adding parallel paths:

  • Workspace store: the reactor tells it, per display, the workspace it starts on and the workspaces bound elsewhere. step_workspace and last_workspace skip those, so next/prev (commands, swipes, overscroll), relative moves and back-and-forth need no routing of their own.
  • Moves: bound moves go through the existing move-window-to-display workflow, which gains an optional target workspace. move_window_to_space now delegates to the new move_window_to_workspace_on_space.
  • Rehome: a single flag re-applies bindings after the events that can misplace a window, gated like inventory refreshes are.

Also included

These came up while running the feature on a laptop with a display above it:

  • Cross-display moves: after move-node carries the focused window to another display, the next command acts on that display.
  • Snap-back: windows rift just moved across displays don't snap back while macOS catches up.
  • Disconnect: a window keeps its workspace number when its display disconnects.
  • Hidden windows: they are parked where they overlap no other display.
  • settings.new_window_display: open new windows on the focused or cursor display.
  • Accessibility: rift retries attaching to apps that refused accessibility.

Testing

  • Every commit passes cargo +nightly fmt --all --check and cargo check. At the tip, cargo check --locked and cargo nextest run --lib pass, apart from tests that fail the same way on unmodified main on this machine, because they read the live window server.
  • Running live on macOS 26.7 (Apple Silicon) with the built-in display and a Dell U3225QE above it, with workspaces 1–5 bound to the built-in and 6–9 to the Dell.

My arrangement
image

@sj-cstar sj-cstar changed the title Implements per-display workspaces feat: bind workspaces to displays Sep 30, 2026
@sj-cstar
sj-cstar force-pushed the sjoshi/workspace-display-bindings branch 2 times, most recently from fc40ac9 to 1996833 Compare October 5, 2026 09:32
@sj-cstar

sj-cstar commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Updated as some fixes related to cross-display moves overlapped with my changes.

An app actor gives up when registering for an app's accessibility
notifications fails, and nothing tries again until the app relaunches or
opens a new window, so its windows stay unmanaged. Registration had no
grace period outside JetBrains IDEs and Emacs.

- give every app a two second grace period for the first registration
- retry attaching whenever the user activates an app that has no app
  actor

🤖 Generated with Claude Code
Rift hides the windows of inactive workspaces by parking them past the
bottom-left or bottom-right corner of their display, leaving a sliver on
screen. With a display arranged above another, a window parked past the
upper display's bottom edge sits on the lower display. Rift then adopts it
there, and it turns up in the wrong display's workspace.

- add right-edge and left-edge placements, top-aligned with the display
  and clamped to its height
- pick the placement that overlaps other displays least
- treat a sliver in either dimension as hidden

🤖 Generated with Claude Code
After move-node carried the focused window onto another display, the
next command still acted on the display the window had left: macOS
moves its active display along with the key window only later. Make the
window's new display the command context, as focus_display would, so a
second move-node or a focus command acts where the window now is.

🤖 Generated with Claude Code
After rift moves a window to another display, macOS and the app take a
moment to catch up. Until then the window server still reports the old
display, and the app can send frame reports from before the move. Rift
treated a live report of the old display as the user moving the window
back, so it followed the window there, then followed its own move again
once the window landed, and the window flipped between displays.
move_window_to_display, move_workspace_to_display, overview drops and
move-node across displays all go through this path.

Record every cross-display move rift starts and, for two seconds, resolve
the window to its new display instead of following reports of the old
one. After that, a real move by the user is followed as before.

🤖 Generated with Claude Code
When a display disconnects, macOS moves its windows onto a remaining
display. Rift's active-space reconcile, and rediscovery after display
churn, then put each of those windows into the workspace that display was
showing, so a window in workspace 9 turned up in workspace 1. The topology
delta that keeps the workspace number runs after the reconcile has already
moved the window, too late to help.

Remember the native spaces of displays that leave the display set. A
window moving off one of them keeps its workspace number, on both the
reconcile and the rediscovery paths. Windows dragged between connected
displays still join the workspace the target display shows.

🤖 Generated with Claude Code
…or cursor display

A new window opens wherever macOS and the app put it. Launchers that
activate an app before asking it for a window get the window next to that
app's other windows, even while the user works on another display.

Add settings.new_window_display:
- "default": unchanged behaviour
- "focused": move a new window to the display holding keyboard focus
- "cursor": move a new window to the display under the mouse cursor

A window whose app rule names a workspace stays where the rule puts it.
The move is recorded as a display move in flight, so reports of the old
display that arrive while macOS catches up do not pull the window back.

🤖 Generated with Claude Code
Workspace display bindings need to focus a display from inside other
commands. Move the body of the focus_display handler into
Reactor::focus_display_by_selector unchanged; the handler now calls it.

No behaviour change.

🤖 Generated with Claude Code
Let a workspace rule name the display its workspace lives on:

    workspace_rules = [{ workspace = "web", display = "37D8832A-…" }]

`display` is a DisplaySelector, the type display commands already take: a
display UUID (stable across reconnects) or an index in physical order.
Directions make no sense for a binding and are reported by validation, as
are empty UUIDs and rules that set neither layout nor display. `layout`
becomes optional so a rule can set only a display; rules keep matching by
index or name, the last one giving a setting winning.

This only adds the setting; the following commits make rift honour it.

🤖 Generated with Claude Code
…cling

With workspace display bindings, every display started on
default_workspace, so a display could come up showing a workspace bound
to another one, and windows found on it were filed there. Cycling and
back-and-forth could land on such workspaces too.

On every snapshot and config reload the reactor now tells the workspace
store, for each display, which workspace it starts on (default_workspace
if it may live there, else its first own workspace, else the first
unbound one) and which workspaces are bound to another connected display.
The store starts new spaces on the former, and step_workspace and
last_workspace skip the latter, so next/prev (commands, swipes and
scroll overscroll), relative window moves, back-and-forth and
switch_to_last_workspace stay on a display's own workspaces with no
further routing.

🤖 Generated with Claude Code
While a bound workspace's display is connected, keep the workspace there:

- switch_to_workspace, or picking the workspace in the overview, from
  another display focuses the owning display and switches there; when
  the workspace is already showing there it just focuses the display
- move_window_to_workspace moves the window into the owning display's
  copy of the workspace, with or without follow; a window named by id may
  live on any display
- move_workspace_to_display refuses to move a bound workspace off its
  display

Bound moves go through the existing move-window-to-display workflow,
which now takes an optional target workspace and follow flag, and
LayoutEngine::move_window_to_workspace_on_space, which move_window_to_space
now delegates to instead of repeating the relocation bookkeeping.

🤖 Generated with Claude Code
A window can end up in a workspace bound to a display it is not on: an
app rule placed it there, it was dropped there in the overview, or macOS
parked it on the remaining display while the owner was unplugged. And
after a display joins, leaves or moves, a display can be left showing a
workspace whose owner is back.

After each event that can cause this (window discovery and placement, a
display change, an overview drop, a config reload) re-apply the bindings
once the event's outcome has settled window membership:

- a display showing a workspace bound elsewhere switches back to its last
  own workspace, or the one it starts on; when the owner shows nothing it
  takes the workspace over, so the user keeps seeing it
- windows in a bound workspace on another display move to the owner's
  copy of that workspace, without taking focus
- a window macOS moves back onto its workspace's display keeps its
  workspace number instead of joining the workspace that display shows

The work waits while the native topology is invalidated (sleep, wake,
lock, display churn), since moving windows on transient data fights
macOS; the authoritative snapshot that ends the instability runs it.

🤖 Generated with Claude Code
@sj-cstar
sj-cstar force-pushed the sjoshi/workspace-display-bindings branch from 1996833 to 3526ac0 Compare October 5, 2026 23:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant