Tiling algorithms
Author custom tiling algorithms in Luau. They run in a frozen, read-only sandbox, with no recompile.
A tiling algorithm is one .luau script. It takes the current window count and screen area and returns a list of zones (rectangles) to place windows into. Scripts are written against pluau — a pre-loaded, frozen standard library of geometry primitives. The generic Luau host (sandbox, watchdog, marshalling) lives in phosphor-scripting; the tiling contract and the pluau library live in phosphor-tiles. The 26 built-in algorithms ship under data/algorithms/ in the PlasmaZones repo and use the exact same API, so they are the best reference once you know the shape.
Why Luau? It is a small, fast, gradually-typed Lua dialect that runs in a read-only sandbox. Your script cannot touch the filesystem, network, or the rest of the daemon. See ADR-0001.
Where algorithms live
| Your algorithms | ~/.local/share/plasmazones/algorithms/*.luau |
| Bundled (read-only) | /usr/share/plasmazones/algorithms/*.luau |
pluau type stubs | /usr/share/plasmazones/pluau.d.luau |
Each .luau file in the user directory is one algorithm. The file name (minus extension) is the fallback id; metadata.id overrides it. A user algorithm overrides a bundled one with the same id. The daemon hot-reloads the directory — save the file and the algorithm list refreshes, no restart needed. The settings app's Algorithms page can also create, duplicate, and edit them.
Quick start
Save this as ~/.local/share/plasmazones/algorithms/my-columns.luau:
local pluau = pluau
return pluau.algorithm { metadata = { name = "My Columns", id = "my-columns", description = "Equal-width vertical columns", defaultMaxWindows = 4, minimumWindows = 1, },
tile = function(ctx) if ctx.windowCount <= 0 then return {} end -- Split the screen area into N equal, gap-aware columns. return ctx.area:columns(ctx.windowCount, ctx.innerGap) end,}That is a complete algorithm. pluau.algorithm{…} wraps the table and returns it (it adapts ctx.area to a Rect for your tile function); pluau is a pre-loaded, frozen global — do not reassign it.
The algorithm table
pluau.algorithm{} takes a table with these keys:
| Key | Required | Type | Purpose |
|---|---|---|---|
metadata | yes | table | Display name, capabilities, defaults — see metadata. |
tile | yes | (ctx) -> {Zone} | Computes the zones — see the tile contract. |
onWindowAdded | no | (state, index) -> () | Memory-aware hook (index 0-based) — see memory. |
onWindowRemoved | no | (state, index) -> () | Memory-aware hook (index 0-based) — see memory. |
onWindowResized | no | (state, resize) -> table? | Interactive-resize hook — see resize. |
Metadata
All fields are optional except where your UI needs them. Unset fields fall back to sensible defaults, but name, id, and description are what users actually see, so treat those three as mandatory.
| Field | Type | Meaning |
|---|---|---|
name | string | Display name in the settings UI / picker. |
id | string | Stable identifier (defaults to the file name). |
description | string | One-line description shown under the preview. |
defaultMaxWindows | number | Default window cap shown in the UI (0 = unset). |
minimumWindows | number | Smallest window count the layout supports. |
supportsMasterCount | boolean | Exposes the "master count" control; sets ctx.masterCount. |
supportsSplitRatio | boolean | Exposes the split-ratio slider; sets ctx.splitRatio. |
defaultSplitRatio | number | Initial split ratio, clamped to [pluau.MIN_SPLIT, pluau.MAX_SPLIT]. |
supportsMinSizes | boolean | Honours per-window minimum sizes (defaults to true; set false for cluster/tatami-style layouts). |
supportsMemory | boolean | Uses the persistent split tree + add/remove hooks. Unlocks ctx.tree — see memory. |
supportsScriptState | boolean | Persists an opaque ctx.state table across retiles (non-tree memory). Required for resize hooks that store layout state — see resize. |
producesOverlappingZones | boolean | Zones may overlap (stacking / deck layouts). |
centerLayout | boolean | Layout is centered rather than filling the screen. |
masterZoneIndex | number | 0-based index of the "master" zone for highlighting; -1 = none. |
zoneNumberDisplay | string | One of "all", "last", "firstAndLast", "none". Omit to let the renderer decide. |
customParams | list | User-tunable parameters. See custom parameters. |
The tile(ctx) contract
Define tile as a function that takes one Context table and returns a list of Zones. The sandbox calls it once per layout request. A Zone is a plain table of absolute pixel coordinates:
{ x = 0, y = 0, width = 960, height = 1080 }Rules:
- Keep
tilepure and fast — it runs on every relevant layout change. - Return
{}whenctx.windowCount ≤ 0. - Guard tiny areas: when the work area is smaller than
pluau.MIN_ZONE_SIZEon either axis, fall back topluau.fillArea(ctx.area, count). - The return count should match
ctx.windowCount. Return fewer and the overflow windows float; return more and the tail is dropped. - All coordinates are absolute pixels (
ctx.areais already inset by the outer-gap setting, so only ever applyctx.innerGapbetween zones).
Context fields
| Field | Type | Notes |
|---|---|---|
windowCount / count | number | Windows to place (same value, two names). |
area | Rect | Screen work area, in absolute pixels, with split helpers — see the pluau library. |
innerGap / gap | number | Pixels between adjacent zones. Always ≥ 0. |
masterCount | number | Master windows (when supportsMasterCount). |
splitRatio | number | Master/stack split 0.1–0.9 (when supportsSplitRatio). |
minSizes | { {w, h} } | Per-window minimum sizes, 1-indexed. |
focusedIndex | number | 0-based index of the focused window; -1 if none. |
windows | { {appId, focused, windowId} }? | Per-window info, when available. |
screen | {id, portrait, aspectRatio}? | Output info. |
tree | SplitNode? | Persistent split tree (memory algorithms only). |
state | {[string]: any}? | Persistent script-state table (when supportsScriptState) — see resize. |
currentGeometries | {Zone}? | Last-applied zones, advisory. Empty on the first tile of a layout. |
custom | {[string]: any}? | Your customParams values, keyed by name. |
windows,screen,tree,state,currentGeometries, andcustomare only present when the engine has something to hand in. Always nil-check before drilling into nested fields.
The pluau standard library
pluau is a frozen global injected before your script runs. It carries the Rect helpers ctx.area is built from, the layout constants, and the same high-level layout helpers the built-in algorithms use. Full signatures are in the type stubs (pluau.d.luau) and the bundled data/algorithms/*.luau.
Rect (what ctx.area is)
Gap-aware split helpers. Each split* returns the named side first, then the remainder; columns/rows return a list. Rect also has the plain fields x, y, width, height — a Rect is a valid Zone, so you can return one directly.
local left, rest = ctx.area:splitLeft(0.6, ctx.innerGap) -- 60% on the leftlocal top, rest2 = rest:splitTop(0.5, ctx.innerGap)local cols = ctx.area:columns(3, ctx.innerGap) -- { Rect, Rect, Rect }local rows = ctx.area:rows(2, ctx.innerGap)Constants
| Constant | Value | Meaning |
|---|---|---|
pluau.MIN_ZONE_SIZE | 50 | Minimum width / height per zone, in pixels. |
pluau.MIN_SPLIT | 0.1 | Lower bound for split ratios. |
pluau.MAX_SPLIT | 0.9 | Upper bound for split ratios. |
pluau.MAX_TREE_DEPTH | 50 | Recursion guard for tree-walking algorithms. |
High-level layout helpers
These build a full {Zone} list for common patterns — the same ones the built-in algorithms use:
pluau.fillArea(area, count)pluau.fillRegion(x, y, w, h, count)pluau.equalColumnsLayout(area, count, gap, minSizes)pluau.masterStackLayout(area, count, gap, splitRatio, masterCount, minSizes, horizontal)pluau.deckLayout(area, count, focusedFraction, horizontal)pluau.lShapeLayout(area, count, gap, splitRatio, distribute, bottomWidth, rightHeight)pluau.dwindleLayout(area, count, splitRatio, innerGap, minSizes)pluau.threeColumnLayout(area, count, gap, splitRatio, masterCount, minSizes)pluau.applyTreeGeometry(node, rect, gap) -- memory algorithmsUtilities
pluau.rect(x, y, w, h), pluau.join(...lists), pluau.clampSplitRatio(r), and the distribution / min-size primitives (pluau.distributeWithGaps, pluau.distributeWithMinSizes, pluau.distributeWithOptionalMins, pluau.distributeEvenly, pluau.extractMinWidths, pluau.extractMinHeights, pluau.solveTwoPart, pluau.solveThreeColumn, pluau.applyPerWindowMinSize, pluau.computeCumulativeMinDims, pluau.appendGracefulDegradation, …). Full signatures live in pluau.d.luau.
Custom parameters
Expose user-tunable knobs via metadata.customParams; read them from ctx.custom. The settings UI renders a control for each and stores the value the daemon passes back.
metadata = { name = "Cluster", customParams = { { name = "focusBoost", type = "number", default = 0.2, min = 0.0, max = 0.5, description = "Extra width given to the focused cluster" }, { name = "horizontal", type = "bool", default = false, description = "Stack horizontally" }, { name = "mode", type = "enum", default = "even", options = { "even", "weighted" }, description = "Distribution mode" }, },},
tile = function(ctx) local boost = (ctx.custom and ctx.custom.focusBoost) or 0.2 -- …end,| Type | Required keys | Optional keys |
|---|---|---|
"number" | name, type, default | min, max, description |
"bool" | name, type, default | description |
"enum" | name, type, default, options | description |
An enum whose default isn't in options is rejected. Defensive reads matter: ctx.custom may be absent when the engine didn't marshal any custom values for a call, so guard with ctx.custom and ctx.custom.<name> and fall back to the default.
Memory-aware algorithms (advanced)
Set metadata.supportsMemory = true to receive a persistent ctx.tree (a SplitNode) that survives across window add/remove events. This is how BSP-style layouts remember where each window sits, so moving one window only shuffles that leaf instead of rebuilding the whole layout. A SplitNode carries splitRatio, splitHorizontal, windowId, isLeaf, optional first / second children, and leafCount on the root. Use pluau.applyTreeGeometry(ctx.tree, ctx.area, ctx.innerGap) in tile to turn the tree into zones, and fall back to a stateless layout when the tree is absent or its leafCount doesn't match the window count:
tile = function(ctx) local count = ctx.windowCount if count <= 0 then return {} end
local area = ctx.area if area.width < pluau.MIN_ZONE_SIZE or area.height < pluau.MIN_ZONE_SIZE then return pluau.fillArea(area, count) end
-- Use the persistent split tree when present and matching. if ctx.tree and ctx.tree.leafCount == count then return pluau.applyTreeGeometry(ctx.tree, area, ctx.innerGap) end
return pluau.dwindleLayout(area, count, ctx.splitRatio, ctx.innerGap, ctx.minSizes)end,The host owns the tree's lifecycle. Optionally implement onWindowAdded(state, index) / onWindowRemoved(state, index) to react to those events (index is 0-based); defining either flags the script as lifecycle-aware. Both are optional and most layouts are stateless and don't need any of this. See data/algorithms/dwindle-memory.luau for a full example.
Interactive resize (advanced)
When a user drags the edge of a tiled window, the engine can hand the gesture to your algorithm so the layout reflows instead of snapping back. Implement the optional onWindowResized(state, resize) hook. It receives a state table (windowCount, masterCount, splitRatio, and the current scriptState) and a resize descriptor, and returns a table the engine acts on.
The resize descriptor
resize is a ResizeEvent. The index is the 0-based tiled index of the window being dragged; edges reports which edge moved. At most one edge per axis is ever set (left xor right, top xor bottom); a plain move with no resize reports none.
-- resize: ResizeEvent{ index = 1, -- 0-based tiled index oldRect = { x = 0, y = 0, width = 960, height = 1080 }, newRect = { x = 0, y = 0, width = 720, height = 1080 }, edges = { left = false, right = true, top = false, bottom = false },}Reflow by split ratio
The simplest response: derive a new split ratio and return it. The engine clamps it to [pluau.MIN_SPLIT, pluau.MAX_SPLIT], applies it to that screen and virtual desktop, and retiles. No supportsScriptState is needed for this path. The built-in master-stack and deck algorithms use it:
onWindowResized = function(state, resize) -- Master is index 0; dragging its right edge changes the split. if resize.index ~= 0 or not resize.edges.right then return nil -- ignore: let the engine snap the window back end -- Scale the stored ratio by how far the master edge moved. local ratio = state.splitRatio * resize.newRect.width / math.max(1, resize.oldRect.width) return { splitRatio = ratio } -- engine clamps + retilesend,Reflow by script state
For layouts with more than one degree of freedom (per-column widths, a grid of fractions), set metadata.supportsScriptState = true and return any other keys from onWindowResized. Those keys become ctx.state on the next tile, persisted across retiles. Read ctx.state in tile to honour the stored fractions, and fall back to defaults when it is absent. The built-in aligned-grid algorithm is the canonical example — it stores colFractions / rowFractions and updates them when a cell edge is dragged.
return pluau.algorithm { metadata = { name = "Aligned Grid", id = "aligned-grid", description = "Resizable grid with shared column and row tracks", supportsScriptState = true, },
tile = function(ctx) local cols = (ctx.state and ctx.state.colFractions) or defaultColumns(ctx) -- … build zones from the stored fractions … end,
onWindowResized = function(state, resize) local cols = recomputeColumns(state.scriptState, resize) return { colFractions = cols } -- persisted as ctx.state end,}ctx.currentGeometries holds the zones the engine last applied (advisory, after min-size enforcement), which is handy when computing a new fraction from where a window actually ended up. It is empty on the first tile of a layout, so always nil-check it.
Worked example
A two-column layout with a master ratio and graceful min-size handling:
local pluau = pluau
return pluau.algorithm { metadata = { name = "Two-Column", id = "two-column", description = "Master column on the left, stack on the right", supportsSplitRatio = true, defaultSplitRatio = 0.5, minimumWindows = 1, },
tile = function(ctx) local count = ctx.windowCount if count <= 0 then return {} end
local area = ctx.area if area.width < pluau.MIN_ZONE_SIZE or area.height < pluau.MIN_ZONE_SIZE then return pluau.fillArea(area, count) end
if count == 1 then return { area } end
local ratio = pluau.clampSplitRatio(ctx.splitRatio) local master, stack = area:splitLeft(ratio, ctx.innerGap)
local heights = pluau.distributeWithOptionalMins( stack.height, count - 1, ctx.innerGap, pluau.extractMinHeights(ctx.minSizes, count - 1))
local zones = { master } local y = stack.y for i = 1, count - 1 do zones[#zones + 1] = pluau.rect(stack.x, y, stack.width, heights[i]) y += heights[i] + ctx.innerGap end return zones end,}Editor support & validation
Drop a .luaurc next to your algorithms so luau-lsp autocomplete and luau-analyze know about the injected pluau global:
// ~/.local/share/plasmazones/algorithms/.luaurc{ "languageMode": "nonstrict", "lint": { "*": true }, "lintErrors": false, "globals": ["pluau"]}For full pluau.* type information, point your editor at the shipped stubs (/usr/share/plasmazones/pluau.d.luau). Type-check before relying on a layout — CI runs exactly this over the bundled set:
luau-analyze ~/.local/share/plasmazones/algorithms/my-columns.luauA parse or type error means the daemon will skip the algorithm, so a clean luau-analyze run is the quickest way to know a script will load.
Sandbox & limits
pluauand the standard library are frozen before your script runs — you cannot monkey-patch them or reach outside the sandbox (noio,os.execute, filesystem, or network).- A long-running or infinite-looping
tileis interrupted by a watchdog (a per-call deadline of roughly 100 ms), so a runaway script can't hang the compositor. - Each script's heap is capped (default 64 MiB). A runaway allocation surfaces as a catchable Luau out-of-memory error — the script fails, the daemon keeps running.
- Persisted
ctx.stateis bounded too (about 64 KiB, 16 levels deep, a few thousand keys), so a resize hook can't grow state without limit.
Install and hot-reload
Drop your <id>.luau into ~/.local/share/plasmazones/algorithms/. The settings app picks it up immediately and shows it in the picker. Edits hot-reload: save the file, switch windows, and the new layout applies within a tick.
Parse or runtime errors are logged and the affected algorithm is skipped. Check journalctl --user -u plasmazones.service -f while iterating.
Full API: see phosphor-tiles for the pluau standard library, LuauTileAlgorithm, and the registry, and phosphor-scripting for the Luau host. The 26 bundled algorithms under data/algorithms/ in the PlasmaZones source are the canonical reference for every pattern in this guide.