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:

KeyRequiredTypePurpose
metadatayestableDisplay name, capabilities, defaults — see metadata.
tileyes(ctx) -> {Zone}Computes the zones — see the tile contract.
onWindowAddedno(state, index) -> ()Memory-aware hook (index 0-based) — see memory.
onWindowRemovedno(state, index) -> ()Memory-aware hook (index 0-based) — see memory.
onWindowResizedno(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.

FieldTypeMeaning
namestringDisplay name in the settings UI / picker.
idstringStable identifier (defaults to the file name).
descriptionstringOne-line description shown under the preview.
defaultMaxWindowsnumberDefault window cap shown in the UI (0 = unset).
minimumWindowsnumberSmallest window count the layout supports.
supportsMasterCountbooleanExposes the "master count" control; sets ctx.masterCount.
supportsSplitRatiobooleanExposes the split-ratio slider; sets ctx.splitRatio.
defaultSplitRationumberInitial split ratio, clamped to [pluau.MIN_SPLIT, pluau.MAX_SPLIT].
supportsMinSizesbooleanHonours per-window minimum sizes (defaults to true; set false for cluster/tatami-style layouts).
supportsMemorybooleanUses the persistent split tree + add/remove hooks. Unlocks ctx.tree — see memory.
supportsScriptStatebooleanPersists an opaque ctx.state table across retiles (non-tree memory). Required for resize hooks that store layout state — see resize.
producesOverlappingZonesbooleanZones may overlap (stacking / deck layouts).
centerLayoutbooleanLayout is centered rather than filling the screen.
masterZoneIndexnumber0-based index of the "master" zone for highlighting; -1 = none.
zoneNumberDisplaystringOne of "all", "last", "firstAndLast", "none". Omit to let the renderer decide.
customParamslistUser-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:

Context fields

FieldTypeNotes
windowCount / countnumberWindows to place (same value, two names).
areaRectScreen work area, in absolute pixels, with split helpers — see the pluau library.
innerGap / gapnumberPixels between adjacent zones. Always ≥ 0.
masterCountnumberMaster windows (when supportsMasterCount).
splitRationumberMaster/stack split 0.1–0.9 (when supportsSplitRatio).
minSizes{ {w, h} }Per-window minimum sizes, 1-indexed.
focusedIndexnumber0-based index of the focused window; -1 if none.
windows{ {appId, focused, windowId} }?Per-window info, when available.
screen{id, portrait, aspectRatio}?Output info.
treeSplitNode?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, and custom are 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 left
local 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

ConstantValueMeaning
pluau.MIN_ZONE_SIZE50Minimum width / height per zone, in pixels.
pluau.MIN_SPLIT0.1Lower bound for split ratios.
pluau.MAX_SPLIT0.9Upper bound for split ratios.
pluau.MAX_TREE_DEPTH50Recursion 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 algorithms

Utilities

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,
TypeRequired keysOptional keys
"number"name, type, defaultmin, max, description
"bool"name, type, defaultdescription
"enum"name, type, default, optionsdescription

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 + retiles
end,

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.luau

A 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

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.