SCRIPTING.md

Lua Scripting Guide

Paneru embeds a Lua runtime, letting a script declare the entire configuration via paneru.setup{...}, hook into window-manager events (paneru.on), bind keys to Lua callbacks or command strings (paneru.bind), query state, persist data across reloads, and programmatically manipulate window sets.


1. Getting Started

Script Locations

By default, Paneru looks for a Lua script in the following locations (in order):

  1. $PANERU_LUA (environment variable)
  2. $HOME/.paneru.lua
  3. $XDG_CONFIG_HOME/paneru/init.lua

TOML Replacement

A script replaces the TOML. When any of those files exist, the TOML path is switched off completely: no paneru.toml is read, created, or watched, and anything the script does not set takes its built-in default. Two authoritative configs cannot coexist, so a leftover TOML never quietly overrides the script. Conversely, no init.lua is created for you when a paneru.toml already exists — TOML setups keep working untouched until you write a script yourself.

Like the TOML config, the script is automatically reloaded when the file is saved.

Paneru provides the paneru table as a global in init.lua. You can also write local paneru = require("paneru") there; it returns the same embedded API and does not need a separate paneru.so file. The loadable paneru.so client module is only for Lua scripts run outside Paneru. Installing the Paneru executable with cargo install does not install that separate module.

lua
1paneru.on("window_focused", function(event, ws)
2 paneru.run("window balance")
3end)
4
5paneru.bind("alt - j", "window focus east")

2. Configuration from Lua (paneru.setup)

paneru.setup{...} declares the whole configuration from Lua, so init.lua can replace paneru.toml entirely. The table mirrors the TOML sections one-for-one — options, padding, swipe, decorations, restore, windows, and top-level default_workspaces.

lua
1paneru.setup {
2 default_workspaces = 3,
3 options = {
4 focus_follows_mouse = true,
5 sliver_width = 5,
6 animation_speed = 12.0, -- write floats with a decimal point
7 preset_stack_heights = { 0.25, 0.5, 0.75 },
8 },
9 padding = { top = 10, bottom = 10, left = 8, right = 8 },
10 swipe = { sensitivity = 0.4, scroll = { modifier = "alt" } },
11 decorations = {
12 active = { border = { enabled = true, color = "#89b4fa", width = 2.0 } },
13 },
14 restore = { enabled = true, startup_grace_ms = 2000 },
15 windows = {
16 -- keys are just rule names; `title` is a required regex
17 term = { title = "kitty", floating = true, bindings_passthrough = { "ctrl+alt-h" } },
18 },
19}

Keybindings

Two equivalent ways to bind keys, both accepting the exact chord syntax of the TOML [bindings] table:

  • paneru.bind(chord, handler) — the handler is a command string or a Lua function (function handlers receive a state snapshot; only paneru.bind supports them).
  • a bindings sub-table inside setup, keyed by command with the chord as the value — a shorthand that desugars onto the same path as paneru.bind:
lua
1paneru.setup {
2 bindings = {
3 ["window focus east"] = "alt - l",
4 ["quit"] = "ctrl + alt - q",
5 },
6}

Reading the Active Configuration (paneru.config)

The active configuration is exposed on paneru.config, mirroring the paneru.setup schema (paneru.config.options, paneru.config.padding, paneru.config.swipe, paneru.config.decorations, paneru.config.restore, paneru.config.windows, and paneru.config.default_workspaces).

  • Pre-populated with defaults: Even before (or without) calling paneru.setup, paneru.config is populated with Paneru's built-in defaults (e.g., paneru.config.options.sliver_width == 5, paneru.config.options.preset_column_widths, etc.).
  • Merged on paneru.setup: Calling paneru.setup{...} merges your configuration table over the resolved defaults (and preserves any extra custom keys you include in the table).
  • Read-only snapshot semantics: paneru.config is a plain Lua table for inspection by snippets and callbacks. Mutating fields on paneru.config directly does not change the running window manager's settings unless you pass the modified table back to paneru.setup(paneru.config).
lua
1paneru.bind("alt - i", function()
2 local widths = paneru.config.options.preset_column_widths
3 paneru.flash("Configured sliver width: " .. tostring(paneru.config.options.sliver_width))
4end)

Precedence & Reloading

An init.lua disables the TOML entirely, whether or not it calls paneru.setup. With setup, that table is the configuration; without it, the built-in defaults are used — never a paneru.toml sitting next to the script. To keep using TOML, do not create a script.

Editing and saving init.lua hot-reloads the whole configuration (including menubar and passthrough updates), just like editing the TOML file.

Notes:

  • Float-valued options (animation_speed, border width/opacity, window width, …) should be written with a decimal point (12.0, not 12).
  • A reload that removes a previous paneru.setup call keeps the last config it produced rather than reverting to TOML.
  • With Nix modules, set services.paneru.config to this init.lua (Lua source or a path). See nix/README.md.

