Skip to content

feat: bind workspaces to displays - #545

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

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

@sj-cstar
sj-cstar force-pushed the sjoshi/workspace-display-bindings branch from 1996833 to 3526ac0 Compare October 5, 2026 23:51
gmjain added a commit to gmjain/rift that referenced this pull request Oct 7, 2026
With scope = "global" every display still holds its own copy of every
workspace, but a workspace now has one owner: the display whose copy of
it is showing or holds windows. A workspace rule binding it to a display
forces the owner. A workspace no display owns does not exist yet, as in
i3.

The reactor feeds the owners to the workspace store through the display
bindings acsandmann#545 added for workspace rules, so the store needs no new
state: a display starts on default_workspace if no other display owns
it, else on the first workspace nobody owns (displays connected together
claim their starting workspaces left to right), and cycling, relative
window moves and swipes on a display skip the workspaces owned by
another one. Unlike rule bindings the owners change with every switch
and window move, so they are recomputed after each event.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGErZJPSEqYf1pfQ3crYKa
gmjain added a commit to gmjain/rift that referenced this pull request Oct 7, 2026
… snapshot

The passes acsandmann#545 runs for workspace rule bindings now run for derived
owners too: a display showing a workspace another display owns switches
back to its last own workspace, or the one it starts on, and windows in
a workspace's copy on another display move to the owner's copy. With
shared workspaces that repairs what the routing cannot prevent: a layout
restored from per-display workspaces (two displays showing the same
workspace; the copy with more windows wins, then the display the user is
on, then the leftmost), a window an app rule or an overview drop put in
a workspace that lives elsewhere, and a display back from native
fullscreen. The passes stay edge-triggered, idempotent and bounded, and
run after every full snapshot in global scope since a restore or a
fullscreen exit announces itself no other way.

A display in native fullscreen keeps its workspaces: the spaces actor
reports it without a Space, so the owner is looked up through the user
Space it left. Switching to or moving a window into one of its
workspaces is refused with a warning until it is back, and cycling on
the other displays skips them, instead of opening second copies that
would have to be merged later.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGErZJPSEqYf1pfQ3crYKa
@sj-cstar
sj-cstar force-pushed the sjoshi/workspace-display-bindings branch from 3526ac0 to de126c5 Compare October 8, 2026 06:33
gmjain added a commit to gmjain/rift that referenced this pull request Oct 8, 2026
With scope = "global" every display still holds its own copy of every
workspace, but a workspace now has one owner: the display whose copy of
it is showing or holds windows. A workspace rule binding it to a display
forces the owner. A workspace no display owns does not exist yet, as in
i3.

The reactor feeds the owners to the workspace store through the display
bindings acsandmann#545 added for workspace rules, so the store needs no new
state: a display starts on default_workspace if no other display owns
it, else on the first workspace nobody owns (displays connected together
claim their starting workspaces left to right), and cycling, relative
window moves and swipes on a display skip the workspaces owned by
another one. Unlike rule bindings the owners change with every switch
and window move, so they are recomputed after each event.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGErZJPSEqYf1pfQ3crYKa
gmjain added a commit to gmjain/rift that referenced this pull request Oct 8, 2026
… snapshot

The passes acsandmann#545 runs for workspace rule bindings now run for derived
owners too: a display showing a workspace another display owns switches
back to its last own workspace, or the one it starts on, and windows in
a workspace's copy on another display move to the owner's copy. With
shared workspaces that repairs what the routing cannot prevent: a layout
restored from per-display workspaces (two displays showing the same
workspace; the copy with more windows wins, then the display the user is
on, then the leftmost), a window an app rule or an overview drop put in
a workspace that lives elsewhere, and a display back from native
fullscreen. The passes stay edge-triggered, idempotent and bounded, and
run after every full snapshot in global scope since a restore or a
fullscreen exit announces itself no other way.

A display in native fullscreen keeps its workspaces: the spaces actor
reports it without a Space, so the owner is looked up through the user
Space it left. Switching to or moving a window into one of its
workspaces is refused with a warning until it is back, and cycling on
the other displays skips them, instead of opening second copies that
would have to be merged later.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VGErZJPSEqYf1pfQ3crYKa
@sj-cstar
sj-cstar force-pushed the sjoshi/workspace-display-bindings branch 2 times, most recently from 4e27f54 to 0a8ffd7 Compare October 11, 2026 02:18
@acsandmann

