TesseraGitHub
English
6 min readEdit on GitHub

Custom JavaScript layouts

Tessera can evaluate a classic JavaScript layout in a separate QuickJS worker. A script defines a global layout() function and returns an object that supplies window frames. The worker implements a tiling-model-compatible API, with Tessera-specific validation and sandboxing. See Built-in layouts and Configuration for the native alternatives. These capabilities do not establish a verified support profile: XFCE/X11 is the first-release target and its full profile remains unverified.

Install and select

Put a regular .js file directly in the layouts directory beside your configuration file, normally ~/.config/tessera/layouts/ on Linux. Discovery excludes symlinks, subdirectories and uppercase .JS extensions. Files are discovered at startup; the action key is the filename without .js. Discovery is not an automatic directory watcher; restart the controller after adding a file.

For the application action interface, use a filename stem containing only ASCII letters, digits and hyphens: other discovered names, such as my_layout.js, are not registered as GActions. For example, with two-columns.js present before the controller starts, activate its select-custom-two-columns action from the same session:

gapplication action org.xfce.Tessera select-custom-two-columns

The layouts: YAML cycle accepts built-in keys, not custom keys. An unknown custom key in that list is diagnosed and remains inert. The source checkout includes examples/layouts/two-columns.js; copy it into the layout directory to try it. An unknown key or unreadable file is refused. When a custom layout fails during evaluation, Tessera reports the failure and leaves windows in place rather than silently falling back to a native arrangement.

Layout object

Property Meaning
name Optional display name.
initialState Optional initial JavaScript state. Prefer JSON-serializable values.
getFrameAssignments Function returning window IDs mapped to frames.
commands Optional object with command1–command4, expandMain, shrinkMain, increaseMain and decreaseMain handlers.
updateWithChange Optional function receiving a change and current state.
extends Optional built-in layout key whose computed frames are passed to the script.
recommendMainPaneRatio The worker exposes this hook, but the controller does not call it for custom layouts; do not rely on it.

The script is evaluated as a classic script, not an ES module. The worker captures the result of layout() once per loaded context; a missing global function or invalid return is an error. A missing getFrameAssignments produces no assignments. The worker JSON-decodes geometry arguments and JSON-clones truthy state; falsy state is passed as undefined. Changes to those arguments do not mutate host state: return your result explicitly.

Frames

getFrameAssignments(windows, screenFrame, state, extendedFrames) receives:

  • windows: tiled windows for the output, in host-provided order. Each has an opaque string id, current frame, and isFocused. Do not assume the ID encoding, focus order or stacking order. Identity fields include applicationIdentity and optional appId; role is provided as windowRole and role. An optional coordinateSpace describes origin, rational scale and rotation when available.
  • screenFrame: { x, y, width, height } in global coordinates. On X11 these are root-window pixels, so another output can have a nonzero or negative origin.
  • state: initial or updated state. See below.
  • extendedFrames: undefined when no extended placements exist, otherwise an array of { id, frame } from the named built-in layout.

Return an object keyed by IDs received in windows:

{ "42": { x: 0, y: 0, width: 800, height: 600, isMain: true } }

All four numeric frame fields are required. isMain is optional and defaults to true; unconstrainedDimension may be "horizontal" or "vertical", defaulting to horizontal. An omitted window has no assignment. Unknown IDs, malformed, non-finite, non-positive, unrepresentable or out-of-screen frames are rejected. The worker rounds frame edges to pixels and clips at most one rounded pixel of edge overflow. The window manager may still apply a different effective frame due to its own constraints.

State, commands and events

updateWithChange(change, state) returns the new state. A command is an object with updateState(state, focusedWindowID); the second argument is omitted if no window is focused. Return the new state; a null or undefined handler result leaves the prior state unchanged. Missing handlers are no-ops. Geometry evaluation receives queued changes before its next frame calculation; events can wait while outputs have no windows. The bounded queue coalesces focus changes, and an overflow replaces the backlog with layout_change and the overflowing event. Treat layout_change as a reason to rebuild from the next window list.