3. Event Handling (paneru.on)

paneru.on registers callback functions that execute when window-manager events occur.

Registration Syntax

paneru.on accepts 2 or 3 arguments:

lua
1paneru.on(event_name, [filter,] handler)
  • event_name (string): The event to listen for.
  • filter (optional table or function): Filter criteria. Only events matching the filter will trigger the handler. Non-matching events avoid cross-thread state queries and Lua execution.
  • handler (function): The callback function, receiving (event, workspace).
lua
1-- Unfiltered handler:
2paneru.on("window_focused", function(event, ws)
3 paneru.log("Focused window: " .. tostring(event.window_id))
4end)
5
6-- Filtered handler with table spec:
7paneru.on("window_spawned", { bundle = "libreoffice" }, function(event, ws)
8 if event.frame.width < 400 or event.frame.height < 400 then
9 return ws:float(event.window_id)
10 end
11end)
12
13-- Filtered handler with paneru.match:
14paneru.on("window_spawned", paneru.match{ app = "Ghostty" }, function(event, ws)
15 paneru.log("Spawned Ghostty window")
16end)

Supported Events

Event NameDescriptionEvent Payload Fields
window_spawnedA window was fully spawned and initialized in Panerutype, window_id, pid, app_name, bundle_id, title, role, subrole, frame ({x, y, width, height}), floating, managed
window_focusedA window gained focustype, window_id
window_destroyedA window was closed / destroyedtype, window_id
window_movedA window was movedtype, window_id
window_resizedA window was resizedtype, window_id
window_minimizedA window was minimizedtype, window_id
window_deminimizedA window was un-minimizedtype, window_id
window_title_changedA window's title changedtype, window_id
application_activatedAn application was activatedtype, pid
application_deactivatedAn application was deactivatedtype, pid
application_visibleAn application became visibletype, pid
application_hiddenAn application became hiddentype, pid
mouse_down / mouse_up / mouse_dragged / mouse_movedMouse actionstype, x, y, modifiers
space_changedActive workspace changedtype
space_created / space_destroyedVirtual workspace added or removedtype, space_id

4. Querying State

Inside a paneru.on handler or a paneru.bind callback, the script can read the same state documents paneru query … returns — no round trip, no io.popen:

lua
1paneru.on("window_focused", function(event, ws)
2 for _, window in ipairs(paneru.query_on_screen()) do -- actually visible
3 paneru.log(window.app_name .. ": " .. window.title)
4 end
5
6 local active = paneru.query_active()
7 paneru.flash("workspace " .. tostring(active.virtual_workspace_number))
8end)
FunctionReturns
paneru.query(kind)the raw JSON string, kind defaulting to "state"
paneru.query_json(kind)the same document, decoded into a table
paneru.query_state()the complete state document
paneru.query_active()the active display, workspace and focused window
paneru.query_workspaces()the virtual workspace rows
paneru.query_on_screen()the windows currently visible

These are spelled exactly as in the loadable client module (require("paneru"), implemented in src/lua/client.rs), so a helper that reads state works unchanged in either host. The payloads are documented in QUERY_AND_SUBSCRIBE_FORMAT.md.

To build the standalone loadable paneru.so shared library for an external Lua interpreter (e.g. Lua 5.4 or LuaJIT):

sh
1cargo build --release --lib --no-default-features --features module,lua54
2ln -sf target/release/libpaneru.dylib paneru.so

State is gathered on demand and at most once per callback, so handlers that never query cost nothing extra. Outside a callback there is no window-manager state to read, so calling one of these at script top level raises an error; call them inside a handler or keybinding callback.


5. Persistent State (paneru.state)

A handler that wants to remember something — which window is the scratchpad, what was focused a moment ago, how many times something has happened — cannot keep it in a Lua global. Saving init.lua rebuilds the interpreter, and every global goes with it. paneru.state is the store that survives reloads and daemon restarts.

lua
1paneru.state.set("pads.term", 4213) -- any JSON-shaped value
2paneru.state.get("pads.term") -- 4213, or nil
3paneru.state.set("pads.term", nil) -- nil removes the key
4
5paneru.state.mutate("count", function(n) return (n or 0) + 1 end)
FunctionDescription
paneru.state.get(key)Returns the stored value, or nil
paneru.state.set(key, value)Stores a value; passing nil removes the key
paneru.state.mutate(key, fn)Passes the current value to fn and atomically stores what it returns

Reach for mutate whenever the new value depends on the old one. It reads, runs your function, and stores the result only if the value is still what it read; if something else modified it first, mutate re-runs your function against the new value.

Keys are plain strings; values can be strings, numbers, booleans, or JSON-shaped tables. The store is saved in $XDG_STATE_HOME/paneru/script-state.json.


