Files
att_wm/src/action.zig
T
2026-08-18 21:30:51 +02:00

364 lines
12 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;
}
/// The tags belonging to output `index` of `count`, when the tag set is split
/// across the displays.
///
/// The tags are divided into contiguous ranges in the order the outputs are
/// arranged on the desk, so with two monitors the left one owns 15 and the
/// right 69. Contiguous rather than interleaved because the keys are what the
/// user reaches for: 15 under the left hand for the left screen reads as one
/// screen's worth of workspaces, 1,3,5,7,9 does not.
///
/// A lone output owns every tag, which is what makes the split invisible on a
/// laptop with nothing plugged in.
pub fn tagsForOutput(index: usize, count: usize) u32 {
if (count <= 1 or index >= count) return all_tags;
// More outputs than tags: one each, and the outputs left over share the
// last tag rather than getting none. An output owning no tag could show no
// window at all, which is worse than two screens showing the same one.
if (count >= tag_count) {
return @as(u32, 1) << @intCast(@min(index, tag_count - 1));
}
// Earlier outputs take one of the leftover tags each, so the ranges differ
// by at most one and it is never the first screen that comes up short.
const base = tag_count / count;
const rem = tag_count % count;
const start = index * base + @min(index, rem);
const len = base + @as(usize, @intFromBool(index < rem));
const ones: u32 = (@as(u32, 1) << @intCast(len)) - 1;
return ones << @intCast(start);
}
/// The lowest tag in a mask, as a mask of its own. What a view falls back to
/// when the tags it was showing have moved to another screen.
pub fn lowestTag(tags: u32) u32 {
const t = tags & all_tags;
if (t == 0) return 0;
return @as(u32, 1) << @intCast(@ctz(t));
}
/// Keyboard modifiers, matching the values of river_seat_v1.modifiers so the
/// mask can be bit-cast straight into the protocol type.
pub const Mods = struct {
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;
};