Tiling algorithms

Author custom tiling algorithms in JavaScript. They run in a sandboxed QJSEngine, with no recompile.

The contract is defined by phosphor-tiles: ScriptedAlgorithm loads your file, ScriptedAlgorithmSandbox freezes a small set of globals and built-ins into scope, then calls your calculateZones(params) once per layout request. 25 built-in algorithms ship under data/algorithms/ in the PlasmaZones repo as reference implementations.

Metadata is a JS object, not comment tags. If you have an algorithm that uses // @name-style headers, migrate it to var metadata = { ... } — the comment parser has been removed.

File layout

One algorithm is one .js file. No directory, no manifest. The metadata comes from a top-level var metadata object; the rest of the file defines whatever internal functions you want plus the required calculateZones function.

Bundled algorithms ship under data/algorithms/ in the PlasmaZones source tree. User-installed algorithms go under ~/.local/share/plasmazones/algorithms/. User files override bundled ones with the same id.

Metadata object

Export a top-level var metadata = { ... } object. The loader reads it after evaluating the script and uses it to drive the settings UI, the autotile picker, and the built-in registry. No fields are strictly required at parse time — anything missing falls back to the engine default — but name, id, and description are what users actually see, so treat those three as mandatory.

// SPDX-FileCopyrightText: 2026 you
// SPDX-License-Identifier: GPL-3.0-or-later
var metadata = {
name: "Binary Split",
id: "bsp",
description: "Balanced recursive splitting into equal regions",
producesOverlappingZones: false,
supportsMasterCount: false,
supportsSplitRatio: true,
defaultSplitRatio: 0.5,
defaultMaxWindows: 5,
minimumWindows: 1,
zoneNumberDisplay: "all",
supportsMemory: false,
supportsMinSizes: true
};
FieldTypeMeaning
namestringDisplay name in the picker. Trimmed to 100 chars.
idstringStable ID used in config. Must match ^[a-z][a-z0-9-]*$ and be unique across bundled + user algorithms.
descriptionstringOne-line description under the preview. Trimmed to 500 chars.
supportsMasterCountbooleantrue if params.masterCount is read.
supportsSplitRatiobooleantrue if params.splitRatio is read.
defaultSplitRationumberDefault when supportsSplitRatio is true. Clamped to [PZ_MIN_SPLIT, PZ_MAX_SPLIT].
defaultMaxWindowsintegerSoft cap before overflow behavior kicks in. Must be ≥ minimumWindows when both are set.
minimumWindowsintegerPicker grays the algorithm out below this count.
zoneNumberDisplaystringOne of "all", "last", "firstAndLast", "none". Omitted ≡ let the renderer decide.
supportsMemorybooleantrue if the algorithm keeps state across calls (e.g. dwindle-memory). Unlocks params.tree.
supportsMinSizesbooleanDefaults to true. Set false for cluster/tatami-style layouts that ignore params.minSizes.
producesOverlappingZonesbooleantrue if zones can overlap (stacking / deck layouts).
centerLayoutbooleantrue if the algorithm centers its output in the area. Rarely set.
masterZoneIndexintegerWhich returned zone is the master (for zone-number display). Defaults to -1 (no master).
customParamsarrayUser-facing tuneables. See Custom parameters.

JSDoc-style @param and @returns comments are fine — they're documentation, not metadata, and the parser silently ignores them. Declare parameters inside metadata.customParams, not in comments.

calculateZones contract

Define a top-level function named calculateZones that takes a params object and returns an array of zone rectangles. The sandbox calls it once per layout request.

/**
* @param {Object} params
* @returns {Array<{x: number, y: number, width: number, height: number}>}
*/
function calculateZones(params) {
// ... compute and return zones
}

Rules:

params fields

FieldTypeMeaning
windowCountnumberHow many windows need tiling.
area{x, y, width, height}Usable region after outer gaps are subtracted.
innerGapnumberPixel gap between adjacent zones. Always ≥ 0.
masterCountnumberPresent only when @supportsMasterCount.
splitRationumberIn [PZ_MIN_SPLIT, PZ_MAX_SPLIT]. Present only when @supportsSplitRatio.
minSizesArrayPer-window {w, h} minimum sizes. Length matches windowCount, or is empty for no constraints.
windowsArrayPer-window metadata. See below.
focusedIndexnumberIndex of the focused window in the tiled list. -1 when unknown.
screenObjectScreen metadata. See below.
customObjectUser-declared custom parameters. See below.
treeObjectSplit tree for memory-aware scripts. See below.

params.area is already inset by the outer-gap setting, so algorithms only ever deal with innerGap between zones. Don't reapply the outer gap.

windows, screen, custom, and tree are only present when the engine has something to hand in. Always null-check before drilling into nested fields.

Per-window metadata (params.windows)

One entry per tiled window, index-aligned with params.minSizes and params.focusedIndex. Populated from the current window list and kept up to date across appId renames (the engine resolves the current app class on every call).