6. Programmatic Window Management

Handlers are given a window set (ws): the whole layout — displays, workspaces, columns, and the windows in them — as a value you can transform. It is modeled on xmonad's StackSet, and it is pure: every operation returns a new window set rather than changing the one you were given, and nothing touches a real window until you return it.

lua
1paneru.bind("alt - h", function(ws) return ws:focus(ws:west(ws:focused())) end)
2paneru.bind("alt - shift-h", function(ws) return ws:swap(ws:focused(), ws:west(ws:focused())) end)
3paneru.bind("alt - 3", function(ws) return ws:view(3) end)
4paneru.bind("alt - shift-3", function(ws) return ws:shift(ws:focused(), 3) end)

Because the window set is pure:

lua
1paneru.bind("alt - b", function(ws)
2 local tidied = ws:width(ws:focused(), 0.6) -- computed, not applied
3 if #ws:columns() < 3 then
4 return -- returning nothing changes nothing
5 end
6 return tidied -- only what you return commits
7end)

A handler that raises partway through changes nothing either, because it never returned anything. You can branch, compute candidate layouts, and return the chosen one.

paneru.windows(fn) is the same contract for use partway through a handler: it hands fn the window set and commits what it gives back.

Reading Layout State

MethodReturns
ws:focused()ID of the focused window, or nil
ws:windows()Every window, as records
ws:window(id)One window record
ws:find(pred) / ws:filter(pred)The first / all windows matching a predicate
ws:current()The number of the workspace on screen
ws:workspaces()Every workspace number
ws:workspace_windows(n)The windows on workspace n
ws:columns([n])The columns of a workspace, each a list of window IDs
ws:column_of(id) / ws:workspace_of(id)The column index / workspace number a window is on
ws:display_of(id)The display a window is on: { id, active, x, y, width, height }
ws:east(id) / ws:west(id)The window one column over
ws:next(id) / ws:prev(id)The next/previous window, wrapping

A window record contains id, app_name, bundle_id, title, role, subrole, frame, floating, managed, visible and focused.

paneru.match{ app = …, bundle = …, title = …, role = …, subrole = …, floating = …, managed = … } builds a compiled predicate; app, bundle and title are regular expressions, while role and subrole match exact strings.

Transforming Layout State

Each method returns a new window set:

  • ws:focus(id)
  • ws:swap(a, b)
  • ws:shift(id, workspace[, follow])
  • ws:view(workspace)
  • ws:float(id[, rect])
  • ws:sink(id)
  • ws:manage(id)
  • ws:unmanage(id)
  • ws:width(id, ratio)
  • ws:stack(id, onto)
  • ws:tab(id, onto)
  • ws:unstack(id)

ws:float(id) takes a window out of the tiling layout and leaves it where it is. ws:float(id, rect) places it relative to display fractions:

lua
1ws:float(id, { x = 0.1, y = 0.05, width = 0.8, height = 0.5 })

7. Example: Named Scratchpads

A worked port of xmonad's NamedScratchpad. A scratchpad is a window you toggle in and out of view; when not wanted, it is parked on a workspace you never look at (stash = 9).

lua
1scratchpad = { stash = 9, pads = {}, order = {} }
2
3function scratchpad.define(name, spec)
4 scratchpad.pads[name] = spec
5 table.insert(scratchpad.order, name)
6end
7
8-- The pad a window belongs to, if any. Declaration order decides ties.
9function scratchpad.pad_of(window)
10 for _, name in ipairs(scratchpad.order) do
11 if scratchpad.pads[name].match(window) then
12 return name, scratchpad.pads[name]
13 end
14 end
15end
16
17-- Park every pad in `names` that is currently on screen.
18function scratchpad.hide(ws, names)
19 for _, name in ipairs(names) do
20 local window = ws:find(scratchpad.pads[name].match)
21 if window and ws:workspace_of(window.id) == ws:current() then
22 ws = ws:shift(window.id, scratchpad.stash)
23 end
24 end
25 return ws
26end
27
28function scratchpad.toggle(name)
29 return function(ws)
30 local pad = scratchpad.pads[name]
31 local window = ws:find(pad.match)
32 if not window then
33 os.execute(pad.spawn .. " &") -- not running: start it
34 return
35 end
36 if ws:workspace_of(window.id) == ws:current() then
37 return ws:shift(window.id, scratchpad.stash) -- in view: put it away
38 end
39 ws = scratchpad.hide(ws, scratchpad.order)
40 return ws:shift(window.id, ws:current(), true):focus(window.id)
41 end
42end
43
44-- Place a pad window the first time we see it
45paneru.on("window_spawned", { bundle = "org.libreoffice.script" }, function(event, ws)
46 if event.frame.width < 400 or event.frame.height < 400 then
47 return ws:float(event.window_id)
48 end
49end)