The event kind is in change.change; change.type is an alias. Change kinds include add, remove, focus_changed, window_swap, application_activate, application_deactivate, space_change, layout_change, tab_change, unknown and none. Window-related changes use windowID; swaps also use otherWindowID.

// Examples: { change: "focus_changed", type: "focus_changed", windowID: "42" }
//           { change: "window_swap", type: "window_swap", windowID: "42", otherWindowID: "43" }
updateWithChange: function (change, state) {
    if (change.change === "focus_changed") {
        return { lastFocused: change.windowID };
    }
    return state;
}

The worker can retain its JavaScript context between evaluations, but an interrupted or restarted worker and a heap-exhaustion reset can discard module-level variables. Do not rely on such variables surviving failure; derive placement from the current inputs wherever possible. State returned by handlers is passed through the host, so do not assume a worker restart necessarily discards the host’s state snapshot either.

Complete example

This is the source checkout’s examples/layouts/two-columns.js without its header. One window fills the screen; with more windows, the first takes the left column and the rest stack on the right, separated by a one-pixel gap.

function layout() {
    var GAP = 1;

    return {
        name: "Two Columns",

        initialState: {},

        // Called with (windows, screenFrame, state, extendedFrames). Only the
        // first two arguments are used here; extra arguments are ignored.
        getFrameAssignments: function (windows, screenFrame) {
            var frames = {};

            if (!windows || windows.length === 0) {
                return frames;
            }

            var x = Math.round(screenFrame.x);
            var y = Math.round(screenFrame.y);
            var width = Math.round(screenFrame.width);
            var height = Math.round(screenFrame.height);

            // Degenerate screen: hand back the screen frame unchanged rather
            // than producing negative widths.
            if (screenFrame.width <= GAP || height <= 0) {
                frames[windows[0].id] = { x: x, y: y, width: width, height: height };
                return frames;
            }

            if (windows.length === 1) {
                frames[windows[0].id] = { x: x, y: y, width: width, height: height };
                return frames;
            }

            var usable = width - GAP;
            var leftWidth = Math.floor(usable / 2);
            var rightWidth = usable - leftWidth;
            var rightX = x + leftWidth + GAP;

            frames[windows[0].id] = {
                x: x,
                y: y,
                width: leftWidth,
                height: height,
                isMain: true
            };

            var column = windows.slice(1);
            var count = column.length;
            var slotHeight = Math.floor(height / count);
            var offset = 0;

            for (var i = 0; i < count; i += 1) {
                // The last window absorbs the rounding remainder so the column
                // always ends exactly at the bottom edge of the screen frame.
                var frameHeight = i === count - 1 ? height - offset : slotHeight;

                frames[column[i].id] = {
                    x: rightX,
                    y: y + offset,
                    width: rightWidth,
                    height: frameHeight,
                    isMain: false,
                    unconstrainedDimension: "horizontal"
                };

                offset += slotHeight;
            }

            return frames;
        }
    };
}

Sandbox and troubleshooting

No module loader, filesystem, process, network or shell API is exposed to JavaScript. require, import and export are not supported. console.log exists but discards its messages. QuickJS uses a 32 MiB heap limit and a 1 MiB stack limit, with a deadline for evaluation; these are implementation limits, not promises of future budgets. A timeout interrupts evaluation and can restart the worker. On Linux, Landlock and seccomp confinement is installed before scripts are read. If the sandbox cannot be installed, execution fails closed with a diagnostic; there is no unconfined fallback. Partial Landlock enforcement is reported in the worker log.

If a layout is missing, check its directory, lowercase extension and selection action. For a JavaScript error, check that layout is global and returns an object. For invalid geometry, check every returned ID and finite positive frame against the screen bounds. If windows are not placed, ensure the result includes their IDs. For timeouts, bound loops and recursion. A missing command handler does nothing; ensure the action is actually bound and the handler returns state.

From a source checkout, node --check examples/layouts/two-columns.js checks JavaScript syntax if Node is installed. It does not execute the layout in Tessera or verify sandbox behavior.