FieldTypeMeaning
appIdstringReverse-DNS or WM_CLASS style identifier. Examples: firefox, org.kde.dolphin. Empty string when unresolved.
focusedbooleantrue if this window currently has focus.
// "Pin the focused browser as master" pattern
const browsers = ["firefox", "org.mozilla.firefox", "org.chromium.Chromium"];
const masterIdx = params.windows
? params.windows.findIndex(w => browsers.includes(w.appId) && w.focused)
: -1;

Screen metadata (params.screen)

Physical context for the monitor this call is tiling. Lets algorithms auto-flip axes on portrait monitors or pick different defaults for ultrawide setups.

FieldTypeMeaning
idstringConnector name. Examples: HDMI-1, DP-2, eDP-1.
portraitbooleantrue when height > width.
aspectRationumberwidth / height. 1.78 for 16:9, 0.56 for a rotated 16:9.

Custom parameters (params.custom)

Declare tuneable knobs inside metadata.customParams. Each entry shows up in the settings UI and its resolved value lands on params.custom.<name> in every call.

var metadata = {
name: "Cluster",
id: "cluster",
description: "Groups windows by application",
// ... other metadata fields ...
customParams: [
{ name: "focusBoost", type: "number", default: 0.2, min: 0.0, max: 0.5, description: "Extra width given to the focused cluster" },
{ name: "minClusterRatio", type: "number", default: 0.1, min: 0.05, max: 0.5, description: "Minimum width ratio per cluster" },
{ name: "packedMode", type: "bool", default: false, description: "Pack singletons together instead of side by side" },
{ name: "orientation", type: "enum", default: "auto",
options: ["auto", "horizontal", "vertical"], description: "Split direction" }
]
};
TypeRequired keysOptional keysReads as
"number"name, type, defaultmin, max, descriptionnumber (clamped to [min, max], ±Infinity rejected)
"bool"name, type, defaultdescriptionboolean (numeric 0/1 also accepted)
"enum"name, type, default, optionsdescriptionstring (must match an entry in options; empty or all-non-string options arrays are skipped)

The array is capped at 64 entries; each options array is capped at 256 entries. Unknown type values log a warning and are dropped. An enum whose default isn't in options is rejected.

Reading them back:

function calculateZones(params) {
// Defensive read — params.custom may be absent when the engine
// didn't marshal any custom values for this call.
const custom = params.custom || {};
const boost = custom.focusBoost ?? 0.2;
const minRatio = custom.minClusterRatio ?? 0.1;
// ...
}

Memory (split tree)

Set supportsMemory: true in metadata and the engine hands you a serialized split tree on params.tree. This is how BSP-style algorithms (dwindle-memory, paper, spiral) remember where each window sits across calls, so moving a window only shuffles that one leaf instead of rebuilding the whole layout.

The tree is a read-only deep copy. Mutating it in your script doesn't affect the next call; to persist changes, return the updated geometry and let the engine observe which leaves moved.

Field on params.treeTypeMeaning
leafCountnumberNumber of leaves in the tree. Handy for a quick validity check before traversing.
root, first, secondnodeBinary tree nodes. Leaves have no first/second; internal nodes carry a split ratio and direction.

Optional JS overrides

Most fields live on the metadata object, but any of these can also be exposed as a top-level JS function with the same name. When both exist, the JS function wins. The sandbox evaluates each override once at load time (watchdog-guarded against infinite loops), caches the result, and uses the cache thereafter.

FunctionReturnsOverrides
masterZoneIndex()numbermetadata.masterZoneIndex.
supportsMasterCount()booleanmetadata.supportsMasterCount.
supportsSplitRatio()booleanmetadata.supportsSplitRatio.
defaultSplitRatio()numbermetadata.defaultSplitRatio. Clamped to [PZ_MIN_SPLIT, PZ_MAX_SPLIT].
minimumWindows()numbermetadata.minimumWindows.
defaultMaxWindows()numbermetadata.defaultMaxWindows.
producesOverlappingZones()booleanmetadata.producesOverlappingZones.
centerLayout()booleanmetadata.centerLayout.

Most algorithms don't need any of these — the metadata object is plenty. Reach for a JS function when you want a value to depend on another declared parameter or on computed state at load time.

Lifecycle callbacks

Memory-aware scripts can react to window add and remove events by exporting callbacks at the top level. Both are optional. Define either one and the engine flags the script as lifecycle-aware; define neither and the script runs purely through calculateZones.

/**
* @param {Object} state
* state.windowCount — total tiled windows after the change
* state.masterCount — current master count
* state.splitRatio — current split ratio (clamped)
* state.windows[] — {appId, focused} per window
* state.focusedIndex — focused-window index, -1 if unknown
* state.countAfterRemoval (onWindowRemoved only)
* @param {number} windowIndex — index of the added/removed window
*/
function onWindowAdded(state, windowIndex) {
// Update your per-script memory here. The tree arrives on the
// next calculateZones call via params.tree.
}
function onWindowRemoved(state, windowIndex) {
// state.countAfterRemoval is state.windowCount - 1, provided so
// you don't have to subtract by hand.
}

Callbacks run inside the same watchdog as calculateZones. A hook that runs long or throws an error is discarded without touching layout state; the next tiling call proceeds as if nothing happened.

