// SPDX-FileCopyrightText: © 2026 mtmn // // SPDX-License-Identifier: BSD-3-Clause // Analyzer does not resolve method-call syntax to free functions. // Module type aliases use PascalCase per Zig convention. // zwanzig-disable: identifier-style, unused-decl const WindowManager = @This(); const std = @import("std"); const wl = @import("wayland").client.wl; const river = @import("wayland").client.river; const zwlr = @import("wayland").client.zwlr; const Binding = @import("Binding.zig"); const Command = @import("Command.zig"); const Config = @import("Config.zig"); const Osd = @import("Osd.zig"); const focus_policy = @import("focus.zig"); const Input = @import("Input.zig"); const Layout = @import("Layout.zig"); const Output = @import("Output.zig"); const PointerBinding = @import("PointerBinding.zig"); const Rule = @import("Rule.zig"); const Seat = @import("Seat.zig"); const Spawn = @import("Spawn.zig"); const Window = @import("Window.zig"); const gpa = std.heap.c_allocator; const log = std.log.scoped(.laguna); const pending_max = 64; /// ensureBuiltinModes creates these two modes first, so their indices never /// change. river keeps bindings live while the session is locked, so the /// locked mode holds the only bindings that may exist then. const normal_mode_index = 0; const locked_mode_index = 1; const BindingSpecification = struct { keysym: u32, modifiers: river.SeatV1.Modifiers, command: [:0]const u8, }; const PointerSpecification = struct { button: u32, modifiers: river.SeatV1.Modifiers, action: PointerBinding.Action, command: [:0]const u8, }; const Mode = struct { name: []const u8, specifications: std.ArrayListUnmanaged(BindingSpecification) = .empty, pointer_specifications: std.ArrayListUnmanaged(PointerSpecification) = .empty, }; pub const FocusFollowsCursor = focus_policy.FollowsCursor; windows: std.ArrayListUnmanaged(*Window) = .empty, /// Counter for Window.serial. window_serial: u64 = 0, outputs: std.ArrayListUnmanaged(*Output) = .empty, seats: std.ArrayListUnmanaged(*Seat) = .empty, input: Input = .{}, modes: std.ArrayListUnmanaged(Mode) = .empty, rules: std.ArrayListUnmanaged(Rule.Rule) = .empty, pending: std.ArrayListUnmanaged(Command) = .empty, /// Scratch space for arrangeOutput, kept between manage sequences. placements: std.ArrayListUnmanaged(Layout.Placement) = .empty, /// stackKey of each visible window in the order the last render sequence /// stacked them. Stacking requests are sent only when this changes. stacked: std.ArrayListUnmanaged(u64) = .empty, object: *river.WindowManagerV1, registry: *wl.Registry, default_attach: Layout.Attach = .top, spawn_tagmask: ?u32 = null, /// Session lock state from the latest session_locked or session_unlocked /// event. lock_applied is the state the seat modes were last switched to. locked: bool = false, lock_applied: bool = false, /// Set by reload. The next manage sequence re-applies rules to live windows. rules_dirty: bool = false, border_width: i32 = 2, border_focused: [4]u32 = expandHex(0x93a1a1ff), border_unfocused: [4]u32 = expandHex(0x586e75ff), focus_follows_cursor: FocusFollowsCursor = .disabled, /// When true, every window draws its own decorations. When false, laguna /// asks windows to use SSD and draws none, so they have no title bar. A csd /// rule keeps client-side decorations for the windows it matches. csd: bool = false, /// When true, an output may present without vsync while the fullscreen /// window on top of it asks for that, trading visible tearing for lower /// latency in games. Off by default, so every output stays tear-free. allow_tearing: bool = false, cursor_warp: bool = false, cursor_theme_name: ?[:0]const u8 = null, cursor_theme_size: u32 = 24, xkb: ?*river.XkbBindingsV1 = null, layer_shell: ?*river.LayerShellV1 = null, layer_default_set: bool = false, /// Draws the desktop number box. Owns its own client-side surfaces, /// independent of the manage and render sequences. osd: Osd = .{}, pub fn init( manager: *WindowManager, object: *river.WindowManagerV1, registry: *wl.Registry, xkb: ?*river.XkbBindingsV1, layer_shell: ?*river.LayerShellV1, compositor: ?*wl.Compositor, shm: ?*wl.Shm, osd_layer_shell: ?*zwlr.LayerShellV1, ) void { manager.* = .{ .object = object, .registry = registry, .xkb = xkb, .layer_shell = layer_shell, }; manager.osd.init(compositor, shm, osd_layer_shell); object.setListener(*WindowManager, handleEvent, manager); manager.ensureBuiltinModes(); } pub fn deinit(manager: *WindowManager) void { manager.osd.deinit(); for (manager.windows.items) |window| { window.deinit(); gpa.destroy(window); } for (manager.outputs.items) |output| { output.deinit(); gpa.destroy(output); } for (manager.seats.items) |seat| { seat.deinit(); gpa.destroy(seat); } if (manager.cursor_theme_name) |name| gpa.free(name); for (manager.modes.items) |*mode| { for (mode.specifications.items) |specification| gpa.free(specification.command); for (mode.pointer_specifications.items) |specification| gpa.free(specification.command); mode.specifications.deinit(gpa); mode.pointer_specifications.deinit(gpa); gpa.free(mode.name); } for (manager.rules.items) |rule| { gpa.free(rule.app_id_glob); gpa.free(rule.title_glob); } manager.input.deinit(); manager.windows.deinit(gpa); manager.outputs.deinit(gpa); manager.seats.deinit(gpa); manager.modes.deinit(gpa); manager.rules.deinit(gpa); manager.pending.deinit(gpa); manager.placements.deinit(gpa); manager.stacked.deinit(gpa); manager.object.destroy(); if (manager.xkb) |object| object.destroy(); if (manager.layer_shell) |object| object.destroy(); manager.xkb = null; manager.layer_shell = null; } /// Queue a command for the next manage sequence. Protocol state may only /// change inside manage_start, so commands never execute inline. Commands /// come from binding presses, which river always follows with manage_start, /// so no manage_dirty is needed. /// Unknown commands fail here so callers get an error synchronously /// instead of a late warning with an earlier ok reply. fn queueText(manager: *WindowManager, text: []const u8) error{ QueueFull, UnknownCommand, CommandTooLong, TooManyArguments }!void { if (manager.pending.items.len == pending_max) return error.QueueFull; const command = manager.pending.addOne(gpa) catch return error.QueueFull; errdefer _ = manager.pending.pop(); try command.fromText(text); command.from_config = false; var arguments = command.arguments(); const name = arguments.next() orelse return error.UnknownCommand; if (!isKnownCommand(name)) return error.UnknownCommand; } /// Request a manage sequence. Called when a tiled client resized itself /// outside the assigned geometry: dimensions events belong to a render /// sequence, so river does not start a manage sequence on its own. The /// layout then re-proposes the tile size, snapping the window back. pub fn requestRearrange(manager: *WindowManager, window: *const Window) void { // A window under a pointer operation stays out of the layout until the // operation ends, and the manage sequence that ends it lays the window // out again. Asking earlier would start one wasted manage sequence for // every frame of an interactive resize. if (manager.windowBusy(window)) return; manager.object.manageDirty(); } /// Every command name. Commands are looked up through the enum so that /// queueing and execution share one list. const CommandName = enum { exit, close, @"focus-view", @"set-focused-tags", @"toggle-focused-tags", @"set-view-tags", @"toggle-view-tags", @"focus-output", @"send-to-output", @"declare-mode", @"enter-mode", map, @"map-pointer", unmap, @"unmap-pointer", @"main-ratio", @"main-count", @"main-location", zoom, spawn, swap, move, resize, snap, @"toggle-float", @"toggle-fullscreen", @"rule-add", @"rule-del", @"set-repeat", input, @"keyboard-layout", @"keyboard-layout-file", @"attach-mode", @"default-attach-mode", @"output-attach-mode", @"spawn-tagmask", @"focus-previous-tags", @"send-to-previous-tags", @"border-width", @"border-color-focused", @"border-color-unfocused", @"focus-follows-cursor", @"set-cursor-warp", @"xcursor-theme", csd, @"allow-tearing", }; fn isKnownCommand(name: []const u8) bool { return std.meta.stringToEnum(CommandName, name) != null; } test "all commands validate" { const testing = std.testing; inline for (@typeInfo(CommandName).@"enum".field_names) |name| try testing.expect(isKnownCommand(name)); try testing.expect(!isKnownCommand("frobnicate")); try testing.expect(!isKnownCommand("")); } /// Queue one parsed command for the next manage sequence. Only the /// static config builds commands this way, so they never trigger the /// tag OSD. fn queueParsed(manager: *WindowManager, arguments: []const []const u8) !void { const command = manager.pending.addOne(gpa) catch return error.OutOfMemory; command.fromSlices(arguments) catch |err| { _ = manager.pending.pop(); log.err("invalid config command: {s}", .{@errorName(err)}); return err; }; command.from_config = true; } /// Reload configuration after SIGHUP: reset running state to defaults, /// apply the already-parsed config, restore seat modes by name, and flag /// live windows for rule re-application in the next manage sequence. pub fn reload(manager: *WindowManager, config: *const Config) void { const SavedSeat = struct { mode: []u8, unlocked: ?[]u8, }; var saved: std.ArrayListUnmanaged(SavedSeat) = .empty; defer { for (saved.items) |entry| { gpa.free(entry.mode); if (entry.unlocked) |name| gpa.free(name); } saved.deinit(gpa); } for (manager.seats.items) |seat| { std.debug.assert(seat.mode_index < manager.modes.items.len); const mode_name = manager.modes.items[seat.mode_index].name; const unlocked_name = if (seat.unlocked_mode_index) |index| manager.modes.items[index].name else null; saved.append(gpa, .{ .mode = gpa.dupe(u8, mode_name) catch std.process.fatal("out of memory", .{}), .unlocked = if (unlocked_name) |name| gpa.dupe(u8, name) catch std.process.fatal("out of memory", .{}) else null, }) catch std.process.fatal("out of memory", .{}); } for (manager.modes.items) |*mode| { for (mode.specifications.items) |specification| gpa.free(specification.command); for (mode.pointer_specifications.items) |specification| gpa.free(specification.command); mode.specifications.clearRetainingCapacity(); mode.pointer_specifications.clearRetainingCapacity(); gpa.free(mode.name); } manager.modes.clearRetainingCapacity(); manager.ensureBuiltinModes(); for (manager.rules.items) |rule| { gpa.free(rule.app_id_glob); gpa.free(rule.title_glob); } manager.rules.clearRetainingCapacity(); manager.input.clearStoredConfigs(); manager.default_attach = .top; manager.spawn_tagmask = null; manager.border_width = 2; manager.border_focused = expandHex(0x93a1a1ff); manager.border_unfocused = expandHex(0x586e75ff); manager.focus_follows_cursor = .disabled; manager.csd = false; manager.allow_tearing = false; manager.cursor_warp = false; if (manager.cursor_theme_name) |name| gpa.free(name); manager.cursor_theme_name = null; manager.cursor_theme_size = 24; // Repeat rate and keymap have no default handle. A reload without // those keys leaves the current ones in place. manager.loadConfig(config) catch |err| { log.err("cannot apply config: {s}", .{@errorName(err)}); }; for (manager.seats.items, 0..) |seat, seat_index| { if (seat_index >= saved.items.len) break; const index = manager.modeIndex(saved.items[seat_index].mode) orelse normal_mode_index; manager.setSeatMode(seat, index); if (saved.items[seat_index].unlocked) |name| { seat.unlocked_mode_index = manager.modeIndex(name); } else { seat.unlocked_mode_index = null; } } manager.rules_dirty = true; manager.object.manageDirty(); } /// Format a number into the scratch buffer for command arguments. fn formatNumber(scratch: *[32]u8, value: anytype) error{NoSpaceLeft}![]const u8 { return std.fmt.bufPrint(scratch, "{d}", .{value}); } /// Apply the static config file. Every entry becomes a queued command that /// runs in the next manage sequence, exactly as if sent at startup. /// Input settings are also stored, so devices arriving later converge. pub fn loadConfig(manager: *WindowManager, config: *const Config) !void { // The OSD is laguna-internal state, not a river command, so it is // applied directly. Resetting first matches every other option: a // reload without the block restores defaults. manager.osd.applyConfig(config.osd); // Two scratch buffers: set-repeat formats two numbers into one command. var scratch: [2][32]u8 = undefined; // SAFETY: Only indices below the running count are ever read. var words: [Command.arguments_max][]const u8 = undefined; for (config.modes) |mode| { try manager.queueParsed(&.{ "declare-mode", mode.name }); } if (config.main_ratio) |ratio| { try manager.queueParsed(&.{ "main-ratio", try formatNumber(&scratch[0], ratio) }); } if (config.main_count) |count| { try manager.queueParsed(&.{ "main-count", try formatNumber(&scratch[0], count) }); } if (config.main_location) |location| { try manager.queueParsed(&.{ "main-location", location }); } if (config.attach_mode) |mode| { try manager.queueParsed(&.{ "default-attach-mode", mode }); } if (config.output_attach_mode) |mode| { try manager.queueParsed(&.{ "output-attach-mode", mode }); } if (config.spawn_tagmask) |mask| { try manager.queueParsed(&.{ "spawn-tagmask", try formatNumber(&scratch[0], mask) }); } if (config.border_width) |width| { try manager.queueParsed(&.{ "border-width", try formatNumber(&scratch[0], width) }); } if (config.border_color_focused) |color| { try manager.queueParsed(&.{ "border-color-focused", color }); } if (config.border_color_unfocused) |color| { try manager.queueParsed(&.{ "border-color-unfocused", color }); } if (config.focus_follows_cursor) |mode| { try manager.queueParsed(&.{ "focus-follows-cursor", mode }); } if (config.csd) |csd| { try manager.queueParsed(&.{ "csd", if (csd) "true" else "false" }); } if (config.allow_tearing) |allow| { try manager.queueParsed(&.{ "allow-tearing", if (allow) "true" else "false" }); } if (config.cursor_warp) |mode| { try manager.queueParsed(&.{ "set-cursor-warp", mode }); } if (config.xcursor_theme) |theme| { try manager.queueParsed(&.{ "xcursor-theme", theme.name, try formatNumber(&scratch[0], theme.size), }); } if (config.repeat) |repeat| { try manager.queueParsed(&.{ "set-repeat", try formatNumber(&scratch[0], repeat.rate), try formatNumber(&scratch[1], repeat.delay), }); } if (config.keyboard_layout) |layout| { var count: usize = 0; words[count] = "keyboard-layout"; count += 1; if (layout.rules) |rules| { words[count] = "-rules"; words[count + 1] = rules; count += 2; } if (layout.model) |model| { words[count] = "-model"; words[count + 1] = model; count += 2; } if (layout.variant) |variant| { words[count] = "-variant"; words[count + 1] = variant; count += 2; } if (layout.options) |options| { words[count] = "-options"; words[count + 1] = options; count += 2; } words[count] = layout.layout; count += 1; try manager.queueParsed(words[0..count]); } if (config.keyboard_layout_file) |path| { try manager.queueParsed(&.{ "keyboard-layout-file", path }); } for (config.inputs) |rule| { try manager.input.storeConfig(rule.pattern, rule.category, rule.setting, rule.value); var count: usize = 0; words[count] = "input"; words[count + 1] = rule.pattern; count += 2; if (rule.category) |category| { words[count] = category; count += 1; } words[count] = rule.setting; count += 1; if (rule.value) |value| { words[count] = value; count += 1; } try manager.queueParsed(words[0..count]); } for (config.rules) |rule| { var count: usize = 0; words[count] = "rule-add"; count += 1; if (rule.app_id) |app_id| { words[count] = "-app-id"; words[count + 1] = app_id; count += 2; } if (rule.title) |title| { words[count] = "-title"; words[count + 1] = title; count += 2; } words[count] = rule.action; count += 1; if (rule.tags) |tags| { words[count] = try formatNumber(&scratch[0], tags); count += 1; } try manager.queueParsed(words[0..count]); } for (config.modes) |mode| { for (mode.bindings) |group| { for (group.modifiers) |modifiers| { for (group.bindings) |binding| { var count: usize = 0; words[count] = "map"; words[count + 1] = mode.name; words[count + 2] = modifiers; words[count + 3] = binding.keysym; count += 4; count += splitCommand(binding.command, words[count..]) catch |err| { log.warn("skipping oversized binding command: {s}", .{@errorName(err)}); continue; }; try manager.queueParsed(words[0..count]); } } } for (mode.pointer_bindings) |pointer_group| { for (pointer_group.modifiers) |modifiers| { for (pointer_group.bindings) |binding| { var count: usize = 0; words[count] = "map-pointer"; words[count + 1] = mode.name; words[count + 2] = modifiers; words[count + 3] = binding.button; count += 4; count += splitCommand(binding.action, words[count..]) catch |err| { log.warn("skipping oversized pointer command: {s}", .{@errorName(err)}); continue; }; try manager.queueParsed(words[0..count]); } } } } if (manager.pending.items.len > 0) manager.object.manageDirty(); } /// Split a command string on spaces, mirroring how riverctl argv arrives. /// Returns the number of words written. fn splitCommand(text: []const u8, out: [][]const u8) error{TooManyArguments}!usize { var count: usize = 0; var parts = std.mem.splitScalar(u8, text, ' '); while (parts.next()) |part| { if (part.len == 0) continue; if (count == out.len) return error.TooManyArguments; out[count] = part; count += 1; } return count; } fn execute(manager: *WindowManager, command: *const Command) Error!void { var arguments = command.arguments(); const name = arguments.next() orelse return error.UnknownCommand; const kind = std.meta.stringToEnum(CommandName, name) orelse return error.UnknownCommand; switch (kind) { .exit => { if (arguments.next() != null) return error.InvalidArgument; manager.object.exitSession(); }, .close => { if (arguments.next() != null) return error.InvalidArgument; if (manager.seats.items.len == 0) return; const window = manager.seats.items[0].focused_window orelse return; window.close(); }, .@"focus-view" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; if (std.mem.eql(u8, raw, "next")) { manager.cycleFocus(true); } else if (std.mem.eql(u8, raw, "previous")) { manager.cycleFocus(false); } else { manager.focusDirection(raw) catch return error.InvalidArgument; } }, .@"attach-mode", .@"default-attach-mode" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.default_attach = parseAttach(raw) catch return error.InvalidArgument; }, .@"output-attach-mode" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const output = manager.focusedOutput() orelse return; output.attach_mode = parseAttach(raw) catch return error.InvalidArgument; }, .@"spawn-tagmask" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.spawn_tagmask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; }, .@"focus-previous-tags" => { if (arguments.next() != null) return error.InvalidArgument; const output = manager.focusedOutput() orelse return; const current = output.tags; output.tags = output.previous_tags; output.previous_tags = current; if (!command.from_config) manager.osd.showTag(output, output.tags); }, .@"send-to-previous-tags" => { if (arguments.next() != null) return error.InvalidArgument; const output = manager.focusedOutput() orelse return; const window = manager.focusedWindow() orelse return; window.tags = output.previous_tags; }, .@"set-focused-tags" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; const output = manager.focusedOutput() orelse return; output.previous_tags = output.tags; output.tags = mask; if (!command.from_config) manager.osd.showTag(output, output.tags); }, .@"toggle-focused-tags" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; const output = manager.focusedOutput() orelse return; output.previous_tags = output.tags; output.tags ^= mask; if (!command.from_config) manager.osd.showTag(output, output.tags); }, .@"toggle-view-tags" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; const window = manager.focusedWindow() orelse return; window.tags ^= mask; }, .@"focus-output" => { const target = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const output = manager.resolveOutput(target) orelse return error.InvalidArgument; if (manager.seats.items.len == 0) return; manager.seats.items[0].focused_output = output; }, .@"send-to-output" => { var current_tags = false; var target = arguments.next() orelse return error.InvalidArgument; if (std.mem.eql(u8, target, "-current-tags")) { current_tags = true; target = arguments.next() orelse return error.InvalidArgument; } if (arguments.next() != null) return error.InvalidArgument; const output = manager.resolveOutput(target) orelse return error.InvalidArgument; const window = manager.focusedWindow() orelse return; window.output = output; if (current_tags) window.tags = output.tags; // The old coordinates belong to the source output's geometry and // can land off-screen on the target. Recenter like a fresh float. if (window.float) window.placed = false; }, .@"set-view-tags" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const mask = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; const window = manager.focusedWindow() orelse return; window.tags = mask; }, .@"declare-mode" => { const mode_name = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; _ = manager.ensureMode(mode_name); }, .@"enter-mode" => { const mode_name = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const index = manager.modeIndex(mode_name) orelse return error.InvalidArgument; for (manager.seats.items) |seat| manager.setSeatMode(seat, index); }, .map => { try manager.defineBinding(&arguments); }, .@"map-pointer" => { try manager.definePointerBinding(&arguments); }, .unmap => { const mode_name = arguments.next() orelse return error.InvalidArgument; const modifiers_raw = arguments.next() orelse return error.InvalidArgument; const keysym_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; const keysym = Binding.parseKeysym(keysym_raw) catch return error.InvalidArgument; manager.removeBinding(mode_name, keysym, modifiers); }, .@"unmap-pointer" => { const mode_name = arguments.next() orelse return error.InvalidArgument; const modifiers_raw = arguments.next() orelse return error.InvalidArgument; const button_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; const button = PointerBinding.parseButton(button_raw) catch return error.InvalidButton; manager.removePointerBinding(mode_name, button, modifiers); }, .@"main-ratio" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.adjustRatio(raw) catch return error.InvalidArgument; }, .@"main-count" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.adjustCount(raw) catch return error.InvalidArgument; }, .@"main-location" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.setLocation(raw) catch return error.InvalidArgument; }, .zoom => { if (arguments.next() != null) return error.InvalidArgument; manager.zoom(); }, .spawn => { const program = arguments.next() orelse return error.InvalidArgument; const shell_command = try joinArguments(&arguments, program); defer gpa.free(shell_command); Spawn.run(shell_command); }, .swap => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const forward = if (std.mem.eql(u8, raw, "next")) true else if (std.mem.eql(u8, raw, "previous")) false else return error.InvalidArgument; manager.swap(forward); }, .move => { const direction = arguments.next() orelse return error.InvalidArgument; const delta_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const delta = std.fmt.parseInt(i32, delta_raw, 10) catch return error.InvalidArgument; manager.moveFocused(direction, delta) catch return error.InvalidArgument; }, .resize => { const axis = arguments.next() orelse return error.InvalidArgument; const delta_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const delta = std.fmt.parseInt(i32, delta_raw, 10) catch return error.InvalidArgument; manager.resizeFocused(axis, delta) catch return error.InvalidArgument; }, .@"border-width" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.border_width = try parseBoundedI32(raw, border_width_max); }, .@"border-color-focused", .@"border-color-unfocused" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const color = parseRGBA(raw) catch return error.InvalidArgument; if (kind == .@"border-color-focused") { manager.border_focused = color; } else { manager.border_unfocused = color; } }, .@"focus-follows-cursor" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; if (std.mem.eql(u8, raw, "always")) { manager.focus_follows_cursor = .always; } else if (std.mem.eql(u8, raw, "normal")) { manager.focus_follows_cursor = .normal; } else if (std.mem.eql(u8, raw, "disabled")) { manager.focus_follows_cursor = .disabled; } else return error.InvalidArgument; }, .csd => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.csd = try parseBool(raw); manager.markDecorationsPending(); }, .@"allow-tearing" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; // The render sequence after this manage sequence applies it. manager.allow_tearing = try parseBool(raw); }, .@"set-cursor-warp" => { const raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; if (std.mem.eql(u8, raw, "on-focus")) { manager.cursor_warp = true; } else if (std.mem.eql(u8, raw, "disabled")) { manager.cursor_warp = false; } else return error.InvalidArgument; }, .@"xcursor-theme" => { const theme = arguments.next() orelse return error.InvalidArgument; const size_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const size = try parseBoundedU32(size_raw, cursor_theme_size_max); if (manager.cursor_theme_name) |old| gpa.free(old); manager.cursor_theme_name = gpa.dupeSentinel(u8, theme, 0) catch return error.OutOfMemory; manager.cursor_theme_size = size; for (manager.seats.items) |seat| manager.applyCursorTheme(seat); }, .snap => { const direction = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; manager.snapFocused(direction) catch return error.InvalidArgument; }, .@"set-repeat" => { const rate_raw = arguments.next() orelse return error.InvalidArgument; const delay_raw = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; const rate = std.fmt.parseInt(i32, rate_raw, 10) catch return error.InvalidArgument; const delay = std.fmt.parseInt(i32, delay_raw, 10) catch return error.InvalidArgument; if (rate < 0 or delay < 0) return error.InvalidArgument; manager.input.setRepeat(rate, delay); }, .@"keyboard-layout" => { var rules: ?[]const u8 = null; var model: ?[]const u8 = null; var variant: ?[]const u8 = null; var options: ?[]const u8 = null; var layout: ?[]const u8 = null; while (arguments.next()) |argument| { if (std.mem.eql(u8, argument, "-rules")) { rules = arguments.next() orelse return error.InvalidArgument; } else if (std.mem.eql(u8, argument, "-model")) { model = arguments.next() orelse return error.InvalidArgument; } else if (std.mem.eql(u8, argument, "-variant")) { variant = arguments.next() orelse return error.InvalidArgument; } else if (std.mem.eql(u8, argument, "-options")) { options = arguments.next() orelse return error.InvalidArgument; } else if (layout == null) { layout = argument; } else return error.InvalidArgument; } const chosen = layout orelse return error.InvalidArgument; Input.setLayout(&manager.input, rules, model, chosen, variant, options) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.InvalidArgument, }; }, .@"keyboard-layout-file" => { const path = arguments.next() orelse return error.InvalidArgument; if (arguments.next() != null) return error.InvalidArgument; Input.setLayoutFile(&manager.input, path) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.InvalidArgument, }; }, .input => { const pattern = arguments.next() orelse return error.InvalidArgument; var setting = arguments.next() orelse return error.InvalidArgument; var value = arguments.next(); var extra = arguments.next(); var category: ?[]const u8 = null; if (extra != null) { category = setting; setting = value orelse return error.InvalidArgument; value = extra; extra = arguments.next(); } if (extra != null) return error.InvalidArgument; Rule.validate(pattern) catch return error.InvalidGlob; var matched = false; for (manager.input.devices.items) |device| { if (!Input.matches(device, pattern, category)) continue; matched = true; Input.configure(device, setting, value) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, // Virtual devices such as nested-backend keyboards have // no libinput object. Skip them and keep applying to the // remaining matches. error.NoLibinputDevice => continue, else => { const device_name = device.name orelse "(unnamed)"; log.warn("input setting '{s}' failed for {s}: {s}", .{ setting, device_name, @errorName(err), }); }, }; } // Not an error: the setting is stored and applied to any device // that appears later. A pattern that never matches logs here // once per apply, which is enough to spot typos. if (!matched) log.warn("no input device matches '{s}' yet", .{pattern}); }, .@"toggle-float" => { if (arguments.next() != null) return error.InvalidArgument; const window = manager.focusedWindow() orelse return; window.float = !window.float; }, .@"toggle-fullscreen" => { if (arguments.next() != null) return error.InvalidArgument; const window = manager.focusedWindow() orelse return; manager.setFullscreen(window, !window.fullscreen); }, .@"rule-add", .@"rule-del" => { try manager.defineRule(&arguments, kind == .@"rule-del"); manager.markDecorationsPending(); }, } } /// rule-add [-app-id glob] [-title glob] [value] /// rule-del [-app-id glob] [-title glob] fn defineRule(manager: *WindowManager, arguments: *Command.Cursor, remove: bool) Error!void { var app_id_glob: []const u8 = "*"; var title_glob: []const u8 = "*"; var action_raw: ?[]const u8 = null; var value_raw: ?[]const u8 = null; while (arguments.next()) |argument| { if (std.mem.eql(u8, argument, "-app-id")) { app_id_glob = arguments.next() orelse return error.InvalidArgument; } else if (std.mem.eql(u8, argument, "-title")) { title_glob = arguments.next() orelse return error.InvalidArgument; } else if (action_raw == null) { action_raw = argument; } else if (value_raw == null) { value_raw = argument; } else return error.InvalidArgument; } const action_name = action_raw orelse return error.InvalidArgument; const action: Rule.Action = if (std.mem.eql(u8, action_name, "float")) .float else if (std.mem.eql(u8, action_name, "fullscreen")) .fullscreen else if (std.mem.eql(u8, action_name, "csd")) .csd else if (std.mem.eql(u8, action_name, "tags")) .tags else return error.InvalidArgument; Rule.validate(app_id_glob) catch return error.InvalidGlob; Rule.validate(title_glob) catch return error.InvalidGlob; if (remove) { var index: usize = 0; while (index < manager.rules.items.len) { const rule = manager.rules.items[index]; if (rule.action == action and std.mem.eql(u8, rule.app_id_glob, app_id_glob) and std.mem.eql(u8, rule.title_glob, title_glob)) { _ = manager.rules.swapRemove(index); gpa.free(rule.app_id_glob); gpa.free(rule.title_glob); return; } index += 1; } return; } var tags: u32 = 0; if (action == .tags) { const raw = value_raw orelse return error.InvalidArgument; tags = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; } else if (value_raw != null) return error.InvalidArgument; const owned_app_id = gpa.dupe(u8, app_id_glob) catch return error.OutOfMemory; errdefer gpa.free(owned_app_id); const owned_title = gpa.dupe(u8, title_glob) catch return error.OutOfMemory; errdefer gpa.free(owned_title); for (manager.rules.items) |*rule| { if (rule.action != action or !std.mem.eql(u8, rule.app_id_glob, app_id_glob) or !std.mem.eql(u8, rule.title_glob, title_glob)) continue; gpa.free(rule.app_id_glob); gpa.free(rule.title_glob); rule.* = .{ .app_id_glob = owned_app_id, .title_glob = owned_title, .action = action, .tags = tags, }; return; } manager.rules.append(gpa, .{ .app_id_glob = owned_app_id, .title_glob = owned_title, .action = action, .tags = tags, }) catch return error.OutOfMemory; } const Error = error{ UnknownCommand, InvalidArgument, InvalidModifier, InvalidKeysym, InvalidButton, InvalidGlob, OutOfMemory, }; /// map fn defineBinding(manager: *WindowManager, arguments: *Command.Cursor) Error!void { const mode_name = arguments.next() orelse return error.InvalidArgument; const modifiers_raw = arguments.next() orelse return error.InvalidArgument; const keysym_raw = arguments.next() orelse return error.InvalidArgument; const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; const keysym = try Binding.parseKeysym(keysym_raw); const first = arguments.next() orelse return error.InvalidArgument; const command = try joinArguments(arguments, first); errdefer gpa.free(command); const mode_index = manager.ensureMode(mode_name); const mode = &manager.modes.items[mode_index]; const specification: BindingSpecification = .{ .keysym = keysym, .modifiers = modifiers, .command = command }; for (mode.specifications.items) |*existing| { if (existing.keysym == keysym and std.meta.eql(existing.modifiers, modifiers)) { gpa.free(existing.command); existing.* = specification; manager.refreshBinding(mode_index, specification); return; } } mode.specifications.append(gpa, specification) catch return error.OutOfMemory; manager.refreshBinding(mode_index, specification); } fn removeBinding(manager: *WindowManager, mode_name: []const u8, keysym: u32, modifiers: river.SeatV1.Modifiers) void { const index = manager.modeIndex(mode_name) orelse return; const mode = &manager.modes.items[index]; for (mode.specifications.items, 0..) |specification, specification_index| { if (specification.keysym != keysym or !std.meta.eql(specification.modifiers, modifiers)) continue; _ = mode.specifications.swapRemove(specification_index); gpa.free(specification.command); break; } for (manager.seats.items) |seat| { if (seat.mode_index != index) continue; var binding_index: usize = 0; while (binding_index < seat.bindings.items.len) { const binding = seat.bindings.items[binding_index]; if (binding.keysym != keysym or !std.meta.eql(binding.modifiers, modifiers)) { binding_index += 1; continue; } _ = seat.bindings.swapRemove(binding_index); binding.deinit(); gpa.destroy(binding); } } } fn removePointerBinding(manager: *WindowManager, mode_name: []const u8, button: u32, modifiers: river.SeatV1.Modifiers) void { const index = manager.modeIndex(mode_name) orelse return; const mode = &manager.modes.items[index]; for (mode.pointer_specifications.items, 0..) |specification, specification_index| { if (specification.button != button or !std.meta.eql(specification.modifiers, modifiers)) continue; _ = mode.pointer_specifications.swapRemove(specification_index); gpa.free(specification.command); break; } for (manager.seats.items) |seat| { if (seat.mode_index != index) continue; var binding_index: usize = 0; while (binding_index < seat.pointer_bindings.items.len) { const binding = seat.pointer_bindings.items[binding_index]; if (binding.button != button or !std.meta.eql(binding.modifiers, modifiers)) { binding_index += 1; continue; } _ = seat.pointer_bindings.swapRemove(binding_index); binding.deinit(); gpa.destroy(binding); } } } /// map-pointer