initial commit

This commit is contained in:
2026-07-26 20:47:19 +02:00
commit fff3a5e734
37 changed files with 10781 additions and 0 deletions
+755
View File
@@ -0,0 +1,755 @@
//! Input device configuration: keyboard layout and repeat, and the libinput
//! knobs that matter on a touchpad.
//!
//! Three river globals cooperate here. `river_input_manager_v1` enumerates
//! devices and owns the settings river implements itself (key repeat, scroll
//! factor). `river_xkb_config_v1` compiles keymaps and assigns them to
//! keyboards. `river_libinput_config_v1` exposes libinput's own configuration —
//! tap to click and the rest — one object per device that libinput drives.
//!
//! None of these requests are part of a manage sequence: unlike window state,
//! input configuration is not sequenced by river, so it can be sent the moment
//! we know what to send.
//!
//! What the handlers do *not* do is apply settings, because a device's identity
//! and its per-protocol objects arrive as separate events. They record what
//! arrived and mark us dirty; `flush()` — called once per event loop iteration,
//! after a whole batch of events has been dispatched — is the only place that
//! matches rules and issues requests. That way it never matters which order the
//! events came in.
const InputManager = @This();
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const xkb = @import("xkbcommon");
const config = @import("config");
const input = @import("input");
const Wm = @import("Wm.zig");
const sys = @import("sys.zig");
const log = std.log.scoped(.input);
wm: *Wm,
manager: *river.InputManagerV1,
libinput_config: ?*river.LibinputConfigV1 = null,
xkb_config: ?*river.XkbConfigV1 = null,
devices: std.ArrayList(*Device) = .empty,
/// libinput settings whose result river has yet to report. Tracked only so that
/// shutting down mid-flight frees them.
pending_results: std.ArrayList(*Result) = .empty,
/// The keymap compiled from `config.keymap`. river validates it asynchronously,
/// so it may only be handed to a keyboard once `success` has arrived.
keymap: ?*river.XkbKeymapV1 = null,
keymap_state: enum { unset, pending, ready, failed } = .unset,
/// Some event handler recorded something `flush` has yet to act on.
dirty: bool = false,
/// One input device, and whichever of river's per-device objects have shown up
/// for it so far.
pub const Device = struct {
im: *InputManager,
device: *river.InputDeviceV1,
name: ?[]u8 = null,
kind: ?input.Type = null,
/// Present only for devices libinput drives. river cannot offer these at
/// all when it has no access to the hardware, which is the case whenever it
/// runs nested inside another compositor.
libinput: ?*river.LibinputDeviceV1 = null,
/// Present only for keyboards.
keyboard: ?*river.XkbKeyboardV1 = null,
/// The settings living on each object, once sent. Tracked separately
/// because the objects appear independently of one another.
applied_core: bool = false,
applied_libinput: bool = false,
applied_keymap: bool = false,
/// The output this device is currently mapped to, so the request is only
/// re-sent when it actually changes. Borrowed, and cleared by `forgetOutput`
/// before the proxy is destroyed.
mapped_output: ?*wl.Output = null,
/// Set once we have complained that a rule names an output that is not here,
/// so unplugging a monitor costs one log line rather than one per flush.
warned_missing_output: bool = false,
removed: bool = false,
fn destroy(self: *Device) void {
const gpa = self.im.wm.gpa;
if (self.name) |n| gpa.free(n);
if (self.libinput) |l| l.destroy();
if (self.keyboard) |k| k.destroy();
self.device.destroy();
gpa.destroy(self);
}
fn displayName(self: *const Device) []const u8 {
return self.name orelse "";
}
};
pub fn create(wm: *Wm, manager: *river.InputManagerV1) !*InputManager {
const self = try wm.gpa.create(InputManager);
self.* = .{ .wm = wm, .manager = manager };
manager.setListener(*InputManager, onManagerEvent, self);
return self;
}
pub fn destroy(self: *InputManager) void {
const gpa = self.wm.gpa;
for (self.devices.items) |device| device.destroy();
self.devices.deinit(gpa);
for (self.pending_results.items) |pending| gpa.destroy(pending);
self.pending_results.deinit(gpa);
if (self.keymap) |k| k.destroy();
if (self.libinput_config) |l| l.destroy();
if (self.xkb_config) |x| x.destroy();
self.manager.destroy();
gpa.destroy(self);
}
/// Bind the two configuration globals, which must happen after
/// `river_input_manager_v1` — river only tells us which input device a libinput
/// device or xkb keyboard belongs to if we already hold an object for it.
pub fn bindConfigGlobals(
self: *InputManager,
registry: *wl.Registry,
libinput_name: ?u32,
xkb_name: ?u32,
) void {
if (libinput_name) |name| {
self.libinput_config = registry.bind(name, river.LibinputConfigV1, 1) catch null;
if (self.libinput_config) |lc| {
lc.setListener(*InputManager, onLibinputConfigEvent, self);
}
}
if (xkb_name) |name| {
self.xkb_config = registry.bind(name, river.XkbConfigV1, 1) catch null;
if (self.xkb_config) |xc| {
xc.setListener(*InputManager, onXkbConfigEvent, self);
self.createKeymap(xc);
}
}
}
// ─── Events ──────────────────────────────────────────────────────────────────
fn onManagerEvent(_: *river.InputManagerV1, event: river.InputManagerV1.Event, self: *InputManager) void {
switch (event) {
.input_device => |ev| {
const device = self.wm.gpa.create(Device) catch {
log.err("out of memory tracking a new input device", .{});
ev.id.destroy();
return;
};
device.* = .{ .im = self, .device = ev.id };
self.devices.append(self.wm.gpa, device) catch {
self.wm.gpa.destroy(device);
ev.id.destroy();
return;
};
// Lets the libinput and xkb objects find their way back to us from
// the river_input_device_v1 they name.
ev.id.setListener(*Device, onDeviceEvent, device);
self.dirty = true;
},
.finished => {},
}
}
fn onDeviceEvent(_: *river.InputDeviceV1, event: river.InputDeviceV1.Event, device: *Device) void {
switch (event) {
.name => |ev| {
const gpa = device.im.wm.gpa;
if (device.name) |old| gpa.free(old);
device.name = gpa.dupe(u8, std.mem.span(ev.name)) catch null;
},
.type => |ev| device.kind = switch (ev.type) {
.keyboard => .keyboard,
.pointer => .pointer,
.touch => .touch,
.tablet => .tablet,
// A device type this build of att_wm has never heard of. Leaving the
// kind unset means rules that name a type skip it, which is the
// conservative reading.
_ => null,
},
.removed => device.removed = true,
}
device.im.dirty = true;
}
fn onLibinputConfigEvent(
_: *river.LibinputConfigV1,
event: river.LibinputConfigV1.Event,
self: *InputManager,
) void {
switch (event) {
.libinput_device => |ev| {
// Which device it belongs to arrives in its own event; park a
// listener on it until then.
ev.id.setListener(*InputManager, onLibinputDeviceEvent, self);
},
.finished => {},
}
}
fn onLibinputDeviceEvent(
proxy: *river.LibinputDeviceV1,
event: river.LibinputDeviceV1.Event,
self: *InputManager,
) void {
switch (event) {
.input_device => |ev| {
const device = deviceFromProxy(ev.device) orelse return;
device.libinput = proxy;
self.dirty = true;
},
.removed => {
if (self.deviceForLibinput(proxy)) |device| device.libinput = null;
proxy.destroy();
},
// The support, default and current events describe what the device can
// do and what it is doing. att_wm states what it wants and lets the
// result object report whether the device could oblige, so none of this
// needs tracking.
else => {},
}
}
fn onXkbConfigEvent(_: *river.XkbConfigV1, event: river.XkbConfigV1.Event, self: *InputManager) void {
switch (event) {
.xkb_keyboard => |ev| ev.id.setListener(*InputManager, onXkbKeyboardEvent, self),
.finished => {},
}
}
fn onXkbKeyboardEvent(
proxy: *river.XkbKeyboardV1,
event: river.XkbKeyboardV1.Event,
self: *InputManager,
) void {
switch (event) {
.input_device => |ev| {
const device = deviceFromProxy(ev.device) orelse return;
device.keyboard = proxy;
self.dirty = true;
},
.removed => {
if (self.deviceForKeyboard(proxy)) |device| device.keyboard = null;
proxy.destroy();
},
// Sent on creation and on every layout switch, so a `grp:` option makes
// this routine — hence debug rather than info.
.layout => |ev| {
const device = self.deviceForKeyboard(proxy);
log.debug("layout {d} ({s}) active on {s}", .{
ev.index,
if (ev.name) |n| std.mem.span(n) else "unnamed",
if (device) |d| d.displayName() else "?",
});
},
// Capslock and numlock state; att_wm does not model either.
else => {},
}
}
/// setListener stores our pointer as the proxy's user data, which is how an
/// event naming a river_input_device_v1 gets back to our own struct.
fn deviceFromProxy(proxy: ?*river.InputDeviceV1) ?*Device {
const p = proxy orelse return null;
return @ptrCast(@alignCast(p.getUserData()));
}
fn deviceForLibinput(self: *InputManager, proxy: *river.LibinputDeviceV1) ?*Device {
for (self.devices.items) |device| {
if (device.libinput == proxy) return device;
}
return null;
}
fn deviceForKeyboard(self: *InputManager, proxy: *river.XkbKeyboardV1) ?*Device {
for (self.devices.items) |device| {
if (device.keyboard == proxy) return device;
}
return null;
}
// ─── Applying configuration ──────────────────────────────────────────────────
/// Act on everything the handlers have recorded since the last call. Safe to
/// call as often as you like; it does nothing unless something changed.
pub fn flush(self: *InputManager) void {
if (!self.dirty) return;
self.dirty = false;
var i: usize = 0;
while (i < self.devices.items.len) {
const device = self.devices.items[i];
if (device.removed) {
_ = self.devices.orderedRemove(i);
device.destroy();
continue;
}
i += 1;
}
for (self.devices.items) |device| {
// Both the name and the type are sent as the device object is created,
// so waiting for them costs at most one turn of the event loop, and
// matching a rule before they land would match the wrong thing.
const kind = device.kind orelse continue;
const name = device.name orelse continue;
const rule = ruleFor(name, kind);
if (!device.applied_core) {
device.applied_core = true;
log.info("input device: {s} ({t})", .{ name, kind });
self.applyCore(device, kind, rule);
}
if (device.libinput != null and !device.applied_libinput) {
device.applied_libinput = true;
self.applyLibinput(device, rule);
}
self.applyOutputMapping(device, kind, rule);
if (device.keyboard) |keyboard| {
if (!device.applied_keymap and self.keymap_state == .ready) {
device.applied_keymap = true;
keyboard.setKeymap(self.keymap.?);
log.debug("keymap set on {s}", .{name});
}
}
}
}
/// An output has appeared, or one has been named. Either may be the output an
/// input rule is waiting for.
pub fn outputsChanged(self: *InputManager) void {
self.dirty = true;
}
/// An output is going away: drop it from any device mapped to it, so the proxy
/// is not remembered past its destruction and the device is mapped afresh should
/// the output return.
pub fn forgetOutput(self: *InputManager, proxy: *wl.Output) void {
for (self.devices.items) |device| {
if (device.mapped_output == proxy) device.mapped_output = null;
}
self.dirty = true;
}
/// Confine a device to one output.
///
/// Unlike every other setting this is re-evaluated on every flush rather than
/// applied once, because outputs come and go — and river drops its own side of
/// the mapping when the output named is destroyed, so a monitor that comes back
/// has to be mapped again.
///
/// Rotation needs no such care: wlroots reads the output's transform on each
/// input event, so a mapped device follows the display around without anything
/// being re-sent.
fn applyOutputMapping(self: *InputManager, device: *Device, kind: input.Type, rule: input.Rule) void {
const want = rule.map_to_output orelse return;
// Keyboards have no coordinates to map, and river ignores the request for
// them. Skipping quietly keeps a catch-all rule from being noisy.
if (kind == .keyboard) return;
const proxy = self.wm.wlOutputByName(want) orelse {
if (!device.warned_missing_output) {
device.warned_missing_output = true;
log.warn("cannot map {s} to output {s}: no output by that name", .{
device.displayName(),
want,
});
}
return;
};
if (device.mapped_output == proxy) return;
device.device.mapToOutput(proxy);
device.mapped_output = proxy;
device.warned_missing_output = false;
log.info("{s}: mapped to output {s}", .{ device.displayName(), want });
}
/// Fold every matching rule together, in declaration order, so a later rule can
/// override an earlier one field by field.
fn ruleFor(name: []const u8, kind: input.Type) input.Rule {
var rule: input.Rule = .{};
for (config.input_rules) |candidate| {
if (candidate.matchesDevice(name, kind)) rule = rule.merge(candidate);
}
return rule;
}
/// Well beyond anything usable, and comfortably inside what a 24.8 fixed point
/// number can hold.
const max_scroll_factor = 1000;
/// The settings river implements itself, on river_input_device_v1.
fn applyCore(self: *InputManager, device: *Device, kind: input.Type, rule: input.Rule) void {
_ = self;
if (kind == .keyboard) {
const repeat = rule.repeat orelse config.repeat;
// Either negative is a protocol error, and river would disconnect us
// over a typo in a config file.
if (repeat.rate < 0 or repeat.delay < 0) {
log.err("ignoring negative key repeat for {s}: rate {d}, delay {d}", .{
device.displayName(),
repeat.rate,
repeat.delay,
});
} else {
device.device.setRepeatInfo(repeat.rate, repeat.delay);
log.debug("{s}: repeat rate {d}, delay {d}", .{
device.displayName(),
repeat.rate,
repeat.delay,
});
}
}
if (rule.scroll_factor) |factor| {
// Likewise a protocol error below zero. The upper bound is ours: the
// protocol carries the factor as a 24.8 fixed point number, and
// converting something that does not fit is undefined rather than
// merely wrong.
if (factor < 0 or factor > max_scroll_factor) {
log.err("ignoring out of range scroll factor for {s}: {d} (want 0 to {d})", .{
device.displayName(),
factor,
max_scroll_factor,
});
} else {
device.device.setScrollFactor(.fromDouble(factor));
log.debug("{s}: scroll factor {d}", .{ device.displayName(), factor });
}
}
}
/// libinput's own configuration, on river_libinput_device_v1.
///
/// Every request here returns a result object reporting whether the device could
/// honour it, which is the only way to find out that, say, a mouse has no tap to
/// click to enable. `track` attaches the listener that turns that into a log
/// line naming the setting.
fn applyLibinput(self: *InputManager, device: *Device, rule: input.Rule) void {
const li = device.libinput.?;
const name = device.displayName();
if (rule.tap) |on| {
self.track(name, "tap", li.setTap(if (on) .enabled else .disabled));
}
if (rule.tap_button_map) |map| {
self.track(name, "tap-button-map", li.setTapButtonMap(switch (map) {
.lrm => .lrm,
.lmr => .lmr,
}));
}
if (rule.drag) |on| {
self.track(name, "drag", li.setDrag(if (on) .enabled else .disabled));
}
if (rule.drag_lock) |state| {
self.track(name, "drag-lock", li.setDragLock(switch (state) {
.disabled => .disabled,
.timeout => .enabled_timeout,
.sticky => .enabled_sticky,
}));
}
if (rule.three_finger_drag) |state| {
self.track(name, "three-finger-drag", li.setThreeFingerDrag(switch (state) {
.disabled => .disabled,
.three_finger => .enabled_3fg,
.four_finger => .enabled_4fg,
}));
}
if (rule.click_method) |method| {
self.track(name, "click-method", li.setClickMethod(switch (method) {
.none => .none,
.button_areas => .button_areas,
.clickfinger => .clickfinger,
}));
}
if (rule.clickfinger_button_map) |map| {
self.track(name, "clickfinger-button-map", li.setClickfingerButtonMap(switch (map) {
.lrm => .lrm,
.lmr => .lmr,
}));
}
if (rule.middle_emulation) |on| {
self.track(name, "middle-emulation", li.setMiddleEmulation(if (on) .enabled else .disabled));
}
if (rule.left_handed) |on| {
self.track(name, "left-handed", li.setLeftHanded(if (on) .enabled else .disabled));
}
if (rule.natural_scroll) |on| {
self.track(name, "natural-scroll", li.setNaturalScroll(if (on) .enabled else .disabled));
}
if (rule.scroll_method) |method| {
self.track(name, "scroll-method", li.setScrollMethod(switch (method) {
.none => .no_scroll,
.two_finger => .two_finger,
.edge => .edge,
.on_button_down => .on_button_down,
}));
}
if (rule.scroll_button) |button| {
self.track(name, "scroll-button", li.setScrollButton(button));
}
if (rule.scroll_button_lock) |on| {
self.track(name, "scroll-button-lock", li.setScrollButtonLock(if (on) .enabled else .disabled));
}
if (rule.accel_profile) |profile| {
self.track(name, "accel-profile", li.setAccelProfile(switch (profile) {
.none => .none,
.flat => .flat,
.adaptive => .adaptive,
}));
}
if (rule.accel_speed) |speed| {
// libinput takes a native-endian double, which the protocol carries as
// an array of bytes for want of a floating point argument type.
var value = speed;
var array: wl.Array = .{
.size = @sizeOf(f64),
.alloc = @sizeOf(f64),
.data = @ptrCast(&value),
};
self.track(name, "accel-speed", li.setAccelSpeed(&array));
}
if (rule.disable_while_typing) |on| {
self.track(name, "disable-while-typing", li.setDwt(if (on) .enabled else .disabled));
}
if (rule.disable_while_trackpointing) |on| {
self.track(name, "disable-while-trackpointing", li.setDwtp(if (on) .enabled else .disabled));
}
if (rule.rotation) |angle| {
self.track(name, "rotation", li.setRotation(angle));
}
if (rule.send_events) |mode| {
self.track(name, "send-events", li.setSendEvents(switch (mode) {
.enabled => .{},
.disabled => .{ .disabled = true },
.disabled_on_external_mouse => .{ .disabled_on_external_mouse = true },
}));
}
}
/// A pending libinput setting, waiting to hear whether it took.
///
/// The device name is copied in rather than borrowed: a device can be unplugged
/// between the request and the reply, and a diagnostic is not worth a dangling
/// slice. `what` is always a literal from `applyLibinput`.
const Result = struct {
im: *InputManager,
what: []const u8,
name_buf: [64]u8 = undefined,
name_len: usize = 0,
fn name(self: *const Result) []const u8 {
return self.name_buf[0..self.name_len];
}
};
fn track(
self: *InputManager,
device_name: []const u8,
what: []const u8,
result: anyerror!*river.LibinputResultV1,
) void {
const object = result catch |err| {
log.err("failed to set {s} on {s}: {s}", .{ what, device_name, @errorName(err) });
return;
};
const pending = self.wm.gpa.create(Result) catch {
// Without the listener we simply never learn the outcome; the setting
// itself was still requested.
object.destroy();
return;
};
pending.* = .{ .im = self, .what = what };
pending.name_len = @min(device_name.len, pending.name_buf.len);
@memcpy(pending.name_buf[0..pending.name_len], device_name[0..pending.name_len]);
self.pending_results.append(self.wm.gpa, pending) catch {
self.wm.gpa.destroy(pending);
object.destroy();
return;
};
object.setListener(*Result, onResultEvent, pending);
}
fn onResultEvent(
object: *river.LibinputResultV1,
event: river.LibinputResultV1.Event,
pending: *Result,
) void {
switch (event) {
.success => {},
.unsupported => log.warn(
"{s} does not support {s}; setting ignored",
.{ pending.name(), pending.what },
),
.invalid => log.err(
"invalid {s} setting for {s}; setting ignored",
.{ pending.what, pending.name() },
),
}
// All three events are destructors, so the object is spent either way.
object.destroy();
pending.im.forgetResult(pending);
}
fn forgetResult(self: *InputManager, pending: *Result) void {
for (self.pending_results.items, 0..) |item, i| {
if (item == pending) {
_ = self.pending_results.swapRemove(i);
break;
}
}
self.wm.gpa.destroy(pending);
}
// ─── Keymap ──────────────────────────────────────────────────────────────────
/// Compile `config.keymap` and hand it to river.
///
/// river validates it asynchronously and answers on the keymap object, so
/// nothing can be assigned to a keyboard until then; `flush` picks it up once
/// `success` arrives.
fn createKeymap(self: *InputManager, xkb_config: *river.XkbConfigV1) void {
// All-null names are exactly what river compiles by default, so there is
// nothing to gain by sending our own.
if (comptime config.keymap.isDefault()) return;
const names: xkb.RuleNames = comptime .{
.rules = zeroTerminate(config.keymap.rules),
.model = zeroTerminate(config.keymap.model),
.layout = zeroTerminate(config.keymap.layout),
.variant = zeroTerminate(config.keymap.variant),
.options = zeroTerminate(config.keymap.options),
};
const context = xkb.Context.new(.no_flags) orelse {
log.err("failed to create an xkb context; keeping river's default keymap", .{});
return;
};
defer xkb_context_unref(context);
const keymap = xkb.Keymap.newFromNames(context, &names, .no_flags) orelse {
log.err("failed to compile keymap (layout {s}, variant {s}, options {s})", .{
config.keymap.layout orelse "default",
config.keymap.variant orelse "default",
config.keymap.options orelse "none",
});
return;
};
defer keymap.unref();
const text = keymap.getAsString(.text_v1) orelse {
log.err("failed to serialise the compiled keymap", .{});
return;
};
defer std.c.free(text);
const fd = keymapFd(std.mem.span(text)) catch |err| {
log.err("failed to stage the keymap for river: {s}", .{@errorName(err)});
return;
};
defer sys.close(fd);
self.keymap = xkb_config.createKeymap(fd, .text_v1) catch |err| {
log.err("failed to send the keymap to river: {s}", .{@errorName(err)});
return;
};
self.keymap.?.setListener(*InputManager, onKeymapEvent, self);
self.keymap_state = .pending;
}
/// Put a keymap in a sealed memfd for river to mmap.
///
/// The trailing NUL goes in the file: river sizes the keymap as the file length
/// minus one and requires the content to be zero terminated.
fn keymapFd(text: []const u8) !sys.fd_t {
const fd = try posix.memfd_create(
"att_wm-keymap",
linux.MFD.CLOEXEC | linux.MFD.ALLOW_SEALING,
);
errdefer sys.close(fd);
var written: usize = 0;
while (written < text.len) {
written += try sys.write(fd, text[written..]);
}
if (try sys.write(fd, &.{0}) != 1) return error.ShortWrite;
// Sealing tells river the bytes cannot change under its mmap. Only a
// courtesy — it maps the fd read-only and privately either way — so a
// kernel that refuses is no reason to give up on the keymap.
sys.addSeals(fd, linux.F.SEAL_SHRINK | linux.F.SEAL_GROW |
linux.F.SEAL_WRITE | linux.F.SEAL_SEAL) catch {};
return fd;
}
fn onKeymapEvent(_: *river.XkbKeymapV1, event: river.XkbKeymapV1.Event, self: *InputManager) void {
switch (event) {
.success => {
// Worth saying out loud: a rejected keymap leaves every keyboard on
// river's default, and the symptom is simply that the configured
// layout is not the one typing produces.
log.info("river accepted the keymap (layout {s}, variant {s}, options {s})", .{
config.keymap.layout orelse "default",
config.keymap.variant orelse "default",
config.keymap.options orelse "none",
});
self.keymap_state = .ready;
self.dirty = true;
},
.failure => |ev| {
log.err("river rejected the keymap: {s}", .{std.mem.span(ev.error_msg)});
self.keymap_state = .failed;
if (self.keymap) |k| k.destroy();
self.keymap = null;
},
}
}
/// Turn a comptime config string into the NUL terminated one xkbcommon wants.
fn zeroTerminate(comptime s: ?[]const u8) ?[*:0]const u8 {
const value = s orelse return null;
return (value ++ "\x00")[0..value.len :0].ptr;
}
/// zig-xkbcommon 0.4.0 aliases `Context.unref` to `xkb_rmlvo_builder_unref`,
/// which would hand a context to the wrong destructor. Declared here so we call
/// the right one.
extern fn xkb_context_unref(context: *xkb.Context) void;
+297
View File
@@ -0,0 +1,297 @@
//! A logical output, and the window management state that dwm keeps per
//! monitor: the visible tag set, the layout, nmaster and mfact.
//!
//! The arrangement settings are stored per tag rather than per output, as
//! dwm's pertag patch does, so switching tags restores the layout that tag was
//! last arranged with.
const Output = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const config = @import("config");
const action = @import("action");
const Wm = @import("Wm.zig");
const Window = @import("Window.zig");
const shm = @import("shm.zig");
const color = @import("color.zig");
const layout = @import("layout.zig");
const Box = layout.Box;
wm: *Wm,
output: *river.OutputV1,
layer_output: ?*river.LayerShellOutputV1 = null,
/// The global name of the corresponding wl_output, used to pair the two up.
wl_output_name: u32 = 0,
/// Owned by Wm.wl_outputs; holds the human readable output name.
wl_output: ?*Wm.WlOutput = null,
/// Full output area in the compositor's logical coordinate space.
box: Box = .{},
/// The part of `box` not covered by layer-shell exclusive zones. Windows are
/// laid out here so bars are not overlapped.
usable: Box = .{},
/// Whether `usable` has been reported; before that it tracks `box`.
have_usable: bool = false,
tags: u32 = config.default_tags,
prev_tags: u32 = config.default_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(.{}),
removed: bool = false,
/// Written by the layout pass each manage sequence, read by the render pass.
tabbar_box: ?Box = null,
/// True when the current layout stacks windows, so only `stack_top` shows.
stacked: bool = false,
stack_top: ?*Window = null,
tabbar: TabBar,
pub fn create(wm: *Wm, output: *river.OutputV1) !*Output {
const self = try wm.gpa.create(Output);
self.* = .{
.wm = wm,
.output = output,
.tabbar = .{ .wm = wm },
};
output.setListener(*Output, onEvent, self);
if (wm.layer_shell) |ls| {
self.layer_output = ls.getOutput(output) catch null;
if (self.layer_output) |lo| {
lo.setListener(*Output, onLayerEvent, self);
}
}
return self;
}
pub fn destroy(self: *Output) void {
const gpa = self.wm.gpa;
self.tabbar.deinit();
if (self.layer_output) |lo| lo.destroy();
// The wl_output entry is owned by Wm; just break the back reference.
if (self.wl_output) |entry| entry.output = null;
self.output.destroy();
gpa.destroy(self);
}
pub fn displayName(self: *const Output) []const u8 {
const entry = self.wl_output orelse return "?";
return entry.name orelse "?";
}
/// The area windows are laid out in.
pub fn layoutArea(self: *const Output) Box {
return if (self.have_usable) self.usable else self.box;
}
/// How one tag is arranged. dwm's pertag patch keeps exactly these four.
pub const TagState = struct {
layout: action.Layout = config.default_layout,
prev_layout: action.Layout = config.default_layout,
nmaster: i32 = config.nmaster,
mfact: f32 = config.mfact,
};
/// The arrangement settings in force on this output right now, i.e. those of
/// the tag being viewed. See `action.tagSlot` for how a view picks its slot.
pub fn state(self: *Output) *TagState {
return &self.tag_state[action.tagSlot(self.tags)];
}
pub fn setLayout(self: *Output, mode: action.Layout) void {
const st = self.state();
if (mode == st.layout) return;
st.prev_layout = st.layout;
st.layout = mode;
}
pub fn setTags(self: *Output, tags: u32) void {
const masked = tags & action.all_tags;
if (masked == 0 or masked == self.tags) return;
self.prev_tags = self.tags;
self.tags = masked;
}
fn onEvent(_: *river.OutputV1, event: river.OutputV1.Event, self: *Output) void {
switch (event) {
.removed => {
self.removed = true;
self.wm.needsManage();
},
.position => |ev| {
self.box.x = ev.x;
self.box.y = ev.y;
self.wm.needsManage();
},
.dimensions => |ev| {
self.box.width = ev.width;
self.box.height = ev.height;
self.wm.needsManage();
},
.wl_output => |ev| {
self.wl_output_name = ev.name;
self.wm.attachWlOutput(self);
},
}
}
fn onLayerEvent(_: *river.LayerShellOutputV1, event: river.LayerShellOutputV1.Event, self: *Output) void {
switch (event) {
.non_exclusive_area => |ev| {
self.usable = .{ .x = ev.x, .y = ev.y, .width = ev.width, .height = ev.height };
self.have_usable = true;
self.wm.needsManage();
},
}
}
/// The strip of solid colour blocks drawn above the windows in the tabbed
/// layout. One block per window, the focused one highlighted; titles are not
/// drawn here but are published over IPC for bars that want to render them.
pub const TabBar = struct {
wm: *Wm,
surface: ?*wl.Surface = null,
shell: ?*river.ShellSurfaceV1 = null,
node: ?*river.NodeV1 = null,
pool: ?shm.Pool = null,
/// Currently mapped, i.e. showing a buffer.
mapped: bool = false,
/// Where the bar is, in global coordinates.
box: Box = .{},
/// Hit rectangles for the tabs currently drawn, in global coordinates,
/// parallel to `windows`.
rects: std.ArrayList(Box) = .empty,
windows: std.ArrayList(*Window) = .empty,
pub fn deinit(self: *TabBar) void {
const gpa = self.wm.gpa;
self.rects.deinit(gpa);
self.windows.deinit(gpa);
if (self.pool) |*p| p.deinit();
if (self.node) |n| n.destroy();
if (self.shell) |s| s.destroy();
if (self.surface) |s| s.destroy();
}
fn ensureSurface(self: *TabBar) !void {
if (self.surface != null) return;
const compositor = self.wm.compositor orelse return error.NoCompositor;
const wm_proxy = self.wm.window_manager orelse return error.NoWindowManager;
const wl_shm = self.wm.shm orelse return error.NoShm;
const surface = try compositor.createSurface();
errdefer surface.destroy();
const shell = try wm_proxy.getShellSurface(surface);
errdefer shell.destroy();
const node = try shell.getNode();
self.surface = surface;
self.shell = shell;
self.node = node;
self.pool = shm.Pool.init(self.wm.gpa, wl_shm);
}
/// Draw and place the bar. Must be called during a render sequence.
pub fn show(self: *TabBar, box: Box, windows: []const *Window, focused: ?*Window) void {
self.ensureSurface() catch |err| {
std.log.err("tab bar: {s}", .{@errorName(err)});
return;
};
if (box.width <= 0 or box.height <= 0 or windows.len == 0) {
self.hide();
return;
}
const gpa = self.wm.gpa;
const pool = &self.pool.?;
const buffer = pool.acquire(box.width, box.height) catch |err| {
std.log.err("tab bar buffer: {s}", .{@errorName(err)});
return;
};
// Recompute the hit rectangles, in buffer-local coordinates first.
self.rects.clearRetainingCapacity();
self.windows.clearRetainingCapacity();
self.rects.ensureTotalCapacity(gpa, windows.len) catch return;
self.windows.appendSlice(gpa, windows) catch return;
self.rects.resize(gpa, windows.len) catch return;
const local: Box = .{ .x = 0, .y = 0, .width = box.width, .height = box.height };
layout.tabRects(local, windows.len, self.rects.items);
const sep = color.toArgb8888(config.tab_separator);
buffer.fill(local, sep);
for (self.rects.items, windows) |rect, win| {
const is_focused = focused != null and focused.? == win;
const argb = color.toArgb8888(if (is_focused) config.tab_focused else config.tab_normal);
// Leave a one pixel separator on the right of every tab but the
// last, which the background colour shows through.
const inner: Box = .{
.x = rect.x,
.y = rect.y,
.width = @max(0, rect.width - 1),
.height = rect.height,
};
buffer.fill(inner, argb);
}
// Translate the hit rectangles into global coordinates for click
// handling, now that drawing is done.
for (self.rects.items) |*rect| {
rect.x += box.x;
rect.y += box.y;
}
const surface = self.surface.?;
buffer.busy = true;
surface.attach(buffer.wl_buffer, 0, 0);
surface.damageBuffer(0, 0, box.width, box.height);
self.shell.?.syncNextCommit();
surface.commit();
self.node.?.setPosition(box.x, box.y);
self.box = box;
self.mapped = true;
}
/// Unmap the bar. Must be called during a render sequence if it was
/// previously shown.
pub fn hide(self: *TabBar) void {
if (!self.mapped) return;
const surface = self.surface orelse return;
surface.attach(null, 0, 0);
self.shell.?.syncNextCommit();
surface.commit();
self.mapped = false;
self.rects.clearRetainingCapacity();
self.windows.clearRetainingCapacity();
}
/// Which window's tab covers this global coordinate, if any.
pub fn windowAt(self: *const TabBar, x: i32, y: i32) ?*Window {
if (!self.mapped) return null;
for (self.rects.items, self.windows.items) |rect, win| {
if (rect.contains(x, y)) return win;
}
return null;
}
};
+461
View File
@@ -0,0 +1,461 @@
//! A seat: keyboard focus, key and pointer bindings, and interactive
//! move/resize operations.
//!
//! river delivers binding and pointer events immediately but requires the
//! matching protocol requests to be made inside a manage sequence. Every
//! handler here therefore records intent in a field and calls `needsManage`;
//! `applyManage` is the only place that talks back to the compositor.
const Seat = @This();
const std = @import("std");
const wayland = @import("wayland");
const wl = wayland.client.wl;
const river = wayland.client.river;
const config = @import("config");
const action = @import("action");
const Wm = @import("Wm.zig");
const Window = @import("Window.zig");
const Output = @import("Output.zig");
const layout = @import("layout.zig");
const Box = layout.Box;
pub const KeyBinding = struct {
seat: *Seat,
/// Index into config.keys.
index: usize,
object: *river.XkbBindingV1,
};
pub const PointerBinding = struct {
seat: *Seat,
/// Index into config.buttons.
index: usize,
object: *river.PointerBindingV1,
};
pub const Op = struct {
kind: enum { move, resize },
window: *Window,
/// The window's cell when the operation started.
start: Box,
/// For resize: which corner is being dragged.
edges: river.WindowV1.Edges = .{},
};
wm: *Wm,
seat: *river.SeatV1,
layer_seat: ?*river.LayerShellSeatV1 = null,
wl_seat_name: u32 = 0,
wl_seat: ?*wl.Seat = null,
pointer: ?*wl.Pointer = null,
keys: std.ArrayList(*KeyBinding) = .empty,
buttons: std.ArrayList(*PointerBinding) = .empty,
/// Set once the bindings have been enabled in a manage sequence.
bindings_enabled: bool = false,
focused: ?*Window = null,
/// Focus we want river to apply in the next manage sequence.
pending_focus: ?*Window = null,
/// True when focus should be cleared rather than moved.
pending_clear_focus: bool = false,
/// A layer surface (a bar, a launcher) holds focus; our focus requests are
/// either ignored or would steal it.
layer_focus: enum { none, exclusive, non_exclusive } = .none,
/// Pointer position in the compositor's logical coordinate space.
pointer_x: i32 = 0,
pointer_y: i32 = 0,
/// The window the pointer is currently inside.
hovered: ?*Window = null,
op: ?Op = null,
/// An operation to start in the next manage sequence.
pending_op: ?Op = null,
/// The running operation should be ended in the next manage sequence.
pending_op_end: bool = false,
/// One of our own surfaces has pointer focus (the tab bar).
pointer_surface: ?*wl.Surface = null,
pointer_local_x: f64 = 0,
pointer_local_y: f64 = 0,
removed: bool = false,
pub fn create(wm: *Wm, seat: *river.SeatV1) !*Seat {
const self = try wm.gpa.create(Seat);
self.* = .{ .wm = wm, .seat = seat };
seat.setListener(*Seat, onEvent, self);
if (wm.layer_shell) |ls| {
self.layer_seat = ls.getSeat(seat) catch null;
if (self.layer_seat) |lseat| lseat.setListener(*Seat, onLayerEvent, self);
}
try self.createBindings();
if (config.cursor_theme) |theme| {
var buf: [256]u8 = undefined;
const z = std.fmt.bufPrintZ(&buf, "{s}", .{theme}) catch null;
if (z) |name| seat.setXcursorTheme(name, config.cursor_size);
}
return self;
}
pub fn destroy(self: *Seat) void {
const gpa = self.wm.gpa;
for (self.keys.items) |binding| {
binding.object.destroy();
gpa.destroy(binding);
}
for (self.buttons.items) |binding| {
binding.object.destroy();
gpa.destroy(binding);
}
self.keys.deinit(gpa);
self.buttons.deinit(gpa);
if (self.pointer) |p| p.release();
if (self.wl_seat) |s| s.release();
if (self.layer_seat) |l| l.destroy();
self.seat.destroy();
gpa.destroy(self);
}
fn createBindings(self: *Seat) !void {
const gpa = self.wm.gpa;
if (self.wm.xkb_bindings) |xkb_bindings| {
try self.keys.ensureTotalCapacity(gpa, config.keys.len);
for (config.keys, 0..) |key, i| {
const mods: river.SeatV1.Modifiers = @bitCast(key.mods);
const object = xkb_bindings.getXkbBinding(self.seat, @intFromEnum(key.keysym), mods) catch |err| {
std.log.err("failed to bind key {t}: {s}", .{ key.keysym, @errorName(err) });
continue;
};
const binding = try gpa.create(KeyBinding);
binding.* = .{ .seat = self, .index = i, .object = object };
object.setListener(*KeyBinding, onKeyEvent, binding);
self.keys.appendAssumeCapacity(binding);
}
}
std.log.info("registered {d}/{d} key bindings", .{ self.keys.items.len, config.keys.len });
try self.buttons.ensureTotalCapacity(gpa, config.buttons.len);
for (config.buttons, 0..) |button, i| {
const mods: river.SeatV1.Modifiers = @bitCast(button.mods);
const object = self.seat.getPointerBinding(button.button, mods) catch |err| {
std.log.err("failed to bind button {d}: {s}", .{ button.button, @errorName(err) });
continue;
};
const binding = try gpa.create(PointerBinding);
binding.* = .{ .seat = self, .index = i, .object = object };
object.setListener(*PointerBinding, onButtonEvent, binding);
self.buttons.appendAssumeCapacity(binding);
}
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.
pub fn currentOutput(self: *Seat) ?*Output {
if (self.focused) |win| {
if (win.output) |out| return out;
}
return self.wm.outputAt(self.pointer_x, self.pointer_y) orelse self.wm.firstOutput();
}
pub fn focus(self: *Seat, window: ?*Window) void {
if (window) |win| {
self.pending_focus = win;
self.pending_clear_focus = false;
} else {
self.pending_focus = null;
self.pending_clear_focus = true;
}
self.wm.needsManage();
}
pub fn startMove(self: *Seat, window: *Window) void {
if (window.fullscreen) return;
self.pending_op = .{ .kind = .move, .window = window, .start = window.cell };
self.wm.needsManage();
}
pub fn startResize(self: *Seat, window: *Window, edges: river.WindowV1.Edges) void {
if (window.fullscreen) return;
self.pending_op = .{
.kind = .resize,
.window = window,
.start = window.cell,
.edges = edges,
};
self.wm.needsManage();
}
/// Issue the requests recorded by the event handlers. Manage sequence only.
pub fn applyManage(self: *Seat) void {
if (!self.bindings_enabled) {
for (self.keys.items) |binding| binding.object.enable();
for (self.buttons.items) |binding| binding.object.enable();
self.bindings_enabled = true;
}
if (self.pending_op) |op| {
// Dragging a tiled window pops it out into floating, as in dwm.
if (!op.window.floating) {
op.window.floating = true;
op.window.floating_forced = true;
op.window.float_box = op.window.cell;
}
self.seat.opStartPointer();
self.op = op;
self.op.?.start = op.window.cell;
self.pending_op = null;
op.window.window.informResizeStart();
}
if (self.pending_op_end) {
if (self.op) |op| {
self.seat.opEnd();
if (!op.window.closed) op.window.window.informResizeEnd();
}
self.op = null;
self.pending_op_end = false;
}
// A layer surface with exclusive focus outranks us entirely.
if (self.layer_focus == .exclusive) {
self.pending_focus = null;
self.pending_clear_focus = false;
return;
}
if (self.pending_focus) |win| {
if (!win.closed and win.mapped) {
self.seat.focusWindow(win.window);
self.focused = win;
self.wm.focus_serial += 1;
win.focus_serial = self.wm.focus_serial;
self.warpTo(win);
self.wm.ipcDirty();
}
self.pending_focus = null;
} else if (self.pending_clear_focus) {
self.seat.clearFocus();
self.focused = null;
self.pending_clear_focus = false;
self.wm.ipcDirty();
}
}
/// Pull the pointer to the middle of a newly focused window.
///
/// Skipped when the pointer is already inside it, so keyboard focus following
/// the mouse does not yank the cursor out from under the user. Manage sequence
/// only.
fn warpTo(self: *Seat, win: *Window) void {
if (!config.warp_cursor) return;
if (self.op != null) return;
if (win.cell.contains(self.pointer_x, self.pointer_y)) return;
self.seat.pointerWarp(
win.cell.x + @divTrunc(win.cell.width, 2),
win.cell.y + @divTrunc(win.cell.height, 2),
);
}
fn onEvent(_: *river.SeatV1, event: river.SeatV1.Event, self: *Seat) void {
switch (event) {
.removed => {
self.removed = true;
self.wm.needsManage();
},
.wl_seat => |ev| {
self.wl_seat_name = ev.name;
self.wm.attachWlSeat(self);
},
.pointer_position => |ev| {
self.pointer_x = ev.x;
self.pointer_y = ev.y;
},
.pointer_enter => |ev| {
const win = Wm.windowFromProxy(ev.window) orelse return;
self.hovered = win;
if (config.focus_follows_mouse and self.op == null) {
if (win.mapped and win.visible) self.focus(win);
}
},
.pointer_leave => {
self.hovered = null;
},
.window_interaction => |ev| {
const win = Wm.windowFromProxy(ev.window) orelse return;
// Clicking a window focuses it and, if floating, raises it.
self.focus(win);
if (win.output) |out| self.wm.focusOutput(out);
},
.shell_surface_interaction => {
// Our own tab bar; handled through wl_pointer where we know the
// coordinates.
},
.op_delta => |ev| {
const op = self.op orelse return;
const win = op.window;
if (win.closed) return;
switch (op.kind) {
.move => {
win.cell.x = op.start.x + ev.dx;
win.cell.y = op.start.y + ev.dy;
win.float_box = win.cell;
},
.resize => {
var box = op.start;
if (op.edges.left) {
box.x = op.start.x + ev.dx;
box.width = op.start.width - ev.dx;
} else {
box.width = op.start.width + ev.dx;
}
if (op.edges.top) {
box.y = op.start.y + ev.dy;
box.height = op.start.height - ev.dy;
} else {
box.height = op.start.height + ev.dy;
}
const min = 2 * config.border_width + 1;
box.width = @max(min, box.width);
box.height = @max(min, box.height);
win.cell = box;
win.float_box = box;
},
}
self.wm.needsManage();
},
.op_release => {
self.pending_op_end = true;
self.wm.needsManage();
},
}
}
fn onLayerEvent(_: *river.LayerShellSeatV1, event: river.LayerShellSeatV1.Event, self: *Seat) void {
switch (event) {
.focus_exclusive => self.layer_focus = .exclusive,
.focus_non_exclusive => self.layer_focus = .non_exclusive,
.focus_none => {
self.layer_focus = .none;
// Hand focus back to whatever the user was using.
if (self.focused) |win| {
if (!win.closed and win.visible) self.focus(win);
}
},
}
self.wm.needsManage();
}
fn onKeyEvent(_: *river.XkbBindingV1, event: river.XkbBindingV1.Event, binding: *KeyBinding) void {
const self = binding.seat;
const key = config.keys[binding.index];
switch (event) {
.pressed => {
self.wm.perform(self, key.action);
if (key.shouldRepeat()) self.wm.startRepeat(self, binding.index);
},
.released, .stop_repeat => {
self.wm.stopRepeat(binding.index);
},
}
}
fn onButtonEvent(_: *river.PointerBindingV1, event: river.PointerBindingV1.Event, binding: *PointerBinding) void {
const self = binding.seat;
const button = config.buttons[binding.index];
switch (event) {
.pressed => {
const win = self.hovered orelse self.wm.windowAt(self.pointer_x, self.pointer_y) orelse return;
self.focus(win);
switch (button.action) {
.move => self.startMove(win),
.resize => {
// Resize from whichever corner the pointer is nearest, so
// the drag pulls the expected edge.
const mid_x = win.cell.x + @divTrunc(win.cell.width, 2);
const mid_y = win.cell.y + @divTrunc(win.cell.height, 2);
self.startResize(win, .{
.left = self.pointer_x < mid_x,
.right = self.pointer_x >= mid_x,
.top = self.pointer_y < mid_y,
.bottom = self.pointer_y >= mid_y,
});
},
}
},
.released => {
self.pending_op_end = true;
self.wm.needsManage();
},
}
}
// ─── wl_pointer, used only to click the tab bar ──────────────────────────────
pub fn onWlSeatEvent(_: *wl.Seat, event: wl.Seat.Event, self: *Seat) void {
switch (event) {
.capabilities => |ev| {
if (ev.capabilities.pointer and self.pointer == null) {
self.pointer = self.wl_seat.?.getPointer() catch null;
if (self.pointer) |p| p.setListener(*Seat, onPointerEvent, self);
}
},
.name => {},
}
}
fn onPointerEvent(_: *wl.Pointer, event: wl.Pointer.Event, self: *Seat) void {
switch (event) {
.enter => |ev| {
self.pointer_surface = ev.surface;
self.pointer_local_x = ev.surface_x.toDouble();
self.pointer_local_y = ev.surface_y.toDouble();
},
.leave => {
self.pointer_surface = null;
},
.motion => |ev| {
self.pointer_local_x = ev.surface_x.toDouble();
self.pointer_local_y = ev.surface_y.toDouble();
},
.button => |ev| {
if (ev.state != .pressed) return;
const surface = self.pointer_surface orelse return;
const out = self.wm.outputForTabBarSurface(surface) orelse return;
const gx = out.tabbar.box.x + @as(i32, @intFromFloat(self.pointer_local_x));
const gy = out.tabbar.box.y + @as(i32, @intFromFloat(self.pointer_local_y));
if (out.tabbar.windowAt(gx, gy)) |win| {
self.wm.focusOutput(out);
self.focus(win);
}
},
else => {},
}
}
+239
View File
@@ -0,0 +1,239 @@
//! A single managed window.
//!
//! Event handlers here only ever mutate plain fields. Every protocol request
//! that changes window management or rendering state is issued from Wm's
//! manage/render sequence handlers, because river only permits those requests
//! between manage_start/manage_finish and render_start/render_finish.
const Window = @This();
const std = @import("std");
const Allocator = std.mem.Allocator;
const wayland = @import("wayland");
const river = wayland.client.river;
const config = @import("config");
const Wm = @import("Wm.zig");
const Output = @import("Output.zig");
const layout = @import("layout.zig");
const Box = layout.Box;
wm: *Wm,
window: *river.WindowV1,
/// Created lazily: the protocol allows get_node exactly once per window.
node: ?*river.NodeV1 = null,
title: ?[]u8 = null,
app_id: ?[]u8 = null,
identifier: ?[]u8 = null,
parent: ?*Window = null,
tags: u32 = 0,
output: ?*Output = null,
floating: bool = false,
/// Set by rules or by the user; distinguishes "floating because it is a
/// dialog" from "floating because it was asked to be".
floating_forced: bool = false,
fullscreen: bool = false,
/// What we last told the window, so we only send changes.
informed_fullscreen: bool = false,
/// Target rectangle including the border, computed by the layout.
cell: Box = .{},
/// Geometry to restore when a floating window stops being fullscreen.
float_box: Box = .{},
/// The content size river last reported.
content_width: i32 = 0,
content_height: i32 = 0,
/// The last size we proposed, so we do not re-propose every manage sequence.
proposed_width: i32 = -1,
proposed_height: i32 = -1,
min_width: i32 = 0,
min_height: i32 = 0,
max_width: i32 = 0,
max_height: i32 = 0,
/// True once river has sent dimensions, i.e. the window is on screen.
mapped: bool = false,
/// True once we have sent the one-time setup requests.
configured: bool = false,
/// Computed each manage sequence.
visible: bool = false,
/// Whether the window is currently hidden, so we only send changes.
hidden: bool = false,
/// river has closed this window; it must be reaped and not touched again.
closed: bool = false,
/// A close was requested. `close` modifies window management state, so it has
/// to wait for the next manage sequence like everything else.
pending_close: bool = false,
/// Bumped whenever the window takes focus, giving a cheap "most recently
/// focused" ordering without maintaining dwm's second linked list.
focus_serial: u64 = 0,
/// Cleared once the window has had its one chance at taking focus as it maps.
wants_initial_focus: bool = true,
pub fn create(wm: *Wm, window: *river.WindowV1) !*Window {
const self = try wm.gpa.create(Window);
self.* = .{ .wm = wm, .window = window };
window.setListener(*Window, onEvent, self);
return self;
}
pub fn destroy(self: *Window) void {
const gpa = self.wm.gpa;
if (self.node) |node| node.destroy();
self.window.destroy();
if (self.title) |t| gpa.free(t);
if (self.app_id) |a| gpa.free(a);
if (self.identifier) |i| gpa.free(i);
gpa.destroy(self);
}
/// The node is needed to position and stack the window; create it on demand.
pub fn getNode(self: *Window) ?*river.NodeV1 {
if (self.node) |node| return node;
self.node = self.window.getNode() catch |err| {
std.log.err("failed to create node for window: {s}", .{@errorName(err)});
return null;
};
return self.node;
}
/// Clamp a proposed size to the window's advertised limits. These are hints,
/// but respecting them avoids pointless configure round-trips with windows
/// that will refuse the size anyway.
pub fn clampSize(self: *const Window, width: i32, height: i32) struct { i32, i32 } {
var w = width;
var h = height;
if (self.min_width > 0) w = @max(w, self.min_width);
if (self.min_height > 0) h = @max(h, self.min_height);
if (self.max_width > 0) w = @min(w, self.max_width);
if (self.max_height > 0) h = @min(h, self.max_height);
return .{ @max(1, w), @max(1, h) };
}
/// Apply matching rules from config to a newly created window.
pub fn applyRules(self: *Window) void {
for (config.rules) |rule| {
if (rule.app_id) |want| {
const have = self.app_id orelse continue;
if (!std.mem.eql(u8, want, have)) continue;
}
if (rule.title) |want| {
const have = self.title orelse continue;
if (std.mem.indexOf(u8, have, want) == null) continue;
}
if (rule.tags) |t| self.tags = t;
if (rule.floating) |f| {
self.floating = f;
self.floating_forced = f;
}
}
}
fn setString(self: *Window, field: *?[]u8, value: ?[*:0]const u8) void {
const gpa = self.wm.gpa;
if (field.*) |old| gpa.free(old);
field.* = null;
if (value) |v| {
field.* = gpa.dupe(u8, std.mem.span(v)) catch null;
}
}
fn onEvent(_: *river.WindowV1, event: river.WindowV1.Event, self: *Window) void {
switch (event) {
.closed => {
self.closed = true;
self.wm.needsManage();
},
.dimensions => |ev| {
self.content_width = ev.width;
self.content_height = ev.height;
if (!self.mapped) {
self.mapped = true;
// Focus and stacking for a new window are settled in the
// manage sequence, and only once it is mapped.
self.wm.needsManage();
self.wm.ipcDirty();
}
// A window may resize itself; if it is floating its cell must
// follow, otherwise the border is drawn around the wrong area.
if (self.floating and !self.fullscreen) {
const bw = config.border_width;
self.cell.width = ev.width + 2 * bw;
self.cell.height = ev.height + 2 * bw;
self.float_box = self.cell;
}
},
.dimensions_hint => |ev| {
self.min_width = ev.min_width;
self.min_height = ev.min_height;
self.max_width = ev.max_width;
self.max_height = ev.max_height;
},
.app_id => |ev| {
self.setString(&self.app_id, ev.app_id);
self.applyRules();
self.wm.ipcDirty();
},
.title => |ev| {
self.setString(&self.title, ev.title);
self.applyRules();
self.wm.ipcDirty();
},
.identifier => |ev| {
self.setString(&self.identifier, ev.identifier);
},
.parent => |ev| {
self.parent = if (ev.parent) |p| Wm.windowFromProxy(p) else null;
// Dialogs and file pickers float, as in dwm.
if (config.float_children and self.parent != null and !self.floating_forced) {
self.floating = true;
}
},
.fullscreen_requested => |ev| {
self.fullscreen = true;
if (ev.output) |o| {
if (Wm.outputFromProxy(o)) |out| self.output = out;
}
self.wm.needsManage();
},
.exit_fullscreen_requested => {
self.fullscreen = false;
self.wm.needsManage();
},
.pointer_move_requested => |ev| {
if (Wm.seatFromProxy(ev.seat)) |seat| seat.startMove(self);
},
.pointer_resize_requested => |ev| {
if (Wm.seatFromProxy(ev.seat)) |seat| seat.startResize(self, ev.edges);
},
// We advertise only the fullscreen capability, so these should not
// arrive; ignoring them is the documented option either way.
.maximize_requested,
.unmaximize_requested,
.minimize_requested,
.show_window_menu_requested,
.decoration_hint,
.unreliable_pid,
.presentation_hint,
=> {},
}
}
+1427
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
//! The vocabulary of things att_wm can be asked to do.
//!
//! This module deliberately depends on nothing but xkbcommon. Keeping it free
//! of Wayland objects and window manager state is what lets `config.zig` import
//! it to declare key bindings without creating an import cycle back into the
//! window manager, and it is what lets key bindings and IPC commands share a
//! single execution path: both become an `Action`, and `Wm.perform` is the only
//! place that interprets one.
const std = @import("std");
const mem = std.mem;
pub const xkb = @import("xkbcommon");
/// Number of tags. Nine is dwm's default and what the example quickshell bar
/// assumes; changing it here changes it everywhere.
pub const tag_count = 9;
pub const all_tags: u32 = (1 << tag_count) - 1;
/// Which slot of an output's per-tag arrangement settings a view of `tags` uses.
///
/// A view of exactly one tag gets that tag's own slot, numbered from 1. Viewing
/// several at once has no single tag whose settings should win, so all such
/// views share slot 0 — the compromise dwm's pertag patch makes. It leaves the
/// individual tags' settings untouched, so they are still there on the way back.
/// An empty mask is not reachable through `Output.setTags`, but shares slot 0
/// too rather than being a case callers have to think about.
pub fn tagSlot(tags: u32) usize {
const t = tags & all_tags;
if (@popCount(t) != 1) return 0;
return @ctz(t) + 1;
}
/// 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 {
pub const none: u32 = 0;
pub const shift: u32 = 1;
pub const ctrl: u32 = 4;
/// Commonly called alt.
pub const alt: u32 = 8;
pub const mod3: u32 = 32;
/// Commonly called super or logo.
pub const super: u32 = 64;
pub const mod5: u32 = 128;
};
pub const Direction = enum {
next,
prev,
pub fn parse(s: []const u8) ?Direction {
if (mem.eql(u8, s, "next")) return .next;
if (mem.eql(u8, s, "prev") or mem.eql(u8, s, "previous")) return .prev;
return null;
}
};
pub const Layout = enum {
master,
monocle,
tabbed,
pub fn parse(s: []const u8) ?Layout {
return std.meta.stringToEnum(Layout, s);
}
/// dwm-style short symbol for the bar.
pub fn symbol(self: Layout) []const u8 {
return switch (self) {
.master => "[]=",
.monocle => "[M]",
.tabbed => "|||",
};
}
};
/// A relative or absolute adjustment to a numeric setting. dwm only ever does
/// relative ones, but IPC callers frequently want to set a value outright.
pub fn Delta(comptime T: type) type {
return union(enum) {
relative: T,
absolute: T,
const Self = @This();
/// A leading `+` or `-` means relative, anything else absolute, so
/// `att_wmctl mfact +0.05` nudges and `att_wmctl mfact 0.5` sets.
pub fn parse(s: []const u8) ?Self {
if (s.len == 0) return null;
const signed = s[0] == '+' or s[0] == '-';
const value = switch (@typeInfo(T)) {
.int => std.fmt.parseInt(T, s, 10) catch return null,
.float => std.fmt.parseFloat(T, s) catch return null,
else => @compileError("unsupported Delta type"),
};
return if (signed) Self{ .relative = value } else Self{ .absolute = value };
}
pub fn apply(self: Self, current: T) T {
return switch (self) {
.relative => |d| current + d,
.absolute => |v| v,
};
}
};
}
pub const Action = union(enum) {
/// Run a command. The slice is argv; it is executed without a shell.
spawn: []const []const u8,
/// Ask the focused window to close.
close,
/// Terminate att_wm, leaving river running.
quit,
/// End the Wayland session entirely (river exits too).
exit_session,
/// Move keyboard focus through the visible windows of the focused output.
focus: Direction,
/// Focus one particular window, named by the identifier published over IPC.
/// Key bindings only ever want a direction; a bar's task list needs to name
/// the window the user clicked, and river's `identifier` is the only handle
/// that is stable and never reused.
focus_window: []const u8,
/// Close one particular window, likewise by identifier, so a bar need not
/// focus a window first just to close it.
close_window: []const u8,
/// Move the focused window through the arrangement order.
swap: Direction,
/// Promote the focused window to master, or if it is already master,
/// promote the one below it. This is dwm's zoom().
zoom,
/// Replace the set of visible tags on the focused output.
view: u32,
/// Add or remove tags from the visible set.
toggle_view: u32,
/// Switch back to the previously viewed tag set.
view_prev,
/// Replace the focused window's tags.
tag: u32,
/// Add or remove tags from the focused window's tags.
toggle_tag: u32,
set_layout: Layout,
cycle_layout: Direction,
/// Toggle between the current layout and the previous one, as dwm's
/// Mod+space does.
toggle_layout,
nmaster: Delta(i32),
mfact: Delta(f32),
toggle_float,
toggle_fullscreen,
focus_output: Direction,
send_to_output: Direction,
/// Re-broadcast state to IPC subscribers. A hook for bars that reconnect.
refresh,
/// True for actions where holding the key down should keep applying the
/// action. river reports key press/release and leaves repeat up to us.
pub fn repeats(self: Action) bool {
return switch (self) {
.focus, .swap, .nmaster, .mfact, .cycle_layout => true,
else => false,
};
}
};
pub const ParseError = error{
UnknownCommand,
MissingArgument,
InvalidArgument,
};
/// Parse an `att_wmctl` command line into an Action.
///
/// Tag arguments accept either a 1-based tag index (`view 3`) or an explicit
/// bitmask (`view 0x4`, `view mask:4`, `view all`), because bars find masks
/// convenient and humans find indices convenient.
pub fn parse(argv: []const []const u8) ParseError!Action {
if (argv.len == 0) return error.UnknownCommand;
const rest = argv[1..];
const Cmd = enum {
spawn,
close,
quit,
@"exit-session",
focus,
@"focus-window",
@"close-window",
swap,
zoom,
view,
@"toggle-view",
@"view-prev",
tag,
@"toggle-tag",
layout,
@"cycle-layout",
@"toggle-layout",
nmaster,
mfact,
@"toggle-float",
@"toggle-fullscreen",
@"focus-output",
@"send-to-output",
refresh,
};
const c = std.meta.stringToEnum(Cmd, argv[0]) orelse return error.UnknownCommand;
return switch (c) {
.spawn => if (rest.len == 0) error.MissingArgument else Action{ .spawn = rest },
.close => .close,
.quit => .quit,
.@"exit-session" => .exit_session,
.zoom => .zoom,
.@"view-prev" => .view_prev,
.@"toggle-layout" => .toggle_layout,
.@"toggle-float" => .toggle_float,
.@"toggle-fullscreen" => .toggle_fullscreen,
.refresh => .refresh,
.focus => .{ .focus = try dir(rest) },
.@"focus-window" => .{ .focus_window = try windowId(rest) },
.@"close-window" => .{ .close_window = try windowId(rest) },
.swap => .{ .swap = try dir(rest) },
.@"focus-output" => .{ .focus_output = try dir(rest) },
.@"send-to-output" => .{ .send_to_output = try dir(rest) },
.@"cycle-layout" => .{ .cycle_layout = dir(rest) catch .next },
.view => .{ .view = try tagMask(rest) },
.@"toggle-view" => .{ .toggle_view = try tagMask(rest) },
.tag => .{ .tag = try tagMask(rest) },
.@"toggle-tag" => .{ .toggle_tag = try tagMask(rest) },
.layout => blk: {
if (rest.len == 0) return error.MissingArgument;
break :blk .{ .set_layout = Layout.parse(rest[0]) orelse return error.InvalidArgument };
},
.nmaster => blk: {
if (rest.len == 0) return error.MissingArgument;
break :blk .{ .nmaster = Delta(i32).parse(rest[0]) orelse return error.InvalidArgument };
},
.mfact => blk: {
if (rest.len == 0) return error.MissingArgument;
break :blk .{ .mfact = Delta(f32).parse(rest[0]) orelse return error.InvalidArgument };
},
};
}
fn dir(rest: []const []const u8) ParseError!Direction {
if (rest.len == 0) return error.MissingArgument;
return Direction.parse(rest[0]) orelse error.InvalidArgument;
}
/// The identifier is opaque to us — river only promises up to 32 printable
/// ASCII bytes — so the one thing worth rejecting is an empty argument, which
/// would otherwise silently match no window.
fn windowId(rest: []const []const u8) ParseError![]const u8 {
if (rest.len == 0) return error.MissingArgument;
if (rest[0].len == 0) return error.InvalidArgument;
return rest[0];
}
fn tagMask(rest: []const []const u8) ParseError!u32 {
if (rest.len == 0) return error.MissingArgument;
const s = rest[0];
if (mem.eql(u8, s, "all")) return all_tags;
if (mem.startsWith(u8, s, "mask:")) {
const v = std.fmt.parseInt(u32, s["mask:".len..], 0) catch return error.InvalidArgument;
return v & all_tags;
}
// A 0x/0b-prefixed value is a mask; a bare decimal is a 1-based index.
if (mem.startsWith(u8, s, "0x") or mem.startsWith(u8, s, "0b")) {
const v = std.fmt.parseInt(u32, s, 0) catch return error.InvalidArgument;
return v & all_tags;
}
const idx = std.fmt.parseInt(u32, s, 10) catch return error.InvalidArgument;
if (idx < 1 or idx > tag_count) return error.InvalidArgument;
return @as(u32, 1) << @intCast(idx - 1);
}
/// A single key binding, as declared in config.zig.
pub const Key = struct {
mods: u32,
keysym: xkb.Keysym,
action: Action,
/// Overrides `Action.repeats()` when set.
repeat: ?bool = null,
pub fn shouldRepeat(self: Key) bool {
return self.repeat orelse self.action.repeats();
}
};
/// A pointer binding, as declared in config.zig.
pub const Button = struct {
mods: u32,
/// Linux input event code, e.g. `btn.left`.
button: u32,
action: PointerAction,
};
pub const PointerAction = enum { move, resize };
/// Linux input event codes for the buttons worth binding.
pub const btn = struct {
pub const left: u32 = 0x110;
pub const right: u32 = 0x111;
pub const middle: u32 = 0x112;
};
+89
View File
@@ -0,0 +1,89 @@
//! Colour conversion.
//!
//! Config declares colours as the familiar 0xRRGGBBAA with straight alpha.
//! The two sinks want something different:
//!
//! * `river_window_v1.set_borders` takes one full-range u32 per channel —
//! river divides each by maxInt(u32) — with premultiplied alpha.
//! * wl_shm ARGB8888 wants premultiplied 8-bit channels packed into a u32.
const std = @import("std");
pub const Rgba = u32;
pub const Channels = struct {
r: u32,
g: u32,
b: u32,
a: u32,
};
fn premul8(c: u8, a: u8) u8 {
// Round to nearest rather than truncating, so 0xff at full alpha stays
// 0xff instead of drifting down.
return @intCast((@as(u32, c) * @as(u32, a) + 127) / 255);
}
/// Expand an 8-bit channel to the full u32 range: 0xff maps exactly to
/// 0xffffffff, which is what river treats as 1.0.
fn expand(c: u8) u32 {
return @as(u32, c) * 0x01010101;
}
fn split(rgba: Rgba) [4]u8 {
return .{
@intCast((rgba >> 24) & 0xff),
@intCast((rgba >> 16) & 0xff),
@intCast((rgba >> 8) & 0xff),
@intCast(rgba & 0xff),
};
}
/// Premultiplied, full-range channels for `set_borders`.
pub fn toChannels(rgba: Rgba) Channels {
const c = split(rgba);
const a = c[3];
return .{
.r = expand(premul8(c[0], a)),
.g = expand(premul8(c[1], a)),
.b = expand(premul8(c[2], a)),
.a = expand(a),
};
}
/// Premultiplied ARGB8888 as a native-endian u32, for wl_shm buffers.
pub fn toArgb8888(rgba: Rgba) u32 {
const c = split(rgba);
const a = c[3];
return (@as(u32, a) << 24) |
(@as(u32, premul8(c[0], a)) << 16) |
(@as(u32, premul8(c[1], a)) << 8) |
@as(u32, premul8(c[2], a));
}
test "opaque white survives both conversions intact" {
const ch = toChannels(0xffffffff);
try std.testing.expectEqual(@as(u32, 0xffffffff), ch.r);
try std.testing.expectEqual(@as(u32, 0xffffffff), ch.a);
try std.testing.expectEqual(@as(u32, 0xffffffff), toArgb8888(0xffffffff));
}
test "fully transparent premultiplies to zero" {
const ch = toChannels(0xffffff00);
try std.testing.expectEqual(@as(u32, 0), ch.r);
try std.testing.expectEqual(@as(u32, 0), ch.a);
try std.testing.expectEqual(@as(u32, 0), toArgb8888(0xffffff00));
}
test "opaque colour keeps its channels in argb order" {
// 0xRRGGBBAA -> 0xAARRGGBB
try std.testing.expectEqual(@as(u32, 0xff5294e2), toArgb8888(0x5294e2ff));
}
test "half alpha premultiplies channels but not alpha" {
const ch = toChannels(0xff000080);
try std.testing.expectEqual(@as(u32, 0x80808080), ch.a);
// 0xff * 0x80 / 0xff == 0x80
try std.testing.expectEqual(@as(u32, 0x80808080), ch.r);
try std.testing.expectEqual(@as(u32, 0), ch.g);
}
+235
View File
@@ -0,0 +1,235 @@
//! att_wm configuration, in the spirit of dwm's config.h: edit and rebuild.
//!
//! Nix users need not patch the source tree — pass a replacement path instead:
//!
//! zig build -Dconfig=/path/to/my-config.zig
//! att_wm.override { config = ./my-config.zig; }
const action = @import("action");
const input = @import("input");
const xkb = @import("xkbcommon");
const Key = action.Key;
const Button = action.Button;
const Mods = action.Mods;
const btn = action.btn;
/// The dwm "MODKEY". Alt, as dwm ships it; use `Mods.super` if you would
/// rather not compete with applications that bind Alt themselves.
pub const mod = Mods.alt;
// ─── Appearance ──────────────────────────────────────────────────────────────
pub const border_width: i32 = 2;
/// Gap between adjacent windows. Zero is dwm-faithful.
pub const gap: i32 = 0;
/// Gap between windows and the edge of the usable area.
pub const outer_gap: i32 = 0;
/// Colours are 0xRRGGBBAA, straight-alpha; they are premultiplied on the way
/// to the protocol.
pub const border_focused: u32 = 0x5294e2ff;
pub const border_normal: u32 = 0x444444ff;
/// Height of the tab bar drawn in the tabbed layout. Set to 0 to let a bar
/// such as quickshell draw the tabs instead, using the IPC `windows` list.
pub const tabbar_height: i32 = 22;
pub const tab_focused: u32 = 0x5294e2ff;
pub const tab_normal: u32 = 0x2c2c2cff;
/// Drawn as a 1px line between adjacent tabs.
pub const tab_separator: u32 = 0x1a1a1aff;
// ─── Layout ──────────────────────────────────────────────────────────────────
pub const default_layout = action.Layout.master;
/// Windows in the master area.
pub const nmaster: i32 = 1;
/// Fraction of the output width given to the master area.
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.
pub const default_tags: u32 = 1;
/// 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",
};
// ─── Behaviour ───────────────────────────────────────────────────────────────
/// dwm's sloppy focus: moving the pointer over a window focuses it.
pub const focus_follows_mouse = false;
/// Warp the pointer to the centre of a window when focus moves there by
/// keyboard. dwm does not do this; it is handy on multi-head setups.
pub const warp_cursor = false;
/// Windows with a parent (dialogs, file pickers) start floating, as in dwm.
pub const float_children = true;
/// How fast a held-down *binding* re-fires, in milliseconds. river reports key
/// press and release and leaves repeating to att_wm, so this is what governs
/// `Mod+j` held down — not what applications see, which is `repeat` below.
pub const binding_repeat_delay: u32 = 300;
pub const binding_repeat_interval: u32 = 40;
pub const cursor_theme: ?[]const u8 = null;
pub const cursor_size: u32 = 24;
/// Commands run once at startup, after the connection to river is up.
pub const autostart = [_][]const []const u8{
// .{ "quickshell", "-c", "att_wm" },
};
pub const terminal = [_][]const u8{"foot"};
pub const menu = [_][]const u8{ "wmenu-run", "-f", "monospace 10" };
// ─── Input ───────────────────────────────────────────────────────────────────
/// The xkb layout every keyboard gets, as `setxkbmap` takes it. All-null — the
/// default — leaves river's own choice alone, which honours the `XKB_DEFAULT_*`
/// environment variables and otherwise gives you `us`.
pub const keymap: input.Keymap = .{
// .layout = "us,se",
// .options = "grp:alt_shift_toggle,caps:escape",
};
/// Key repeat as applications see it — not to be confused with
/// `binding_repeat_delay` above, which is how fast a held-down att_wm binding
/// re-fires. Per-device overrides go in `input_rules`.
///
/// These are river's own defaults, so leaving them alone changes nothing.
pub const repeat: input.Repeat = .{
// Repeats per second. Zero turns key repeat off.
.rate = 70,
// Milliseconds a key is held before repeating starts.
.delay = 150,
};
/// Per-device settings, matched on name and type. `name` is a glob, so `*` does
/// the work of writing out "ELAN0501:00 04F3:3060 Touchpad" in full.
///
/// att_wm logs one line per device as it appears — name and type — which is where
/// to find the names; there is no `list-inputs` command because the protocol
/// shows input devices to the window manager alone.
///
/// Every setting defaults to null, meaning "leave libinput's own default". Rules
/// are applied in order and a later one overrides an earlier one field by field.
pub const input_rules = [_]input.Rule{
// A laptop touchpad. Tap to click and ignoring the pad mid-keystroke are
// near-universally wanted; scroll direction and click method are matters of
// taste, so they are left to you.
.{
.name = "*Touchpad*",
.tap = true,
.disable_while_typing = true,
// .natural_scroll = true,
// .click_method = .clickfinger,
},
// A per-device key repeat, faster than the default above.
// .{ .type = .keyboard, .repeat = .{ .rate = 50, .delay = 250 } },
// Confining a touchscreen or pen to one output is what makes touch follow
// display rotation: mapped devices have the output's transform applied to
// every event, so `wlr-randr --transform` or rot8 rotates touch with the
// screen — no rotation hook, no calibration matrix. It is also what stops a
// touchscreen spanning both monitors on a multi-head setup.
//
// The glob catches both halves of the panel — "Wacom HID 5380 Finger" is the
// touchscreen and "... Pen" the stylus, which river reports as `touch` and
// `tablet` respectively. `.{ .type = .touch, ... }` and a second rule for
// `.tablet` would do the same job without naming the hardware.
.{ .name = "Wacom HID 5380*", .map_to_output = "eDP-1" },
};
// ─── Rules ───────────────────────────────────────────────────────────────────
/// Matched against a window's app_id and title. A null field matches anything.
pub const Rule = struct {
app_id: ?[]const u8 = null,
title: ?[]const u8 = null,
tags: ?u32 = null,
floating: ?bool = null,
};
pub const rules = [_]Rule{
.{ .app_id = "pavucontrol", .floating = true },
.{ .app_id = "org.pulseaudio.pavucontrol", .floating = true },
.{ .title = "Picture-in-Picture", .floating = true },
};
// ─── Key bindings ────────────────────────────────────────────────────────────
pub const keys = tagKeys() ++ [_]Key{
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.Return, .action = .{ .spawn = &terminal } },
.{ .mods = mod, .keysym = xkb.Keysym.p, .action = .{ .spawn = &menu } },
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.c, .action = .close },
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.q, .action = .quit },
.{ .mods = mod | Mods.ctrl | Mods.shift, .keysym = xkb.Keysym.q, .action = .exit_session },
// Focus and arrangement.
.{ .mods = mod, .keysym = xkb.Keysym.j, .action = .{ .focus = .next } },
.{ .mods = mod, .keysym = xkb.Keysym.k, .action = .{ .focus = .prev } },
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.j, .action = .{ .swap = .next } },
.{ .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 } } },
.{ .mods = mod, .keysym = xkb.Keysym.i, .action = .{ .nmaster = .{ .relative = 1 } } },
.{ .mods = mod, .keysym = xkb.Keysym.d, .action = .{ .nmaster = .{ .relative = -1 } } },
// Layouts.
.{ .mods = mod, .keysym = xkb.Keysym.t, .action = .{ .set_layout = .master } },
.{ .mods = mod, .keysym = xkb.Keysym.m, .action = .{ .set_layout = .monocle } },
.{ .mods = mod, .keysym = xkb.Keysym.u, .action = .{ .set_layout = .tabbed } },
.{ .mods = mod, .keysym = xkb.Keysym.space, .action = .toggle_layout },
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.space, .action = .toggle_float },
.{ .mods = mod, .keysym = xkb.Keysym.f, .action = .toggle_fullscreen },
// Tags.
.{ .mods = mod, .keysym = xkb.Keysym.@"0", .action = .{ .view = action.all_tags } },
.{ .mods = mod | Mods.shift, .keysym = xkb.Keysym.@"0", .action = .{ .tag = action.all_tags } },
.{ .mods = mod, .keysym = xkb.Keysym.Tab, .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 } },
};
/// dwm's TAGKEYS macro: Mod+N views, Mod+Shift+N tags, Mod+Ctrl+N toggles the
/// view, Mod+Ctrl+Shift+N toggles the window's tag.
fn tagKeys() [action.tag_count * 4]Key {
// Evaluated at comptime, so the loop costs nothing at runtime.
@setEvalBranchQuota(10_000);
var out: [action.tag_count * 4]Key = undefined;
for (0..action.tag_count) |i| {
const mask: u32 = @as(u32, 1) << @intCast(i);
const sym: xkb.Keysym = @enumFromInt(@intFromEnum(xkb.Keysym.@"1") + i);
out[i * 4 + 0] = .{ .mods = mod, .keysym = sym, .action = .{ .view = mask } };
out[i * 4 + 1] = .{ .mods = mod | Mods.shift, .keysym = sym, .action = .{ .tag = mask } };
out[i * 4 + 2] = .{ .mods = mod | Mods.ctrl, .keysym = sym, .action = .{ .toggle_view = mask } };
out[i * 4 + 3] = .{
.mods = mod | Mods.ctrl | Mods.shift,
.keysym = sym,
.action = .{ .toggle_tag = mask },
};
}
return out;
}
// ─── Pointer bindings ────────────────────────────────────────────────────────
pub const buttons = [_]Button{
.{ .mods = mod, .button = btn.left, .action = .move },
.{ .mods = mod, .button = btn.right, .action = .resize },
};
+147
View File
@@ -0,0 +1,147 @@
//! att_wmctl - drive a running att_wm over its IPC socket.
const std = @import("std");
const posix = std.posix;
const linux = std.os.linux;
const sys = @import("sys.zig");
const sock = @import("sock.zig");
const usage =
\\att_wmctl - control a running att_wm
\\
\\Usage: att_wmctl <command> [arguments]
\\
\\Tags are a 1-based index (3), a mask (0x4 or mask:4), or "all".
\\Window ids are the "id" field of each window in the state JSON.
\\
\\Commands:
\\ view <tag> Show only these tags
\\ toggle-view <tag> Add or remove tags from the view
\\ view-prev Return to the previously viewed tags
\\ tag <tag> Move the focused window to these tags
\\ toggle-tag <tag> Add or remove tags from the focused window
\\
\\ focus next|prev Move focus through the visible windows
\\ focus-window <id> Focus this window, viewing its tags if need be
\\ swap next|prev Move the focused window in the arrangement
\\ zoom Promote the focused window to master
\\ close Close the focused window
\\ close-window <id> Close this window
\\
\\ layout master|monocle|tabbed
\\ cycle-layout [next|prev]
\\ toggle-layout Switch to the previous layout
\\ nmaster <+1|-1|N> Windows in the master area
\\ mfact <+0.05|-0.05|F> Master area width fraction
\\
\\ toggle-float Float or tile the focused window
\\ toggle-fullscreen Fullscreen the focused window
\\
\\ focus-output next|prev
\\ send-to-output next|prev
\\
\\ spawn <cmd> [args...] Run a command
\\ quit Stop att_wm (river keeps running)
\\ exit-session End the Wayland session
\\
\\ state Print the current state as JSON and exit
\\ subscribe Stream a JSON state line on every change
\\
;
pub fn main(init: std.process.Init) !u8 {
const gpa = init.gpa;
const args = try init.minimal.args.toSlice(init.arena.allocator());
if (args.len < 2 or isHelp(args[1])) {
sys.writeAllBestEffort(1, usage);
return if (args.len < 2) 1 else 0;
}
const path = try sock.path(gpa, init.minimal.environ);
defer gpa.free(path);
const fd = sys.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0) catch |err| {
std.log.err("failed to create socket: {s}", .{@errorName(err)});
return 1;
};
defer sys.close(fd);
const addr = sys.sockaddrUn(path) catch {
std.log.err("socket path too long: {s}", .{path});
return 1;
};
sys.connect(fd, @ptrCast(&addr), sys.sockaddrUnLen(&addr)) catch |err| {
std.log.err(
"cannot reach att_wm at {s}: {s}\nIs att_wm running under this Wayland display?",
.{ path, @errorName(err) },
);
return 1;
};
// Reassemble argv into one newline-terminated line.
var line: std.ArrayList(u8) = .empty;
defer line.deinit(gpa);
for (args[1..], 0..) |arg, i| {
if (i > 0) try line.append(gpa, ' ');
try line.appendSlice(gpa, arg);
}
try line.append(gpa, '\n');
try writeAll(fd, line.items);
const streaming = std.mem.eql(u8, args[1], "subscribe");
return relay(fd, streaming);
}
fn isHelp(arg: []const u8) bool {
return std.mem.eql(u8, arg, "-h") or
std.mem.eql(u8, arg, "--help") or
std.mem.eql(u8, arg, "help");
}
fn writeAll(fd: sys.fd_t, bytes: []const u8) !void {
var written: usize = 0;
while (written < bytes.len) {
written += try sys.write(fd, bytes[written..]);
}
}
/// Copy the reply to stdout. For one-shot commands att_wm closes the connection
/// after replying, so this returns; `subscribe` runs until interrupted.
fn relay(fd: sys.fd_t, streaming: bool) !u8 {
var buf: [8192]u8 = undefined;
// Copied out rather than aliased: `buf` is overwritten by later reads.
var first: [3]u8 = undefined;
var first_len: usize = 0;
while (true) {
const n = sys.read(fd, &buf) catch |err| switch (err) {
// att_wm closes the connection after replying to a one-shot
// command; a reset here just means it got in first.
error.ConnectionReset => break,
else => {
std.log.err("read failed: {s}", .{@errorName(err)});
return 1;
},
};
if (n == 0) break;
if (first_len == 0 and n > 0) {
first_len = @min(n, first.len);
@memcpy(first[0..first_len], buf[0..first_len]);
}
// "ok" is the success acknowledgement for a command; printing it would
// be noise, so swallow it and let the exit status speak.
if (!streaming and std.mem.startsWith(u8, buf[0..n], "ok\n")) {
if (n == 3) return 0;
}
try writeAll(1, buf[0..n]);
}
if (std.mem.eql(u8, first[0..first_len], "err")) return 1;
return 0;
}
+281
View File
@@ -0,0 +1,281 @@
//! Input device configuration, as declared in config.zig.
//!
//! Like action.zig this module depends on nothing but the standard library, so
//! that config.zig can import it without a cycle back into the window manager.
//! `src/InputManager.zig` is what puts these values onto river's protocol
//! objects.
//!
//! Every device setting is optional, and null means "leave it alone" — libinput
//! picks per-device defaults that are usually right, so a rule should say only
//! what it wants changed.
const std = @import("std");
/// The kind of device, mirroring `river_input_device_v1.type`.
///
/// Note that a touchpad reports `pointer`, not `touch`: libinput models it as a
/// pointer that happens to support tapping. `touch` is a touchscreen.
pub const Type = enum { keyboard, pointer, touch, tablet };
/// xkb rule names — the RMLVO that `setxkbmap` and every other Wayland
/// compositor take. att_wm compiles these into a keymap and hands it to every
/// keyboard river reports.
///
/// A null field is left to xkbcommon, which reads the `XKB_DEFAULT_*`
/// environment variables and otherwise falls back to a plain `us` layout. So
/// the default of all-null is exactly what you get without this protocol at all.
pub const Keymap = struct {
/// Rules file, e.g. "evdev". Rarely worth setting.
rules: ?[]const u8 = null,
model: ?[]const u8 = null,
/// One layout, or several separated by commas: "us,se".
layout: ?[]const u8 = null,
/// Variants, positionally matching `layout`: "dvorak," is dvorak for the
/// first layout and the default variant for the second.
variant: ?[]const u8 = null,
/// Comma separated, e.g. "grp:alt_shift_toggle,caps:escape". With more than
/// one layout configured, a `grp:` option is how you switch between them —
/// xkb does the switching itself, so att_wm needs no binding for it.
options: ?[]const u8 = null,
/// True when nothing is set, in which case there is no point compiling a
/// keymap: river's own default is already what we would produce.
pub fn isDefault(self: Keymap) bool {
inline for (std.meta.fields(Keymap)) |field| {
if (@field(self, field.name) != null) return false;
}
return true;
}
};
/// Key repeat as applied by the compositor to the focused client.
///
/// This is not the same thing as `config.binding_repeat_delay` and
/// `config.binding_repeat_interval`, which govern how fast att_wm re-runs a held-down
/// *binding*: river reports binding press and release and leaves repeating to
/// us. These two are what every other application sees.
/// The defaults are river's own, so a config that says nothing about repeat
/// leaves keyboards exactly as they would have been.
pub const Repeat = struct {
/// Repeats per second. Zero disables key repeat entirely.
rate: i32 = 40,
/// Milliseconds a key must be held before repeating starts.
delay: i32 = 400,
};
pub const ButtonMap = enum {
/// One finger left, two right, three middle. libinput's default.
lrm,
/// One finger left, two middle, three right.
lmr,
};
pub const DragLock = enum {
disabled,
/// Lifting the finger keeps the drag alive for a short timeout.
timeout,
/// Lifting the finger keeps the drag alive until the next tap.
sticky,
};
pub const ThreeFingerDrag = enum { disabled, three_finger, four_finger };
pub const ClickMethod = enum {
none,
/// Bottom of the touchpad split into left/middle/right zones.
button_areas,
/// Number of fingers on the pad decides the button.
clickfinger,
};
pub const AccelProfile = enum {
/// No acceleration: movement maps to pointer travel one to one.
none,
/// Constant factor, no acceleration.
flat,
/// Speed-dependent acceleration. libinput's default for most devices.
adaptive,
};
pub const ScrollMethod = enum {
none,
two_finger,
edge,
/// Moving the device while `scroll_button` is held scrolls.
on_button_down,
};
pub const SendEvents = enum {
enabled,
disabled,
/// Useful for a laptop touchpad that should go quiet when a mouse is
/// plugged in.
disabled_on_external_mouse,
};
/// Matched against the name and type of every input device river reports.
///
/// att_wm logs one line per device at startup — name and type — which is where
/// the names come from; there is no `list-inputs` to run because the protocol
/// only shows devices to the window manager itself.
pub const Rule = struct {
/// Device name to match. `*` matches any run of characters, so
/// `"*Touchpad*"` catches the usual "ELAN0501:00 04F3:3060 Touchpad"
/// without you having to write it out. Null matches every device.
name: ?[]const u8 = null,
/// Restrict the rule to one kind of device. Null matches every kind.
type: ?Type = null,
// ─── Keyboards ───
/// Per-device override of `config.repeat`.
repeat: ?Repeat = null,
// ─── Pointers, touchpads, touchscreens ───
/// Confine a touchscreen or tablet to one output, named as river names it —
/// "eDP-1", the same name the IPC `outputs` list uses.
///
/// Two reasons to want this. On multiple monitors an unmapped touchscreen
/// spans the whole output layout, so touching the left of the panel lands on
/// the wrong screen. And it is what makes touch survive **display
/// rotation**: a mapped device has the output's transform applied to its
/// coordinates on every event, so rotating with `wlr-randr` or rot8 rotates
/// touch along with it, with no rotation hook and no calibration matrix.
///
/// Do not combine with an external calibration matrix for rotation — the two
/// transforms compose, and the result is rotated twice.
///
/// Ignored for keyboards, which have no coordinates to map.
map_to_output: ?[]const u8 = null,
/// Multiplier on scroll distance: 0.5 scrolls half as far, 3.0 three times
/// as far. Applied by river rather than libinput, so it works on any
/// pointer.
scroll_factor: ?f64 = null,
/// Tap to click.
tap: ?bool = null,
/// Which button each finger count taps.
tap_button_map: ?ButtonMap = null,
/// Tap and then drag without a second tap.
drag: ?bool = null,
/// Whether lifting the finger mid-drag ends it.
drag_lock: ?DragLock = null,
/// Hold three (or four) fingers to drag.
three_finger_drag: ?ThreeFingerDrag = null,
/// What a physical click on a touchpad means.
click_method: ?ClickMethod = null,
/// Which button each finger count clicks, under `.clickfinger`.
clickfinger_button_map: ?ButtonMap = null,
/// Left and right buttons together act as middle click.
middle_emulation: ?bool = null,
/// Swap left and right buttons.
left_handed: ?bool = null,
/// Content follows the fingers rather than the viewport, as on a phone.
natural_scroll: ?bool = null,
scroll_method: ?ScrollMethod = null,
/// Linux input event code — `input.btn.middle` and friends. Only meaningful
/// with `scroll_method = .on_button_down`.
scroll_button: ?u32 = null,
/// Whether the scroll button must be held, or toggles.
scroll_button_lock: ?bool = null,
accel_profile: ?AccelProfile = null,
/// Pointer speed in [-1, 1]; 0 is the device's default.
accel_speed: ?f64 = null,
/// Ignore the touchpad while the keyboard is being typed on.
disable_while_typing: ?bool = null,
/// Ignore the touchpad while the trackpoint is in use.
disable_while_trackpointing: ?bool = null,
/// Clockwise rotation in degrees, for a device mounted sideways.
rotation: ?u32 = null,
/// Whether the device sends events at all.
send_events: ?SendEvents = null,
/// Fields that select which devices a rule applies to rather than
/// configuring them, and so are not merged by `merge`.
const selectors = .{ "name", "type" };
/// Fold `other` on top of `self`: every setting `other` states wins, every
/// setting it leaves null keeps the value it had.
///
/// Rules are applied in the order they are declared, so a broad rule can set
/// a house style and a later, narrower one can dissent from it — the same
/// last-one-wins that dwm's window rules have.
pub fn merge(self: Rule, other: Rule) Rule {
var out = self;
inline for (std.meta.fields(Rule)) |field| {
comptime var is_selector = false;
inline for (selectors) |name| {
if (comptime std.mem.eql(u8, field.name, name)) is_selector = true;
}
if (!is_selector) {
if (@field(other, field.name)) |v| @field(out, field.name) = v;
}
}
return out;
}
/// True if this rule should apply to a device with the given name and type.
pub fn matchesDevice(self: Rule, device_name: []const u8, device_type: Type) bool {
if (self.type) |t| {
if (t != device_type) return false;
}
if (self.name) |pattern| {
if (!matches(pattern, device_name)) return false;
}
return true;
}
};
/// Glob match supporting `*` as "any run of characters, including none".
///
/// Deliberately no `?` or character classes: device names are long, noisy and
/// full of punctuation, and `*` on either end is all anyone needs to pin one
/// down. Iterative with a backtrack point rather than recursive, so a pattern
/// like `"*a*a*a*"` cannot blow the stack.
pub fn matches(pattern: []const u8, name: []const u8) bool {
var p: usize = 0;
var n: usize = 0;
// Where to resume if the run we are in turns out not to match: the `*` that
// let us in, and how far it had consumed.
var star: ?usize = null;
var star_n: usize = 0;
while (n < name.len) {
if (p < pattern.len and pattern[p] == '*') {
star = p;
p += 1;
star_n = n;
} else if (p < pattern.len and pattern[p] == name[n]) {
p += 1;
n += 1;
} else if (star) |s| {
// Let the last `*` swallow one more byte and try again.
p = s + 1;
star_n += 1;
n = star_n;
} else {
return false;
}
}
// Trailing `*`s can still match the empty remainder.
while (p < pattern.len and pattern[p] == '*') p += 1;
return p == pattern.len;
}
/// Linux input event codes for the buttons worth binding to scrolling. The same
/// values `action.btn` has; duplicated rather than imported so this module keeps
/// its single dependency on the standard library.
pub const btn = struct {
pub const left: u32 = 0x110;
pub const right: u32 = 0x111;
pub const middle: u32 = 0x112;
};
+398
View File
@@ -0,0 +1,398 @@
//! JSON-lines IPC over a unix socket.
//!
//! Two things talk to this: bars (quickshell) which send `subscribe` and then
//! read a state object every time anything changes, and `att_wmctl` which sends
//! one command and reads one reply. Both directions are newline delimited so a
//! quickshell `SplitParser` can consume the stream directly.
const std = @import("std");
const posix = std.posix;
const Allocator = std.mem.Allocator;
const linux = std.os.linux;
const sys = @import("sys.zig");
const act = @import("action");
const config = @import("config");
const Wm = @import("Wm.zig");
/// Generous, but a runaway subscriber must not be able to make the window
/// manager grow without bound.
const max_out_buffer = 1 << 20;
const max_in_buffer = 64 * 1024;
const sock = @import("sock.zig");
const Client = struct {
fd: sys.fd_t,
/// Receives a state object on every change.
subscribed: bool = false,
in: std.ArrayList(u8) = .empty,
out: std.ArrayList(u8) = .empty,
/// Close once the output buffer has drained.
closing: bool = false,
fn deinit(self: *Client, gpa: Allocator) void {
self.in.deinit(gpa);
self.out.deinit(gpa);
sys.close(self.fd);
}
};
pub const Ipc = struct {
gpa: Allocator,
path: []u8,
listener: sys.fd_t,
clients: std.ArrayList(*Client) = .empty,
pub fn init(gpa: Allocator, environ: std.process.Environ) !Ipc {
const path = try sock.path(gpa, environ);
errdefer gpa.free(path);
// A socket left behind by a crashed instance would block bind(); only
// remove it if nothing is listening, so we never kick out a running
// window manager.
if (isStale(path)) sys.unlink(path);
const listener = try sys.socket(
linux.AF.UNIX,
linux.SOCK.STREAM | linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC,
0,
);
errdefer sys.close(listener);
const addr = try sys.sockaddrUn(path);
try sys.bind(listener, @ptrCast(&addr), sys.sockaddrUnLen(&addr));
try sys.listen(listener, 16);
std.log.info("ipc socket: {s}", .{path});
return .{ .gpa = gpa, .path = path, .listener = listener };
}
/// True if a socket file is left over from a crashed instance. Connecting
/// is the only reliable test: a refused connection means nobody is
/// listening, whereas a missing file is not stale at all and a successful
/// connection means another att_wm owns it.
fn isStale(path: []const u8) bool {
const probe = sys.socket(linux.AF.UNIX, linux.SOCK.STREAM | linux.SOCK.CLOEXEC, 0) catch return false;
defer sys.close(probe);
const addr = sys.sockaddrUn(path) catch return false;
sys.connect(probe, @ptrCast(&addr), sys.sockaddrUnLen(&addr)) catch |err| {
return err == error.ConnectionRefused;
};
return false;
}
pub fn deinit(self: *Ipc) void {
for (self.clients.items) |client| {
client.deinit(self.gpa);
self.gpa.destroy(client);
}
self.clients.deinit(self.gpa);
sys.close(self.listener);
sys.unlink(self.path);
self.gpa.free(self.path);
}
/// Append the listener and every client fd, in that order. `handle` expects
/// the same slice back.
pub fn pollFds(self: *Ipc, fds: *std.ArrayList(posix.pollfd), gpa: Allocator) !void {
try fds.append(gpa, .{ .fd = self.listener, .events = posix.POLL.IN, .revents = 0 });
for (self.clients.items) |client| {
var events: i16 = posix.POLL.IN;
if (client.out.items.len > 0) events |= posix.POLL.OUT;
try fds.append(gpa, .{ .fd = client.fd, .events = events, .revents = 0 });
}
}
pub fn handle(self: *Ipc, wm: *Wm, fds: []posix.pollfd) !void {
if (fds.len == 0) return;
if (fds[0].revents & posix.POLL.IN != 0) self.accept();
// Walk the poll results and look each client up by fd rather than by
// position. Dropping a client shifts the list, so index-based pairing
// would hand the next client the departed one's revents — and a HUP
// from a finished att_wmctl would then disconnect a subscribed bar.
for (fds[1..]) |pfd| {
const idx = self.indexOfFd(pfd.fd) orelse continue;
const client = self.clients.items[idx];
var drop = false;
if (pfd.revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
drop = true;
} else {
if (pfd.revents & posix.POLL.IN != 0) drop = !self.read(wm, client);
if (!drop and pfd.revents & posix.POLL.OUT != 0) drop = !self.write(client);
}
if (!drop and client.closing and client.out.items.len == 0) drop = true;
if (drop) {
_ = self.clients.orderedRemove(idx);
client.deinit(self.gpa);
self.gpa.destroy(client);
}
}
}
fn indexOfFd(self: *Ipc, fd: sys.fd_t) ?usize {
for (self.clients.items, 0..) |client, i| {
if (client.fd == fd) return i;
}
return null;
}
fn accept(self: *Ipc) void {
while (true) {
const fd = sys.accept4(
self.listener,
linux.SOCK.NONBLOCK | linux.SOCK.CLOEXEC,
) catch return;
const client = self.gpa.create(Client) catch {
sys.close(fd);
return;
};
client.* = .{ .fd = fd };
self.clients.append(self.gpa, client) catch {
client.deinit(self.gpa);
self.gpa.destroy(client);
return;
};
}
}
/// Returns false if the client should be dropped.
fn read(self: *Ipc, wm: *Wm, client: *Client) bool {
var buf: [4096]u8 = undefined;
while (true) {
const n = sys.read(client.fd, &buf) catch |err| switch (err) {
error.Again => break,
else => return false,
};
if (n == 0) return false;
if (client.in.items.len + n > max_in_buffer) return false;
client.in.appendSlice(self.gpa, buf[0..n]) catch return false;
}
while (std.mem.indexOfScalar(u8, client.in.items, '\n')) |idx| {
const line = client.in.items[0..idx];
self.command(wm, client, line);
// Drop the consumed line, including its newline.
const rest = client.in.items[idx + 1 ..];
std.mem.copyForwards(u8, client.in.items, rest);
client.in.shrinkRetainingCapacity(rest.len);
}
return true;
}
/// Returns false if the client should be dropped.
fn write(self: *Ipc, client: *Client) bool {
while (client.out.items.len > 0) {
const n = sys.write(client.fd, client.out.items) catch |err| switch (err) {
error.Again => return true,
else => return false,
};
const rest = client.out.items[n..];
std.mem.copyForwards(u8, client.out.items, rest);
client.out.shrinkRetainingCapacity(rest.len);
}
_ = self;
return true;
}
fn send(self: *Ipc, client: *Client, bytes: []const u8) void {
if (client.out.items.len + bytes.len > max_out_buffer) {
// The peer is not reading. Dropping it beats unbounded growth.
client.closing = true;
client.out.clearRetainingCapacity();
return;
}
client.out.appendSlice(self.gpa, bytes) catch {
client.closing = true;
};
}
fn command(self: *Ipc, wm: *Wm, client: *Client, line_raw: []const u8) void {
const line = std.mem.trim(u8, line_raw, " \t\r");
if (line.len == 0) return;
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(self.gpa);
var it = std.mem.tokenizeAny(u8, line, " \t");
while (it.next()) |tok| argv.append(self.gpa, tok) catch return;
if (argv.items.len == 0) return;
const cmd = argv.items[0];
if (std.mem.eql(u8, cmd, "subscribe")) {
client.subscribed = true;
self.sendState(wm, client);
return;
}
if (std.mem.eql(u8, cmd, "state")) {
self.sendState(wm, client);
// A subscriber asking for state is refreshing, not saying goodbye.
if (!client.subscribed) client.closing = true;
return;
}
const action = act.parse(argv.items) catch |err| {
var buf: [128]u8 = undefined;
const msg = std.fmt.bufPrint(&buf, "err {s}\n", .{@errorName(err)}) catch "err\n";
self.send(client, msg);
if (!client.subscribed) client.closing = true;
return;
};
wm.performIpc(action);
self.send(client, "ok\n");
// One-shot clients (att_wmctl) are done; subscribers stay connected so a
// bar can drive the window manager over the same socket it listens on.
if (!client.subscribed) client.closing = true;
}
pub fn broadcast(self: *Ipc, wm: *Wm) !void {
if (self.clients.items.len == 0) return;
var json: std.ArrayList(u8) = .empty;
defer json.deinit(self.gpa);
try encodeState(wm, self.gpa, &json);
for (self.clients.items) |client| {
if (!client.subscribed or client.closing) continue;
self.send(client, json.items);
}
// Push it out now rather than waiting for the next poll, so bars update
// in the same frame the change happens.
for (self.clients.items) |client| {
_ = self.write(client);
}
}
fn sendState(self: *Ipc, wm: *Wm, client: *Client) void {
var json: std.ArrayList(u8) = .empty;
defer json.deinit(self.gpa);
encodeState(wm, self.gpa, &json) catch return;
self.send(client, json.items);
}
};
/// Serialise the whole window manager state as one JSON object followed by a
/// newline. Sending everything on every change keeps bars stateless, and the
/// payload is small enough that diffing would not pay for itself.
fn encodeState(wm: *Wm, gpa: Allocator, out: *std.ArrayList(u8)) !void {
var allocating = std.Io.Writer.Allocating.fromArrayList(gpa, out);
defer out.* = allocating.toArrayList();
const w = &allocating.writer;
try w.writeAll("{\"tag_count\":");
try w.print("{d}", .{act.tag_count});
try w.writeAll(",\"tag_names\":[");
for (config.tag_names, 0..) |name, i| {
if (i > 0) try w.writeAll(",");
try writeJsonString(w, name);
}
try w.writeAll("]");
try w.print(",\"locked\":{s}", .{if (wm.locked) "true" else "false"});
try w.writeAll(",\"outputs\":[");
for (wm.outputs.items, 0..) |output, oi| {
if (oi > 0) try w.writeAll(",");
// A tag is "occupied" if any window carries it, and "urgent" is not
// modelled: river-window-management-v1 has no attention-request event.
var occupied: u32 = 0;
for (wm.windows.items) |win| {
if (win.output == output and !win.closed) occupied |= win.tags;
}
// The layout and its knobs belong to the tag being viewed, so what is
// published is whatever is in force right now.
const st = output.state();
try w.writeAll("{\"name\":");
try writeJsonString(w, output.displayName());
try w.print(
",\"focused\":{s},\"tags\":{d},\"occupied\":{d},\"layout\":\"{s}\",\"layout_symbol\":",
.{
if (wm.focused_output == output) "true" else "false",
output.tags,
occupied & act.all_tags,
@tagName(st.layout),
},
);
try writeJsonString(w, st.layout.symbol());
try w.print(",\"nmaster\":{d},\"mfact\":{d:.3}", .{ st.nmaster, st.mfact });
try w.print(
",\"x\":{d},\"y\":{d},\"width\":{d},\"height\":{d}",
.{ output.box.x, output.box.y, output.box.width, output.box.height },
);
// The area left after layer-shell exclusive zones, i.e. where windows
// actually get laid out.
const usable = output.layoutArea();
try w.print(
",\"usable\":{{\"x\":{d},\"y\":{d},\"width\":{d},\"height\":{d}}}",
.{ usable.x, usable.y, usable.width, usable.height },
);
try w.writeAll(",\"windows\":[");
var first = true;
for (wm.windows.items) |win| {
if (win.output != output or win.closed) continue;
if (!first) try w.writeAll(",");
first = false;
try w.writeAll("{\"id\":");
try writeJsonString(w, win.identifier orelse "");
try w.writeAll(",\"title\":");
try writeJsonString(w, win.title orelse "");
try w.writeAll(",\"app_id\":");
try writeJsonString(w, win.app_id orelse "");
try w.print(
",\"tags\":{d},\"focused\":{s},\"visible\":{s},\"floating\":{s},\"fullscreen\":{s}",
.{
win.tags,
if (isFocused(wm, win)) "true" else "false",
if (win.visible) "true" else "false",
if (win.floating) "true" else "false",
if (win.fullscreen) "true" else "false",
},
);
try w.writeAll("}");
}
try w.writeAll("]}");
}
try w.writeAll("]}\n");
}
fn isFocused(wm: *Wm, win: anytype) bool {
for (wm.seats.items) |seat| {
if (seat.focused == win) return true;
}
return false;
}
fn writeJsonString(w: *std.Io.Writer, s: []const u8) !void {
try w.writeAll("\"");
for (s) |c| switch (c) {
'"' => try w.writeAll("\\\""),
'\\' => try w.writeAll("\\\\"),
'\n' => try w.writeAll("\\n"),
'\r' => try w.writeAll("\\r"),
'\t' => try w.writeAll("\\t"),
else => {
if (c < 0x20) {
try w.print("\\u{x:0>4}", .{c});
} else {
try w.writeByte(c);
}
},
};
try w.writeAll("\"");
}
+179
View File
@@ -0,0 +1,179 @@
//! Pure layout geometry.
//!
//! Nothing here touches Wayland or window manager state: `arrange` is given an
//! area and a window count and fills in a slice of cells. That keeps the
//! tiling maths unit-testable without a compositor, which matters because the
//! master/stack remainder handling is fiddly and easy to get subtly wrong.
const std = @import("std");
const math = std.math;
const action = @import("action");
pub const Layout = action.Layout;
pub const Box = struct {
x: i32 = 0,
y: i32 = 0,
width: i32 = 0,
height: i32 = 0,
pub fn contains(self: Box, x: i32, y: i32) bool {
return x >= self.x and x < self.x + self.width and
y >= self.y and y < self.y + self.height;
}
/// Shrink by `amount` on every side, never going below zero size.
pub fn inset(self: Box, amount: i32) Box {
return .{
.x = self.x + amount,
.y = self.y + amount,
.width = @max(0, self.width - 2 * amount),
.height = @max(0, self.height - 2 * amount),
};
}
};
pub const Params = struct {
/// The area available for tiling: the output minus any layer-shell
/// exclusive zones.
area: Box,
nmaster: u32,
mfact: f32,
/// Gap between adjacent windows.
gap: i32 = 0,
/// Gap between the windows and the edge of the usable area.
outer_gap: i32 = 0,
/// Height of the tab bar strip in the tabbed layout.
tabbar_height: i32 = 0,
};
pub const Result = struct {
/// Where the tab bar goes, if this layout has one.
tabbar: ?Box = null,
/// True when the layout stacks all windows in the same place, so only the
/// topmost one is worth showing.
stacked: bool = false,
};
/// Whether a layout puts every window in the same place, so only the top one
/// is rendered. `arrange` reports the same thing after the fact; this answers
/// it for callers that need to know before the geometry is computed.
pub fn stacks(layout: Layout) bool {
return switch (layout) {
.master => false,
.monocle, .tabbed => true,
};
}
/// Fill `cells` with one rectangle per window, in arrangement order.
///
/// Each cell is the *outer* rectangle including space for the border; the
/// caller insets by the border width to get the content geometry to propose.
pub fn arrange(layout: Layout, p: Params, cells: []Box) Result {
if (cells.len == 0) return .{};
const area = p.area.inset(p.outer_gap);
return switch (layout) {
.master => tile(p, area, cells),
.monocle => stack(p, area, cells, null),
.tabbed => blk: {
// Reserve the strip at the top for the tab bar. If the area is too
// short to give the windows anything, drop the bar rather than
// producing zero-height windows.
if (area.height <= p.tabbar_height * 2) break :blk stack(p, area, cells, null);
const bar: Box = .{
.x = area.x,
.y = area.y,
.width = area.width,
.height = p.tabbar_height,
};
const rest: Box = .{
.x = area.x,
.y = area.y + p.tabbar_height,
.width = area.width,
.height = area.height - p.tabbar_height,
};
break :blk stack(p, rest, cells, bar);
},
};
}
/// dwm's tile(): `nmaster` windows share a column of width `mfact`, the rest
/// share the remainder. Height is divided by "remaining space / remaining
/// windows" so leftover pixels are absorbed rather than accumulating a gap at
/// the bottom.
fn tile(p: Params, area: Box, cells: []Box) Result {
const n: u32 = @intCast(cells.len);
const half_gap = @divTrunc(p.gap, 2);
const nmaster = @min(p.nmaster, n);
const mw: i32 = if (n > nmaster)
(if (nmaster > 0) @as(i32, @intFromFloat(@as(f32, @floatFromInt(area.width)) * p.mfact)) else 0)
else
area.width;
var my: i32 = 0;
var ty: i32 = 0;
for (cells, 0..) |*cell, i| {
const idx: u32 = @intCast(i);
if (idx < nmaster) {
const remaining = nmaster - idx;
const h = @divTrunc(area.height - my, @as(i32, @intCast(remaining)));
cell.* = .{
.x = area.x,
.y = area.y + my,
.width = mw,
.height = h,
};
my += h;
} else {
const remaining = n - idx;
const h = @divTrunc(area.height - ty, @as(i32, @intCast(remaining)));
cell.* = .{
.x = area.x + mw,
.y = area.y + ty,
.width = area.width - mw,
.height = h,
};
ty += h;
}
if (half_gap > 0) cell.* = cell.inset(half_gap);
}
return .{};
}
/// Every window fills the whole area; only the top one is worth rendering.
fn stack(p: Params, area: Box, cells: []Box, bar: ?Box) Result {
const half_gap = @divTrunc(p.gap, 2);
for (cells) |*cell| {
cell.* = if (half_gap > 0) area.inset(half_gap) else area;
}
return .{ .tabbar = bar, .stacked = true };
}
/// Split a tab bar into one rectangle per tab, absorbing the remainder into
/// the leftmost tabs so the strip is exactly filled.
pub fn tabRects(bar: Box, count: usize, out: []Box) void {
std.debug.assert(out.len >= count);
if (count == 0) return;
const n: i32 = @intCast(count);
const base = @divTrunc(bar.width, n);
var extra = @mod(bar.width, n);
var x = bar.x;
for (out[0..count]) |*rect| {
var w = base;
if (extra > 0) {
w += 1;
extra -= 1;
}
rect.* = .{ .x = x, .y = bar.y, .width = w, .height = bar.height };
x += w;
}
}
+73
View File
@@ -0,0 +1,73 @@
const std = @import("std");
const posix = std.posix;
const sys = @import("sys.zig");
const Wm = @import("Wm.zig");
pub const std_options: std.Options = .{
.log_level = if (@import("builtin").mode == .Debug) .debug else .info,
};
const version = "0.1.0";
const usage =
\\att_wm - a dwm-like window manager for the river Wayland compositor
\\
\\Usage: att_wm [options]
\\
\\att_wm is a river-window-management-v1 client and must be started by river
\\0.4 or newer:
\\
\\ river -c att_wm
\\
\\Options:
\\ -h, --help Show this help
\\ -v, --version Show the version
\\
;
pub fn main(init: std.process.Init) !u8 {
const gpa = init.gpa;
var args = init.minimal.args.iterate();
_ = args.next();
while (args.next()) |arg| {
if (std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help")) {
sys.writeAllBestEffort(1, usage);
return 0;
}
if (std.mem.eql(u8, arg, "-v") or std.mem.eql(u8, arg, "--version")) {
sys.writeAllBestEffort(1, version ++ "\n");
return 0;
}
std.log.err("unknown argument: {s}", .{arg});
sys.writeAllBestEffort(2, usage);
return 1;
}
// Spawned children are double-forked and reparented to init, so we never
// wait on them. Ignoring SIGPIPE keeps a bar disconnecting mid-write from
// taking the window manager down with it.
const ignore: posix.Sigaction = .{
.handler = .{ .handler = posix.SIG.IGN },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(posix.SIG.PIPE, &ignore, null);
const wm = Wm.init(gpa, init.minimal.environ) catch |err| switch (err) {
error.NoWindowManagerGlobal => return 1,
else => {
std.log.err("failed to start: {s}", .{@errorName(err)});
return 1;
},
};
defer wm.deinit();
wm.run() catch |err| {
std.log.err("event loop failed: {s}", .{@errorName(err)});
return 1;
};
return 0;
}
+149
View File
@@ -0,0 +1,149 @@
//! Minimal wl_shm buffer pool for the tab bar.
//!
//! The tab bar is the only thing att_wm draws itself, and it draws nothing but
//! solid rectangles, so this deliberately stops at "memfd, mmap, fill" rather
//! than pulling in pixman or a font stack.
const std = @import("std");
const posix = std.posix;
const Allocator = std.mem.Allocator;
const sys = @import("sys.zig");
const wayland = @import("wayland");
const wl = wayland.client.wl;
const layout = @import("layout.zig");
const Box = layout.Box;
/// Two buffers is enough: we redraw at most once per render sequence and the
/// compositor releases the previous one promptly.
const buffer_count = 2;
pub const Buffer = struct {
wl_buffer: *wl.Buffer,
data: []align(std.heap.page_size_min) u8,
width: i32,
height: i32,
/// Held by the compositor; must not be drawn into until released.
busy: bool = false,
fn onRelease(_: *wl.Buffer, event: wl.Buffer.Event, self: *Buffer) void {
switch (event) {
.release => self.busy = false,
}
}
pub fn pixels(self: *Buffer) []u32 {
const count: usize = @intCast(self.width * self.height);
const ptr: [*]u32 = @ptrCast(@alignCast(self.data.ptr));
return ptr[0..count];
}
/// Fill a rectangle, in buffer-local coordinates, clipped to the buffer.
pub fn fill(self: *Buffer, rect: Box, argb: u32) void {
const x0 = @max(0, rect.x);
const y0 = @max(0, rect.y);
const x1 = @min(self.width, rect.x + rect.width);
const y1 = @min(self.height, rect.y + rect.height);
if (x1 <= x0 or y1 <= y0) return;
const px = self.pixels();
const stride: usize = @intCast(self.width);
var y: i32 = y0;
while (y < y1) : (y += 1) {
const row_start = @as(usize, @intCast(y)) * stride;
const from = row_start + @as(usize, @intCast(x0));
const to = row_start + @as(usize, @intCast(x1));
@memset(px[from..to], argb);
}
}
fn deinit(self: *Buffer, gpa: Allocator) void {
self.wl_buffer.destroy();
posix.munmap(self.data);
gpa.destroy(self);
}
};
pub const Pool = struct {
gpa: Allocator,
shm: *wl.Shm,
buffers: [buffer_count]?*Buffer = @splat(null),
pub fn init(gpa: Allocator, shm: *wl.Shm) Pool {
return .{ .gpa = gpa, .shm = shm };
}
pub fn deinit(self: *Pool) void {
for (&self.buffers) |*slot| {
if (slot.*) |buf| buf.deinit(self.gpa);
slot.* = null;
}
}
/// Return a buffer of the requested size that the compositor is not
/// currently reading from, creating or resizing one as needed.
pub fn acquire(self: *Pool, width: i32, height: i32) !*Buffer {
if (width <= 0 or height <= 0) return error.InvalidSize;
// Reuse an idle buffer that is already the right size.
for (self.buffers) |maybe| {
if (maybe) |buf| {
if (!buf.busy and buf.width == width and buf.height == height) return buf;
}
}
// Otherwise take a free slot, evicting an idle wrong-sized buffer.
for (&self.buffers) |*slot| {
if (slot.* == null) {
slot.* = try self.create(width, height);
return slot.*.?;
}
}
for (&self.buffers) |*slot| {
const buf = slot.*.?;
if (!buf.busy) {
buf.deinit(self.gpa);
slot.* = try self.create(width, height);
return slot.*.?;
}
}
return error.AllBuffersBusy;
}
fn create(self: *Pool, width: i32, height: i32) !*Buffer {
const stride = width * 4;
const size: usize = @intCast(stride * height);
const fd = try posix.memfd_create("att_wm-shm", std.os.linux.MFD.CLOEXEC);
defer sys.close(fd);
try sys.ftruncate(fd, size);
const data = try posix.mmap(
null,
size,
.{ .READ = true, .WRITE = true },
.{ .TYPE = .SHARED },
fd,
0,
);
errdefer posix.munmap(data);
const shm_pool = try self.shm.createPool(fd, @intCast(size));
defer shm_pool.destroy();
const wl_buffer = try shm_pool.createBuffer(0, width, height, stride, .argb8888);
errdefer wl_buffer.destroy();
const buf = try self.gpa.create(Buffer);
buf.* = .{
.wl_buffer = wl_buffer,
.data = data,
.width = width,
.height = height,
};
wl_buffer.setListener(*Buffer, Buffer.onRelease, buf);
return buf;
}
};
+19
View File
@@ -0,0 +1,19 @@
//! Where the IPC socket lives. Shared by the window manager and att_wmctl, so
//! it deliberately imports nothing else.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Environ = std.process.Environ;
/// One socket per Wayland display, so nested or parallel river sessions do not
/// collide. `ATT_WM_SOCKET` overrides it outright.
pub fn path(gpa: Allocator, environ: Environ) ![]u8 {
if (environ.getPosix("ATT_WM_SOCKET")) |explicit| {
return gpa.dupe(u8, explicit);
}
const display = environ.getPosix("WAYLAND_DISPLAY") orelse "wayland-0";
if (environ.getPosix("XDG_RUNTIME_DIR")) |dir| {
return std.fmt.allocPrint(gpa, "{s}/att_wm-{s}.sock", .{ dir, display });
}
return std.fmt.allocPrint(gpa, "/tmp/att_wm-{d}-{s}.sock", .{ std.os.linux.getuid(), display });
}
+204
View File
@@ -0,0 +1,204 @@
//! Thin typed wrappers over the Linux syscalls att_wm needs.
//!
//! Zig 0.16 moved most of `std.posix` behind the new `std.Io` interface, which
//! is the wrong shape for a window manager: everything here is a raw fd driven
//! by a single `poll()` loop, with no allocator and no async runtime. Going
//! straight to `std.os.linux` is both simpler and closer to what the code
//! actually does.
const std = @import("std");
const linux = std.os.linux;
/// Must be the linux decoder, not `std.posix.errno`: with libc linked the
/// latter expects a libc-style -1 return and reports every raw syscall error
/// as success, which then overflows the casts below.
const errno = linux.errno;
pub const fd_t = linux.fd_t;
pub const pid_t = linux.pid_t;
pub const E = linux.E;
pub const Error = error{
Again,
Interrupted,
ConnectionReset,
AddressInUse,
NotFound,
PermissionDenied,
ConnectionRefused,
BrokenPipe,
NameTooLong,
OutOfMemory,
Unexpected,
};
fn check(rc: usize) Error!usize {
return switch (errno(rc)) {
.SUCCESS => rc,
.AGAIN => error.Again,
.INTR => error.Interrupted,
.ADDRINUSE => error.AddressInUse,
.NOENT => error.NotFound,
.ACCES, .PERM => error.PermissionDenied,
.CONNREFUSED => error.ConnectionRefused,
.CONNRESET => error.ConnectionReset,
.PIPE => error.BrokenPipe,
.NAMETOOLONG => error.NameTooLong,
.NOMEM => error.OutOfMemory,
else => error.Unexpected,
};
}
pub fn read(fd: fd_t, buf: []u8) Error!usize {
if (buf.len == 0) return 0;
while (true) {
return check(linux.read(fd, buf.ptr, buf.len)) catch |err| switch (err) {
error.Interrupted => continue,
else => err,
};
}
}
pub fn write(fd: fd_t, bytes: []const u8) Error!usize {
if (bytes.len == 0) return 0;
return check(linux.write(fd, bytes.ptr, bytes.len));
}
/// Write everything, retrying short writes. Best effort: errors are swallowed
/// because every caller is emitting diagnostics or usage text.
pub fn writeAllBestEffort(fd: fd_t, bytes: []const u8) void {
var off: usize = 0;
while (off < bytes.len) {
off += write(fd, bytes[off..]) catch return;
}
}
pub fn close(fd: fd_t) void {
_ = linux.close(fd);
}
pub fn socket(domain: u32, socket_type: u32, protocol: u32) Error!fd_t {
return @intCast(try check(linux.socket(domain, socket_type, protocol)));
}
pub fn bind(fd: fd_t, addr: *const linux.sockaddr, len: linux.socklen_t) Error!void {
_ = try check(linux.bind(fd, addr, len));
}
pub fn listen(fd: fd_t, backlog: u31) Error!void {
_ = try check(linux.listen(fd, backlog));
}
pub fn accept4(fd: fd_t, flags: u32) Error!fd_t {
return @intCast(try check(linux.accept4(fd, null, null, flags)));
}
pub fn connect(fd: fd_t, addr: *const linux.sockaddr, len: linux.socklen_t) Error!void {
_ = try check(linux.connect(fd, addr, len));
}
pub fn ftruncate(fd: fd_t, length: u64) Error!void {
_ = try check(linux.ftruncate(fd, @intCast(length)));
}
/// Make a memfd immutable, so a compositor mapping it cannot have the bytes
/// changed underneath it.
pub fn addSeals(fd: fd_t, seals: usize) Error!void {
_ = try check(linux.fcntl(fd, linux.F.ADD_SEALS, seals));
}
pub fn timerfdCreate(flags: linux.TFD) Error!fd_t {
return @intCast(try check(linux.timerfd_create(.MONOTONIC, flags)));
}
pub fn timerfdSetTime(fd: fd_t, spec: *const linux.itimerspec) Error!void {
_ = try check(linux.timerfd_settime(fd, .{}, spec, null));
}
pub fn fork() Error!pid_t {
return @intCast(try check(linux.fork()));
}
pub fn setsid() void {
_ = linux.setsid();
}
pub fn exit(code: u8) noreturn {
linux.exit(code);
}
pub fn waitpid(pid: pid_t) void {
var status: u32 = undefined;
while (true) {
const rc = linux.wait4(pid, &status, 0, null);
switch (errno(rc)) {
.INTR => continue,
else => return,
}
}
}
/// Remove a path. Best effort: the only caller is clearing a stale socket.
pub fn unlink(path: []const u8) void {
var buf: [std.fs.max_path_bytes]u8 = undefined;
if (path.len >= buf.len) return;
@memcpy(buf[0..path.len], path);
buf[path.len] = 0;
_ = linux.unlink(@ptrCast(&buf));
}
/// Build a unix socket address. Paths must fit in sun_path with room for the
/// terminating NUL.
pub fn sockaddrUn(path: []const u8) Error!linux.sockaddr.un {
var addr: linux.sockaddr.un = .{ .family = linux.AF.UNIX, .path = undefined };
if (path.len >= addr.path.len) return error.NameTooLong;
@memset(&addr.path, 0);
@memcpy(addr.path[0..path.len], path);
return addr;
}
pub fn sockaddrUnLen(addr: *const linux.sockaddr.un) linux.socklen_t {
_ = addr;
return @sizeOf(linux.sockaddr.un);
}
/// execvp: run `argv[0]`, searching PATH when it contains no slash.
///
/// Only ever called between fork() and exec in the child, so it must not
/// allocate; the candidate path is assembled in a stack buffer.
pub fn execvpe(
argv: [*:null]const ?[*:0]const u8,
envp: [*:null]const ?[*:0]const u8,
path_env: ?[]const u8,
) Error {
const file = std.mem.span(argv[0].?);
if (std.mem.indexOfScalar(u8, file, '/') != null) {
return execErr(linux.execve(argv[0].?, argv, envp));
}
const search = path_env orelse "/usr/local/bin:/usr/bin:/bin";
var buf: [std.fs.max_path_bytes]u8 = undefined;
var last: Error = error.NotFound;
var it = std.mem.tokenizeScalar(u8, search, ':');
while (it.next()) |dir| {
if (dir.len + 1 + file.len + 1 > buf.len) continue;
@memcpy(buf[0..dir.len], dir);
buf[dir.len] = '/';
@memcpy(buf[dir.len + 1 ..][0..file.len], file);
buf[dir.len + 1 + file.len] = 0;
const candidate: [*:0]const u8 = @ptrCast(&buf);
last = execErr(linux.execve(candidate, argv, envp));
// ENOENT just means "not in this directory"; keep looking.
if (last != error.NotFound) return last;
}
return last;
}
/// execve only returns on failure, so its result is always an error.
fn execErr(rc: usize) Error {
_ = check(rc) catch |err| return err;
return error.Unexpected;
}
+425
View File
@@ -0,0 +1,425 @@
const std = @import("std");
const testing = std.testing;
const act = @import("action");
const input = @import("input");
const layout = @import("layout.zig");
const Box = layout.Box;
comptime {
_ = act;
_ = input;
_ = @import("color.zig");
}
const area: Box = .{ .x = 0, .y = 0, .width = 1000, .height = 600 };
fn params(nmaster: u32, mfact: f32) layout.Params {
return .{ .area = area, .nmaster = nmaster, .mfact = mfact };
}
// ─── master/stack tiling ─────────────────────────────────────────────────────
test "single window fills the whole area" {
var cells: [1]Box = undefined;
_ = layout.arrange(.master, params(1, 0.55), &cells);
try testing.expectEqual(area, cells[0]);
}
test "two windows split at mfact" {
var cells: [2]Box = undefined;
_ = layout.arrange(.master, params(1, 0.55), &cells);
try testing.expectEqual(@as(i32, 550), cells[0].width);
try testing.expectEqual(@as(i32, 600), cells[0].height);
try testing.expectEqual(@as(i32, 550), cells[1].x);
try testing.expectEqual(@as(i32, 450), cells[1].width);
try testing.expectEqual(@as(i32, 600), cells[1].height);
}
test "stack column divides height with no gap or overlap" {
// Three windows, one master: the stack column holds two.
var cells: [3]Box = undefined;
_ = layout.arrange(.master, params(1, 0.5), &cells);
try testing.expectEqual(@as(i32, 600), cells[0].height);
try testing.expectEqual(cells[1].y + cells[1].height, cells[2].y);
try testing.expectEqual(area.height, cells[1].height + cells[2].height);
}
test "odd heights are absorbed rather than leaving a gap at the bottom" {
// 600 / 7 does not divide evenly; the last window must still end exactly
// at the bottom edge.
var cells: [7]Box = undefined;
_ = layout.arrange(.master, params(0, 0.55), &cells);
var y: i32 = area.y;
for (cells) |cell| {
try testing.expectEqual(y, cell.y);
y += cell.height;
}
try testing.expectEqual(area.y + area.height, y);
}
test "nmaster zero puts every window in the stack column" {
var cells: [3]Box = undefined;
_ = layout.arrange(.master, params(0, 0.55), &cells);
for (cells) |cell| {
try testing.expectEqual(@as(i32, 0), cell.x);
try testing.expectEqual(area.width, cell.width);
}
}
test "windows all fit in master when count does not exceed nmaster" {
var cells: [2]Box = undefined;
_ = layout.arrange(.master, params(3, 0.55), &cells);
// No stack column, so master spans the full width.
for (cells) |cell| {
try testing.expectEqual(area.width, cell.width);
}
try testing.expectEqual(area.height, cells[0].height + cells[1].height);
}
test "master column also divides height when nmaster exceeds one" {
var cells: [4]Box = undefined;
_ = layout.arrange(.master, params(2, 0.5), &cells);
try testing.expectEqual(@as(i32, 500), cells[0].width);
try testing.expectEqual(@as(i32, 500), cells[1].width);
try testing.expectEqual(area.height, cells[0].height + cells[1].height);
try testing.expectEqual(area.height, cells[2].height + cells[3].height);
}
// ─── stacking layouts ────────────────────────────────────────────────────────
test "monocle gives every window the full area and reports stacked" {
var cells: [3]Box = undefined;
const result = layout.arrange(.monocle, params(1, 0.55), &cells);
try testing.expect(result.stacked);
try testing.expect(result.tabbar == null);
for (cells) |cell| try testing.expectEqual(area, cell);
}
test "stacks agrees with what arrange reports" {
var cells: [2]Box = undefined;
var p = params(1, 0.55);
p.tabbar_height = 22;
for ([_]layout.Layout{ .master, .monocle, .tabbed }) |mode| {
try testing.expectEqual(layout.arrange(mode, p, &cells).stacked, layout.stacks(mode));
}
}
test "tabbed reserves the bar strip above the windows" {
var cells: [3]Box = undefined;
var p = params(1, 0.55);
p.tabbar_height = 22;
const result = layout.arrange(.tabbed, p, &cells);
try testing.expect(result.stacked);
const bar = result.tabbar.?;
try testing.expectEqual(@as(i32, 22), bar.height);
try testing.expectEqual(area.y, bar.y);
// Windows start below the bar and the two together cover the area exactly.
for (cells) |cell| {
try testing.expectEqual(area.y + 22, cell.y);
try testing.expectEqual(area.height - 22, cell.height);
}
}
test "tabbed drops the bar rather than crushing the windows" {
var cells: [2]Box = undefined;
var p: layout.Params = .{
.area = .{ .x = 0, .y = 0, .width = 400, .height = 30 },
.nmaster = 1,
.mfact = 0.55,
.tabbar_height = 22,
};
const result = layout.arrange(.tabbed, p, &cells);
try testing.expect(result.tabbar == null);
try testing.expectEqual(@as(i32, 30), cells[0].height);
p.tabbar_height = 0;
}
test "no windows is not a crash" {
var cells: [0]Box = undefined;
const result = layout.arrange(.master, params(1, 0.55), &cells);
try testing.expect(result.tabbar == null);
}
// ─── gaps ────────────────────────────────────────────────────────────────────
test "outer gap insets the whole area" {
var cells: [1]Box = undefined;
var p = params(1, 0.55);
p.outer_gap = 10;
_ = layout.arrange(.master, p, &cells);
try testing.expectEqual(@as(i32, 10), cells[0].x);
try testing.expectEqual(@as(i32, 10), cells[0].y);
try testing.expectEqual(@as(i32, 980), cells[0].width);
try testing.expectEqual(@as(i32, 580), cells[0].height);
}
test "inner gap separates adjacent windows" {
var cells: [2]Box = undefined;
var p = params(1, 0.5);
p.gap = 10;
_ = layout.arrange(.master, p, &cells);
// Each cell shrinks by half the gap per side, leaving a full gap between.
const right_of_master = cells[0].x + cells[0].width;
try testing.expect(cells[1].x - right_of_master == 10);
}
// ─── tab rectangles ──────────────────────────────────────────────────────────
test "tab rects tile the bar exactly with no rounding gap" {
const bar: Box = .{ .x = 5, .y = 0, .width = 101, .height = 22 };
var rects: [4]Box = undefined;
layout.tabRects(bar, 4, &rects);
try testing.expectEqual(bar.x, rects[0].x);
var total: i32 = 0;
for (rects, 0..) |rect, i| {
total += rect.width;
if (i > 0) {
try testing.expectEqual(rects[i - 1].x + rects[i - 1].width, rect.x);
}
}
try testing.expectEqual(bar.width, total);
try testing.expectEqual(bar.x + bar.width, rects[3].x + rects[3].width);
}
test "single tab spans the bar" {
const bar: Box = .{ .x = 0, .y = 0, .width = 300, .height = 22 };
var rects: [1]Box = undefined;
layout.tabRects(bar, 1, &rects);
try testing.expectEqual(@as(i32, 300), rects[0].width);
}
// ─── Box helpers ─────────────────────────────────────────────────────────────
test "inset never produces negative dimensions" {
const tiny: Box = .{ .x = 0, .y = 0, .width = 4, .height = 4 };
const r = tiny.inset(10);
try testing.expectEqual(@as(i32, 0), r.width);
try testing.expectEqual(@as(i32, 0), r.height);
}
test "contains is half open on the far edges" {
const b: Box = .{ .x = 10, .y = 10, .width = 100, .height = 50 };
try testing.expect(b.contains(10, 10));
try testing.expect(b.contains(109, 59));
try testing.expect(!b.contains(110, 30));
try testing.expect(!b.contains(9, 30));
}
// ─── per-tag settings slots ──────────────────────────────────────────────────
test "each single tag gets its own settings slot" {
for (0..act.tag_count) |i| {
const mask = @as(u32, 1) << @intCast(i);
try testing.expectEqual(i + 1, act.tagSlot(mask));
}
}
test "views of more than one tag share the shared slot" {
try testing.expectEqual(@as(usize, 0), act.tagSlot(0b11));
try testing.expectEqual(@as(usize, 0), act.tagSlot(0b101));
try testing.expectEqual(@as(usize, 0), act.tagSlot(act.all_tags));
}
test "an empty view falls back to the shared slot" {
try testing.expectEqual(@as(usize, 0), act.tagSlot(0));
}
test "bits above the tag range do not affect the slot" {
// A single valid tag stays on its own slot even with junk in the high bits,
// so a mask that survived a sloppy IPC caller cannot index past the array.
const junk: u32 = ~act.all_tags;
try testing.expectEqual(@as(usize, 1), act.tagSlot(0b1 | junk));
try testing.expectEqual(act.tag_count, act.tagSlot(@as(u32, 1) << (act.tag_count - 1)));
}
// ─── command parsing ─────────────────────────────────────────────────────────
fn parseOk(argv: []const []const u8) act.Action {
return act.parse(argv) catch unreachable;
}
test "tag arguments accept indices, masks and all" {
try testing.expectEqual(@as(u32, 1), parseOk(&.{ "view", "1" }).view);
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "3" }).view);
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "0x4" }).view);
try testing.expectEqual(@as(u32, 4), parseOk(&.{ "view", "mask:4" }).view);
try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "all" }).view);
}
test "tag indices outside the range are rejected" {
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "0" }));
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "10" }));
try testing.expectError(error.InvalidArgument, act.parse(&.{ "view", "nope" }));
try testing.expectError(error.MissingArgument, act.parse(&.{"view"}));
}
test "masks are clamped to the valid tag range" {
try testing.expectEqual(act.all_tags, parseOk(&.{ "view", "0xffffffff" }).view);
}
test "deltas distinguish relative from absolute" {
switch (parseOk(&.{ "mfact", "+0.05" }).mfact) {
.relative => |v| try testing.expectApproxEqAbs(@as(f32, 0.05), v, 1e-6),
.absolute => return error.TestUnexpectedResult,
}
switch (parseOk(&.{ "mfact", "0.5" }).mfact) {
.absolute => |v| try testing.expectApproxEqAbs(@as(f32, 0.5), v, 1e-6),
.relative => return error.TestUnexpectedResult,
}
switch (parseOk(&.{ "nmaster", "-1" }).nmaster) {
.relative => |v| try testing.expectEqual(@as(i32, -1), v),
.absolute => return error.TestUnexpectedResult,
}
}
test "delta application respects relative and absolute" {
const rel = act.Delta(i32){ .relative = -1 };
const abs = act.Delta(i32){ .absolute = 3 };
try testing.expectEqual(@as(i32, 4), rel.apply(5));
try testing.expectEqual(@as(i32, 3), abs.apply(5));
}
test "unknown commands are rejected rather than guessed at" {
try testing.expectError(error.UnknownCommand, act.parse(&.{"nonsense"}));
try testing.expectError(error.UnknownCommand, act.parse(&.{}));
}
test "spawn keeps its whole argv" {
const a = parseOk(&.{ "spawn", "foot", "-e", "htop" });
try testing.expectEqual(@as(usize, 3), a.spawn.len);
try testing.expectEqualStrings("htop", a.spawn[2]);
try testing.expectError(error.MissingArgument, act.parse(&.{"spawn"}));
}
test "directions parse both spellings" {
try testing.expectEqual(act.Direction.next, parseOk(&.{ "focus", "next" }).focus);
try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "prev" }).focus);
try testing.expectEqual(act.Direction.prev, parseOk(&.{ "focus", "previous" }).focus);
try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus", "sideways" }));
}
test "window commands keep the identifier verbatim" {
try testing.expectEqualStrings("w-17", parseOk(&.{ "focus-window", "w-17" }).focus_window);
try testing.expectEqualStrings("w-17", parseOk(&.{ "close-window", "w-17" }).close_window);
// An identifier is opaque, so a direction-looking one is still an id.
try testing.expectEqualStrings("next", parseOk(&.{ "focus-window", "next" }).focus_window);
try testing.expectError(error.MissingArgument, act.parse(&.{"focus-window"}));
try testing.expectError(error.InvalidArgument, act.parse(&.{ "focus-window", "" }));
}
test "layouts parse by name" {
try testing.expectEqual(act.Layout.monocle, parseOk(&.{ "layout", "monocle" }).set_layout);
try testing.expectEqual(act.Layout.tabbed, parseOk(&.{ "layout", "tabbed" }).set_layout);
try testing.expectError(error.InvalidArgument, act.parse(&.{ "layout", "spiral" }));
}
test "only navigation-style actions repeat on key hold" {
try testing.expect(parseOk(&.{ "focus", "next" }).repeats());
try testing.expect(parseOk(&.{ "mfact", "+0.05" }).repeats());
try testing.expect(!parseOk(&.{"zoom"}).repeats());
try testing.expect(!parseOk(&.{"close"}).repeats());
try testing.expect(!parseOk(&.{ "view", "1" }).repeats());
}
// ─── input device rules ──────────────────────────────────────────────────────
test "device name globs match the way a config author expects" {
const touchpad = "ELAN0501:00 04F3:3060 Touchpad";
try testing.expect(input.matches("*Touchpad*", touchpad));
try testing.expect(input.matches("*Touchpad", touchpad));
try testing.expect(input.matches("ELAN*", touchpad));
try testing.expect(input.matches("*", touchpad));
try testing.expect(input.matches(touchpad, touchpad));
try testing.expect(!input.matches("*Trackpoint*", touchpad));
try testing.expect(!input.matches("Touchpad", touchpad));
// A literal pattern must match the whole name, not merely a prefix.
try testing.expect(!input.matches("ELAN0501", touchpad));
}
test "globs handle empty runs and repeated stars" {
try testing.expect(input.matches("", ""));
try testing.expect(input.matches("*", ""));
try testing.expect(input.matches("***", ""));
try testing.expect(!input.matches("a", ""));
// The backtracking case: each star has to be willing to give ground.
try testing.expect(input.matches("*a*b*c*", "xxaxxbxxcxx"));
try testing.expect(!input.matches("*a*b*c*", "xxaxxcxxbxx"));
// Only the last 'a' lets the rest of the pattern through.
try testing.expect(input.matches("*aab", "aaab"));
}
test "rules match on name and type independently" {
const rule: input.Rule = .{ .name = "*Touchpad*", .type = .pointer, .tap = true };
try testing.expect(rule.matchesDevice("Foo Touchpad", .pointer));
// Right name, wrong kind of device.
try testing.expect(!rule.matchesDevice("Foo Touchpad", .touch));
try testing.expect(!rule.matchesDevice("Foo Keyboard", .pointer));
// A rule with neither selector applies to everything.
const catch_all: input.Rule = .{ .natural_scroll = true };
try testing.expect(catch_all.matchesDevice("anything", .tablet));
try testing.expect(catch_all.matchesDevice("", .keyboard));
}
test "later rules override earlier ones field by field" {
const broad: input.Rule = .{ .name = "*", .tap = true, .natural_scroll = true };
const narrow: input.Rule = .{ .name = "*Touchpad*", .tap = false, .click_method = .clickfinger };
const merged = (input.Rule{}).merge(broad).merge(narrow);
// Stated twice: the later rule wins.
try testing.expectEqual(@as(?bool, false), merged.tap);
// Stated only by the broad rule: survives.
try testing.expectEqual(@as(?bool, true), merged.natural_scroll);
// Stated only by the narrow rule: applied.
try testing.expectEqual(@as(?input.ClickMethod, .clickfinger), merged.click_method);
// Stated by neither: still null, so the device keeps libinput's default.
try testing.expectEqual(@as(?bool, null), merged.middle_emulation);
}
test "merging leaves the selectors alone" {
// Otherwise a merged rule would claim to be about whichever device matched
// last, which is not a thing anything should be able to read back out.
const merged = (input.Rule{ .name = "a", .type = .pointer })
.merge(.{ .name = "b", .type = .keyboard, .tap = true });
try testing.expectEqualStrings("a", merged.name.?);
try testing.expectEqual(@as(?input.Type, .pointer), merged.type);
try testing.expectEqual(@as(?bool, true), merged.tap);
}
test "an unset keymap is recognised as the default" {
try testing.expect((input.Keymap{}).isDefault());
try testing.expect(!(input.Keymap{ .layout = "us" }).isDefault());
try testing.expect(!(input.Keymap{ .options = "caps:escape" }).isDefault());
}
test "map_to_output merges like any other setting" {
// It is a string rather than a scalar, so worth pinning that the generic
// merge handles it and that a rule silent about it does not clear it.
const merged = (input.Rule{})
.merge(.{ .type = .touch, .map_to_output = "eDP-1" })
.merge(.{ .name = "*", .tap = true });
try testing.expectEqualStrings("eDP-1", merged.map_to_output.?);
// And that a later rule naming a different output does win.
const moved = merged.merge(.{ .map_to_output = "DP-2" });
try testing.expectEqualStrings("DP-2", moved.map_to_output.?);
}