Sandbox globals

Four numeric constants are injected and frozen before your script runs. They mirror C++ defaults, so using them keeps every algorithm in sync when those defaults change.

GlobalTypeValue
PZ_MIN_ZONE_SIZEintegerMinimum width / height per zone, in pixels.
PZ_MIN_SPLITfloatLower bound for split ratios.
PZ_MAX_SPLITfloatUpper bound for split ratios.
MAX_TREE_DEPTHintegerRecursion guard for tree-walking algorithms.

Built-in helpers

The sandbox pre-injects a library of helper functions ported from the C++ layout math, so algorithms can share complex distribution and tree-walking logic without reimplementing it. All are global names; all are frozen.

Distribution

distributeWithGaps(total, count, gap)Equal distribution with gaps. Returns number[].
distributeWithMinSizes(total, count, gap, minDims)As above with per-item min sizes. Falls back proportionally when infeasible.
distributeWithOptionalMins(total, count, gap, minDims)Dispatches to the right one based on minDims.length.
distributeEvenly(start, total, count, gap)Positional distribution. Returns Array<{pos, size}>.
equalColumnsLayout(area, count, gap, minSizes)Full equal-columns zones. Also the universal fallback.

Split solvers

solveTwoPart(contentDim, firstDim, secondDim, minFirst, minSecond)Two-part split solver. Returns {first, second}.
solveThreeColumn(...)Three-column solver. Returns {leftWidth, centerWidth, rightWidth, leftX, centerX, rightX}.
clampSplitRatio(ratio)Clamps to [PZ_MIN_SPLIT, PZ_MAX_SPLIT].

Composite layouts

masterStackLayout(area, count, gap, splitRatio, masterCount, minSizes, horizontal)Shared implementation for master-stack and wide layouts.
threeColumnLayout(area, count, gap, splitRatio, masterCount, minSizes)Centered-master and three-column layouts.
dwindleLayout(area, count, splitRatio, innerGap, minSizes)Dwindle layout core.
lShapeLayout(area, count, gap, splitRatio, distribute, bottomWidth, rightHeight)L-shape with master in top-left.
deckLayout(area, count, focusedFraction, horizontal)Deck (monocle-with-peek) layout.

Tree + min-dim helpers

applyTreeGeometry(node, rect, gap, _depth)Recursively computes zone rects from a binary split tree. Depth-limited to MAX_TREE_DEPTH.
computeCumulativeMinDims(windowCount, minSizes, innerGap)Returns {minW, minH} arrays for alternating-axis algorithms.
applyPerWindowMinSize(w, h, minSizes, i)Clamps one window to its min sizes. Returns {w, h}.
extractMinWidths(minSizes, count), extractMinHeights(minSizes, count)Pull per-axis arrays out of minSizes.
extractRegionMaxMin(minSizes, startIdx, endIdx, axis)Max min-dim over a slice.
appendGracefulDegradation(zones, remaining, leftover, innerGap)Adds fallback zones in-place when the primary pass ran short.

Fallbacks

fillArea(area, count)N identical zones covering the full area. Degenerate-screen fallback.
fillRegion(x, y, w, h, count)As above for a partial region.

Minimal example

Two-column layout, with a master ratio and graceful min-size handling:

var metadata = {
name: "Two-Column",
id: "two-column",
description: "Master column on the left, stack on the right",
supportsSplitRatio: true,
defaultSplitRatio: 0.5,
minimumWindows: 1
};
function calculateZones(params) {
const count = params.windowCount;
if (count <= 0) return [];
const { area, innerGap, minSizes } = params;
if (area.width < PZ_MIN_ZONE_SIZE || area.height < PZ_MIN_ZONE_SIZE) {
return fillArea(area, count);
}
if (count === 1) return [{ x: area.x, y: area.y, width: area.width, height: area.height }];
const ratio = clampSplitRatio(params.splitRatio);
const solve = solveTwoPart(
area.width,
Math.round(area.width * ratio) - Math.floor(innerGap / 2),
area.width - Math.round(area.width * ratio) - Math.ceil(innerGap / 2),
PZ_MIN_ZONE_SIZE,
PZ_MIN_ZONE_SIZE,
);
const master = { x: area.x, y: area.y, width: solve.first, height: area.height };
const stackX = area.x + solve.first + innerGap;
const stackW = solve.second;
const heights = distributeWithOptionalMins(area.height, count - 1, innerGap,
extractMinHeights(minSizes, count - 1));
const zones = [master];
let y = area.y;
for (let i = 0; i < count - 1; i++) {
zones.push({ x: stackX, y, width: stackW, height: heights[i] });
y += heights[i] + innerGap;
}
return zones;
}

Install and hot-reload

Drop your <id>.js 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 raise a console warning and the sandbox falls back to equalColumnsLayout for the affected call. Check journalctl --user -u plasmazones.service -f while iterating.


Full API: see phosphor-tiles for ScriptedAlgorithm, the sandbox, and the helper registry. The 25 bundled algorithms under data/algorithms/ in the PlasmaZones source are the canonical reference for every pattern in this guide.