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
+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 {