From f60dd06e7ebceb574c34a4bedbe486038ab0cbc5 Mon Sep 17 00:00:00 2001 From: Asmir A Date: Tue, 18 Aug 2026 21:30:51 +0200 Subject: [PATCH] add multimonitor --- README.md | 75 +++++++++++++++--- quickshell/AttWm.qml | 10 +++ quickshell/shell.qml | 5 ++ src/Output.zig | 30 ++++++- src/Seat.zig | 49 +++++++++++- src/Wm.zig | 184 ++++++++++++++++++++++++++++++++++++++++--- src/action.zig | 40 ++++++++++ src/config.zig | 33 +++++--- src/ipc.zig | 6 +- src/test.zig | 72 +++++++++++++++++ 10 files changed, 470 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 6df2c17..64e845d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ river 0.4 is *non-monolithic*: it ships no window management policy of its own `riverctl`, no `rivertile` — and instead hands the entire job to a single external client speaking `river-window-management-v1`. att_wm is that client. It gives you dwm's model on top of river: nine tags, a master/stack layout, monocle and tabbed -layouts, and keybindings compiled into the binary. +layouts, and keybindings compiled into the binary. On more than one monitor the +tags are split across the screens, so every tag lives on exactly one of them. Tag and window state is published as JSON lines on a unix socket so bars such as [quickshell](https://quickshell.org) can render it, and `att_wmctl` drives the same @@ -22,6 +23,9 @@ Verified working against river 0.4.5 / Zig 0.16 / quickshell 0.3.0: - tags, master / monocle / tabbed layouts, floating and fullscreen windows - focus cycling, `zoom`, stack reordering, per-tag layout state +- multiple monitors, with the tag set split across them: verified on a headless + river with two outputs, driven over IPC — the split, moving between screens, + windows following their tags across, and views clamped to the screen's own tags - the IPC socket, `att_wmctl`, and a quickshell bar that maps as a layer surface and whose exclusive zone correctly shrinks the tiling area @@ -149,7 +153,7 @@ if you would rather not compete with applications that bind Alt themselves. | `Mod+j` / `Mod+k` | Focus next / previous window | | `Mod+Shift+j` / `Mod+Shift+k` | Move focused window down / up the stack | | `Mod+Return` | Zoom — promote focused window to master | -| `Mod+h` / `Mod+l` | Shrink / grow the master area | +| `Mod+,` / `Mod+.` | Shrink / grow the master area | | `Mod+i` / `Mod+d` | Increase / decrease windows in master | | `Mod+t` / `Mod+m` / `Mod+u` | Master / monocle / tabbed layout | | `Mod+space` | Toggle between current and previous layout | @@ -161,8 +165,8 @@ if you would rather not compete with applications that bind Alt themselves. | `Mod+Ctrl+Shift+1..9` | Toggle tag on focused window | | `Mod+0` / `Mod+Shift+0` | View all tags / put window on all tags | | `Mod+Tab` / `Mod+Esc` | Back to previously viewed tags | -| `Mod+,` / `Mod+.` | Focus previous / next output | -| `Mod+Shift+,` / `Mod+Shift+.` | Send window to previous / next output | +| `Mod+h` / `Mod+l` | Focus the screen to the left / right | +| `Mod+Shift+h` / `Mod+Shift+l` | Send window to the screen left / right | | `Mod+Left drag` | Move window (floats it) | | `Mod+Right drag` | Resize window | @@ -212,6 +216,48 @@ keep theirs for the way back. --- +## Multiple monitors + +The tag set is **split across the screens**: every tag lives on exactly one of +them. With two monitors the left owns tags 1–5 and the right 6–9; with three +they get 1–3, 4–6 and 7–9. Screens are ordered by where they sit in the output +layout, left to right and then top to bottom — not by the order they were +plugged in — so the arrangement follows the monitors on the desk rather than the +cables behind it. + +| | | +|---|---| +| `Mod+h` / `Mod+l` | Move to the screen on the left / right | +| `Mod+Shift+h` / `Mod+Shift+l` | Send the focused window there | +| `Mod+7` | Go to the screen tag 7 lives on and show tag 7 | +| `Mod+Shift+7` | Send the focused window to tag 7, wherever that is | + +Because a tag names a screen as well as a workspace, `Mod+7` and `Mod+h` are two +ways of doing the same thing, and the tag keys alone are enough to drive the +whole desk. Sending a window away leaves you where you are, as dwm's `tagmon` +does; the keyboard goes to whatever is left on the screen you are still on. + +`Mod+0` means everything on *this* screen — a view is always clamped to the tags +its screen owns, so no screen can be made to show another's tag. + +Unplugging a monitor hands its tags to the screens that remain, and the windows +wearing those tags follow them there rather than being stranded on a tag nothing +can show. Plug it back in and they go home. A laptop with nothing attached owns +all nine tags and behaves exactly as it did before, which is why none of this is +visible until there is a second screen. + +Set `split_tags = false` in `config.zig` for dwm's model instead: every screen +gets a full set of nine tags of its own, and the tag keys never leave the screen +you are on. `Mod+h`/`Mod+l` and `Mod+Shift+h`/`Mod+Shift+l` still move between +screens and are then the only way to. + +Layer surfaces (bars) that do not name an output land on the focused screen. +`warp_cursor` is worth turning on here: it pulls the pointer along when the +keyboard moves to another screen, including onto an empty one, where there is no +window to warp to and the cursor would otherwise be left behind. + +--- + ## Configuration att_wm is configured at compile time, like dwm. Edit `src/config.zig` and rebuild. @@ -390,6 +436,7 @@ quickshell config uses one socket for both, avoiding a process spawn per click. "name": "DP-1", "focused": true, "tags": 1, + "owned_tags": 31, "occupied": 5, "layout": "master", "layout_symbol": "[]=", @@ -411,8 +458,12 @@ quickshell config uses one socket for both, avoiding a process spawn per click. } ``` -`tags` and `occupied` are bitmasks — `occupied` is the set of tags holding at -least one window, which is what dwm's bar draws its corner squares from. +`tags`, `owned_tags` and `occupied` are bitmasks. `occupied` is the set of tags +holding at least one window, which is what dwm's bar draws its corner squares +from. `owned_tags` is the slice of the tag set this screen owns — see +[Multiple monitors](#multiple-monitors) — so a bar can draw its own screen's +tags and leave the rest to the bar on the screen they belong to. It is all nine +when `split_tags` is off, so a bar that honours it works either way. `usable` is the area left after layer-shell exclusive zones, i.e. where windows are actually laid out. @@ -464,9 +515,13 @@ quickshell -p /path/to/att_wm/quickshell ``` `AttWm.qml` is a singleton wrapping the socket — reconnecting if att_wm restarts, -exposing `outputs`, `focusedOutput`, `focusedTitle`, `tagActive()` and -`tagOccupied()`, plus `send()` for commands. Reuse it in your own bar and ignore -`shell.qml`. +exposing `outputs`, `focusedOutput`, `focusedTitle`, `tagActive()`, +`tagOccupied()` and `tagOwned()`, plus `send()` for commands. Reuse it in your +own bar and ignore `shell.qml`. + +One bar is created per screen and each draws only the tags its screen owns, via +`tagOwned()` — with the tag set split across monitors the other tags belong to +the bar next door. Note that **a bar only works because att_wm binds `river_layer_shell_v1`**. river refuses to map layer surfaces at all unless the window manager declares support @@ -489,7 +544,7 @@ river's identifier is shared between them. | --- | --- | | `src/Wm.zig` | Globals, the manage/render sequence state machine, actions, event loop | | `src/Window.zig` | Per-window state; handlers only mutate fields | -| `src/Output.zig` | Output tags, the per-tag layout/nmaster/mfact, and the tab bar | +| `src/Output.zig` | Output tags and which of them it owns, the per-tag layout/nmaster/mfact, and the tab bar | | `src/Seat.zig` | Focus, key and pointer bindings, interactive move/resize | | `src/InputManager.zig` | Input devices: keymaps, key repeat, libinput settings | | `src/layout.zig` | Pure tiling geometry — no Wayland, unit tested | diff --git a/quickshell/AttWm.qml b/quickshell/AttWm.qml index 8b74348..cf62a50 100644 --- a/quickshell/AttWm.qml +++ b/quickshell/AttWm.qml @@ -59,6 +59,16 @@ Singleton { return output ? (output.tags & (1 << index)) !== 0 : false; } + /// Whether a tag belongs to this screen at all. + /// + /// With `split_tags` on, each screen owns one slice of the tag set and the + /// rest live on the other monitors, so a bar that drew all nine would be + /// offering tags its screen cannot show. Every tag is owned when the split + /// is off, so the same bar works either way. + function tagOwned(output, index) { + return output ? (output.owned_tags & (1 << index)) !== 0 : true; + } + // A Socket that fails to connect cannot be revived: no state change is // reported and re-asserting `connected` does not make it try again. So the // socket lives in a Loader and a retry means building a new one. This is diff --git a/quickshell/shell.qml b/quickshell/shell.qml index f70bdd2..5d8bead 100644 --- a/quickshell/shell.qml +++ b/quickshell/shell.qml @@ -49,6 +49,11 @@ ShellRoot { readonly property bool active: AttWm.tagActive(bar.output, index) readonly property bool occupied: AttWm.tagOccupied(bar.output, index) + // Tags belonging to another screen are that screen's to + // draw; a hidden item is out of the layout entirely, so + // each bar shows its own slice with no gap. + visible: AttWm.tagOwned(bar.output, index) + Layout.fillHeight: true implicitWidth: label.implicitWidth + 16 color: active ? "#5294e2" : "transparent" diff --git a/src/Output.zig b/src/Output.zig index f680262..82380f4 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -46,6 +46,11 @@ have_usable: bool = false, tags: u32 = config.default_tags, prev_tags: u32 = config.default_tags, +/// The tags this output owns. Every tag when `config.split_tags` is off or this +/// is the only screen; one slice of the set otherwise. `Wm.assignTagRanges` +/// is what divides them up. +owned_tags: u32 = action.all_tags, + /// One slot per tag, plus slot 0 for views of more than one tag. Indexed /// through `state()`, never directly. tag_state: [action.tag_count + 1]TagState = @splat(.{}), @@ -119,13 +124,36 @@ pub fn setLayout(self: *Output, mode: action.Layout) void { st.layout = mode; } +/// Show a set of tags. Tags belonging to another screen are dropped rather than +/// shown here, so `Mod+0` means "everything on this screen" and not "everything +/// everywhere"; a mask with nothing of this output's in it leaves the view alone. pub fn setTags(self: *Output, tags: u32) void { - const masked = tags & action.all_tags; + const masked = tags & self.owned_tags; if (masked == 0 or masked == self.tags) return; self.prev_tags = self.tags; self.tags = masked; } +/// Hand this output its slice of the tag set. +/// +/// A view left with nothing it owns falls back to the lowest tag it does, which +/// is what settles a screen the moment it is plugged in — it starts on +/// `default_tags` like every other, and only here finds out that tag is not +/// its to show. +pub fn setOwnedTags(self: *Output, mask: u32) void { + const owned = mask & action.all_tags; + if (owned == 0 or owned == self.owned_tags) return; + self.owned_tags = owned; + + self.tags = clampView(self.tags, owned); + self.prev_tags = clampView(self.prev_tags, owned); +} + +fn clampView(tags: u32, owned: u32) u32 { + const kept = tags & owned; + return if (kept != 0) kept else action.lowestTag(owned); +} + fn onEvent(_: *river.OutputV1, event: river.OutputV1.Event, self: *Output) void { switch (event) { .removed => { diff --git a/src/Seat.zig b/src/Seat.zig index a1951d8..9759006 100644 --- a/src/Seat.zig +++ b/src/Seat.zig @@ -76,6 +76,10 @@ pointer_y: i32 = 0, /// The window the pointer is currently inside. hovered: ?*Window = null, +/// A screen to warp the pointer onto in the next manage sequence. Set when +/// focus moves to an output with no window on it to warp to. +pending_warp_output: ?*Output = null, + op: ?Op = null, /// An operation to start in the next manage sequence. pending_op: ?Op = null, @@ -165,12 +169,19 @@ fn createBindings(self: *Seat) !void { std.log.info("registered {d}/{d} pointer bindings", .{ self.buttons.items.len, config.buttons.len }); } -/// The output this seat is working on: the one holding the focused window, -/// else the one under the pointer. +/// The output this seat is working on: the one holding the focused window, else +/// the one focus was last moved to, else the one under the pointer. +/// +/// That middle case is what makes an empty screen a place the user can be. +/// Moving to a screen with nothing on it clears the focused window, and with +/// only the pointer to fall back on the seat would go on reporting the screen it +/// came from — so the tag keys, the layout keys and the next window spawned +/// would all land back on the monitor just left. pub fn currentOutput(self: *Seat) ?*Output { if (self.focused) |win| { if (win.output) |out| return out; } + if (self.wm.focused_output) |out| return out; return self.wm.outputAt(self.pointer_x, self.pointer_y) orelse self.wm.firstOutput(); } @@ -256,6 +267,11 @@ pub fn applyManage(self: *Seat) void { self.pending_clear_focus = false; self.wm.ipcDirty(); } + + if (self.pending_warp_output) |out| { + self.warpToOutput(out); + self.pending_warp_output = null; + } } /// Pull the pointer to the middle of a newly focused window. @@ -274,6 +290,25 @@ fn warpTo(self: *Seat, win: *Window) void { ); } +/// Pull the pointer onto a screen the keyboard has just moved to, when there is +/// no window there to warp to instead. +/// +/// Without it, moving to an empty screen leaves the cursor on the one before — +/// and with `focus_follows_mouse` on, the first window the pointer then brushes +/// past takes the focus straight back. Manage sequence only. +fn warpToOutput(self: *Seat, out: *Output) void { + if (!config.warp_cursor) return; + if (self.op != null) return; + const area = out.layoutArea(); + if (area.width <= 0 or area.height <= 0) return; + if (area.contains(self.pointer_x, self.pointer_y)) return; + + self.seat.pointerWarp( + area.x + @divTrunc(area.width, 2), + area.y + @divTrunc(area.height, 2), + ); +} + fn onEvent(_: *river.SeatV1, event: river.SeatV1.Event, self: *Seat) void { switch (event) { .removed => { @@ -289,6 +324,16 @@ fn onEvent(_: *river.SeatV1, event: river.SeatV1.Event, self: *Seat) void { .pointer_position => |ev| { self.pointer_x = ev.x; self.pointer_y = ev.y; + + // Sloppy focus crosses screens too, as dwm's motion handler does: + // the pointer leaving a monitor is what moves the seat to the next + // one, so the tag and layout keys follow the cursor even over a + // screen with no window on it to focus. + if (config.focus_follows_mouse and self.op == null) { + if (self.wm.outputAt(ev.x, ev.y)) |out| { + if (self.wm.focused_output != out) self.wm.enterOutput(self, out); + } + } }, .pointer_enter => |ev| { diff --git a/src/Wm.zig b/src/Wm.zig index e85f055..cf948fc 100644 --- a/src/Wm.zig +++ b/src/Wm.zig @@ -107,6 +107,8 @@ tab_face_tried: bool = false, scratch_windows: std.ArrayList(*Window) = .empty, scratch_cells: std.ArrayList(Box) = .empty, scratch_order: std.ArrayList(*Window) = .empty, +/// Outputs in desk order; see `orderedOutputs`. +scratch_outputs: std.ArrayList(*Output) = .empty, pub fn init(gpa: Allocator, environ: std.process.Environ) !*Wm { const display = try wl.Display.connect(null); @@ -261,6 +263,7 @@ pub fn deinit(self: *Wm) void { self.scratch_windows.deinit(gpa); self.scratch_cells.deinit(gpa); self.scratch_order.deinit(gpa); + self.scratch_outputs.deinit(gpa); self.ipc.deinit(); sys.close(self.repeat_fd); @@ -519,6 +522,8 @@ fn manage(self: *Wm) void { defer self.in_manage = false; self.reap(); + self.assignTagRanges(); + self.rehomeWindows(); self.assignOutputs(); self.focusNewWindows(); self.arrangeAll(); @@ -587,6 +592,7 @@ fn reap(self: *Wm) void { if (seat.focused) |f| if (f.output == null) { seat.focused = null; }; + if (seat.pending_warp_output == out) seat.pending_warp_output = null; } out.destroy(); self.ipcDirty(); @@ -608,6 +614,103 @@ fn reap(self: *Wm) void { } } +/// Divide the tag set among the outputs, left to right. +/// +/// Recomputed every manage sequence rather than only when a screen comes or +/// goes, because dragging a monitor to the other side of the desk reorders them +/// too and the ranges have to follow. `setOwnedTags` is a no-op when nothing +/// has actually changed, which is the common case. +fn assignTagRanges(self: *Wm) void { + const ordered = self.orderedOutputs(); + for (ordered, 0..) |out, i| { + const mask = if (config.split_tags) act.tagsForOutput(i, ordered.len) else act.all_tags; + out.setOwnedTags(mask); + } +} + +/// Move windows to the screen that owns their tags. +/// +/// This is what makes the split work in one direction and heal in the other: +/// `Mod+Shift+7` retags a window and the window follows tag 7 to the screen it +/// lives on, and unplugging a monitor hands its tags to a neighbour, which the +/// windows wearing them follow rather than being stranded on tags their new +/// screen cannot show. Plug the monitor back in and they go home. +/// +/// A window keeps the screen it is on for as long as that screen owns any of +/// its tags, so putting a window on every tag — `Mod+Shift+0` — does not yank +/// it away to whichever screen owns tag 1. +fn rehomeWindows(self: *Wm) void { + if (!config.split_tags) return; + + for (self.windows.items) |win| { + if (win.closed or win.tags == 0) continue; + if (win.output) |out| { + if (win.tags & out.owned_tags != 0) continue; + } + const target = self.outputForTags(win.tags) orelse continue; + if (win.output == target) continue; + + win.output = target; + // The new screen is a different size, so the size we last proposed + // says nothing about the size it should have there. + win.proposed_width = -1; + win.proposed_height = -1; + self.windowLeftOutput(win); + self.ipcDirty(); + } +} + +/// Keep a seat where it is when the window it was focusing moves to another +/// screen. +/// +/// `Mod+Shift+7` is a way of getting a window off the screen, not of following +/// it across to the next one, and `validateFocus` on its own would do the +/// opposite: it resolves against the focused window's output, which is now the +/// far screen. So the keyboard goes to whatever is left where the user still is. +fn windowLeftOutput(self: *Wm, win: *Window) void { + const out = self.focused_output orelse return; + for (self.seats.items) |seat| { + if (seat.focused != win and seat.pending_focus != win) continue; + // `win` has already been moved, so it is not a candidate here. + if (self.topOnTags(out, out.tags)) |next| seat.focus(next) else seat.focus(null); + } +} + +/// The output owning the lowest tag in a mask. +pub fn outputForTags(self: *Wm, tags: u32) ?*Output { + const t = act.lowestTag(tags); + if (t == 0) return null; + for (self.outputs.items) |out| { + if (out.owned_tags & t != 0) return out; + } + return null; +} + +/// The outputs in the order they are arranged on the desk: left to right, then +/// top to bottom. +/// +/// This is the order `focus_output` steps through and the order the tag set is +/// split in, which is what ties the two together — `Mod+l` moves to the screen +/// on the right, and the screen on the right is the one holding the higher +/// tags. Connection order would put either wherever the cables happened to go. +/// +/// Returns a scratch buffer, valid until the next call. +fn orderedOutputs(self: *Wm) []*Output { + self.scratch_outputs.clearRetainingCapacity(); + // Unsorted is a poor order but a working one; nothing here is worth + // failing a manage sequence over. + self.scratch_outputs.appendSlice(self.gpa, self.outputs.items) catch return self.outputs.items; + // A stable sort, so two screens stacked exactly on top of each other keep + // connection order rather than swapping about between frames. + std.mem.sort(*Output, self.scratch_outputs.items, {}, lessByPosition); + return self.scratch_outputs.items; +} + +fn lessByPosition(_: void, a: *Output, b: *Output) bool { + if (a.box.x != b.box.x) return a.box.x < b.box.x; + return a.box.y < b.box.y; +} + /// Give new windows an output and a tag set. fn assignOutputs(self: *Wm) void { const fallback = self.focused_output orelse self.firstOutput(); @@ -704,6 +807,57 @@ fn validateFocus(self: *Wm, seat: *Seat) void { } } +/// View a set of tags, on whichever screen owns them. +/// +/// With the tag set split across displays, `Mod+7` is as much "go to the screen +/// tag 7 lives on" as it is "show tag 7" — the two are the same thing, and it is +/// what makes the tag keys on their own enough to drive a multi-monitor desk. +/// A mask the current screen owns any part of stays where it is, so `Mod+0` +/// still means "everything here". +fn viewTags(self: *Wm, seat: *Seat, mask: u32) void { + const current = seat.currentOutput(); + const target = blk: { + if (!config.split_tags) break :blk current orelse return; + if (current) |out| { + if (out.owned_tags & mask != 0) break :blk out; + } + break :blk self.outputForTags(mask) orelse current orelse return; + }; + + target.setTags(mask); + if (target != current) self.enterOutput(seat, target); + self.ipcDirty(); +} + +/// Move the seat to another screen: the keyboard goes to the window most +/// recently focused there, and if there is none the pointer is warped instead so +/// that an empty screen is still somewhere the user can be. +pub fn enterOutput(self: *Wm, seat: *Seat, out: *Output) void { + self.focusOutput(out); + if (self.topOnTags(out, out.tags)) |win| { + seat.focus(win); + } else { + seat.focus(null); + seat.pending_warp_output = out; + } +} + +/// The most recently focused window a view of `tags` on `out` would show. +/// +/// Unlike `topVisible` this asks the tags rather than the `visible` flags, which +/// are only as fresh as the last layout pass — a binding that changes what is +/// being viewed and then wants to know what to focus is asking about a view that +/// has not been arranged yet. +fn topOnTags(self: *Wm, out: *Output, tags: u32) ?*Window { + var best: ?*Window = null; + for (self.windows.items) |win| { + if (win.output != out or win.closed or !win.mapped) continue; + if ((win.tags & tags) == 0) continue; + if (best == null or win.focus_serial > best.?.focus_serial) best = win; + } + return best; +} + /// The most recently focused visible window on an output. fn topVisible(self: *Wm, out: *Output) ?*Window { var best: ?*Window = null; @@ -1080,12 +1234,11 @@ pub fn perform(self: *Wm, seat: *Seat, action: act.Action) void { .swap => |dir| self.swapWindow(seat, dir), .zoom => self.zoom(seat), - .view => |mask| if (seat.currentOutput()) |out| { - out.setTags(mask); - self.ipcDirty(); - }, + .view => |mask| self.viewTags(seat, mask), .toggle_view => |mask| if (seat.currentOutput()) |out| { - out.setTags(out.tags ^ mask); + // Only this screen's own tags can be toggled into its view; the + // rest are somewhere else entirely. + out.setTags(out.tags ^ (mask & out.owned_tags)); self.ipcDirty(); }, .view_prev => if (seat.currentOutput()) |out| { @@ -1296,30 +1449,39 @@ fn moveToFront(self: *Wm, win: *Window, before: *Window) void { }; } +/// Step to the next screen along, or send the focused window there. +/// +/// The step is through `orderedOutputs`, so `prev` is the screen to the left and +/// `next` the one to the right whatever order the monitors were plugged in. fn cycleOutput(self: *Wm, seat: *Seat, dir: act.Direction, send: bool) void { - if (self.outputs.items.len < 2) return; + const ordered = self.orderedOutputs(); + if (ordered.len < 2) return; const current = seat.currentOutput() orelse return; var idx: usize = 0; - for (self.outputs.items, 0..) |out, i| { + for (ordered, 0..) |out, i| { if (out == current) idx = i; } - const n = self.outputs.items.len; + const n = ordered.len; const next = switch (dir) { .next => (idx + 1) % n, .prev => (idx + n - 1) % n, }; - const target = self.outputs.items[next]; + const target = ordered[next]; if (send) { const win = seat.focused orelse return; win.output = target; + // Onto the tags that screen is showing, which under a split tag set is + // also what keeps the window there rather than being rehomed straight + // back to where it came from. win.tags = target.tags; win.proposed_width = -1; win.proposed_height = -1; + // dwm's tagmon leaves you on the monitor you were on, and so does this. + self.windowLeftOutput(win); } else { - self.focusOutput(target); - if (self.topVisible(target)) |win| seat.focus(win) else seat.focus(null); + self.enterOutput(seat, target); } self.ipcDirty(); } diff --git a/src/action.zig b/src/action.zig index 989cb4f..fd2d524 100644 --- a/src/action.zig +++ b/src/action.zig @@ -32,6 +32,46 @@ pub fn tagSlot(tags: u32) usize { return @ctz(t) + 1; } +/// The tags belonging to output `index` of `count`, when the tag set is split +/// across the displays. +/// +/// The tags are divided into contiguous ranges in the order the outputs are +/// arranged on the desk, so with two monitors the left one owns 1–5 and the +/// right 6–9. Contiguous rather than interleaved because the keys are what the +/// user reaches for: 1–5 under the left hand for the left screen reads as one +/// screen's worth of workspaces, 1,3,5,7,9 does not. +/// +/// A lone output owns every tag, which is what makes the split invisible on a +/// laptop with nothing plugged in. +pub fn tagsForOutput(index: usize, count: usize) u32 { + if (count <= 1 or index >= count) return all_tags; + + // More outputs than tags: one each, and the outputs left over share the + // last tag rather than getting none. An output owning no tag could show no + // window at all, which is worse than two screens showing the same one. + if (count >= tag_count) { + return @as(u32, 1) << @intCast(@min(index, tag_count - 1)); + } + + // Earlier outputs take one of the leftover tags each, so the ranges differ + // by at most one and it is never the first screen that comes up short. + const base = tag_count / count; + const rem = tag_count % count; + const start = index * base + @min(index, rem); + const len = base + @as(usize, @intFromBool(index < rem)); + + const ones: u32 = (@as(u32, 1) << @intCast(len)) - 1; + return ones << @intCast(start); +} + +/// The lowest tag in a mask, as a mask of its own. What a view falls back to +/// when the tags it was showing have moved to another screen. +pub fn lowestTag(tags: u32) u32 { + const t = tags & all_tags; + if (t == 0) return 0; + return @as(u32, 1) << @intCast(@ctz(t)); +} + /// Keyboard modifiers, matching the values of river_seat_v1.modifiers so the /// mask can be bit-cast straight into the protocol type. pub const Mods = struct { diff --git a/src/config.zig b/src/config.zig index 1d22234..42ed785 100644 --- a/src/config.zig +++ b/src/config.zig @@ -60,9 +60,22 @@ pub const mfact: f32 = 0.55; pub const mfact_min: f32 = 0.05; pub const mfact_max: f32 = 0.95; -/// Tags visible on a newly connected output. +/// Tags visible on a newly connected output. Clamped to the tags that output +/// owns when `split_tags` is on, so a second screen starts on the first tag of +/// its own range rather than on this one. pub const default_tags: u32 = 1; +/// Split the tag set across the connected screens, so that every tag lives on +/// exactly one of them: with two monitors the left owns tags 1–5 and the right +/// 6–9, `Mod+7` moves to the right-hand screen and shows tag 7 there, and +/// `Mod+Shift+7` sends the focused window over to it. Screens are ordered left +/// to right by where they sit in the output layout, not by the order they were +/// plugged in. +/// +/// Set this false for dwm's model instead, where every screen has a full set of +/// nine tags of its own and the tag keys never leave the one you are on. +pub const split_tags = true; + /// Names exported over IPC for bars to label tags with. pub const tag_names = [action.tag_count][]const u8{ "1", "2", "3", "4", "5", "6", "7", "8", "9", @@ -188,9 +201,10 @@ pub const keys = tagKeys() ++ [_]Key{ .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.k, .action = .{ .swap = .prev } }, .{ .mods = mod, .keysym = xkb.Keysym.Return, .action = .zoom }, - // Master area. - .{ .mods = mod, .keysym = xkb.Keysym.h, .action = .{ .mfact = .{ .relative = -0.05 } } }, - .{ .mods = mod, .keysym = xkb.Keysym.l, .action = .{ .mfact = .{ .relative = 0.05 } } }, + // Master area. dwm puts these on Mod+h/l; those are the monitor keys here, + // so the master area takes the pair dwm gives the monitors. + .{ .mods = mod, .keysym = xkb.Keysym.comma, .action = .{ .mfact = .{ .relative = -0.05 } } }, + .{ .mods = mod, .keysym = xkb.Keysym.period, .action = .{ .mfact = .{ .relative = 0.05 } } }, .{ .mods = mod, .keysym = xkb.Keysym.i, .action = .{ .nmaster = .{ .relative = 1 } } }, .{ .mods = mod, .keysym = xkb.Keysym.d, .action = .{ .nmaster = .{ .relative = -1 } } }, @@ -208,11 +222,12 @@ pub const keys = tagKeys() ++ [_]Key{ .{ .mods = mod, .keysym = xkb.Keysym.Tab, .action = .view_prev }, .{ .mods = mod, .keysym = xkb.Keysym.Escape, .action = .view_prev }, - // Outputs. - .{ .mods = mod, .keysym = xkb.Keysym.comma, .action = .{ .focus_output = .prev } }, - .{ .mods = mod, .keysym = xkb.Keysym.period, .action = .{ .focus_output = .next } }, - .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.comma, .action = .{ .send_to_output = .prev } }, - .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.period, .action = .{ .send_to_output = .next } }, + // Outputs. Left and right on the same keys as the h/l of vi, since that is + // the direction they move in: `prev` is the screen to the left. + .{ .mods = mod, .keysym = xkb.Keysym.h, .action = .{ .focus_output = .prev } }, + .{ .mods = mod, .keysym = xkb.Keysym.l, .action = .{ .focus_output = .next } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.h, .action = .{ .send_to_output = .prev } }, + .{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.l, .action = .{ .send_to_output = .next } }, }; /// dwm's TAGKEYS macro: Mod+N views, Mod+Shift+N tags, Mod+Ctrl+N toggles the diff --git a/src/ipc.zig b/src/ipc.zig index b67745f..c56a66f 100644 --- a/src/ipc.zig +++ b/src/ipc.zig @@ -319,10 +319,14 @@ fn encodeState(wm: *Wm, gpa: Allocator, out: *std.ArrayList(u8)) !void { try w.writeAll("{\"name\":"); try writeJsonString(w, output.displayName()); try w.print( - ",\"focused\":{s},\"tags\":{d},\"occupied\":{d},\"layout\":\"{s}\",\"layout_symbol\":", + ",\"focused\":{s},\"tags\":{d},\"owned_tags\":{d},\"occupied\":{d},\"layout\":\"{s}\",\"layout_symbol\":", .{ if (wm.focused_output == output) "true" else "false", output.tags, + // Which tags belong to this screen at all. Every one of them + // unless the tag set is split, in which case a bar wants to + // draw its screen's slice and not all nine. + output.owned_tags, occupied & act.all_tags, @tagName(st.layout), }, diff --git a/src/test.zig b/src/test.zig index 6e319c0..7ae95b9 100644 --- a/src/test.zig +++ b/src/test.zig @@ -244,6 +244,78 @@ test "bits above the tag range do not affect the slot" { try testing.expectEqual(act.tag_count, act.tagSlot(@as(u32, 1) << (act.tag_count - 1))); } +// ─── splitting the tag set across screens ──────────────────────────────────── + +test "a lone screen owns every tag" { + try testing.expectEqual(act.all_tags, act.tagsForOutput(0, 1)); + // No screens at all is not a case callers should have to think about. + try testing.expectEqual(act.all_tags, act.tagsForOutput(0, 0)); +} + +test "two screens split the tags into contiguous halves" { + // Nine tags do not halve evenly; the left screen takes the extra one. + try testing.expectEqual(@as(u32, 0b000011111), act.tagsForOutput(0, 2)); + try testing.expectEqual(@as(u32, 0b111100000), act.tagsForOutput(1, 2)); +} + +test "three screens split the tags into thirds" { + try testing.expectEqual(@as(u32, 0b000000111), act.tagsForOutput(0, 3)); + try testing.expectEqual(@as(u32, 0b000111000), act.tagsForOutput(1, 3)); + try testing.expectEqual(@as(u32, 0b111000000), act.tagsForOutput(2, 3)); +} + +test "every tag lands on exactly one screen" { + // The whole point of the split: no tag is shared and none goes missing, + // whatever the screen count. + for (1..act.tag_count + 1) |count| { + var seen: u32 = 0; + for (0..count) |i| { + const mask = act.tagsForOutput(i, count); + try testing.expect(mask != 0); + try testing.expectEqual(@as(u32, 0), seen & mask); + seen |= mask; + } + try testing.expectEqual(act.all_tags, seen); + } +} + +test "ranges are contiguous and in screen order" { + for (1..act.tag_count + 1) |count| { + var next: u32 = 0; + for (0..count) |i| { + const mask = act.tagsForOutput(i, count); + // Contiguous: a run of ones, starting where the last range ended. + const lowest = @ctz(mask); + const past_highest = 32 - @clz(mask); + try testing.expectEqual(next, lowest); + try testing.expectEqual(past_highest - lowest, @popCount(mask)); + next = past_highest; + } + } +} + +test "more screens than tags leaves no screen without one" { + // Twelve monitors and nine tags: the ones past the end share the last tag + // rather than being handed an empty mask, which could show nothing at all. + const count = act.tag_count + 3; + for (0..count) |i| { + try testing.expect(act.tagsForOutput(i, count) != 0); + } + try testing.expectEqual(act.tagsForOutput(act.tag_count - 1, count), act.tagsForOutput(count - 1, count)); +} + +test "a screen index past the end owns everything rather than nothing" { + try testing.expectEqual(act.all_tags, act.tagsForOutput(2, 2)); +} + +test "the lowest tag is what a stranded view falls back to" { + try testing.expectEqual(@as(u32, 0b100000), act.lowestTag(0b111100000)); + try testing.expectEqual(@as(u32, 1), act.lowestTag(act.all_tags)); + try testing.expectEqual(@as(u32, 0), act.lowestTag(0)); + // Junk above the tag range is not a tag to fall back to. + try testing.expectEqual(@as(u32, 0), act.lowestTag(~act.all_tags)); +} + // ─── command parsing ───────────────────────────────────────────────────────── fn parseOk(argv: []const []const u8) act.Action {