add multimonitor

This commit is contained in:
2026-08-18 21:30:51 +02:00
parent 7d117f4ae5
commit f60dd06e7e
10 changed files with 470 additions and 34 deletions
+65 -10
View File
@@ -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 15 and the right 69; with three
they get 13, 46 and 79. 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 |
+10
View File
@@ -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
+5
View File
@@ -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"
+29 -1
View File
@@ -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 => {
+47 -2
View File
@@ -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| {
+173 -11
View File
@@ -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();
}
+40
View File
@@ -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 15 and the
/// right 69. Contiguous rather than interleaved because the keys are what the
/// user reaches for: 15 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 {
+24 -9
View File
@@ -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 15 and the right
/// 69, `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
+5 -1
View File
@@ -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),
},
+72
View File
@@ -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 {