Copy link
Copy Markdown
Owner

okay this is next on the docket

@sj-cstar

Copy link
Copy Markdown
Contributor Author

I've been running this for a while. It works well for my setup.

One gap which I want to create an issue for discussion: moving focus between displays when several windows border the edge. Will do this after merging.

My arrangement: the Dell above the laptop, with the Dell split into B and C.

  +-------------+-------------+
  |      B      |      C      |   Dell
  +-------------+-------------+
       +-----------------+
       |        A        |        Laptop
       +-----------------+

Moving up from A has two candidates. With the traditional layout, rift always picks C: window_in_direction enters a layout at its last window when moving up or left, and at its first when moving down or right. Going down from B and back up lands on C, not B.

  Today                                Wanted

  +-------------+-------------+        +-------------+-------------+
  |      B      |      C      |        |      B      |      C      |
  +-------------+-------------+        +-------------+-------------+
    down |             ^ up             down | ^ up
         v             |                     v |
       +-----------------+                  +-----------------+
       |        A        |                  |        A        |
       +-----------------+                  +-----------------+

Position doesn't settle it either: A sits under the split between B and C.

Proposal: Use the arrangement as much as possible, but also remember the path in the case of such cases

  • When focus leaves a display, remember the window it left from.
  • Moving back the opposite way returns to that window.
  • With nothing remembered, or once that window closes, keep today's pick.

@Swoorup

Swoorup commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

I also now think we should deprecate/remove --one of rift after this. Since multi-display would work well enough

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
…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
@acsandmann
acsandmann force-pushed the sjoshi/workspace-display-bindings branch from 0a8ffd7 to 4413f64 Compare October 12, 2026 02:17
@acsandmann

Copy link
Copy Markdown
Owner

synced to main

@sj-cstar

Copy link
Copy Markdown
Contributor Author

@acsandmann let me know if you'd need anything. I'd like to work on the move_focus improvement once this lands.

@acsandmann

Copy link
Copy Markdown
Owner

@acsandmann let me know if you'd need anything. I'd like to work on the move_focus improvement once this lands.

Try opening a stacked pr, I haven't used it yet but that would work well so its based on this one. Trying to land 0.7.0 to get some bug fixes out the door but that means a lot of stuff has to be refined in terms of all the ui that will be released then.

@acsandmann

acsandmann commented Oct 21, 2026 •

Copy link
Copy Markdown
Owner

hey guys, i was looking into the implementation and i decided to go a different direction to implement this feature. my wip is in feat/multidisplayv2 and it should generally work the same as this but with a cleaner underlying system as this is clean but adds a lot of complexity. if you want to start move_focus work, open a pr to that branch. (The bulk of the changes are that workspaces will no longer be keyed by space id and that part of ownership will be abstracted away

Edit: you will also of course be credited as a co author. And, if you have any issues with that branch let me know so it can be as good as possible.

@acsandmann

Copy link
Copy Markdown
Owner

#603 is working quite well for me with no issues. Feel free to open a pr to its branch or suggest changes if you have any differing ideas as this is a significant change. This would not have happened without your pr so thank you

@acsandmann acsandmann closed this Oct 21, 2026
@sj-cstar

Copy link
Copy Markdown
Contributor Author

Not a problem, I'd prefer a robust cleaner solution anyway @acsandmann , which seems like you have got one. Haven't had the chance to play around it, but will soon.

@acsandmann

Copy link
Copy Markdown
Owner

Not a problem, I'd prefer a robust cleaner solution anyway @acsandmann , which seems like you have got one. Haven't had the chance to play around it, but will soon.

it should work almost perfectly but id like to test for a while before merging due to the magnitude of the changes

@sj-cstar

Copy link
Copy Markdown
Contributor Author

Will check and report back soon.

@sj-cstar
sj-cstar deleted the sjoshi/workspace-display-bindings branch October 22, 2026 00:44
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.

3 participants