Something went wrong. Try again.
window manager for the river wayland compositor
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598// 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.
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.ArrayList(BindingSpecification) = .empty, pointer_specifications: std.ArrayList(PointerSpecification) = .empty,};
pub const FocusFollowsCursor = focus_policy.FollowsCursor;
windows: std.ArrayList(*Window) = .empty,/// Counter for Window.serial.window_serial: u64 = 0,outputs: std.ArrayList(*Output) = .empty,seats: std.ArrayList(*Seat) = .empty,input: Input = .{},modes: std.ArrayList(Mode) = .empty,rules: std.ArrayList(Rule.Rule) = .empty,pending: std.ArrayList(Command) = .empty,/// Scratch space for arrangeOutput, kept between manage sequences.placements: std.ArrayList(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.ArrayList(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.ArrayList(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.mem.print(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("skip 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("skip 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("cannot apply input setting '{s}' to {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("cannot 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.findNone(u8, text, " ") orelse text.len; const end = std.mem.findScalarPos(u8, text, start, ' ') orelse text.len; if (std.mem.eql(u8, text[start..end], "spawn")) { const rest = std.mem.findNonePos(u8, text, end, " ") orelse text.len; Spawn.run(text[rest.. :0]); return; } manager.queueText(text) catch |err| { log.warn("cannot run bound command: {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("cannot 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("cannot run command: {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("cannot 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); }}