initial commit
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+323
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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.?);
|
||||
}
|
||||
Reference in New Issue
Block a user