#! /usr/bin/env lua -- SPDX-FileCopyrightText: © 2026 FireFly -- SPDX-License-Identifier: 0BSD local wau = require("wau") local xkbcommon = require("planar.xkbcommon") local posix = require("posix") wau:require("planar.protocol.river-window-management-v1") wau:require("planar.protocol.river-xkb-bindings-v1") local globals = {} local required_globals = { ["river_window_manager_v1"] = 4, ["river_xkb_bindings_v1"] = 1, } local Mods = wau.river_seat_v1.Modifiers local term = "foot" local launcher = "rofi -show drun" -- broken on my end. pls check. local xkb_bindings = { {"t", Mods.MOD1, {"spawn", term}}, {"f", Mods.MOD1, {"spawn", launcher}}, -- broken on my end. pls check. {"q", Mods.MOD1, "close"}, {"e", Mods.MOD1, "exit"}, {"r", Mods.MOD1, {"goto", 0, 0}}, {"F1", Mods.MOD1, {"goto", -2000, -2000}}, {"F2", Mods.MOD1, {"goto", 2000, -2000}}, {"F3", Mods.MOD1, {"goto", -2000, 2000}}, {"F4", Mods.MOD1, {"goto", 2000, 2000}} } local pointer_bindings = { {"left", Mods.MOD1, "move"}, {"right", Mods.MOD1, "resize"}, } local wm = { outputs = {}, seats = {}, -- Windows are kept in rendering order; last window is topmost windows = {}, cam = {x = 0, y = 0, dirty = false}, } local function table_index_of(tbl, sought) for i, v in ipairs(tbl) do if v == sought then return i end end return 0 end local function table_filter_inplace(tbl, pred) local removed = 0 for i=1,#tbl do if pred(tbl[i]) then tbl[i - removed] = tbl[i] else removed = removed + 1 end if removed > 0 then tbl[i] = nil end end return tbl end ---- Output --------------------------- local Output = { mt = {}, listener = {} } Output.mt.__index = Output function Output.create(obj) local output = { obj = obj } setmetatable(output, Output.mt) obj:set_user_data(output) obj:add_listener(Output.listener) return output end function Output:maybe_destroy() if self.removed then self.obj:destroy() else return self end end function Output.listener:removed() self:get_user_data().removed = true end ---- Window --------------------------- local Window = { mt = {}, listener = {} } Window.mt.__index = Window function Window.create(obj) local window = { obj = obj, node = obj:get_node(), new = true, } setmetatable(window, Window.mt) obj:set_user_data(window) obj:add_listener(Window.listener) return window end function Window:maybe_destroy() if self.closed then self.obj:destroy() self.node:destroy() else return self end end function Window:manage() if self.new then for i = 1,#wm.outputs do local output = wm.outputs[i] for k,v in pairs(output) do print(k,v) end end self.new = nil self:set_position(wm.cam.x, wm.cam.y) self.obj:propose_dimensions(0, 0) end local move = self.pointer_move_requested if move ~= nil then self.pointer_move_requested = nil move.seat:pointer_move(self) end local resize = self.pointer_resize_requested if resize ~= nil then self.pointer_resize_requested = nil resize.seat:pointer_resize(self, resize.edges) end end function Window:set_position(x, y) self.node:set_position(x - wm.cam.x, y - wm.cam.y) self.x = x self.y = y end function Window.listener:closed() self:get_user_data().closed = true end function Window.listener:dimensions(width, height) local window = self:get_user_data() window.width = width window.height = height end function Window.listener:pointer_move_requested(seat) self:get_user_data().pointer_move_requested = { seat = seat:get_user_data(), } end function Window.listener:pointer_resize_requested(seat, edges) local Edges = wau.river_window_v1.Edges self:get_user_data().pointer_resize_requested = { seat = seat:get_user_data(), edges = { left = (edges & Edges.LEFT) ~= 0, right = (edges & Edges.RIGHT) ~= 0, top = (edges & Edges.TOP) ~= 0, bottom = (edges & Edges.BOTTOM) ~= 0, }, } end ---- Seat ----------------------------- local Seat = { mt = {}, listener = {} } Seat.mt.__index = Seat function Seat.create(obj) local seat = { obj = obj, new = true, xkb_bindings = {}, pointer_bindings = {}, } setmetatable(seat, Seat.mt) obj:set_user_data(seat) obj:add_listener(Seat.listener) return seat end function Seat:focus(window) if window == nil and #wm.windows > 0 then -- Fall back to topmost window window = wm.windows[#wm.windows] end if window then if self.focused ~= window then self.obj:focus_window(window.obj) self.focused = window -- Move to top local i = table_index_of(wm.windows, window) table.remove(wm.windows, i) table.insert(wm.windows, window) window.node:place_top() end else self.obj:clear_focus() self.focused = nil end end function Seat:pointer_move(window) if window then if self.op == nil then self:focus(window) self.obj:op_start_pointer() self.op = { type = "move", window = window, start = { x = window.x, y = window.y }, dx = 0, dy = 0, } end else if self.op == nil then self:focus(window) self.obj:op_start_pointer() self.op = { type = "world_move", dx = 0, dy = 0, pdx = 0, pdy = 0 } end end end function Seat:pointer_resize(window, edges) if self.op == nil then self:focus(window) window.obj:inform_resize_start() self.obj:op_start_pointer() self.op = { type = "resize", window = window, edges = edges, start = { x = window.x, y = window.y, width = window.width, height = window.height, }, dx = 0, dy = 0, } end end function Seat:action(action) if type(action) == "table" then if action[1] == "spawn" then if posix.unistd.fork() == 0 then posix.unistd.execp(action[2], {}) end elseif action[1] == "goto" then wm.cam.x = action[2] or 0 wm.cam.y = action[3] or 0 wm.cam.dirty = true; end elseif action == "close" then if self.focused ~= nil then self.focused.obj:close() end elseif action == "focus-next" then self:focus(wm.windows[1]) elseif action == "move" then -- if self.hovered ~= nil then self:pointer_move(self.hovered) -- end elseif action == "resize" then if self.hovered ~= nil then self:pointer_resize(self.hovered, { bottom = true, right = true }) end elseif action == "exit" then globals["river_window_manager_v1"]:exit_session() else print("Seat:action: unimplemented", action) end end function Seat:add_pointer_binding(button, mods, action) -- From /usr/include/linux/input-event-codes.h local button_code = ({ left = 0x110, right = 0x111 })[button] local obj = self.obj:get_pointer_binding(button_code, mods) local binding = { obj = obj } obj:add_listener { ["pressed"] = function (_) self.pending_action = action end, } obj:enable() table.insert(self.pointer_bindings, binding) end function Seat:add_xkb_binding(key, mods, action) local keysym = xkbcommon.keysym(key) local obj = globals["river_xkb_bindings_v1"]:get_xkb_binding( self.obj, keysym, mods) local binding = { obj = obj } obj:add_listener { ["pressed"] = function (_) self.pending_action = action end, } obj:enable() table.insert(self.xkb_bindings, binding) end function Seat:manage() if self.new then self.new = nil for _, tbl in ipairs(xkb_bindings) do self:add_xkb_binding(table.unpack(tbl)) end for _, tbl in ipairs(pointer_bindings) do self:add_pointer_binding(table.unpack(tbl)) end end if self.focused and self.focused.closed then self.focused = nil end self:focus(self.interacted) self.interacted = nil if self.pending_action ~= nil then self:action(self.pending_action) self.pending_action = nil end if self.op then local op, window = self.op, self.op.window local window = self.op.window if window and window.closed then self.obj:op_end() self.op = nil elseif self.op_release then if window and op.type == "resize" then window.obj:inform_resize_end() end self.obj:op_end() self.op = nil elseif window and op.type == "resize" then local width = math.max( 1, op.edges.left and (op.start.width - op.dx) or op.edges.right and (op.start.width + op.dx) or op.start.width ) local height = math.max( 1, op.edges.top and (op.start.height - op.dy) or op.edges.bottom and (op.start.height + op.dy) or op.start.height ) window.obj:propose_dimensions(width, height) end end self.op_release = nil end function Seat:render() if self.op then local op, window = self.op, self.op.window if window and self.op.type == "move" then window:set_position( op.start.x + op.dx, op.start.y + op.dy ) elseif window and self.op.type == "resize" then local x = op.edges.left and (op.start.x + (op.start.width - window.width)) or op.start.x local y = op.edges.top and (op.start.y + (op.start.height - window.height)) or op.start.y window:set_position(x, y) elseif self.op.type == "world_move" then local adx, ady = op.dx - op.pdx, op.dy - op.pdy wm.cam.x = wm.cam.x - adx -- TODO: make sure should be minus wm.cam.y = wm.cam.y - ady wm.cam.dirty = true; op.pdx, op.pdy = op.dx, op.dy end end end function Seat:maybe_destroy() if self.removed then for _, binding in ipairs(self.xkb_bindings) do binding.obj:destroy() end for _, binding in ipairs(self.pointer_bindings) do binding.obj:destroy() end self.obj:destroy() else return self end end function Seat.listener.removed(self) self:get_user_data().removed = true end function Seat.listener.pointer_enter(self, window) self:get_user_data().hovered = window:get_user_data() end function Seat.listener.pointer_leave(self) self:get_user_data().hovered = nil end function Seat.listener.window_interaction(self, window) self:get_user_data().interacted = window:get_user_data() end function Seat.listener.op_delta(self, dx, dy) local seat = self:get_user_data() seat.op.dx = dx seat.op.dy = dy end function Seat.listener.op_release(self) self:get_user_data().op_release = true end ---- wm ------------------------------- local function wm_manage() table_filter_inplace(wm.outputs, Output.maybe_destroy) table_filter_inplace(wm.windows, Window.maybe_destroy) table_filter_inplace(wm.seats, Seat.maybe_destroy) for _, window in ipairs(wm.windows) do window:manage() end for _, seat in ipairs(wm.seats) do seat:manage() end globals["river_window_manager_v1"]:manage_finish() end local function wm_render() if wm.cam.dirty then for i = 1,#wm.windows do local window = wm.windows[i] window.node:set_position(window.x - wm.cam.x, window.y - wm.cam.y); end wm.cam.dirty = false; end for _, seat in ipairs(wm.seats) do seat:render() end globals["river_window_manager_v1"]:render_finish() end local wm_handlers = { ["unavailable"] = function (self) io.stderr:write("another window manager is already running\n") os.exit(1) end, ["finished"] = function (self) os.exit(0) end, ["manage_start"] = wm_manage, ["render_start"] = wm_render, ["output"] = function (self, obj) table.insert(wm.outputs, Output.create(obj)) end, ["seat"] = function (self, obj) table.insert(wm.seats, Seat.create(obj)) end, ["window"] = function (self, obj) table.insert(wm.windows, Window.create(obj)) end, } ---- Entry point ---------------------- display = wau.wl_display.connect() assert(display, "Failed to connect to wayland compositor") -- Ensure we exit nonzero if an event handler errors local function handle_callback_error(proxy, name, func, err) io.stderr:write(("-- Error calling event handler for %s %q:") :format(tostring(proxy), name)) io.stderr:write(("%s\n"):format(tostring(err))) os.exit(1) end wau.wl_proxy.set_error_callback(handle_callback_error) -- Avoid passing WAYLAND_DEBUG to our children posix.stdlib.setenv("WAYLAND_DEBUG", nil) -- Ensure children are automatically reaped posix.signal.signal(posix.signal.SIGCHLD, posix.signal.SIG_IGN) local registry = display:get_registry() registry:add_listener { ["global"] = function (self, name, iface, version) local required_version = required_globals[iface] if required_version ~= nil then assert(required_version <= version, ("wayland compositor supported %s version too old (need %d, got %d)") :format(iface, required_version, version)) globals[iface] = self:bind(name, wau[iface], required_version) end end, } display:roundtrip() for k in pairs(required_globals) do assert(globals[k] ~= nil, ("wayland compositor does not support %s"):format(k)) end globals["river_window_manager_v1"]:add_listener(wm_handlers) while display:dispatch() do end