Something went wrong. Try again.
window manager for the river wayland compositor
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471// 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] <action> [value]/// rule-del [-app-id glob] [-title glob] <action>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 <mode> <modifiers> <keysym> <command...>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 <mode> <modifiers> <button> <move-view|resize-view|command...>fn definePointerBinding(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 button_raw = arguments.next() orelse return error.InvalidArgument;
const modifiers = Binding.parseModifiers(modifiers_raw) catch return error.InvalidModifier; const button = PointerBinding.parseButton(button_raw) catch return error.InvalidButton;
const action_raw = arguments.next() orelse return error.InvalidArgument; const action: PointerBinding.Action = if (std.mem.eql(u8, action_raw, "move-view")) .move else if (std.mem.eql(u8, action_raw, "resize-view")) .resize else .command;
const command = if (action == .command) try joinArguments(arguments, action_raw) else blk: { if (arguments.next() != null) return error.InvalidArgument; break :blk gpa.dupeSentinel(u8, "", 0) catch return error.OutOfMemory; }; errdefer gpa.free(command);
const mode_index = manager.ensureMode(mode_name); const mode = &manager.modes.items[mode_index]; const specification: PointerSpecification = .{ .button = button, .modifiers = modifiers, .action = action, .command = command, }; for (mode.pointer_specifications.items) |*existing| { if (existing.button == button and std.meta.eql(existing.modifiers, modifiers)) { gpa.free(existing.command); existing.* = specification; manager.refreshPointerBinding(mode_index, specification); return; } } mode.pointer_specifications.append(gpa, specification) catch return error.OutOfMemory; manager.refreshPointerBinding(mode_index, specification);}
/// Join the remaining arguments with spaces. The first token was already/// consumed to decide the action, so it is prepended for command actions.fn joinArguments(arguments: *Command.Cursor, first: []const u8) Error![:0]const u8 { var length: usize = first.len; var rest = arguments.*; while (rest.next()) |argument| { length = std.math.add(usize, length, argument.len + 1) catch return error.InvalidArgument; }
const command = gpa.allocSentinel(u8, length, 0) catch return error.OutOfMemory; errdefer gpa.free(command); @memcpy(command[0..first.len], first); var offset: usize = first.len; while (arguments.next()) |argument| { command[offset] = ' '; offset += 1; @memcpy(command[offset..][0..argument.len], argument); offset += argument.len; } return command;}
/// Bring live bindings in line with one new or changed specification on/// every seat using its mode: update the matching binding or create one.fn refreshBinding(manager: *WindowManager, mode_index: usize, specification: BindingSpecification) void { const xkb = manager.xkb orelse return; for (manager.seats.items) |seat| { if (seat.mode_index != mode_index) continue; if (seatBinding(seat, specification.keysym, specification.modifiers)) |binding| { binding.command = specification.command; } else { manager.createBinding(xkb, seat, specification); } }}
fn seatBinding(seat: *Seat, keysym: u32, modifiers: river.SeatV1.Modifiers) ?*Binding { for (seat.bindings.items) |binding| { if (binding.keysym == keysym and std.meta.eql(binding.modifiers, modifiers)) return binding; } return null;}
fn refreshPointerBinding(manager: *WindowManager, mode_index: usize, specification: PointerSpecification) void { for (manager.seats.items) |seat| { if (seat.mode_index != mode_index) continue; if (seatPointerBinding(seat, specification.button, specification.modifiers)) |binding| { binding.command = specification.command; binding.action = specification.action; } else { manager.createPointerBinding(seat, specification); } }}
fn seatPointerBinding(seat: *Seat, button: u32, modifiers: river.SeatV1.Modifiers) ?*PointerBinding { for (seat.pointer_bindings.items) |binding| { if (binding.button == button and std.meta.eql(binding.modifiers, modifiers)) return binding; } return null;}
fn createPointerBinding( manager: *WindowManager, seat: *Seat, specification: PointerSpecification,) void { const object = seat.object.getPointerBinding( specification.button, specification.modifiers, ) catch { log.err("failed to create pointer binding", .{}); return; }; const binding = gpa.create(PointerBinding) catch { object.destroy(); log.err("out of memory", .{}); return; }; PointerBinding.init( binding, object, specification.button, specification.modifiers, specification.action, specification.command, manager, seat, ); seat.pointer_bindings.append(gpa, binding) catch { binding.deinit(); gpa.destroy(binding); log.err("out of memory", .{}); };}
/// Run a bound command: shell spawns go out directly, window-manager/// commands queue for the next manage sequence like riverctl input.pub fn fireBinding(manager: *WindowManager, text: [:0]const u8) void { const start = std.mem.indexOfNone(u8, text, " ") orelse text.len; const end = std.mem.indexOfScalarPos(u8, text, start, ' ') orelse text.len; if (std.mem.eql(u8, text[start..end], "spawn")) { const rest = std.mem.indexOfNonePos(u8, text, end, " ") orelse text.len; Spawn.run(text[rest.. :0]); return; } manager.queueText(text) catch |err| { log.warn("bound command failed: {s}", .{@errorName(err)}); };}
/// Queue an interactive pointer operation. Runs in the manage sequence river/// starts after every pointer binding press.pub fn requestPointerOperation(seat: *Seat, binding: *PointerBinding) void { if (seat.pending_operation != null or seat.operation != null) return; seat.pending_operation = binding;}
fn createBinding( manager: *WindowManager, xkb: *river.XkbBindingsV1, seat: *Seat, specification: BindingSpecification,) void { const object = xkb.getXkbBinding( seat.object, specification.keysym, specification.modifiers, ) catch { log.err("failed to create key binding", .{}); return; }; const binding = gpa.create(Binding) catch { object.destroy(); log.err("out of memory", .{}); return; }; Binding.init( binding, object, specification.keysym, specification.modifiers, specification.command, manager, ); seat.bindings.append(gpa, binding) catch { binding.deinit(); gpa.destroy(binding); log.err("out of memory", .{}); };}
fn setSeatMode(manager: *WindowManager, seat: *Seat, index: usize) void { std.debug.assert(index < manager.modes.items.len); // op_end is window management state: let processPointerOperation end // the operation inside a manage sequence, as for a button release. if (seat.operation) |*operation| operation.released = true; seat.pending_operation = null; for (seat.bindings.items) |binding| { binding.deinit(); gpa.destroy(binding); } for (seat.pointer_bindings.items) |binding| { binding.deinit(); gpa.destroy(binding); } seat.bindings.clearRetainingCapacity(); seat.pointer_bindings.clearRetainingCapacity(); seat.mode_index = index; const xkb = manager.xkb orelse return; for (manager.modes.items[index].specifications.items) |specification| { manager.createBinding(xkb, seat, specification); } for (manager.modes.items[index].pointer_specifications.items) |specification| { manager.createPointerBinding(seat, specification); }}
fn modeIndex(manager: *WindowManager, name: []const u8) ?usize { for (manager.modes.items, 0..) |mode, index| { if (std.mem.eql(u8, mode.name, name)) return index; } return null;}
fn ensureBuiltinModes(manager: *WindowManager) void { std.debug.assert(manager.modes.items.len == 0); _ = manager.ensureMode("normal"); _ = manager.ensureMode("locked");}
/// Switch every seat to the locked mode when the session locks and back to/// its previous mode when it unlocks. Runs inside a manage sequence, where/// ending a pointer operation is legal.fn syncLockMode(manager: *WindowManager) void { if (manager.locked == manager.lock_applied) return; manager.lock_applied = manager.locked; for (manager.seats.items) |seat| { if (manager.locked) { if (seat.mode_index == locked_mode_index) continue; seat.unlocked_mode_index = seat.mode_index; manager.setSeatMode(seat, locked_mode_index); } else { if (seat.mode_index != locked_mode_index) continue; const index = seat.unlocked_mode_index orelse normal_mode_index; seat.unlocked_mode_index = null; manager.setSeatMode(seat, index); } }}
fn ensureMode(manager: *WindowManager, name: []const u8) usize { if (manager.modeIndex(name)) |index| return index; const owned = gpa.dupe(u8, name) catch std.process.fatal("out of memory", .{}); manager.modes.append(gpa, .{ .name = owned }) catch std.process.fatal("out of memory", .{}); return manager.modes.items.len - 1;}
fn handleEvent(_: *river.WindowManagerV1, event: river.WindowManagerV1.Event, manager: *WindowManager) void { switch (event) { .unavailable => std.process.fatal("river window management is already claimed", .{}), .finished => std.process.exit(0), .window => |created| manager.addWindow(created.id), .output => |created| manager.addOutput(created.id), .seat => |created| manager.addSeat(created.id), .manage_start => manager.manageStart(), .render_start => manager.renderStart(), .session_locked => manager.locked = true, .session_unlocked => manager.locked = false, }}
fn attachMode(manager: *WindowManager) Layout.Attach { if (manager.focusedOutput()) |output| { if (output.attach_mode) |mode| return mode; } return manager.default_attach;}
fn addWindow(manager: *WindowManager, object: *river.WindowV1) void { const window = gpa.create(Window) catch std.process.fatal("out of memory", .{}); manager.window_serial += 1; Window.init(window, object, manager, manager.window_serial); // The top of the stack is index 0: it gets the main layout area and // renders last (visually on top). Attach mode picks where new windows // land in the stack. switch (manager.attachMode()) { .top => manager.windows.insert(gpa, 0, window) catch std.process.fatal("out of memory", .{}), .bottom => manager.windows.append(gpa, window) catch std.process.fatal("out of memory", .{}), }}
fn addOutput(manager: *WindowManager, object: *river.OutputV1) void { const output = gpa.create(Output) catch std.process.fatal("out of memory", .{}); Output.init(output, object, manager.registry); manager.outputs.append(gpa, output) catch std.process.fatal("out of memory", .{}); output.osd = &manager.osd; if (manager.layer_shell) |layer_shell| output.bindLayerShell(layer_shell);}
fn applyCursorTheme(manager: *WindowManager, seat: *Seat) void { const name = manager.cursor_theme_name orelse return; seat.object.setXcursorTheme(name, manager.cursor_theme_size);}
fn addSeat(manager: *WindowManager, object: *river.SeatV1) void { const seat = gpa.create(Seat) catch std.process.fatal("out of memory", .{}); Seat.init(seat, object); seat.focused_output = if (manager.outputs.items.len > 0) manager.outputs.items[0] else null; if (manager.lock_applied) { seat.unlocked_mode_index = seat.mode_index; seat.mode_index = locked_mode_index; } manager.applyCursorTheme(seat); manager.seats.append(gpa, seat) catch std.process.fatal("out of memory", .{}); const xkb = manager.xkb orelse return; for (manager.modes.items[seat.mode_index].specifications.items) |specification| { manager.createBinding(xkb, seat, specification); } for (manager.modes.items[seat.mode_index].pointer_specifications.items) |specification| { manager.createPointerBinding(seat, specification); }}
/// Window.init registers the Window as the listener data of its object, and/// every river_window_v1 gets a Window when river announces it.fn findWindow(_: *WindowManager, object: *river.WindowV1) ?*Window { return @ptrCast(@alignCast(object.getUserData()));}
/// Clear the just_created flag on every new window. Return the newest one/// that is visible on the focused output.fn newestCreated(manager: *WindowManager) ?*Window { var best: ?*Window = null; for (manager.windows.items) |window| { if (!window.just_created) continue; window.just_created = false; if (focus_policy.newer(manager.visibleOn(window), window.serial, if (best) |b| b.serial else null)) best = window; } return best;}
fn focusDirection(manager: *WindowManager, direction: []const u8) error{InvalidArgument}!void { if (!std.mem.eql(u8, direction, "up") and !std.mem.eql(u8, direction, "down") and !std.mem.eql(u8, direction, "left") and !std.mem.eql(u8, direction, "right")) { return error.InvalidArgument; } const focused = manager.focusedWindow() orelse return; const from_x = focused.x + @divFloor(focused.width, 2); const from_y = focused.y + @divFloor(focused.height, 2); var best: ?*Window = null; var best_distance: i64 = std.math.maxInt(i64); for (manager.windows.items) |window| { if (window == focused or !manager.visibleOn(window)) continue; if (window.float or window.fullscreen) continue; const to_x = window.x + @divFloor(window.width, 2); const to_y = window.y + @divFloor(window.height, 2); const delta_x: i64 = @as(i64, to_x) - from_x; const delta_y: i64 = @as(i64, to_y) - from_y; if (!directionMatches(direction, delta_x, delta_y)) continue; const distance = squaredDistance(delta_x, delta_y); if (distance < best_distance) { best_distance = distance; best = window; } } const target = best orelse return; if (manager.seats.items.len == 0) return; manager.seats.items[0].focus(target.object);}
fn cycleFocus(manager: *WindowManager, forward: bool) void { const count = manager.windows.items.len; if (count == 0 or manager.seats.items.len == 0) return; const seat = manager.seats.items[0];
var index: usize = if (forward) count - 1 else 0; if (seat.focused_window) |focused| { for (manager.windows.items, 0..) |window, i| { if (window.object == focused) { index = i; break; } } }
var steps: usize = 0; while (steps < count) : (steps += 1) { index = if (forward) (index + 1) % count else (index + count - 1) % count; const window = manager.windows.items[index]; if (!manager.visibleOn(window)) continue; seat.focus(window.object); return; }}
fn visibleOn(manager: *WindowManager, window: *const Window) bool { return visibleOnOutput(window, manager.focusedOutput());}
fn visibleAnywhere(manager: *WindowManager, window: *const Window) bool { for (manager.outputs.items) |output| { if (visibleOnOutput(window, output)) return true; } return false;}
fn visibleOnOutput(window: *const Window, output: ?*Output) bool { const target = output orelse return false; if (window.output != null and window.output.? != target) return false; return window.tags & target.tags != 0;}
fn focusedOutput(manager: *WindowManager) ?*Output { if (manager.seats.items.len > 0) { if (manager.seats.items[0].focused_output) |output| return output; } if (manager.outputs.items.len > 0) return manager.outputs.items[0]; return null;}
/// Resolve next, previous, directional or named output targets.fn resolveOutput(manager: *WindowManager, target: []const u8) ?*Output { if (manager.outputs.items.len == 0) return null; const current = manager.focusedOutput() orelse manager.outputs.items[0]; if (std.mem.eql(u8, target, "next")) { for (manager.outputs.items, 0..) |output, index| { if (output == current) return manager.outputs.items[(index + 1) % manager.outputs.items.len]; } return manager.outputs.items[0]; } if (std.mem.eql(u8, target, "previous")) { for (manager.outputs.items, 0..) |output, index| { if (output == current) { const count = manager.outputs.items.len; return manager.outputs.items[(index + count - 1) % count]; } } return manager.outputs.items[0]; } if (std.mem.eql(u8, target, "up") or std.mem.eql(u8, target, "down") or std.mem.eql(u8, target, "left") or std.mem.eql(u8, target, "right")) { return manager.outputInDirection(current, target); } for (manager.outputs.items) |output| { if (output.connector) |connector| { if (std.mem.eql(u8, connector, target)) return output; } } return null;}
/// Whether an offset lies in the named direction. Callers pass a validated/// direction, so anything else reads as right.fn directionMatches(direction: []const u8, delta_x: i64, delta_y: i64) bool { if (std.mem.eql(u8, direction, "up")) return delta_y < 0 and @abs(delta_x) <= -delta_y; if (std.mem.eql(u8, direction, "down")) return delta_y > 0 and @abs(delta_x) <= delta_y; if (std.mem.eql(u8, direction, "left")) return delta_x < 0 and @abs(delta_y) <= -delta_x; return delta_x > 0 and @abs(delta_y) <= delta_x;}
/// Squared distance, saturating instead of overflowing.fn squaredDistance(delta_x: i64, delta_y: i64) i64 { const dx_squared = std.math.mul(i64, delta_x, delta_x) catch return std.math.maxInt(i64); const dy_squared = std.math.mul(i64, delta_y, delta_y) catch return std.math.maxInt(i64); return std.math.add(i64, dx_squared, dy_squared) catch std.math.maxInt(i64);}
fn outputInDirection(manager: *WindowManager, from: *Output, direction: []const u8) ?*Output { const from_x = from.x + @divFloor(from.width, 2); const from_y = from.y + @divFloor(from.height, 2); var best: ?*Output = null; var best_distance: i64 = std.math.maxInt(i64); for (manager.outputs.items) |output| { if (output == from) continue; const to_x = output.x + @divFloor(output.width, 2); const to_y = output.y + @divFloor(output.height, 2); const delta_x: i64 = @as(i64, to_x) - from_x; const delta_y: i64 = @as(i64, to_y) - from_y; if (!directionMatches(direction, delta_x, delta_y)) continue; const distance = squaredDistance(delta_x, delta_y); if (distance < best_distance) { best_distance = distance; best = output; } } return best;}
fn manageStart(manager: *WindowManager) void { manager.purgeClosed();
for (manager.pending.items) |*command| { manager.execute(command) catch |err| log.warn("command failed: {s}", .{@errorName(err)}); } // A config load queues hundreds of 4 KiB commands at once. Hand that // memory back instead of holding it for the rest of the session. if (manager.pending.capacity > pending_max) { manager.pending.clearAndFree(gpa); } else { manager.pending.clearRetainingCapacity(); }
// Client fullscreen requests arrive as events, outside any manage // sequence. Apply them here, where the fullscreen request is legal. manager.applyPendingFullscreen(); manager.syncLockMode();
// Bindings are created outside manage sequences too, for example when a // seat appears, but enable is window management state: enable them here. for (manager.seats.items) |seat| { for (seat.bindings.items) |binding| { if (binding.enabled) continue; binding.object.enable(); binding.enabled = true; } for (seat.pointer_bindings.items) |binding| { if (binding.enabled) continue; binding.object.enable(); binding.enabled = true; } manager.processPointerOperation(seat); }
const output = manager.focusedOutput(); var focused: ?*Window = null; var new_window: ?*Window = null; if (manager.layer_shell) |_| { if (!manager.layer_default_set) { if (manager.outputs.items.len > 0) { if (manager.outputs.items[0].layer_output) |layer_output| { layer_output.setDefault(); manager.layer_default_set = true; } } } } if (output != null) { manager.arrange(); if (manager.rules_dirty) { // A config reload re-applies rules to live windows inside the // manage sequence, where fullscreen requests are legal. // Additive only: windows keep user-toggled float and tags that // no rule touches. manager.rules_dirty = false; for (manager.windows.items) |window| manager.applyRules(window); } // Apply app_id and title changes queued by Window event handlers. for (manager.windows.items) |window| { if (window.node == null) continue; if (window.rules_pending) { manager.applyRules(window); } else if (window.decorations_pending) { manager.applyDecorationRules(window); } window.rules_pending = false; window.decorations_pending = false; } // A window that opened since the last manage sequence takes focus. // Pick by creation order, not stack position. With attach bottom // the newest window is last in the stack. new_window = manager.newestCreated(); focused = new_window; // Keep the current focus when no new window claimed it and the // focused window is still valid and visible. Commands such as // focus-view run earlier in this sequence and must not be stomped // by the fallback below. if (focused == null and manager.seats.items.len > 0) { const seat = manager.seats.items[0]; if (seat.focused_window) |current| { if (manager.findWindow(current)) |window| { if (manager.visibleOn(window)) focused = window; } } } if (focused == null) { for (manager.windows.items) |window| { if (manager.visibleOn(window)) { focused = window; break; } } } }
if (manager.seats.items.len > 0) { const seat = manager.seats.items[0]; // A new window keeps focus until the pointer moves. Without this, // always mode would refocus the window under the pointer straight // away. In any mode, a window that slides under a still pointer // would take focus in the next manage sequence. Seats that do not // report movement cannot hold, because the hold would never end. if (new_window) |window| { seat.new_window_hold = if (seat.canHoldFocus()) window.object else null; } else if (seat.new_window_hold) |held_object| { // A tag or output switch hid the held window, so end the hold. const still_shown = if (manager.findWindow(held_object)) |window| manager.visibleOn(window) else false; if (!still_shown) seat.new_window_hold = null; } const held = new_window != null or seat.new_window_hold != null; // Each pointer entry counts once. We mark it as used even when the // window is hidden or a new window is keeping focus. An old hover // then cannot take focus from a new window, such as dmenu, later. if (manager.focus_follows_cursor != .disabled) { if (seat.hovered_window) |hovered| { if (manager.findWindow(hovered)) |hovered_window| { const fresh = hovered != seat.hover_applied; seat.hover_applied = hovered; if (focus_policy.hoverTakesFocus(manager.focus_follows_cursor, held, true, manager.visibleOn(hovered_window), fresh)) { focused = hovered_window; } } } } if (focused) |window| { seat.focus(window.object); } else { seat.object.clearFocus(); } for (manager.windows.items) |window| { window.focused = seat.focused_window != null and window.object == seat.focused_window; } if (manager.cursor_warp) { if (seat.focused_window != seat.last_focused) { seat.last_focused = seat.focused_window; if (seat.focused_window) |target| { if (manager.findWindow(target)) |window| { seat.warp(window.x + @divFloor(window.width, 2), window.y + @divFloor(window.height, 2)); } } } } }
// Clients draw their own title bars and shadows unless told they are // tiled, and may round a proposed size down, for example a terminal // snapping to whole cells, which leaves a strip of background inside // the tile. xdg-shell requires a maximized client to take the configured // size exactly, so tiled windows are also informed they are maximized. for (manager.windows.items) |window| { const tiled = window.controllable(); if (window.tiled_sent) |sent| { if (sent == tiled) continue; } if (tiled) { window.object.setTiled(.{ .top = true, .bottom = true, .left = true, .right = true }); window.object.informMaximized(); } else { window.object.setTiled(.{}); window.object.informUnmaximized(); } window.tiled_sent = tiled; }
manager.object.manageFinish();}
fn renderStart(manager: *WindowManager) void { for (manager.windows.items) |window| { if (manager.visibleAnywhere(window)) { window.render(manager.border_width, if (window.focused) manager.border_focused else manager.border_unfocused); } else { window.hide(); } } if (manager.stackingChanged()) manager.restack(); manager.updatePresentationModes();
manager.object.renderFinish();}
/// Visits shown windows from the bottom of the stack to the top. Tiled/// windows come first and floating windows after them, so a floating window/// is never hidden behind the tiles. Within each group index 0 of the/// window list ends up highest. river lifts fullscreen windows into its own/// layer above both groups.const StackOrder = struct { manager: *WindowManager, floating: bool, index: usize,
fn next(order: *StackOrder) ?*Window { while (true) { if (order.index == 0) { if (order.floating) return null; order.floating = true; order.index = order.manager.windows.items.len; continue; } order.index -= 1; const window = order.manager.windows.items[order.index]; if (window.float != order.floating or window.node == null) continue; if (!order.manager.visibleAnywhere(window)) continue; return window; } }};
fn stackOrder(manager: *WindowManager) StackOrder { return .{ .manager = manager, .floating = false, .index = manager.windows.items.len };}
/// Return true when the stacking order differs from the last one sent.fn stackingChanged(manager: *WindowManager) bool { var position: usize = 0; var order = manager.stackOrder(); while (order.next()) |window| : (position += 1) { if (position >= manager.stacked.items.len) return true; if (manager.stacked.items[position] != stackKey(window)) return true; } return position != manager.stacked.items.len;}
/// Place every shown window on top in stacking order and remember the/// order. Without room to remember it, the list stays empty so the next/// render sequence restacks again.fn restack(manager: *WindowManager) void { manager.stacked.clearRetainingCapacity(); var remember = true; var order = manager.stackOrder(); while (order.next()) |window| { const node = window.node orelse continue; node.placeTop(); if (!remember) continue; manager.stacked.append(gpa, stackKey(window)) catch |err| { log.warn("cannot record stacking order: {s}", .{@errorName(err)}); manager.stacked.clearRetainingCapacity(); remember = false; }; }}
/// Identify a window and its fullscreen state for stacking. Serials, not/// pointers, because a new Window may reuse a freed address. Fullscreen is/// included because river lifts fullscreen windows into their own layer,/// so leaving fullscreen needs a restack.fn stackKey(window: *const Window) u64 { return window.serial << 1 | @intFromBool(window.fullscreen);}
/// Let an output present without vsync only while wantsTearing holds./// Every other output keeps the tear-free default.fn updatePresentationModes(manager: *WindowManager) void { for (manager.outputs.items) |output| { output.setPresentationMode(if (manager.wantsTearing(output)) .async else .vsync); }}
/// True when tearing is allowed and the fullscreen window river shows on/// the output asks to present without vsync.fn wantsTearing(manager: *WindowManager, output: *Output) bool { if (!manager.allow_tearing) return false; const window = manager.topFullscreen(output) orelse return false; return window.presentation_hint == .async;}
/// Return the highest fullscreen window shown on the output. When several/// are fullscreen there, river displays only this one.fn topFullscreen(manager: *WindowManager, output: *Output) ?*Window { for ([_]bool{ true, false }) |floating| { for (manager.windows.items) |window| { if (window.float != floating or !window.fullscreen) continue; if (window.node == null or !visibleOnOutput(window, output)) continue; return window; } } return null;}
/// Build a window for the stacking and tearing tests, which only read/// window and output fields and never touch the protocol objects.fn testWindow(manager: *WindowManager, serial: u64) Window { // SAFETY: the tests never use the protocol object, and only check // whether a node exists. return .{ .object = undefined, .manager = manager, .node = @ptrFromInt(0x1000), .serial = serial };}
test "floating windows stack above tiled windows" { const testing = std.testing; // SAFETY: StackOrder never uses the protocol objects. var manager: WindowManager = .{ .object = undefined, .registry = undefined }; // SAFETY: as above. var output: Output = .{ .object = undefined, .registry = undefined }; var tiled_top = testWindow(&manager, 1); var floating_top = testWindow(&manager, 2); floating_top.float = true; var hidden = testWindow(&manager, 3); hidden.tags = 2; var tiled_bottom = testWindow(&manager, 4); var unmapped = testWindow(&manager, 5); unmapped.node = null; var floating_bottom = testWindow(&manager, 6); floating_bottom.float = true;
try manager.outputs.append(testing.allocator, &output); defer manager.outputs.deinit(testing.allocator); try manager.windows.appendSlice(testing.allocator, &.{ &tiled_top, &floating_top, &hidden, &tiled_bottom, &unmapped, &floating_bottom, }); defer manager.windows.deinit(testing.allocator);
// Bottom to top: tiled windows, then floating ones, and within each // group the start of the window list ends up highest. Hidden windows // and windows without a node are skipped. var serials: [6]u64 = undefined; var count: usize = 0; var order = manager.stackOrder(); while (order.next()) |window| : (count += 1) serials[count] = window.serial; try testing.expectEqualSlices(u64, &.{ 4, 1, 6, 2 }, serials[0..count]);}
test "tearing follows the setting and the top fullscreen window" { const testing = std.testing; // SAFETY: wantsTearing never uses the protocol objects. var manager: WindowManager = .{ .object = undefined, .registry = undefined }; // SAFETY: as above. var output: Output = .{ .object = undefined, .registry = undefined }; var game = testWindow(&manager, 1); game.fullscreen = true; game.presentation_hint = .async; var video = testWindow(&manager, 2); video.fullscreen = true; video.float = true;
try manager.outputs.append(testing.allocator, &output); defer manager.outputs.deinit(testing.allocator); try manager.windows.appendSlice(testing.allocator, &.{ &game, &video }); defer manager.windows.deinit(testing.allocator);
// Off by default, whatever windows ask for. try testing.expect(!manager.wantsTearing(&output)); manager.allow_tearing = true; // The floating fullscreen video is on top and wants vsync. try testing.expect(!manager.wantsTearing(&output)); video.fullscreen = false; try testing.expect(manager.wantsTearing(&output)); // A game on a hidden tag does not make the output tear. game.tags = 2; try testing.expect(!manager.wantsTearing(&output));}
fn adjustRatio(manager: *WindowManager, raw: []const u8) error{InvalidArgument}!void { const output = manager.focusedOutput() orelse return; output.main_ratio = parseRatio(raw, output.main_ratio) catch return error.InvalidArgument;}
fn adjustCount(manager: *WindowManager, raw: []const u8) error{InvalidArgument}!void { const output = manager.focusedOutput() orelse return; output.main_count = parseCount(raw, output.main_count) catch return error.InvalidArgument;}
fn setLocation(manager: *WindowManager, raw: []const u8) error{InvalidArgument}!void { const output = manager.focusedOutput() orelse return; if (std.mem.eql(u8, raw, "top")) { output.main_location = .top; } else if (std.mem.eql(u8, raw, "right")) { output.main_location = .right; } else if (std.mem.eql(u8, raw, "bottom")) { output.main_location = .bottom; } else if (std.mem.eql(u8, raw, "left")) { output.main_location = .left; } else return error.InvalidArgument;}
fn focusedWindow(manager: *WindowManager) ?*Window { if (manager.seats.items.len == 0) return null; const focused = manager.seats.items[0].focused_window orelse return null; return manager.findWindow(focused);}
fn swap(manager: *WindowManager, forward: bool) void { const focused = manager.focusedWindow() orelse return; var current: ?usize = null; var previous: ?usize = null; for (manager.windows.items, 0..) |window, index| { if (!manager.visibleOn(window) or window.float or window.fullscreen) continue; if (window == focused) { current = index; if (!forward) break; } else if (current != null and forward) { manager.windows.items[current.?] = window; manager.windows.items[index] = focused; return; } else { previous = index; } } if (!forward) { const from = current orelse return; const to = previous orelse return; manager.windows.items[from] = manager.windows.items[to]; manager.windows.items[to] = focused; }}
fn moveFocused(manager: *WindowManager, direction: []const u8, delta: i32) error{InvalidArgument}!void { const window = manager.focusedWindow() orelse return; const output = window.output orelse manager.focusedOutput() orelse return; window.float = true; if (std.mem.eql(u8, direction, "up")) { window.y = std.math.sub(i32, window.y, delta) catch return error.InvalidArgument; } else if (std.mem.eql(u8, direction, "down")) { window.y = std.math.add(i32, window.y, delta) catch return error.InvalidArgument; } else if (std.mem.eql(u8, direction, "left")) { window.x = std.math.sub(i32, window.x, delta) catch return error.InvalidArgument; } else if (std.mem.eql(u8, direction, "right")) { window.x = std.math.add(i32, window.x, delta) catch return error.InvalidArgument; } else return error.InvalidArgument; const max_x = subSaturating(addSaturating(output.x, output.width, output.x), window.width, output.x); const max_y = subSaturating(addSaturating(output.y, output.height, output.y), window.height, output.y); window.x = if (max_x >= output.x) std.math.clamp(window.x, output.x, max_x) else output.x; window.y = if (max_y >= output.y) std.math.clamp(window.y, output.y, max_y) else output.y; window.placed = true;}
fn resizeFocused(manager: *WindowManager, axis: []const u8, delta: i32) error{InvalidArgument}!void { const window = manager.focusedWindow() orelse return; window.float = true; if (std.mem.eql(u8, axis, "horizontal")) { const width = std.math.add(i32, window.width, delta) catch return error.InvalidArgument; window.object.proposeDimensions(@max(1, width), window.height); } else if (std.mem.eql(u8, axis, "vertical")) { const height = std.math.add(i32, window.height, delta) catch return error.InvalidArgument; window.object.proposeDimensions(window.width, @max(1, height)); } else return error.InvalidArgument;}
fn snapFocused(manager: *WindowManager, direction: []const u8) error{InvalidArgument}!void { const window = manager.focusedWindow() orelse return; const output = window.output orelse manager.focusedOutput() orelse return; window.float = true; if (std.mem.eql(u8, direction, "up")) { window.y = output.y; } else if (std.mem.eql(u8, direction, "down")) { window.y = subSaturating(addSaturating(output.y, output.height, output.y), window.height, output.y); } else if (std.mem.eql(u8, direction, "left")) { window.x = output.x; } else if (std.mem.eql(u8, direction, "right")) { window.x = subSaturating(addSaturating(output.x, output.width, output.x), window.width, output.x); } else return error.InvalidArgument; window.placed = true;}
/// Saturating addition for window geometry. Overflow saturates to the/// fallback instead of wrapping, keeping windows within reachable bounds.fn addSaturating(a: i32, b: i32, fallback: i32) i32 { return std.math.cast(i32, @as(i64, a) + b) orelse fallback;}
/// Saturating subtraction for window geometry. See addSaturating.fn subSaturating(a: i32, b: i32, fallback: i32) i32 { return std.math.cast(i32, @as(i64, a) - b) orelse fallback;}
/// Expand 0xRRGGBBAA into four full-range channel values.fn expandHex(hex: u32) [4]u32 { const spread = 0x01010101; return .{ ((hex >> 24) & 0xff) * spread, ((hex >> 16) & 0xff) * spread, ((hex >> 8) & 0xff) * spread, (hex & 0xff) * spread, };}
fn parseRGBA(raw: []const u8) error{InvalidArgument}![4]u32 { const hex_text = if (std.mem.startsWith(u8, raw, "0x")) raw[2..] else return error.InvalidArgument; const hex = std.fmt.parseInt(u32, hex_text, 16) catch return error.InvalidArgument; if (hex_text.len == 6) return expandHex((hex << 8) | 0xff); if (hex_text.len == 8) return expandHex(hex); return error.InvalidArgument;}
fn zoom(manager: *WindowManager) void { if (manager.seats.items.len == 0) return; const focused = manager.seats.items[0].focused_window orelse return; // The top of the stack is index 0: it gets the main layout area and // renders last (on top). Zoom bumps the focused window there. for (manager.windows.items, 0..) |window, index| { if (window.object != focused or index == 0) continue; manager.windows.ensureUnusedCapacity(gpa, 1) catch return; _ = manager.windows.orderedRemove(index); manager.windows.insertAssumeCapacity(0, window); return; }}
fn parseBool(raw: []const u8) error{InvalidArgument}!bool { if (std.mem.eql(u8, raw, "true")) return true; if (std.mem.eql(u8, raw, "false")) return false; return error.InvalidArgument;}
fn parseAttach(raw: []const u8) error{InvalidArgument}!Layout.Attach { if (std.mem.eql(u8, raw, "top")) return .top; if (std.mem.eql(u8, raw, "bottom")) return .bottom; return error.InvalidArgument;}
fn parseRatio(raw: []const u8, current: f32) error{InvalidArgument}!f32 { if (raw.len > 0 and (raw[0] == '+' or raw[0] == '-')) { const delta = std.fmt.parseFloat(f32, raw) catch return error.InvalidArgument; const next = current + delta; if (!std.math.isFinite(next)) return error.InvalidArgument; return std.math.clamp(next, 0.1, 0.9); } const value = std.fmt.parseFloat(f32, raw) catch return error.InvalidArgument; if (!std.math.isFinite(value)) return error.InvalidArgument; return std.math.clamp(value, 0.1, 0.9);}
fn parseCount(raw: []const u8, current: u32) error{InvalidArgument}!u32 { // Counts past i32 range would panic the intCast below, and no layout // can show two billion windows, so cap there and saturate instead. const capacity: u32 = @intCast(std.math.maxInt(i32)); const clamped_current = @min(current, capacity); if (raw.len > 0 and (raw[0] == '+' or raw[0] == '-')) { const delta = std.fmt.parseInt(i32, raw, 10) catch return error.InvalidArgument; const base: i32 = @intCast(clamped_current); const next: i32 = std.math.add(i32, base, delta) catch blk: { break :blk if (delta < 0) 1 else std.math.maxInt(i32); }; return @intCast(@max(1, next)); } const value = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; return @max(1, @min(value, capacity));}
test "main-count stays inside i32 range" { const testing = std.testing; try testing.expectEqual(@as(u32, 1), try parseCount("0", 1)); try testing.expectEqual(@as(u32, 5), try parseCount("5", 1)); try testing.expectEqual(@as(u32, std.math.maxInt(i32)), try parseCount("4294967295", 1)); try testing.expectEqual(@as(u32, 1), try parseCount("-99", 1)); try testing.expectEqual(@as(u32, std.math.maxInt(i32)), try parseCount("+99", std.math.maxInt(u32))); try testing.expectError(error.InvalidArgument, parseCount("bogus", 1));}
/// Reasonable upper bound for a border in pixels. Well above anything a/// real theme would want, but small enough to catch fat-fingered config.const border_width_max: i32 = 256;
/// Reasonable upper bound for an xcursor theme size in pixels. Covers all/// common hicolor cursor theme sizes (24 to 256) with headroom for HiDPI.const cursor_theme_size_max: u32 = 512;
/// Parse a non-negative pixel dimension, clamping to [0, max] instead of/// erroring on out-of-range input, matching parseRatio's clamp-not-reject/// convention for scalar settings.fn parseBoundedI32(raw: []const u8, max: i32) error{InvalidArgument}!i32 { const value = std.fmt.parseInt(i32, raw, 10) catch return error.InvalidArgument; return std.math.clamp(value, 0, max);}
/// Parse an unsigned pixel dimension, clamping to [0, max].fn parseBoundedU32(raw: []const u8, max: u32) error{InvalidArgument}!u32 { const value = std.fmt.parseInt(u32, raw, 10) catch return error.InvalidArgument; return @min(value, max);}
test "border-width clamps to a sane pixel range" { const testing = std.testing; try testing.expectEqual(@as(i32, 0), try parseBoundedI32("-5", border_width_max)); try testing.expectEqual(@as(i32, 2), try parseBoundedI32("2", border_width_max)); try testing.expectEqual(@as(i32, border_width_max), try parseBoundedI32("999999", border_width_max)); try testing.expectError(error.InvalidArgument, parseBoundedI32("bogus", border_width_max));}
test "xcursor-theme size clamps to a sane pixel range" { const testing = std.testing; try testing.expectEqual(@as(u32, 24), try parseBoundedU32("24", cursor_theme_size_max)); try testing.expectEqual(@as(u32, cursor_theme_size_max), try parseBoundedU32("999999", cursor_theme_size_max)); try testing.expectError(error.InvalidArgument, parseBoundedU32("bogus", cursor_theme_size_max));}
/// Apply matching rules to a newly managed window.pub fn applyRules(manager: *WindowManager, window: *Window) void { const app_id = window.app_id orelse ""; const title = window.title orelse ""; for (manager.rules.items) |rule| { if (!Rule.match(app_id, rule.app_id_glob)) continue; if (!Rule.match(title, rule.title_glob)) continue; switch (rule.action) { .float => window.float = true, .fullscreen => window.fullscreen = true, .csd => {}, .tags => window.tags = rule.tags, } } manager.applyDecorationRules(window); if (window.fullscreen) manager.setFullscreen(window, true);}
/// Ask the client to use CSD or SSD, based on the csd setting and csd rules./// laguna draws no server-side title bar, so SSD leaves only the border./// Clients that only support CSD ignore the SSD request. Safe to repeat: it/// sends nothing when the mode has not changed. Call it only inside a manage/// sequence.pub fn applyDecorationRules(manager: *WindowManager, window: *Window) void { const csd = wantsCsd( manager.csd, manager.rules.items, window.app_id orelse "", window.title orelse "", ); if (window.csd_sent) |sent| { if (sent == csd) return; } if (csd) window.object.useCsd() else window.object.useSsd(); window.csd_sent = csd;}
/// Queue a decoration update for every managed window after the csd setting/// or a rule changes. manageStart applies it.fn markDecorationsPending(manager: *WindowManager) void { for (manager.windows.items) |window| window.decorations_pending = true;}
/// True when a window should draw client-side decorations: the global csd/// setting is on or a csd rule matches it.fn wantsCsd(csd: bool, rules: []const Rule.Rule, app_id: []const u8, title: []const u8) bool { if (csd) return true; for (rules) |rule| { if (rule.action != .csd) continue; if (!Rule.match(app_id, rule.app_id_glob)) continue; if (!Rule.match(title, rule.title_glob)) continue; return true; } return false;}
test "decorations default to ssd unless csd is set or a csd rule matches" { const testing = std.testing; const rules = [_]Rule.Rule{ .{ .app_id_glob = "firefox", .title_glob = "*", .action = .csd }, .{ .app_id_glob = "*", .title_glob = "*", .action = .float }, }; try testing.expect(!wantsCsd(false, &.{}, "collier", "collier")); try testing.expect(!wantsCsd(false, &rules, "collier", "collier")); try testing.expect(wantsCsd(false, &rules, "firefox", "Mozilla Firefox")); try testing.expect(wantsCsd(true, &.{}, "collier", "collier"));}
/// Set compositor-side fullscreen state and notify the client. The/// fullscreen and exit_fullscreen requests are only legal inside a manage/// sequence, so callers must run there. One helper keeps the state change/// and the set/inform request pairing in sync for every caller: the/// toggle-fullscreen command, fullscreen rules and client requests.fn setFullscreen(manager: *WindowManager, window: *Window, fullscreen: bool) void { window.fullscreen = fullscreen; if (fullscreen) { const target = window.output orelse manager.focusedOutput() orelse return; window.object.fullscreen(target.object); window.object.informFullscreen(); } else { window.object.exitFullscreen(); window.object.informNotFullscreen(); window.resendGeometry(); }}
/// Apply fullscreen changes clients requested between manage sequences.fn applyPendingFullscreen(manager: *WindowManager) void { for (manager.windows.items) |window| { const requested = window.pending_fullscreen orelse continue; window.pending_fullscreen = null; if (window.fullscreen != requested) manager.setFullscreen(window, requested); }}
/// Tile visible windows across every output, skipping floating windows,/// fullscreen windows and windows with an active pointer operation.fn arrange(manager: *WindowManager) void { for (manager.windows.items) |window| { if (window.output == null and manager.outputs.items.len > 0) { window.output = manager.focusedOutput(); } if (window.node == null) { const home = window.output orelse manager.focusedOutput(); if (home) |output| { const base = manager.spawn_tagmask orelse output.tags; window.tags = base; } window.node = window.object.getNode() catch { std.process.fatal("failed to create window node", .{}); }; manager.applyRules(window); } } for (manager.outputs.items) |output| { manager.arrangeOutput(output); }}
fn arrangeOutput(manager: *WindowManager, output: *Output) void { var count: usize = 0; for (manager.windows.items) |window| { if (!visibleOnOutput(window, output)) continue; if (window.float or window.fullscreen) continue; if (manager.windowBusy(window)) continue; count += 1; } if (count > 0) { manager.placements.resize(gpa, count) catch return; const placements = manager.placements.items; const area: Layout.Rect = if (output.usable_valid) output.usable else .{ .x = output.x, .y = output.y, .width = output.width, .height = output.height, }; Layout.compute(placements, area, output.main_count, output.main_ratio, output.main_location);
var index: usize = 0; for (manager.windows.items) |window| { if (!visibleOnOutput(window, output) or manager.windowBusy(window)) continue; if (window.float or window.fullscreen) continue; const placement = placements[index]; index += 1; window.x = placement.x; window.y = placement.y; window.placed = true; window.tile_clip = .{ placement.width, placement.height }; window.configure(placement.width, placement.height); } } for (manager.windows.items) |window| { if (!visibleOnOutput(window, output)) continue; const busy = manager.windowBusy(window); if (!window.float and !window.fullscreen and !busy) continue; // Only tiled windows are clipped. A window under a pointer // operation shows its full size while it is dragged or resized. window.tile_clip = .{ 0, 0 }; if (window.float and !window.placed) { // Center the window on its output instead of piling floats // in the top-left corner. const offset_x = @divFloor(@max(output.width - window.width, 0), 2); const offset_y = @divFloor(@max(output.height - window.height, 0), 2); window.x = output.x + offset_x; window.y = output.y + offset_y; window.placed = true; window.configure(window.width, window.height); } }}
fn windowBusy(manager: *WindowManager, window: *const Window) bool { for (manager.seats.items) |seat| { if (seat.operation) |operation| { if (operation.window == window) return true; } } return false;}
fn processPointerOperation(manager: *WindowManager, seat: *Seat) void { if (seat.pending_operation) |binding| { seat.pending_operation = null; manager.startPointerOperation(seat, binding); } const operation = if (seat.operation) |*current| current else return; if (operation.released) { seat.object.opEnd(); if (operation.action == .resize) operation.window.object.informResizeEnd(); seat.operation = null; return; } const delta = operation.pending_delta orelse return; operation.pending_delta = null; switch (operation.action) { .move => { operation.window.x = addSaturating(operation.start_x, delta.delta_x, operation.start_x); operation.window.y = addSaturating(operation.start_y, delta.delta_y, operation.start_y); // set_position is render-sequence only. The render sequence // following this manage sequence applies the new position. }, .resize => { const width: i32 = @max(1, std.math.add( i32, operation.start_width, delta.delta_x, ) catch 1); const height: i32 = @max(1, std.math.add( i32, operation.start_height, delta.delta_y, ) catch 1); operation.window.object.proposeDimensions(width, height); }, .command => unreachable, }}
fn startPointerOperation(manager: *WindowManager, seat: *Seat, binding: *PointerBinding) void { if (binding.action == .command) { manager.fireBinding(binding.command); return; } if (seat.operation != null) return; const focused = seat.focused_window orelse return; const window = manager.findWindow(focused) orelse return; // Like the move, resize and snap commands, dragging takes a tiled // window out of the layout for good. The others re-tile once, and the // window stays where it is dropped instead of snapping back. if (!window.float and !window.fullscreen) window.float = true; seat.operation = .{ .action = binding.action, .window = window, .start_x = window.x, .start_y = window.y, .start_width = window.width, .start_height = window.height, }; seat.object.opStartPointer(); if (binding.action == .resize) window.object.informResizeStart();}
fn purgeClosed(manager: *WindowManager) void { var index: usize = 0; while (index < manager.windows.items.len) { const window = manager.windows.items[index]; if (!window.closed) { index += 1; continue; } for (manager.seats.items) |seat| { if (seat.focused_window == window.object) seat.focused_window = null; if (seat.hovered_window == window.object) seat.hovered_window = null; if (seat.hover_applied == window.object) seat.hover_applied = null; if (seat.last_focused == window.object) seat.last_focused = null; if (seat.new_window_hold == window.object) seat.new_window_hold = null; if (seat.operation) |operation| { if (operation.window == window) seat.operation = null; } } // Ordered removal keeps the stack stable: the last window must not // jump into the hole left by the closed one. _ = manager.windows.orderedRemove(index); window.deinit(); gpa.destroy(window); }
var output_index: usize = 0; while (output_index < manager.outputs.items.len) { const output = manager.outputs.items[output_index]; if (!output.removed) { output_index += 1; continue; } const fallback = blk: { if (manager.outputs.items.len == 1) break :blk null; for (manager.outputs.items) |candidate| { if (candidate != output and !candidate.removed) break :blk candidate; } break :blk null; }; // Removing an output exits fullscreen on it, which leaves window // geometry undefined until laguna sends it again. for (manager.windows.items) |window| { if (window.output == output) window.output = fallback; window.resendGeometry(); } for (manager.seats.items) |seat| { if (seat.focused_output == output) seat.focused_output = fallback; } _ = manager.outputs.swapRemove(output_index); manager.osd.outputRemoved(output); output.deinit(); gpa.destroy(output); manager.layer_default_set = false; }
var seat_index: usize = 0; while (seat_index < manager.seats.items.len) { const seat = manager.seats.items[seat_index]; if (!seat.removed) { seat_index += 1; continue; } _ = manager.seats.swapRemove(seat_index); seat.deinit(); gpa.destroy(seat); }}