Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mantle

Introduction

Mantle runs a desktop shell written in Lua on Wayland. The engine evaluates your shell.lua, which returns the surfaces to show (bars, windows, popups, a lock screen). Each surface holds a tree of nodes, and any node property can be a live signal that updates itself when a capability (audio, workspaces, the clock) pushes new state.

Build the first shell below, then find the rest by topic or by task. The glossary defines every term.

Your first shell

1. Create the config

mantle init -c ~/.config/mantle

It writes a starter shell.lua and a .luarc.json that points lua-language-server at the API stubs, so your editor completes and type-checks. The config is a directory, not a file (init).

2. A first bar

shell.lua runs top to bottom and returns one surface or an array of them. This bar shows a launcher button, the workspaces of the first output and a clock. The button and a keybind share one piece of named state, launcher_open, which shows a second panel.

local launcher_open = state("launcher_open", false)

local clock = text {
    content = mantle.system:map(function(system)
        return os.date("%H:%M", system and system.time)
    end),
    foreground = "#cdd6f4",
}

local workspaces = list {
    direction = "Horizontal",
    spacing = 4,
    source = mantle.workspaces:map(function(ws)
        return ws and ws.outputs[1] and ws.outputs[1].workspaces or {}
    end),
    key = function(workspace) return tostring(workspace.id) end,
    itemfn = function(workspace)
        return button {
            padding = { left = 6, right = 6 },
            radius = 4,
            background = workspace.populated and "#45475a" or "#00000000",
            on_click = function()
                mantle.workspaces:focus(workspace.id)
            end,
            children = { text { content = tostring(workspace.idx), foreground = "#cdd6f4" } },
        }
    end,
}

local launcher_button = button {
    padding = { left = 8, right = 8 },
    background = launcher_open:map(function(open) return open and "#89b4fa" or "#313244" end),
    on_click = function() launcher_open:set(not launcher_open:get()) end,
    children = { text { content = "Apps", foreground = "#cdd6f4" } },
}

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        exclusive = true,
        width = "Fill",
        height = 32,
        background = "#1e1e2ee6",
        child = row {
            width = "Fill",
            align_v = "Center",
            spacing = 8,
            padding = { left = 8, right = 8 },
            children = { launcher_button, workspaces, rect { width = "Fill" }, clock },
        },
    },
    panel {
        id = "launcher",
        layer = "Overlay",
        visible = launcher_open,
        keyboard_interactivity = "OnDemand",
        width = 400,
        height = 300,
        background = "#1e1e2e",
        radius = 12,
        child = text { content = "Launcher", foreground = "#cdd6f4" },
    },
}

LineWhy
mantle.system:map(...)A derived signal; content re-resolves on every push (once a second unless configure says otherwise). :get() would freeze it
system and system.timeCapabilities read nil until their first push, so every map handles nil
list { source, itemfn, key }Rebuilds one button per workspace when the list changes (list)
:focus(id)Fire and forget; the new active workspace arrives in the next push (actions)
width = "Fill" on the panel and the rowThe panel’s root spans the anchored edges only when asked (size); the "Fill" rect then pushes the clock right (alignment)
visible = launcher_openThe launcher panel maps and unmaps with the state

3. Run it

CommandDoes
mantle checkEvaluates and lays out the config with no Wayland, with every capability nil and again with sample data, then exits; 1 on error. Run after every edit. What it misses
mantle -dStarts the shell detached and prints its pid
mantle log -fFollows the running shell’s output, print included
mantleRuns it in the foreground instead

4. Edit it live

Saving any .lua or .frag file under the config directory re-evaluates shell.lua in the same process, and named state keeps its value. A reload whose evaluation raises keeps the previous scene on screen, logs the error and sets mantle.rescue, which a config can draw as an error banner. The next successful reload clears it. What a reload keeps: what survives it.

5. Split into modules

require("widgets.clock") loads widgets/clock.lua from the config directory, and nothing outside it. Bind every require to a local before listing it in a table: it returns the module and its file path. Example and rules: modules.

6. Bind a key

mantle toggle <name> flips a declared boolean state in the running shell, so the launcher above opens from the compositor:

# Hyprland (hyprland.conf)
bind = SUPER, A, exec, mantle toggle launcher_open
# Hyprland (Lua config)
hl.bind("SUPER + A", hl.dsp.exec_cmd("mantle toggle launcher_open"))
# niri (config.kdl, inside binds {})
Mod+A { spawn "mantle" "toggle" "launcher_open"; }

mantle set writes any value, and mantle call runs an action (commands).

Topic index

The sidebar’s order. Every guide, surface and node page ends with How do I… and Gotchas tables.

PageForSections
installationRequirements, install, autostartRequirements · Install · Set up a config · Run the shell
cliThe mantle binary and keybindsCommands · Flags · Environment variables · Binaries · Which config and which shell · Values and arguments · What check covers · Exit codes
runtimeThe VM, require, reloads, limitsThe VM · Modules and require · Evaluation, reload and generations · What survives a reload · Limits and budgets · Output and logging
signalsReactivity and named stateThe one rule · Reference · Derived signals · Named state · How re-resolution works · Switching views
surfacespanel, window, popup, lockProperties every role takes · Per-output child · Input region
nodesLayout and the node kindsLayout model · Common properties · Identity · Switching
paintHow a box is drawnColours · Box properties · Gradients · Clip · Mask · Shadows · Blurs
animationanimateHow a tween starts · Entry keys · Spring · Keyframes · Exit
inputPointer and keyboardHit testing · Pointer · Hover · Scroll · Text fields · Secure fields
processesRunning other programsWhich one do I use · process.run · process.detach · session_process
scriptingStorage, timers, actions, utilitiespersistent_table · timer · action · json.decode · log · fuzzy · palette.quantize · fonts
capabilitiesmantle.<name> state and actionsReading and acting · Capability list · Renderer members
cookbookComplete widgets to copy
faqA symptom whose cause lives on another pageFirst steps · Nothing shows · A save or a click does nothing · Values are wrong or stale · Errors in the log · Running processes · Capabilities
glossaryTerms; engine-internal ones are in CONTEXT.md
changelogLua API and CLI changes
roadmapGaps, proposed work, non-goals
documentingWriting and testing a page of this book

Editor completion comes from the lua-meta/ stubs that mantle init wires up; mantle.lua is generated from the Rust capability types. DECISIONS.md records why each contract is what it is, cited as ADR-NNNN.

How do I…

TaskAnswer
Install Mantle and start it with the sessionInstall, run the shell
Open UI from a compositor keybindBind a key, drive UI from a keybind
Make a keybind run Lua and print a resultaction
Show a reload error in the barError banner
Find why a reload or budget failedLimits and budgets, output and logging
Show a live value from the systemFirst bar, one rule
Combine two sources into one valueDerive from two capabilities
Debounce a search or hold a valueDebounce a search, delay
Flash a node when a value changespulse
Switch between tabs or viewsSwitching views, with ids
Draw different content per monitorPer-output content, per-output child
Type into a panelKeyboard focus, text fields
Close an overlay or popup on an outside clickClose an overlay, dismissal
Show a dropdown under a buttonAnchor to a node’s geometry, nested menus
Show a tooltip on hoverTooltip
Build a lock screenlock, secure fields, recipe
Centre or space out itemsCentre something, alignment, push items apart
Draw a progress meterrow and column
Show an app’s iconicon
Crossfade a wallpapertransition
Build a list from datalist
Scroll a long listScroll a long list, scroll
Write a shader effectshader
Round and clip contentRound an image’s corners, clip
Blur the desktop behind a barBlurs
Fade or slide a nodeanimation, spring
Show a spinnerKeyframes
Animate a node out before it goesExit
Make a slider or wheel controlPointer
Run a command and read its outputprocess.run
Launch an app that outlives the shellprocess.detach
Keep a daemon running for the sessionsession_process
Remember a setting across restartspersistent_table
Repeat something every few secondstimer
Search a list as you typefuzzy
Theme from the wallpaperpalette.quantize
Handle a capability that has not pushed yetReading and acting
React to a capability change (OSD, sound)on_change, Volume OSD
Copy a complete bar, launcher or lock screenCookbook
Find why something shows nothing or does nothingFAQ

Source: init, starter, CLI, watcher, reload and rescue, require path.

Installation

What Mantle needs, how to install it, and how to start it with the session. The introduction walks through a first config.

mantle init           # ~/.config/mantle/shell.lua and a .luarc.json for lua-language-server
mantle check          # evaluate it with no Wayland; exits 1 on an error
mantle -d             # run the shell detached
mantle log -f         # follow its output

Requirements

FeatureNeeds
SurfacesA Wayland compositor with wlr-layer-shell-v1; ext-session-lock-v1 for lock
window, popup surfacesxdg-shell; skipped when absent
capture nodeext-image-copy-capture-v1, else wlr-screencopy-v1
blur = trueext-background-effect-v1; ignored when absent
Fontsfontconfig (fc-match)
BuildRust 1.89+, PipeWire, PAM, udev, EGL, GBM, xkbcommon, libwayland-client, libwayland-egl. Lua 5.4 is vendored
Editor completionlua-language-server

A capability starts on the config’s first mantle.<name> read and needs its backend only from then. What each one does without it is in its page’s Backend section.

CapabilityNeeds
notifications, trayA session bus, and no other notification daemon or tray host holding the name
mprisA session bus
networkNetworkManager
bluetoothbluetoothd, running before mantle starts
audio, privacyPipeWire, running when the capability starts; it does not reconnect
batteryUPower
powerUPower, power-profiles-daemon
brightnessA /sys/class/backlight device, logind
keyboardRead access to the /dev/input keyboard; niri or Hyprland for layouts
workspacesniri or Hyprland
windowsniri or Hyprland, else wlr-foreign-toplevel-management-v1
idleext-idle-notify-v1, logind
lockext-session-lock-v1, logind, the mantle PAM stack (below)
polkitpolkitd with its helper socket /run/polkit/agent-helper.socket, $XDG_SESSION_ID, no other polkit agent running
sysinfohwmon k10temp, coretemp or acpitz for CPU temperature; amdgpu, nouveau or nvidia for GPU
updatespacman and the curl it depends on, dnf or apt-get; pkexec, answered by the polkit agent; paru or yay for AUR
applications$TERMINAL for Terminal=true entries, xdg-open for open_url

system, files, storage and processes need nothing beyond the paths and programs the config names.

Install

RouteSteps
Archmantle-git from the AUR builds main and installs the PAM stack
Ubuntu, FedoraA release’s sudo apt install ./mantle_<version>_amd64.deb or sudo dnf install ./mantle-<version>-1.x86_64.rpm: under /usr, with its libraries as dependencies and the PAM stack. Ubuntu 26.04 and Fedora 44 or later, since it needs glibc 2.43
Release tarballsudo tar -xzf mantle-<version>-x86_64-linux.tar.gz -C / installs under /usr/local, with the PAM stack and polkit rule under /etc; the libraries are yours to install. Needs glibc 2.43
From sourcecargo build --workspace --release, then copy target/release/mantle and target/release/mantle-renderer into one directory on PATH, such as ~/.local/bin
From a checkout, for developmentjust run [config] builds and runs config (default share/starter). just swap builds an optimised pair into $CARGO_HOME/bin and restarts the running shell

mantle starts mantle-renderer from its own directory, so both must come from one build (binaries).

A source build needs a C compiler and pkg-config for the vendored Lua, clang for PipeWire’s bindings, and the development files of what the binaries link:

DistroPackages
Archbase-devel clang pipewire pam systemd-libs wayland libxkbcommon libglvnd mesa
Fedoragcc pkgconf-pkg-config clang pipewire-devel pam-devel systemd-devel wayland-devel libxkbcommon-devel mesa-libEGL-devel mesa-libgbm-devel
Debian, Ubuntubuild-essential pkg-config clang libclang-dev libpipewire-0.3-dev libpam0g-dev libudev-dev libwayland-dev libxkbcommon-dev libegl-dev libgbm-dev

Rust 1.89 or later comes from rustup where the distro’s cargo is older. Mantle is developed and run on Arch. The Fedora and Debian lists build the workspace in a container; running the shell, and the updates capability’s dnf and apt backends, are untested on Fedora and Ubuntu for now.

Two optional system files ship in packaging/:

FileInstall toWithout it
pam.d/mantle/etc/pam.d/mantleUnlock authenticates against the login stack, whose pam_nologin or pam_shells may refuse the right password. Polkit prompts use polkit’s own stack either way
polkit-1/rules.d/50-mantle-pacman.rules/etc/polkit-1/rules.d/updates installs through pacman ask for the password on every pkexec; with it, a wheel user approves once per run

Set up a config

mantle init writes a starter shell.lua (a one-clock bar) and a .luarc.json into the config directory, ~/.config/mantle by default (which config). It keeps an existing file unless given --force.

The .luarc.json points lua-language-server at the lua-meta/ stubs, which give completion and type checks for every node, surface and capability. A package installs them under $PREFIX/share/mantle/lua-meta. Otherwise init writes the binary’s embedded copy to $XDG_DATA_HOME/mantle/lua-meta, rewriting any stub that differs, and mantle check says when they are out of date. The .luarc.json also raises LuaLS’s type-check, unbalanced, strict and global groups and unused-local to warnings in every file, so a wrong type or a dead require shows up.

Run the shell

WantDo
Start with the sessionniri: spawn-at-startup "mantle". Hyprland: exec-once = mantle
Start from a terminalmantle (foreground) or mantle -d (detached)
Use another configmantle -c DIR (which config)

Launch mantle from inside the compositor session: workspaces, windows and keyboard find niri or Hyprland through the environment the session sets. Every other command is on the CLI page.

Gotchas

TrapFix
cargo run -p supervisor runs a stale Renderer and reports the mismatch as a config errorjust run, which builds both binaries
Another notification daemon, tray host or polkit agent is runningStop it: Mantle takes those names only when they are free, and the tray waits in the queue (FAQ)

See also: introduction, CLI, FAQ.

Source: init, binaries, capability wiring, PAM worker.

CLI

The mantle binary starts the shell, checks a config without starting it, and lets a compositor keybind reach a running shell. Reach for set/toggle when a key should change what the shell shows, and call when it should make the shell do something.

A keybind workflow. The config declares the names:

-- `mantle toggle launcher_open` flips it; `mantle set launcher_open false` closes it.
local launcher_open = state("launcher_open", false)

-- `mantle toggle modal settings` opens "settings", or closes it when it is already open.
local modal = state("modal", "")

-- `mantle call volume.up` or `mantle call volume.up 0.1`.
action("volume.up", function(step)
    local audio = mantle.audio:get()
    if not audio or not audio.volume then
        error("no default output yet")
    end
    local volume = math.min(1.0, audio.volume + (step or 0.05))
    mantle.audio:set_volume(volume)
    return string.format("%d%%", math.floor(volume * 100 + 0.5))
end)

return panel {
    id = "launcher",
    layer = "Overlay",
    keyboard_interactivity = "OnDemand",
    visible = launcher_open,
    width = 480,
    height = 320,
    background = "#1e1e2eff",
    child = text {
        content = modal:map(function(name) return name == "" and "launcher" or name end),
        foreground = "#cdd6f4ff",
    },
}

The compositor binds keys to the commands. Hyprland (hyprland.conf):

bind = SUPER, Space, exec, mantle toggle launcher_open
bind = SUPER, Escape, exec, mantle set launcher_open false
bind = SUPER, Comma, exec, mantle toggle modal settings
bind = , XF86AudioRaiseVolume, exec, mantle call volume.up

Hyprland with a Lua config (0.56+):

hl.bind("SUPER + Space", hl.dsp.exec_cmd("mantle toggle launcher_open"))
hl.bind("SUPER + Comma", hl.dsp.exec_cmd("mantle toggle modal settings"))
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("mantle call volume.up"))

niri (config.kdl, inside binds { }):

Mod+Space repeat=false { spawn "mantle" "toggle" "launcher_open"; }
Mod+Escape { spawn "mantle" "set" "launcher_open" "false"; }
Mod+Comma repeat=false { spawn "mantle" "toggle" "modal" "settings"; }
XF86AudioRaiseVolume allow-when-locked=true { spawn "mantle" "call" "volume.up"; }

In a terminal, mantle call volume.up 0.1 prints the handler’s return value, such as 60%.

Commands

CommandDoes
mantleStarts the shell in the foreground. Stops on Ctrl-C or SIGTERM
mantle -dStarts the shell in its own session with no terminal, waits until it is running (up to 5 s), prints its pid and returns. Its output goes to mantle log
mantle init [--force]Creates the config directory. Writes .luarc.json and a starter shell.lua, keeping any that exist unless --force. Points lua-language-server at the type stubs: an installed package’s, else writes current stubs to $XDG_DATA_HOME/mantle/lua-meta
mantle checkEvaluates and lays out the config once without Wayland and exits. See What check covers
mantle log [-f]Prints a shell’s stdout and stderr. -f keeps printing until that shell exits
mantle listPrints running shells, oldest first: PID, UPTIME, DIR (the instance directory) and CONFIG
mantle stopSends the shell SIGTERM and waits up to 10 s for it to exit. Exits non-zero if it is still running
mantle set <name> <value>Writes the running config’s state(name, ...) and waits for the shell to accept it
mantle toggle <name>Flips that state. It must hold a boolean
mantle toggle <name> <value>Sets the state to value. If it already holds value, restores the initial its state(name, initial) declares
mantle call <name> [args...]Runs the config’s action(name, fn) with args, waits for it and prints what it returned
mantle callPrints each action name the running config declares, one per line, sorted. After a failed reload, the actions it left
mantle set, mantle togglePrints each state the running config declares, sorted, as name<TAB>value. The value is JSON with strings quoted, so passed back as one argument, mantle set <name> <value> restores it: "true" stays a string. A value JSON cannot hold (a function, a number-keyed table that is not a list) prints the name alone. After a failed reload, the states of the scene still on screen
mantle -V, --versionPrints mantle <version>
mantle -h, --helpPrints the built-in help

Flags and the command may come in any order. -V and -h win over anything after them.

Flags

FlagWithDoes
-c <dir>, --config <dir>, --config=<dir>Everything except listThe config directory. A path to a file (shell.lua) means its directory, with a notice. A relative path is made absolute
-d, --detachRun onlyDetached start, as above
-v, --verboseRun onlyRaises the log level. Repeat or group: -v, -vv, -vvv
--profile[=SECS]Run onlyLogs idle-loop, heap and PSS/GPU memory reports every SECS seconds, default 60, with each capability’s last snapshot as name=<bytes>B/<sent>/<deduped>: pushes sent to the renderer and pushes dropped as equal to the last, since start. Implies -v
--forceinit onlyOverwrites .luarc.json and shell.lua
-f, --followlog onlyFollows the log until its shell exits
--pid <pid>, --pid=<pid>set, toggle, call, log, stopAddresses the shell with that pid, as mantle list shows it. Refused together with -c

A flag given to a command it does not apply to is an error, not ignored. --detached is the flag -d passes to the copy it starts; typed by hand, it is ignored and the shell runs in the foreground.

Log levels for a run:

FlagsPrints
noneErrors, warnings, and start, reload, respawn and stop notices
-vAlso info
-vvAlso debug
-vvvAlso the noisiest debug lines. More vs change nothing

MANTLE_LOG overrides the default level: MANTLE_LOG=debug, or per subsystem, MANTLE_LOG=warn,wayland=debug. Levels are off, error, warn, notice, info, debug (same as debug1) and debug2. An unknown entry is ignored with a warning. The config’s own log.* lines print at every level unless MANTLE_LOG names config=<level>.

Each log line reads HH:MM:SS LEVEL subsystem: message. Renderer lines prefix the subsystem with renderer/, config lines use config, and print output is written as is. The subsystem is the name MANTLE_LOG filters on (renderer/wayland: ... is wayland).

Environment variables

VariableRead byEffect
MANTLE_CONFIG_DIREvery commandThe config directory, below -c in precedence
XDG_CONFIG_HOME, HOMEEvery commandThe default config directory, $XDG_CONFIG_HOME/mantle or ~/.config/mantle
XDG_RUNTIME_DIRRun, list, log, set, toggle, callRequired. Instance directories live under $XDG_RUNTIME_DIR/mantle/
XDG_DATA_HOMEinitWhere stubs go when no package provides them. Default ~/.local/share
MANTLE_LOGRunLog filter, as above. Overrides the -v level
MANTLE_DUMP_LAYOUT=<instance>Run, with -vvvLogs every visible node’s kind, rect and text on that surface instance (bar@eDP-1) after each layout pass
RUST_BACKTRACE=1RunAdds a backtrace to a logged panic
__EGL_VENDOR_LIBRARY_DIRS, __EGL_VENDOR_LIBRARY_FILENAMESRunYour own EGL vendor choice. When neither is set and every GPU uses the nvidia driver, the Renderer loads only NVIDIA’s vendor

The Supervisor sets MANTLE_INSTANCE_DIR, MANTLE_GENERATION_ID, MANTLE_VERBOSE, MANTLE_PROFILE and MANTLE_CHECK on the Renderer it starts. They are internal; setting them by hand does nothing useful.

Binaries

BinaryRole
mantleThe Supervisor (the long-lived process that owns backends and restarts the Renderer) and every command on this page
mantle-rendererThe Renderer (the process holding the Lua VM and drawing the surfaces; see runtime). mantle starts it from its own directory, one per generation. Run by hand, it prints a notice and exits 2

Both must come from the same build. After rebuilding one, rebuild both.

Which config and which shell

The config directory resolves in this order, once at startup. Symlinks are resolved then, so retargeting one later does not move a running shell.

OrderSource
1-c / --config
2$MANTLE_CONFIG_DIR, naming the directory itself
3$XDG_CONFIG_HOME/mantle, when $XDG_CONFIG_HOME is absolute
4$HOME/.config/mantle

Several shells can run at once. Each running mantle holds an instance directory, $XDG_RUNTIME_DIR/mantle/<pid>-<start ms>/, with its control socket, its log (shell.log), the config path it runs, and a lock that marks it as running. mantle list prints its name under DIR. Client commands pick one:

Command--pid-cNeither
set, toggle, callThat running shellThe newest running shell on that config, else an errorThe newest running shell on the default config, else the newest running shell of any config
logThat shell, running or stoppedThe newest running shell on that config, else its last stopped runThe newest running shell, with a note when several are running, else the last stopped run this login

A stopped run’s log stays until logout clears $XDG_RUNTIME_DIR. mantle log says so when it prints one.

Values and arguments

set, toggle and call read each value as JSON when it parses, else as a plain string.

TypedArrives in Lua as
true, falseboolean
3, -5, 0.1number. A value starting with - is a value, not a flag
notificationsstring: not JSON, so taken as is
'"true"', '"3"'string, because the JSON quotes survive the shell’s
'[1,2]', '{"a":1}'table
nullnil

call passes any number of arguments to the handler in order. Its output:

Handler returnsmantle call printsExit
nil or nothingNothing0
A stringThe string, unquoted0
Any other valueJSON0
Raises, blows the 5 ms budget, is not declared, or returns over 1 MiB`name` failed: <reason> on stderr1
No answer within 5 sA timeout message. The call may still have run1

The handler contract is in action.

set and toggle wait for the shell to apply the write, up to 5 s like call. The shell refuses a write to an undeclared name, a bare toggle on a non-boolean, or a value that fails the scalar checks. A refusal prints state `name` refused: <reason> on stderr, exits 1 and is also a warning in mantle log. What toggle <name> <value> compares and restores follows named state: scalars compare by value (1 equals 1.0), and a table never equals, so toggling to a table always sets it.

What check covers

mantle check evaluates shell.lua and its requires exactly as a start does, with no Wayland, no GPU, and every capability reading nil. Then it lays every surface out twice with the real layout code, on one 1920x1080 output plus one per monitor name a panel pins:

PassCapabilities readCatches
before capability datanil, as at start before the first pushCode that forgets the nil case
with sample capability dataOne sample push each: every list has one entry, every optional field is set, every string is "sample", every integer 1Typos and bad properties in a list itemfn or a branch that only shows with data

It prints <path>: ok, N surface(s) and one <role> <id> line per surface, preceded by anything the config printed. An error prints as <config dir>: <error> and exits 1; files in it are named relative to the config directory. A layout error names its pass, <config dir>: <pass>: layout: <error>, once per failing pass. With more than one broken node, <error> is N nodes failed: and then one node per line: the first 20, then and N more. A mistake repeated on every output, or by every item of a list, is listed once. The path names the line that built each node and, for a failing :map or computed, the line that created the signal:

/home/me/.config/mantle: widgets/bar.lua:4: attempt to perform arithmetic on a nil value
stack traceback:
	widgets/bar.lua:4: in function 'widgets.bar.build'
	shell.lua:3: in main chunk
/home/me/.config/mantle: before capability data: layout: invalid value for `children`: on `bar@DP-1`: row[0] (shell.lua:7) > children[0]: shell.lua:2: `text` has no property `contnet`; did you mean `content`?
/home/me/.config/mantle: before capability data: layout: invalid value for `content`: on `bar@DP-1`: text[0] (shell.lua:9) > Signal getter on a `text` node failed: signal created at shell.lua:3: shell.lua:4: attempt to index a number value (local 'n')
stack traceback:
	shell.lua:4: in function <shell.lua:3>
CaughtNot caught
Lua syntax errors, in any required moduleHandler errors: on_click, action and timer never fire, and a capability on_change that raises on the sample push only logs a warning
Runtime errors at the top level of shell.lua and its modulesBranches that need a particular value: the samples take the first enum value, true and non-empty lists
A top-level return that is not surfaces, including require’s second valueprocess.run output: commands are queued and never run
Surface and node properties: unknown names, wrong value types, bad colours, out-of-range sizesFonts, images, shaders and the compositor’s response
Errors in :map, computed, list itemfns and function child builders, with nil and with sample capabilitiesSizes that only fail on a smaller or scaled output
More than one lock, and a missing shell.lua

When the stubs mantle init wrote differ from this mantle, check also prints one line asking you to run mantle init again.

It starts no programs and writes no state. On failure it prints only the error, not the config’s print output.

Exit codes

CodeWhen
0Success. For set and toggle: the shell applied the write
1The command failed. It prints the reason on stderr: no shell running, no shell with that --pid, XDG_RUNTIME_DIR unset, the socket unreachable, no log this login, check found an error, call failed or timed out, set or toggle was refused or timed out, -d could not start the shell (not running within 5 s, or it exited), init could not write a file
2Bad arguments: unknown flag, missing name or value, a non-numeric --pid, --profile=0, a flag the command does not take, --pid with -c, -c with list. It prints mantle: <reason> and the help text. Also mantle-renderer run by hand

How do I…

…wire a keybind? Declare a state or an action, then bind the command in the compositor, as in the example at the top. Use set/toggle to change what is shown, call to make the shell act. niri’s repeat=false keeps a held key from toggling repeatedly.

…start or stop the shell? Start it from the compositor (run the shell), which gives it XDG_RUNTIME_DIR and the Wayland socket. From a terminal, mantle -d starts it and gives the prompt back. Stop it with Ctrl-C in the foreground, or mantle stop (-c or --pid picks one of several). A config stops its own shell with process.detach("mantle", { "stop", "--pid", tostring(mantle.pid) }), which outlives the shell it stops.

…read the logs? mantle log prints the whole log of the current shell. mantle log -f follows it. Errors raised in callbacks are warnings, so they show by default. For more detail, restart with mantle -v (info) or mantle -vv (debug). mantle log | grep 'renderer/config: ' keeps only the config’s own log.* lines.

…debug a reload that did nothing? Run mantle check, then mantle log. The full sequence is in runtime.

…target one of two running shells?

$ mantle list
PID     UPTIME  DIR                    CONFIG
4120    2h13m   4120-1727170000000     /home/me/.config/mantle
9051    41s     9051-1727177900000     /home/me/src/mantle-test
$ mantle --pid 9051 toggle launcher_open
$ mantle -c ~/src/mantle-test call volume.up
$ mantle log --pid 9051 -f

…try a config without touching the running shell? mantle check -c ~/src/mantle-test first, then mantle -c ~/src/mantle-test starts a second shell on it. Its surfaces draw beside the first shell’s, so give them other ids or anchors, and address it with -c or --pid.

…see what a keybind can reach? mantle call lists the actions and mantle set the states with their values. Both print nothing when the config declares none, so they pipe into a picker: mantle call | fzf | xargs mantle call.

$ mantle set
launcher_open	false
modal	"settings"

…set a string that looks like a number or boolean? Quote it as JSON: mantle set label '"42"'.

…use an action’s answer in a script? volume=$(mantle call volume.up) captures the printed value, and a non-zero exit means it failed.

…see which node has the wrong size? Run MANTLE_DUMP_LAYOUT=bar@eDP-1 mantle -vvv and read mantle log. The instance id is the surface id, @, and the output name.

…get completion and type checking in an editor? Set up a config.

Gotchas

TrapFix
mantle set label true stores a boolean, mantle set count 3 a numberQuote JSON strings: mantle set label '"true"'
A keybind does nothing and the terminal shows no errorThe compositor discards the command’s stderr. Run it in a terminal, or mantle log and look for asked to write state
mantle toggle modal is refused on a string stateA bare toggle needs a boolean. Pass the value: mantle toggle modal settings
mantle call x says no action exists after a broken saveA failed reload clears actions. Fix the config and save (runtime)
Two bars on screenTwo shells are running. mantle list, then mantle stop --pid <pid>
mantle -c dir list is refusedlist shows every config’s shells; drop -c
mantle log -f exits at onceThat shell has stopped. The command printed its last run
XDG_RUNTIME_DIR is not setThe command runs in an environment without it. Start the compositor from a proper login session

See also: runtime · named state · action · capabilities · glossary for Supervisor, Renderer and generation.

Source: argument parsing, commands, shell selection, set/toggle/call client, state writes, check, init, log, levels.

Runtime

What runs a config: the Lua VM and its libraries, how require finds modules, what a reload re-runs and what it keeps, and the limits the engine enforces. Read it before splitting a config into modules, when a reload does something unexpected, or when a log line names a budget.

Only shell.lua is required. This layout puts the bar in bar.lua and its clock in widgets/clock.lua:

-- shell.lua
local bar = require("bar")
return { bar }
-- bar.lua
local clock = require("widgets.clock") -- widgets/clock.lua

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    background = "#1e1e2eff",
    child = row { padding = 8, children = { clock } },
}
-- widgets/clock.lua
return text {
    content = mantle.system:map(function(system)
        return system and os.date("%H:%M", system.time) or "" -- nil before the first push
    end),
    foreground = "#cdd6f4ff",
}

Saving any of the three re-runs shell.lua in place, and the edited module is loaded fresh.

The VM

Mantle runs as two processes (glossary). The Supervisor is the mantle process: it owns the backends, watches the config and restarts the other one. The Renderer holds the Lua VM, the scene and the Wayland connection, all on one thread. There is one Lua 5.4 VM per generation. Anything that blocks it freezes every surface on every monitor, so the blocking parts of the standard library are removed (ADR-0048).

LibraryAvailableMissing
Baseassert, collectgarbage, dofile, error, getmetatable, ipairs, load, loadfile, next, pairs, pcall, print, rawequal, rawget, rawlen, rawset, require, select, setmetatable, tonumber, tostring, type, warn, xpcall, _G, _VERSION ("Lua 5.4")None. warn logs like log.warn, on by default; warn("@off") / warn("@on") toggle it
coroutineclose, create, isyieldable, resume, running, status, wrap, yieldNone
stringbyte, char, dump, find, format, gmatch, gsub, len, lower, match, pack, packsize, rep, reverse, sub, unpack, upper. Strings have the usual ("x"):upper() metatableNone
tableconcat, insert, move, pack, remove, sort, unpackNone
mathabs, acos, asin, atan, ceil, cos, deg, exp, floor, fmod, huge, log, max, maxinteger, min, mininteger, modf, pi, rad, random, randomseed, sin, sqrt, tan, tointeger, type, ult, plus the 5.3 compatibility functions atan2, cosh, frexp, ldexp, log10, pow, sinh, tanhNone
utf8char, charpattern, codepoint, codes, len, offsetNone
packageconfig, cpath (unused), loaded, path (the config directory only), preload, searchers, searchpathloadlib exists but raises. C modules never load
osclock, date, getenv, time. require("os") returns the same fourdifftime, execute, exit, remove, rename, setlocale, tmpname
ioNothingThe whole library. require("io") fails too
debugNothingThe whole library. debug.traceback included
FFI, native modulesNothingAll

load accepts text and precompiled chunks. dofile and loadfile remain and read any path, but they are synchronous file I/O on the render thread. To read a file, use process.run or persistent_table. To run a program, use process.run. For os.difftime(a, b), write a - b.

The engine adds these globals. Every module sees the same ones.

GlobalKindOwning page
panel, window, popup, lockSurface constructorssurfaces
rect, row, column, text, icon, image, capture, shader, button, list, textfieldNode constructorsnodes
state, computed, delay, pulse, geometrySignal constructorssignals
hover, hover_rect, scrollInput signalsinput
mantleCapabilities and renderer memberscapabilities
process, session_processProcessesprocesses
persistent_table, timer, action, json, log, fuzzy, palette, fontsScriptingscripting

Modules and require

The config is a directory, not a file. shell.lua is the entry, and require resolves module names inside that directory only.

RuleDetail
Search path<config>/?.lua;<config>/?/init.lua, nothing else. No system Lua paths, no ./
NamesEach . in a module name is a /: require("widgets.clock") loads widgets/clock.lua. require("widgets") also finds widgets/init.lua
Cachepackage.loaded behaves as usual within one evaluation. Every reload drops the config’s own modules first, so an edited module is re-read. Standard modules stay
Second valueLua 5.4’s require returns the module and its file path. In the last position of a table constructor both land in the table
SymlinksA symlinked subdirectory is followed, both by require and by the reload watcher

Bind every module to a local before listing it, or the file path becomes a surface:

return { require("bar") }        -- { bar, "/home/me/.config/mantle/bar.lua" }: fails
local bar = require("bar")
return { bar }                   -- works

The failure reads surface 2 is a string, not a node, followed by this hint. Parentheses, (require("bar")), also truncate to one value.

Evaluation, reload and generations

shell.lua returns a surface (panel, window, popup or lock), an array of them, {}, or nothing. Running it top to bottom is one evaluation. The engine runs it at startup and again on every reload.

TermMeaning
EvaluationOne run of shell.lua and whatever it requires. Its top level has no CPU budget
ReloadAn evaluation in the same VM, triggered by a saved file or an output change, then one apply to the live scene
ApplyThe engine reconciles the new surface list with the scene on screen. A surface whose fingerprint changed is rebuilt; everything else updates in place
GenerationOne Renderer process and its VM. A reload never starts a new one. Only a crash does, when the Supervisor respawns the Renderer

What triggers a reload:

EventReloads?
A .lua or .frag file anywhere under the config directory is written, created, renamed in or deletedYes, 200 ms after the last event of a burst
A save with the same bytes as the last one the watcher saw for that fileNo
Any other extension (.json, images, editor swap files)No
A new subdirectoryWatched from then on, and walked if it already has files
An output is added, removed or reconfigured (mantle.screens changes)Yes, at once, by the Renderer itself
The whole directory disappears (a git checkout)Watched again once it is back, polled every second

The watcher follows the config path resolved at startup. Retargeting a symlink later changes nothing until a restart.

How each failure ends:

FailureResult
Startup evaluation raisesNo scene. Surfaces paint nothing. mantle.rescue is set as below. The next successful reload brings the shell up
Startup evaluation succeeds but the scene rejects itNo scene. mantle.rescue is set
Reload evaluation raises (syntax error, runtime error, bad top-level return)The previous scene stays on screen. mantle.rescue becomes { is_rescue = true, error_log = "<the error>" }. The error is logged
Reload evaluates but the scene rejects it (bad property value, a map over budget)The previous scene stays. mantle.rescue is set. The error is logged, one line per broken node, as mantle check prints it
A live update fails later (a pushed value breaks a map)The previous scene stays. mantle.rescue is set until a pass applies. Warning logged
The session lock is refused, or the compositor ends itmantle.rescue becomes { is_rescue = true, error_log = "<the reason>" }. The error is logged (lock)
A reload would recreate the lock surface while lockedRefused with a warning and mantle.rescue; save again after unlocking
The Renderer crashesThe Supervisor starts a new generation. After three crashes within 60 s, it waits 30 s before the next respawn
The compositor goes awayThe Renderer exits with code 71 and the Supervisor shuts down instead of respawning

A failed reload keeps only the scene. The actions, timers, on_change handlers and idle thresholds of the evaluation that drew it are already cleared (what survives). A failed evaluation leaves none registered. A failed apply keeps the new evaluation’s actions, handlers and thresholds but no timers. Fix and save to get them back.

mantle.rescue clears when a reload applies. A rescue from a failed live update or startup apply also clears when a later pass over the same scene applies. A config can draw its own error banner:

local rescue = mantle.rescue

return panel {
    id = "rescue",
    layer = "Overlay",
    anchor = { bottom = true, left = true, right = true },
    visible = rescue:map(function(state) return state ~= nil and state.is_rescue end),
    background = "#f38ba8ff",
    padding = 8,
    child = text {
        content = rescue:map(function(state) return state and state.error_log or "" end),
        foreground = "#11111bff",
    },
}

What survives a reload

ThingIn-place reloadNew generation (crash)Shell stops
state, hover, hover_rect, scroll, geometry values (by name)Kept. A changed scalar state seed re-seeds (named state)LostLost
Plain Lua globals the config assignsKept: same VMLostLost
Config modules in package.loadedDropped, re-requiredLostLost
Derived signals (:map, computed, delay, pulse)Rebuilt. A pending delay or open pulse resetsRebuiltLost
persistent_table (by file)Same tableSame file, new tableOn disk
session_process (by name)Keeps running, same tableKeeps running (the Supervisor holds it)Stopped
process.run childKilled with its process group, failed reloads included. Its exit_cb(nil) runs before the new evaluation; no out_cb followsKilled with its process groupKilled
process.detach programUnaffectedUnaffectedUnaffected
timerCleared. The new evaluation’s timers start when its result is appliedClearedGone
action, mantle.<cap>:on_changeCleared, re-registered by the new evaluationClearedGone
mantle.idle thresholdsCleared, re-registeredClearedGone
fonts { ... } chainNot re-readRe-readGone
Capability state (mantle.<cap>)UnchangedReplayed from the Supervisor’s last snapshotGone

Top-level side effects run again on every reload. A top-level timer chain or long-running process.run is restarted, not doubled, because the old ones are cleared first. To keep a program running through a save, declare it with session_process (processes).

Limits and budgets

Config Lua and the renderer share one thread, so the engine bounds how long config code can hold it.

LimitValueApplies toWhen exceeded
CPU budget5 ms of thread CPU timeEach :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer callback. Nested reads share the outermost deadlineThe call raises exceeded the 5ms CPU budget for one evaluation. pcall inside the callback does not hide it
Signal nesting32 levelsSignal reads nested inside other signal reads (a map of a map of …, a computed reading itself)Raises signal nesting exceeded its maximum depth of 32 levels
Layout pass2 sOne whole pass over the scene, including list itemfns and function child buildersThe pass fails and the previous scene stays
Tree depth64 levelsNested nodes in one surfaceThe pass fails
Scalar valuesNumbers finite, integers within ±(2^53 − 1), strings at most 64 KiBstate seeds, :set(), mantle set, and number or string node properties. Tables are not checkedstate and :set raise. mantle set is refused: it exits 1 and logs a warning. A node property fails the pass
Numeric properties[0, 8192] logical px for most sizes. [-8192, 8192] for translate, rotate, shader progress, shadow offset and spread. opacity and origin [0, 1], scale [0, 64], font_size [1, 8192]. margin, padding, spacing and icon size are unbounded (a tween still clamps them)Node and surface properties (nodes)The pass fails, naming the property
Array length10,000children of one node, items of one list (source, and limit is clamped to it), runs in one text contentThe pass fails
delay, pulse duration[1, 60000] msdelay(signal, ms), pulse(signal, ms) (signals)Raises at the call
timer delay[1, 86400000] ms (one day)timer(ms, fn) (scripting)Raises at the call
Action answer1 MiB of JSONWhat an action handler returnsThe mantle call fails
mantle call wait5 sThe CLI waiting for an answerThe CLI gives up. The handler may still have run
Process output line64 KiBOne line a process.run child writesThe line arrives cut; its tail is dropped
Reload debounce200 ms after the last file eventThe watcherA burst of saves reloads once
Respawn brake3 Renderer deaths within 60 sThe SupervisorThe next respawn waits 30 s

Not budgeted: an evaluation’s top level, input handlers (on_click, on_drag, on_wheel, on_hover, on_link, textfield callbacks, on_close, on_dismiss), process.run callbacks, palette callbacks and idle callbacks. A slow one stalls every surface until it returns.

Keep maps cheap. Build lookup tables once at the top level, which has no budget, and do only an index and a format inside the map:

-- Evaluation has no CPU budget: build lookup tables here, once.
local levels = { "empty", "low", "half", "high", "full" }

-- The map runs on every push, under 5 ms: one nil check, one index, one format.
local battery_label = mantle.battery:map(function(battery)
    if not battery or not battery.present then
        return "" -- nil until the first push; no battery on a desktop
    end
    local level = levels[math.min(#levels, battery.percent // 20 + 1)]
    return string.format("%s %d%%", level, battery.percent)
end)

Work that is slow by nature (parsing a large file, searching many entries) belongs in a program started with process.run, whose output callback sets a state.

Output and logging

CallGoes to
print(...)The Renderer’s stdout, unstamped
log.error/warn/info/debug(...)Stamped lines under the config subsystem (log)
A raise from any callback: input handlers, on_close, on_dismiss, on_change, timer, process.run, palette or idleA warning
A process.run or process.detach that cannot spawn, a failing mantle callA warning
An icon name no theme has, an image that does not decodeA warning, once per name

Both streams land in the shell’s log file. Read it with mantle log. A terminal that started mantle in the foreground also gets a copy.

How do I…

TaskAnswer
Find out why a reload did nothingDebug a reload
Split a config into filesPut modules beside shell.lua and bind each require to a local, as in the example at the top
Share values between modulesShare values
Run something once, not on every reloadRun once
Keep a program running across reloadssession_process. A reload kills every process.run child
Do heavy work without blowing the 5 ms budgetBuild tables at the top level and keep maps to an index and a format (example). Move anything slower into a program run with process.run
Name a file shipped beside shell.luamantle.config_dir .. "/shaders/wave.frag". os.getenv("HOME") and the rest of the shell’s environment work too
Guard code that needs a newer engineCompare mantle.version.major, .minor and .patch (renderer members)
See what print wrotemantle log, or mantle check, which prints it above its report when the config evaluates

Find out why a reload did nothing

StepCommandTells you
1mantle checkSyntax and top-level errors, with file and line. Node and layout errors as laid out with every capability nil, then with sample data (what check covers)
2mantle logshell.lua re-evaluation failed (evaluation error) or the re-evaluated config failed to apply (layout error, previous scene kept), and errors raised in callbacks
3Draw mantle.rescueThe evaluation or apply error on screen, as in the banner above

Share values between modules

A module runs once per evaluation, and every later require of it returns the same table. For a value that changes, use a named state: the same name gives the same signal in any module.

-- lib/palette.lua: every module that requires it in one evaluation gets this same table.
return {
    accent = "#89b4faff",
    surface = "#1e1e2eff",
    dnd = state("dnd", false), -- named state: one signal per name, from any module
}
-- shell.lua
local palette = require("lib.palette")

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    background = palette.surface,
    child = text {
        content = palette.dnd:map(function(on) return on and "silent" or "" end),
        foreground = palette.accent,
    },
}

Run something once, not on every reload

Globals survive a reload, so a global guard runs once per Renderer start:

-- A global lives in the VM: it survives reloads and resets when the Renderer restarts.
if not started_at then
    started_at = os.time()
    log.info("renderer started")
end

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    child = text { content = "up since " .. os.date("%H:%M", started_at) },
}

Timers and actions cannot be guarded this way, because every reload clears them. Declare them at the top level every time.

Gotchas

TrapFix
return { require("a"), require("b") } fails with surface 3 is a stringBind each module to a local first
require("lib.json") from a luarocks install is not foundOnly the config directory is searched. Copy the pure-Lua module into it
A map raises exceeded the 5ms CPU budgetMove the heavy work to the top level or to process.run. The map should only index and format
dofile("/big/file") stutters every frame it runsRead files through process.run or persistent_table
A global counter keeps growing across reloadsGlobals live in the VM, and a reload reuses the VM. Use local, or state when it should persist on purpose
After a broken save, mantle call says no action existsA failed reload clears actions, timers and handlers. Fix the error and save again
A config edit to fonts { ... } does nothingThe font chain is read when the Renderer starts. Restart the shell
Saving a .json or an image beside shell.lua does not reloadOnly .lua and .frag changes trigger a reload

See also: cli · signals · processes · scripting · capabilities · surfaces · glossary for generation, Supervisor and Renderer.

Source: VM setup and require, reload, apply, watcher, budget, scalar checks, property ranges, respawn, respawn brake.

Signals

A config builds its node tree once; signals change it afterwards. Any node or surface property can hold a signal in place of a plain value. The engine reads the signal while it lays out, and a write to it re-resolves only the surfaces that read it.

The one rule

A property that holds a signal stays live. A property that holds a plain value, including whatever :get() returned, keeps that value until the next reload.

local clock = mantle.system:map(function(system)
  if not system then return "--:--" end -- nil until the first push
  return os.date("%H:%M", system.time)
end)

return panel {
  id = "bar", layer = "Top", anchor = { top = true, left = true, right = true }, height = 28,
  child = row { spacing = 12, children = {
    text { content = clock },       -- live: follows every push
    text { content = clock:get() }, -- snapshot: "--:--" until the next reload
  } },
}
Property valueBehaviour
sigRead again once it is written
sig:map(fn)Live: fn of sig’s current value
sig:get()A plain value, taken when the config was evaluated
A table with a signal inside, e.g. { left = sig }Refused at layout. Derive the whole table: sig:map(function(v) return { left = v } end)

A capability (mantle.<name>, see capabilities) is a signal too. It reads nil until its first snapshot arrives (hydration), and in mantle check’s first pass, before a sample push. Every function that reads a capability must handle nil. When a signal resolves to nil, its property counts as absent and takes the property’s default.

Reference

ExpressionReturnsContract
sig:get()valueThe current value, read once. nil before a capability’s first push
sig:map(fn)signalfn(value), run again on every read. Works on capabilities
sig:set(value)nothingState signals only; see who writes each kind
sig:reveal(index)nothingscroll signals only; scrolls the index-th child into view (input)
cap:on_change(fn), cap:<action>(...)nothingCapabilities only (capabilities)
computed({ a, b, ... }, fn)signalfn(a_value, b_value, ...): the values in list order, not the signals. Each entry must be a signal or capability: a nil, another value or a named key raises, naming the entry
state(name, initial)state signalWritable named state, written with :set(value)
delay(sig, ms)signalsig’s value once a new value has held for ms, and the old value until then. A change that reverts sooner is dropped
pulse(sig, ms)boolean signaltrue for ms after sig changes, false otherwise. A change inside the window restarts it. Starts false
geometry(name)rect signalBind it as a node’s geometry. Layout writes that node’s { x, y, width, height } in surface coordinates. Zero until the first layout

delay and pulse take ms in [1, 60000] rounded to whole milliseconds, and raise outside that range. Both compare values with ==, so a table value (every capability payload, for example) counts as a new value on every push.

Who writes each kind

Only state can be written from Lua. :set on any other kind raises an error that names the kind. :reveal works only on a scroll signal.

KindMade by:set:revealWritten by
Statestate(name, initial)✓The config, mantle set, mantle toggle
Capabilitymantle.<name>The capability’s snapshot pushes
Derived:map, computed, delay, pulseNobody: recomputed on read
StoredA persistent_table key (scripting)The table’s own :set(key, value)
Geometrygeometry(name)Layout
Hoverhover(name), hover_rect(name) (input)The pointer
Scrollscroll(name) (input)✓The wheel and the layout clamp

:set refuses the scalars outside the engine’s value limits and leaves tables unchecked. It checks no types: state("x", 1):set({}) succeeds, and only LuaLS flags it. A :set of what the state already holds changes nothing: 1 over 1 is skipped, 1.0 over 1 is a write. A fresh plain-data table (no metatable, only scalars and such tables inside, 256 entries in all) equal entry for entry is skipped too; the same table written again after changing it in place is a write.

Errors

Message starts withCause
computed() dependency 2 is nil / is a tableThat computed list entry is not a signal or capability; nil is often a misspelled variable
computed() dependencies: keyThe computed list has a named key; list the signals in fn’s order
delay() takes a Signal / pulse() takes a SignalThe first argument is not a signal or capability
delay() hold must be within [1, 60000] ms / pulse() window must be withinms out of range, or rounds to 0
signal:set() is only valid on a state(name, initial) signal:set on a derived, capability, hover, scroll or geometry signal
signal:set() refused its value at the marshalling boundaryNaN, infinity, an integer past ±(2^53−1) or a string over 64 KiB
state("name", ...) refused its initial valueThe same checks on initial
signal:reveal() is only valid on a scroll(name) signal / takes a 1-based child index:reveal on another kind, or an index below 1
signal nesting exceeded its maximum depth of 32 levelsA derived chain deeper than 32, or one that reads itself
exceeded the 5ms CPU budget for one evaluationA map or computed body ran too long (runtime)
a Signal resolved to another SignalA map returned a signal; return a plain value
`x` is a Signal handle, not a plain valueA signal in a structural property or inside a property table (see gotchas)

Derived signals

:map and computed run again once a signal they read is written, and their readers re-resolve only when the result changed (what a node reads again): a scalar or a plain-data table by value, anything holding a function, signal or metatable on every run. An HH:MM label or a { { text = hour, bold = true } } run list mapped from a per-second snapshot re-resolves once a minute. Within one pass, a derived signal read by several properties runs once. Keep their functions cheap and side-effect free: no :set, no process, no action. They run under the CPU budget and nesting limit described in runtime. Side effects belong in on_click, a capability’s on_change (capabilities) or a timer (scripting).

A derived colour:

local battery_color = mantle.battery:map(function(battery)
  if not battery or not battery.present then return "#6c7086" end
  return battery.percent <= 20 and "#f38ba8" or "#a6e3a1"
end)

text { content = "●", foreground = battery_color }

Use computed to combine two sources, here a capability and a state that a click toggles:

local show_seconds = state("show_seconds", false)

local clock = computed({ mantle.system, show_seconds }, function(system, seconds)
  if not system then return "" end
  return os.date(seconds and "%H:%M:%S" or "%H:%M", system.time)
end)

button {
  on_click = function() show_seconds:set(not show_seconds:get()) end,
  children = { text { content = clock } },
}

A dropdown under a button is a popup bound to a state the click toggles: dismissal.

delay: hold a value

delay answers the old value until the new one has held for ms. That makes it a trailing debounce, and also a close-hold. OR-ing a signal with its delayed copy keeps a surface mapped for ms after it closes, long enough for an exit fade to play. Hiding a surface or node plays no exit animation of its own (animation).

local open = state("menu_open", false)
local mapped = computed({ open, delay(open, 150) }, function(now, was) return now or was end)

local menu = popup {
  id = "menu", parent = "bar", anchor_rect = { x = 0, y = 0, width = 60, height = 28 },
  anchor = "Bottom", gravity = "Bottom",
  visible = mapped, -- stays mapped 150 ms after `open` goes false
  on_dismiss = function() open:set(false) end,
  child = column {
    padding = 8, background = "#1e1e2e",
    opacity = open:map(function(is_open) return is_open and 1 or 0 end), -- fades out while held
    animate = { opacity = { duration = 150, from = 0 } },
    children = { text { content = "Settings" } },
  },
}

pulse: mark a change

pulse reports that a change just happened. Use it to fire a one-shot flash or a keyframe animation, which a config cannot restart any other way (animation):

local count = state("count", 0)
local flash = pulse(count, 300) -- true for 300 ms after each change

button {
  padding = 6,
  background = flash:map(function(on) return on and "#f9e2af" or "#313244" end),
  animate = { background = 300 },
  on_click = function() count:set(count:get() + 1) end,
  children = { text { content = count:map(tostring) } },
}

To fire on only one edge, combine the pulse with its source: computed({ pulse(plugged, 400), plugged }, function(fired, on) return fired and on end).

geometry: read a node’s laid-out rect

Layout writes the rect after it solves. A change triggers one follow-up pass over the surfaces that read the rect, and never two passes in a row, so a binding that feeds its own measurement cannot loop.

local track = geometry("track")

column { width = 200, children = {
  rect { geometry = track, width = "Fill", height = 4, background = "#45475a" },
  text { content = track:map(function(rect) return string.format("%d px wide", math.floor(rect.width)) end) },
} }

Named state

state(name, initial) is the config’s own writable value. Its identity is its name. Every call with the same name returns the same signal, from any module and across reloads.

RuleDetail
IdentityOne name, one signal. hover, scroll and geometry names are separate namespaces
ReloadKeeps its value across in-place reloads. Lost when the Renderer process is replaced (a crash respawn or a shell restart)
Changed seedA scalar initial (nil, boolean, number, string) that differs from the last evaluation’s re-seeds the value. 0 and 0.0 are equal. Two different scalar seeds for one name in one evaluation raise
Table seedNever re-seeds: tables compare by identity, so a fresh table cannot count as a change
TypesNot checked at runtime; initial is the type LuaLS infers
CLImantle set <name> <value> and mantle toggle <name> [value] write it (cli). A bare toggle needs a boolean. Toggling to the value it already holds restores initial

Derived signals (:map, computed, delay, pulse) have no name. Each evaluation builds them fresh, so a reload drops a pending delay and closes an open pulse window. See runtime for everything else a reload keeps.

How re-resolution works

While a surface instance (one surface on one output) resolves, the engine records every signal it reads, including reads inside :map and computed bodies. A write marks the written signal dirty, and the next pass re-resolves only the instances that read it.

EventRe-resolves
:set, a capability push, a hover or scroll changeInstances that read that signal in their last resolve
The same, under a map or computedInstances that read it, once its result changed
A write to a signal no instance readsNothing
A delay coming due or a pulse window closingEvery instance
A geometry rect movingOne follow-up pass over the instances that read it
A wheel over a container whose scroll signal nothing else readsNothing: its children move where they are
Any write while the session is lockedEvery instance
A reload, or a re-resolve that failedEvery instance

A node reads its children or child table once and keeps what it read while it holds that same table. A node table or children array changed in place is not seen; a signal answering a new table is.

What a node reads again

Within a re-resolved surface, each node keeps the properties it resolved last time until a signal that resolve read is written. A clock written every second resolves its one text again, not the whole bar. A list keeps its items the same way (when items rebuild).

ChangeThe node reads its properties again
A write to a signal bound to one of its properties, or one changing the result of a map or computed bound to one✓
A write to its own hover slot✓
A different table, function, signal or value in its declaration, as in a rebuilt list item✓
A reload✓
For a panel or lock root, a write to a signal its function child read. Every ✓ here runs that function again✓
A write to anything else, even on the same surface

The engine sees signal reads only. A map, computed, itemfn, key or function child must answer from its arguments and the signals it reads; anything else it reads is taken as it was at the node’s last resolve:

Read inside the functionKept until a signal it read changesInstead
os.time(), os.date() with no time, os.clock(), math.random()✓mantle.system:map(function(s) return s and os.date("%H:%M", s.time) or "" end), or a state a timer writes
A local or global changed without :set✓Keep it in a state
A file✓Read it in a timer and :set a state
A table changed in place, such as a list’s source✓:set the table again, or build a new one
A delay or pulseNothing: its readers resolve on every pass while one is pending or open

A visible = false node’s subtree is frozen. Its children keep their nodes, ids, properties and last geometry. None of their signals is read, no list item function runs and nothing re-lays out until the node is shown again. Signals that only a hidden subtree reads therefore trigger nothing.

Switching views

visible = false keeps a subtree in the tree, frozen: right for a section shown and hidden in place. For views that replace each other, bind the parent’s children to a signal that returns only the current view. The old view leaves the tree, playing its animate.exit, and the new one builds fresh. Give each view its own id: switching views with ids.

How do I…

TaskAnswer
Show a live clockThe one rule
Colour a node from a capabilityDerived signals
Derive one value from two capabilitiesBelow
Debounce a search fieldBelow
Open a dropdown under a buttonDismissal
Keep a popup mapped while its exit playsdelay
Flash a node when a value changespulse
Open or close UI from a compositor keybindBelow
Switch tabsSwitching views with ids
Size one node from another’s layoutgeometry
Keep a toggle across shell restartsNamed state is lost with the Renderer; use persistent_table (scripting)
Run a side effect when a capability changeson_change (capabilities), never a map

Derive from two capabilities

List every source in computed. Each one reads nil until its first push.

local status = computed({ mantle.network, mantle.audio }, function(network, audio)
  if not network or not audio then return "..." end
  local net = network.connected and "online" or "offline"
  local sound = audio.muted and "muted" or string.format("%d%%", math.floor((audio.volume or 0) * 100))
  return net .. " / " .. sound
end)

text { content = status }

The field writes every keystroke to a state. The filter reads a delay of that state, so the filter runs once typing pauses for 250 ms.

local query = state("search_query", "")
local settled = delay(query, 250)

local results = settled:map(function(needle)
  local found = {}
  for _, name in ipairs({ "Firefox", "Files", "Terminal", "Settings" }) do
    if needle ~= "" and name:lower():find(needle:lower(), 1, true) then
      found[#found + 1] = text { id = name, content = name }
    end
  end
  return found
end)

column { width = 240, spacing = 4, children = {
  textfield { width = "Fill", height = 28, placeholder = "Search", on_change = function(text) query:set(text) end },
  column { spacing = 2, children = results },
} }

Drive UI from a keybind

Bind the surface’s visible to a named state. A compositor keybind that runs mantle toggle launcher_open flips it, and mantle set launcher_open false closes it. Example and compositor syntax: cli. For a keybind that runs Lua code, use action.

Gotchas

TrapFix
content = sig:get() never updatesPass sig or sig:map(...); :get() is a snapshot
A map errors with attempt to index a nil value at startupCapabilities read nil before hydration and in mantle check’s first pass; return a fallback for nil
visible = cap:map(function(c) return c and c.on end) shows the node before hydrationnil means absent, and visible defaults to true; return false explicitly
margin = { left = sig } fails at layout: `margin.left` is a Signal handleSignals inside a property table do not resolve. Derive the whole table with :map or computed; the error’s :get() advice gives a snapshot
A map that returns a signal fails with a Signal resolved to another SignalResolution happens once; return a plain value, or combine the sources with computed
layer, anchor, monitor, namespace, parent or an id bound to a signal is refusedThese are structural and take plain values only (surfaces)
A named state resets on every reloadIts scalar seed changed between evaluations. Keep it stable
state("x", ...) is declared twice in this evaluationTwo state calls give one name different seeds. Declare it in one module and require that
delay(mantle.system, 2000) never updatesEach push is a fresh table, so the hold restarts every second. Delay a scalar derived with :map
pulse(cap, ms) fires on every pushTable payloads are never ==; pulse a mapped scalar
Hiding a view with visible = false keeps its whole subtreeSwitch views through children = sig:map(...)
A :set inside a map or computedMaps must be side-effect free; write state from on_click, on_change or a timer
A clock from os.date() alone stops updatingNothing it read is a signal, so its node keeps the first answer. Derive it from mantle.system’s time (what a node reads again)

See also: runtime (budgets, reload), capabilities, input (hover, scroll), animation, cli, glossary (generation, hydration).

Source: signal core, globals, read tracking, re-resolve, property resolution, kept nodes, frozen subtrees.

Surfaces

A surface is a top-level Wayland object that holds one node tree. shell.lua returns one surface or a list of them, and every other node lives under some surface’s child. This page holds the rules all four roles share; each role has its own page.

You are buildingRoleProtocolInstances
Bar, dock, wallpaper, OSD, launcher overlay, notification stackpanelzwlr_layer_surface_v1One per matched output, id id@output
Settings window, dialog the user can move, tile or closewindowxdg_toplevelOne, id id
Dropdown, context menu, tooltip hanging off a panel or windowpopupxdg_popupOne, id id
Lock screenlockext_session_lock_surface_v1One per output, id id@output

An instance is one mapped copy of a declared surface; its id keys the retained scene and names the surface in mantle log (glossary).

A 32 px bar on every output that pushes windows down by its height and prints the output’s connector name:

local bar = panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    exclusive = true,
    width = "Fill",
    child = function(output)
        return row {
            width = "Fill",
            height = "Fill",
            padding = { left = 12, right = 12 },
            background = "#1e1e2e",
            children = { text { content = output, foreground = "#cdd6f4", align_v = "Center" } },
        }
    end,
}

return { bar }

Properties every role takes

Every surface takes id (required) and one child node. The root is itself a box node, so it also takes the common and box node properties: its own background, radius, padding, border_*, paint and animate. Any other key is refused, and the error names the closest accepted one or, with none close, lists them all.

PropertyValuesDefaultBehaviour
idStringRequiredThe surface’s identity across reloads and the prefix of its instance ids. Structural. Unique across every role; a duplicate is refused
childOne node; function(output) on a panel or lock (per-output child)NoneThe root’s one child. nil leaves the surface empty
visibleBoolean or signaltrueCreates and destroys the protocol object, not a hidden map. Retained state and id survive. lock refuses it

The surface root

The root has no parent, so a few node properties mean something else on it:

PropertyOn a surface root
width, heightRole-specific: the layer surface’s size on a panel, the root’s size inside the configured window on a window, the popup’s size on a popup. Refused on a lock
min_width, max_width, min_height, max_heightBound the root’s measured size, so they cap a content-sized panel or popup
marginA panel’s offset from its anchored edges. Ignored on the other roles
align_h, align_vIgnored: the root sits at the surface’s origin
Everything elseAs on any box node

Reload and structural fields

The returned list is re-read on every reload. Each surface is matched to the last evaluation by its fingerprint, the fields that the protocol fixes when the object is created.

RuleBehaviour
Return valueOne surface, a list of them, {} or nothing. Any other top-level node is refused
MatchingA surface whose fingerprint is unchanged keeps its Wayland objects; a missing one is destroyed; a new one is created
Fingerprintpanel: id, layer, anchor, monitor, namespace. window, popup, lock: id alone. A changed fingerprint destroys and recreates that surface’s objects under the same instance ids
Structural fieldsid, a panel’s layer, anchor, monitor, namespace and a popup’s parent refuse a signal: they are read once per evaluation. Change them by editing the file
Live fieldsEverything else takes a signal and updates the existing object in place
Invalid live valueA signal that resolves to a bad value logs a warning and keeps the last applied spec
HotplugAn output change re-evaluates the config and adds or removes panel and lock instances; instances on other outputs keep their objects
CountAny number of panels, windows and popups; at most one lock

Per-output child

child = function(output) is for panel and lock, the roles with one instance per output. It runs with that instance’s connector name ("DP-1") on the first pass, and again only when the surface root resolves again: a signal the function or the root’s own properties read is written, or a reload (what a node reads again). Bind signals to properties inside it rather than reading them with :get(), and key per-output state by name: state("wallpaper_" .. output, ...). Returning nil leaves that output’s instance empty. A window, popup or monitor = "Active" panel has no output name and refuses a function child. Example: per-output wallpaper.

Input region

A surface takes pointer input only where its content is solid; everywhere else clicks, hover and focus-follows-mouse pass through to what is below. The engine rebuilds this region on every pass.

Node under the rootClaims input
A box (rect, row, column, button) with a background or a non-zero border_widthIts whole box, painted bounds under its own transform. #00000000 counts
text, icon, image, capture, textfieldIts box
A button with on_click, on_drag, on_wheel or submit = trueIts box, even with nothing painted
A shaderNothing; its alpha is unknown to the engine. Put a button over it for a hit area
A transparent containerNothing; its children are asked instead
The surface root itselfNothing, even with a background
Anything on a layer = "Background" panelOnly such a button

A claiming box that clips its children ends the walk there; visible = false subtrees claim nothing. To make an empty area catch clicks, put a button { width = "Fill", height = "Fill", on_click = ... } there (click outside to close).

How do I…

TaskAnswer
Pick a roleThe table at the top
Put a bar on every monitorThe example at the top
Show and hide a surfaceBind visible to named state, then mantle toggle <name>
Keep different state per monitorPer-output child
See which surfaces a config declaresmantle check -c <dir> prints each role and id (CLI)
Let clicks through the empty part of a surfaceNothing to do; see input region
Make a transparent area catch clicksA full-size button with on_click (input region)
Change a panel’s layer or anchors at run timeDeclare two panels and toggle their visible, or edit the file

Gotchas

TrapFix
layer = state(...) or a signal anchor is refusedStructural fields take literals; switch between two declared panels, or edit the file
A click on a panel’s or window’s background reaches the window behind itThe root’s own background claims no input. Put the background on a width = "Fill", height = "Fill" child; on a panel, make the panel "Fill" on those axes too
two surfaces declare an idSurface ids are unique across every role; rename one
margin or align_h on a window or popup root does nothingSet it on the child
A function child on a window or popup is refusedOnly panel (not monitor = "Active") and lock have an output to pass

See also: nodes, paint, input, signals, runtime, CLI.

Source: surface parsing, accepted properties, fingerprints, instances, function child and root size, input region, reload and hotplug.

panel

A layer-shell surface (zwlr_layer_surface_v1) pinned to screen edges: bars, docks, wallpapers, OSDs, launcher overlays, notification stacks. Rules every role shares are in surfaces.

A 32 px bar across the top of every output, reserving its height so windows start below it:

local clock = mantle.system:map(function(system)
    return system and os.date("%H:%M", system.time) or ""
end)

local bar = panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    width = "Fill",
    height = 32,
    exclusive = true,
    child = row {
        width = "Fill",
        height = "Fill",
        padding = { left = 12, right = 12 },
        background = "#1e1e2e",
        children = {
            text { content = "Workspaces", foreground = "#cdd6f4", align_v = "Center" },
            rect { width = "Fill" },
            text { content = clock, foreground = "#cdd6f4", align_v = "Center" },
            rect { width = "Fill" },
            text { content = "Tray", foreground = "#cdd6f4", align_v = "Center" },
        },
    },
}

return { bar }

The panel’s width = "Fill" makes the root node as wide as the surface (size), and the two "Fill" spacers centre the clock. The background sits on the row, not the panel, so the whole bar takes clicks (input region).

Properties

Beyond the shared properties. Structural fields, the types without Bound, refuse a signal and rebuild the surface when edited; live ones take a signal and update it in place (reload).

PropertyTypeDefaultBehaviour
idstringRequiredThe surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; monitor = "Active" keeps the bare id
layer"Background"|"Bottom"|"Top"|"Overlay"RequiredStacking level, bottom to top. "Overlay" draws over fullscreen windows
anchor{ top?: boolean, bottom?: boolean, left?: boolean, right?: boolean }All falseEdges to pin to; an absent edge is false. None pinned centres the surface; one edge centres it along that edge
monitorstring"All"A connector name, "All" or "Active": which outputs get an instance (monitor)
namespacestring"mantle-{id}"The layer namespace compositor rules match (Hyprland layerrule, niri layer-rule)
widthLength|Bound, [0, 8192]ContentThe surface’s size (size)
heightLength|Bound, [0, 8192]ContentThe surface’s size (size)
exclusiveboolean|integer|"Ignore"|BoundfalseThe space reserved from other windows (exclusive zones)
keyboard_interactivity"None"|"OnDemand"|"Exclusive"|Bound"None"Whether it takes the keyboard (keyboard focus)
marginnumber|Edges|Bound0Offset from the anchored edges, not layout margin; one on an edge the panel is not anchored to does nothing
visibleboolean|BoundtrueHiding destroys the layer surface; showing recreates it
childNode|fun(output: string): Node?|BoundNoneThe root’s content. A function runs per output instance with its connector name; nil leaves that instance empty (per-output child)

monitor

ValueInstancesNotes
"All"One per output: bar@DP-1, bar@HDMI-A-1Follows hotplug
"DP-1"One, bar@DP-1, while that output is connectedAn unknown connector logs a warning and creates nothing
"Active"One, bare id bar, on the output the compositor picksPicked again at each show, because hiding destroys the object. Refuses "NN%" sizes and a function child. No instance while no output exists

Connector names come from mantle.screens or niri msg outputs / hyprctl monitors.

Size

width/height on an axisBoth edges of that axis anchoredOne or no edge anchored
OmittedThe compositor’s span, same as "Fill"Measured from the content
"Fill"The compositor’s spanProtocol error. The panel stays hidden with a warning, or keeps its previous size on a live change
px or "NN%"That sizeThat size

The table sizes the Wayland surface. The root node inside it is laid out like any node: omitted means content-sized even when the surface spans the output. On a spanned axis write "Fill" on the panel so the root, its background and its "Fill" children cover the surface.

Measured content is capped at the output minus the margins on the anchored edges, and by the root’s max_width/max_height. Other clients’ exclusive zones are not subtracted, so content wider than the space they leave is clipped by the compositor.

Exclusive zones

exclusiveReservesCovers others’ zones
falseNothingNo, stays inside them
trueThe configured size along the one anchored edge: height when exactly one of top/bottom is anchored and left/right match (both or neither), width for the transposed case. Any other anchor shape (a corner, all four edges) reserves 0No
Positive integerThat many px whatever the surface’s size; for a tall surface whose top strip is the barNo
"Ignore"NothingYes

The zone counts from the output edge, so it includes the panel’s margin on that edge.

Keyboard focus

Hiding a panel destroys its layer surface and showing creates a fresh one, so a constant keyboard_interactivity applies at every show. Bind it to a signal only to change the mode while the panel stays shown. This launcher opens on mantle toggle launcher_open from a compositor keybind, on the output the compositor picks, and closes on Escape:

local open = state("launcher_open", false)

local launcher = panel {
    id = "launcher",
    layer = "Overlay",
    monitor = "Active",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "Fill",
    height = "Fill",
    visible = open,
    keyboard_interactivity = "Exclusive",
    background = "#11111b99",
    child = column {
        width = 480, align_h = "Center", align_v = "Center",
        padding = 16, radius = 12, background = "#1e1e2e",
        children = {
            textfield {
                width = "Fill",
                height = 32,
                placeholder = "Search",
                autofocus = true,
                on_change = function(query) end,
                on_cancel = function() open:set(false) end,
            },
        },
    },
}

return { launcher }

See named state and text fields.

ModeBehaviour
"None"Never takes the keyboard
"OnDemand"Takes it when the user clicks it; other windows can take it back
"Exclusive"Takes it while mapped on Top or Overlay; nothing else gets keys
Compositor behaviourWhat the engine does or you do
Hyprland refocuses the last window, onto its workspace, when a still-mapped panel drops to "None"The engine sends no layer requests for a panel hidden in the same pass, so hiding one never drops it to "None" first. Hide it with visible, not by lowering the mode
niri hands an xdg_popup the keyboard only if its parent held it when the popup mappedThe engine routes keys that arrive on the parent to the text field in a popup shown under it
niri dismisses a grabbing popup when its parent’s keyboard_interactivity changesRaise the parent’s mode before opening the popup, not from inside it

Per-output content

local wallpaper = panel {
    id = "wallpaper",
    layer = "Background",
    anchor = { top = true, bottom = true, left = true, right = true },
    exclusive = "Ignore",
    width = "Fill",
    height = "Fill",
    child = function(output)
        return image {
            source = state("wallpaper_" .. output, "/usr/share/backgrounds/default.jpg"),
            width = "Fill",
            height = "Fill",
        }
    end,
}

return { wallpaper }

mantle set wallpaper_DP-1 /path/to/picture.jpg changes one output’s picture (CLI, image). On the Background layer only a button with a handler takes input (input region), so the desktop stays click-through.

OSD

A card bottom-centre on the output the compositor picks, shown for 1.5 s after the level changes. No left/right anchor, so the width is measured and the protocol centres it:

local level = state("osd_level", 0.5)

local osd = panel {
    id = "osd",
    layer = "Overlay",
    monitor = "Active",
    anchor = { bottom = true },
    margin = { bottom = 96 },
    visible = pulse(level, 1500),
    background = "#1e1e2ee6", radius = 12, padding = 12,
    child = row {
        spacing = 12,
        children = {
            text { content = "Volume" },
            rect {
                width = 200, height = 8, align_v = "Center", radius = 4, background = "#45475a",
                children = {
                    rect {
                        width = level:map(function(value) return string.format("%d%%", math.floor(value * 100)) end),
                        height = "Fill", radius = 4, background = "#89b4fa",
                    },
                },
            },
        },
    },
}

return { osd }

mantle set osd_level 0.7 shows it. Drive it from a capability by mapping, for example, mantle.audio to the level; see pulse. For a slide-in, animate the root’s translate, not the surface (animation).

How do I…

TaskAnswer
Put a bar on every monitorThe example at the top
Put a bar or dock on one monitorDock on one output
Open a launcher overlay that takes the keyboardKeyboard focus
Close an overlay when the user clicks outside its cardClose an overlay on an outside click
Show a volume or brightness OSDOSD
Draw a wallpaper per outputPer-output content
Stack cards in a screen cornerCorner stack
Draw over fullscreen windowslayer = "Overlay"
Hide the bar from a keybindvisible = state("bar_visible", true), then mantle toggle bar_visible
Reserve only the bar’s strip of a taller surfaceexclusive = 32 (exclusive zones)
Match the panel in compositor rulesmantle-{id} or namespace in a Hyprland layerrule or niri layer-rule

Dock on one output

Floating 8 px above the bottom edge. The zone is the dock’s 48 px plus its 8 px margin; exclusive = true would reserve only the 48 px (exclusive zones):

local dock = panel {
    id = "dock",
    layer = "Bottom",
    monitor = "DP-1",
    anchor = { bottom = true },
    margin = { bottom = 8 },
    exclusive = 56,
    background = "#1e1e2e", radius = 12, padding = 8,
    child = row { spacing = 8, children = {
        icon { name = "web-browser", size = 32 },
        icon { name = "folder", size = 32 },
        icon { name = "utilities-terminal", size = 32 },
    } },
}

return { dock }

Close an overlay on an outside click

A full-screen panel with a transparent button as its first child catches clicks everywhere; the card, declared after it, is on top and takes its own clicks:

local open = state("overlay_open", false)

local overlay = panel {
    id = "overlay",
    layer = "Top",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "Fill",
    height = "Fill",
    visible = open,
    keyboard_interactivity = "OnDemand",
    child = rect {
        width = "Fill",
        height = "Fill",
        children = {
            button { width = "Fill", height = "Fill", on_click = function() open:set(false) end },
            column {
                width = 320, padding = 16, radius = 12, background = "#1e1e2e",
                margin = { top = 40, left = 40 },
                children = { text { content = "Quick settings", foreground = "#cdd6f4" } },
            },
        },
    },
}

return { overlay }

A rect stacks its children, so the card lies over the catcher. A click on the card lands on the card’s own buttons or on nothing; it never reaches the catcher.

Corner stack

Anchored to two edges, so both axes are measured and the panel grows with its cards:

local items = state("toasts", { "Build finished", "Battery at 20%" })

local stack = panel {
    id = "toasts",
    layer = "Overlay",
    anchor = { top = true, right = true },
    margin = { top = 8, right = 8 },
    child = list {
        source = items,
        spacing = 8,
        itemfn = function(message)
            return rect {
                width = 320, padding = 12, radius = 12, background = "#1e1e2e",
                children = { text { content = message, foreground = "#cdd6f4" } },
            }
        end,
    },
}

return { stack }

An empty list still maps a 1×1 px surface; bind visible to whether the list has items.

Gotchas

TrapFix
A panel anchored to both sides shows its background only behind its contentOmitted size spans the surface but not the root node; set width = "Fill" (or height) on the panel
"Fill" on an axis with only one edge anchored leaves the panel hidden with a warning in mantle logAnchor both edges of that axis, or give a size
height = "50%" or a function child on a monitor = "Active" panel is refusedUse "Fill" with anchors and margins, or px
exclusive = true on a corner-anchored panel reserves nothingAnchor one edge, alone or with both perpendicular edges, or give a px count
exclusive = 0, -1 or 32.5 is refusedfalse, "Ignore", or a whole px count
margin = { top = 8 } on a bottom-anchored panel does nothingThe offset applies only to anchored edges
Clicks on the bar’s empty background reach the window belowThe panel’s own background claims no input; put it on a "Fill" child of a "Fill" panel (input region)
On Hyprland, other surfaces stop taking clicks while an "Exclusive" panel is mappedUse "OnDemand" unless the panel must hold every key; it still takes focus when it maps
A panel on monitor = "HDMI-A-1" never appearsThe name must match a connected output exactly; mantle log warns with the connected list

See also: surfaces, popup, input, signals, paint.

Source: panel spec, instances, layer shell, input region.

window

An xdg_toplevel: an application window the compositor places, tiles, decorates and closes. Use it for a settings window or a dialog; use a panel for anything pinned to the desktop. Rules every role shares are in surfaces.

local open = state("settings_open", false)
local page = state("settings_page", "General")

local function tab(name)
    return button {
        width = "Fill",
        padding = { left = 12, right = 12, top = 8, bottom = 8 },
        radius = 8,
        background = page:map(function(current) return current == name and "#313244" or "#00000000" end),
        on_click = function() page:set(name) end,
        children = { text { content = name, foreground = "#cdd6f4" } },
    }
end

local settings = window {
    id = "settings",
    title = page:map(function(name) return "Settings: " .. name end),
    app_id = "org.example.settings",
    min_size = { width = 480, height = 360 },
    visible = open,
    on_close = function() open:set(false) end,
    child = row {
        width = "Fill",
        height = "Fill",
        background = "#1e1e2e",
        children = {
            column { width = 160, height = "Fill", padding = 8, spacing = 4, background = "#181825",
                children = { tab("General"), tab("Display"), tab("Sound"), tab("Power") } },
            column { width = "Fill", padding = 24,
                children = { text { content = page, font_size = 20, foreground = "#cdd6f4" } } },
        },
    },
}

return { settings }

mantle toggle settings_open opens it; the compositor’s close button or keybind closes it through on_close. The title follows the selected tab.

Properties

Beyond the shared properties. Every field but id and on_close takes a signal and updates the open window in place; a change while it is closed applies when it next opens.

PropertyTypeDefaultBehaviour
idstringRequiredThe surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; monitor = "Active" keeps the bare id
titlestring|Bound""The window title
app_idstring|Bound"mantle-{id}"What compositor window rules match
min_size{ width: number, height: number }|Bound, [0, 8192]NoneAdvisory hint to the compositor; layout does not enforce it. Both keys required, 0 leaves that axis unconstrained. Also the opening size on an axis the compositor leaves to the client (size)
max_size{ width: number, height: number }|Bound, [0, 8192]NoneAdvisory, as min_size. A non-zero axis below min_size’s is refused; also clamps the opening size
on_closefun()NoneThe user asked to close. The window stays open until the config sets visible = false; without a handler a close request does nothing
visibleboolean|BoundtrueOpens and closes the window; state and id survive
widthLength|Bound, [0, 8192]Fill the windowThe root’s size inside the window, not the window’s (size)
heightLength|Bound, [0, 8192]Fill the windowAs width
childNode|BoundNoneThe one root node; a function child is refused

The engine requests server-side decorations and draws none itself. A compositor that insists on client-side decorations gets an undecorated window, with a log line.

Size

The window’s size is the compositor’s configure. The root fills it on each axis where it has no width/height of its own; a set one sizes the root inside the window.

CompositorOpening size
Tiling (niri, a tiled Hyprland window)The tile the compositor sends
Floating, leaving an axis to the clientA non-zero min_size on that axis, else 640×480, clamped by a non-zero max_size

To set a floating window’s size or position, use compositor rules on app_id, or min_size for the opening size.

How do I…

TaskAnswer
Open a settings window from a keybindBind visible to named state, as in the example; mantle toggle settings_open
Close it when the user clicks the close buttonon_close = function() open:set(false) end
Ask before closingConfirm before closing
Make it float, or place itA Hyprland windowrule or niri window-rule matching app_id
Give it a starting sizemin_size, or a compositor rule
Scroll content taller than the windowA column { height = "Fill", scroll = scroll("name") } (scroll)
Close it from a button inside itSet its visible state to false from on_click
Open a menu from itA popup with parent set to the window’s id

Confirm before closing

on_close is a request, so it can open a question instead of closing:

local open = state("editor_open", true)
local confirming = state("editor_confirm", false)

local editor = window {
    id = "editor",
    title = "Editor",
    visible = open,
    on_close = function() confirming:set(true) end,
    child = column {
        width = "Fill", height = "Fill", padding = 16, spacing = 8, background = "#1e1e2e",
        children = {
            text { content = "Unsaved changes", foreground = "#cdd6f4" },
            row {
                spacing = 8,
                visible = confirming,
                children = {
                    button { padding = 8, background = "#f38ba8",
                        on_click = function() confirming:set(false); open:set(false) end,
                        children = { text { content = "Discard" } } },
                    button { padding = 8, background = "#313244",
                        on_click = function() confirming:set(false) end,
                        children = { text { content = "Cancel", foreground = "#cdd6f4" } } },
                },
            },
        },
    },
}

return { editor }

Gotchas

TrapFix
The close button does nothingAdd on_close and set visible to false in it
min_size doesn’t stop the root shrinkingIt is advisory to the compositor; layout does not enforce it
min_size = { width = 400 } is refusedName both axes; 0 leaves one unconstrained
max_size below min_size is refusedKeep every non-zero max_size axis at or above min_size’s, or 0
width = 600 on the window doesn’t resize itThat sizes the root inside the window; the compositor owns the window’s size
A click on the window’s empty background reaches the window behind itPut the background on a "Fill" child, not the window (input region)
No title bar under a compositor without server-side decorationsThe engine draws none; draw your own row, or use compositor rules

See also: surfaces, popup, nodes, signals.

Source: window spec, window, root size.

popup

An xdg_popup on a shown panel, window or popup: a dropdown, context menu or tooltip. The compositor places it against a rectangle in the parent, keeps it on screen and, with a grab, dismisses it on an outside click, which a second panel cannot do. A hidden popup has no Wayland object. Rules every role shares are in surfaces.

local menu_open = state("menu_open", false)
local menu_anchor = state("menu_anchor", { x = 0, y = 0, width = 1, height = 1 })

local bar = panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    width = "Fill",
    height = 32,
    exclusive = true,
    child = row {
        width = "Fill", height = "Fill", background = "#1e1e2e",
        children = {
            button {
                padding = 8,
                on_click = function(rect)
                    menu_anchor:set(rect)
                    menu_open:set(not menu_open:get())
                end,
                children = { text { content = "Menu", foreground = "#cdd6f4" } },
            },
        },
    },
}

local menu = popup {
    id = "menu",
    parent = "bar",
    anchor_rect = menu_anchor,
    anchor = "Bottom",
    gravity = "Bottom",
    offset = { y = 4 },
    visible = menu_open,
    on_dismiss = function() menu_open:set(false) end,
    child = column {
        width = 160, padding = 12, spacing = 10, radius = 10, background = "#1e1e2e",
        border_width = 1, border_color = "#45475a",
        children = {
            text { content = "Settings", foreground = "#cdd6f4" },
            text { content = "Log out", foreground = "#cdd6f4" },
        },
    },
}

return { bar, menu }

A dropdown under a bar button. on_click’s rect is the button in its surface’s coordinates, which is what anchor_rect takes (pointer input). An outside click dismisses it and on_dismiss clears the state.

Properties

Beyond the shared properties. Every field but id, parent and on_dismiss takes a signal.

PropertyTypeDefaultBehaviour
idstringRequiredThe surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; monitor = "Active" keeps the bare id
parentstringRequiredThe id of a shown panel, window or popup; hiding the parent closes this popup. On a per-output panel it opens on the clicked instance, else the first. A change applies at the next open; a lock cannot be a parent
anchor_rectRect|BoundRequiredIn the parent’s surface coordinates; width/height in (0, 8192], x/y default 0. Usually the rect on_click passes
anchor"Top"|"Bottom"|"Left"|"Right"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound"Center"The point on anchor_rect the popup hangs from
gravity"Top"|"Bottom"|"Left"|"Right"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound"Center"The direction it extends from that point: "Bottom" hangs it below, "BottomRight" below and to the right
constraint_adjustment("SlideX"|"SlideY"|"FlipX"|"FlipY"|"ResizeX"|"ResizeY")[]|Bound{ "FlipY", "SlideX" }How the compositor may keep it on screen; {} for none, order is ignored
offset{ x?: number, y?: number }|Bound{ x = 0, y = 0 }Pixel nudge after anchor and gravity; an absent axis is 0, negative moves up or left
widthnumber|BoundContentPixels in (0, 8192]; no "Fill" or %. Omitted sizes to the content, capped at the first output’s size and the root’s max_width/max_height; an open popup follows it through xdg_popup.reposition (xdg-shell v3+)
heightnumber|BoundContentAs width; each axis is independent
grabboolean|BoundtrueTakes an input grab so an outside click dismisses it (grab). false for a tooltip
on_dismissfun()NoneThe compositor closed it (click outside, denied grab, parent gone); not called when the config hides it. Set visible = false here, or it reopens on the next click
visibleboolean|BoundtrueOpens and closes the popup; state and id survive
childNode|BoundNoneThe one root node; a function child is refused

Placement

While a popup is open, a change to its measured size or to any positioner field moves it through xdg_popup.reposition. Below xdg_popup version 3 it keeps the size and place it opened at until it closes, and logs a warning.

Grab

A grab needs a left, right or middle press or release on one of the shell’s surfaces in the same turn, so open a grabbing popup from on_click. Without one the popup stays closed and a warning is logged. A compositor that denies the grab dismisses the popup, and on_dismiss runs.

Dismissal

A compositor dismissal destroys the popup but leaves your visible signal true. The engine latches it shut until the next pointer press or release on the shell, then opens it again. Clear your state in on_dismiss, as the example at the top does.

Hiding or dismissing a popup also closes the popups open under it and latches them the same way, so each one whose visible stays true reopens on the next press.

How do I…

TaskAnswer
Show a dropdown under a bar buttonThe example at the top
Open a submenu from a menuNested menus
Show a tooltip on hoverTooltip
Anchor to a node without clicking or hovering itAnchor to a node’s geometry
Open a context menu on right clickon_click = function(rect, which) if which == "right" then ... end end (pointer)
Fade it out before it closesKeep visible true with delay while the child’s opacity animates (delay)
Open it from a keybindgrab = false, since a keybind is no pointer press; it then stays until the config hides it
Keep it on screen near an edgeconstraint_adjustment = { "FlipX", "FlipY", "SlideX", "SlideY" }
Give it a fixed sizewidth and height in px
Open it from a windowparent = "<window id>"

Nested menus

A popup can parent another popup. The submenu hangs off the right edge of the clicked row and flips left near the screen edge:

local sub_open = state("sub_open", false)
local sub_anchor = state("sub_anchor", { x = 0, y = 0, width = 1, height = 1 })

local power_row = button {
    padding = 4,
    on_click = function(rect)
        sub_anchor:set(rect)
        sub_open:set(true)
    end,
    children = { text { content = "Power ›" } },
}

local submenu = popup {
    id = "power_menu",
    parent = "menu",
    anchor_rect = sub_anchor,
    anchor = "TopRight",
    gravity = "BottomRight",
    constraint_adjustment = { "FlipX", "SlideY" },
    visible = sub_open,
    on_dismiss = function() sub_open:set(false) end,
    background = "#1e1e2e", padding = 8,
    child = column { spacing = 4, children = { text { content = "Suspend" }, text { content = "Reboot" } } },
}

power_row goes inside the menu popup. on_click’s rect is in the menu’s own coordinates, which is what a child popup’s anchor_rect expects. Set sub_open to false wherever the menu closes (its on_dismiss included), or the submenu reopens on the next click.

Tooltip

A hover opens no grab, so grab = false. hover_rect tracks the hovered node and is 1×1 before the first hover, which keeps anchor_rect valid:

local clock = text { content = "12:30", padding = 8, hover = hover("clock") }

local tooltip = popup {
    id = "clock_tooltip",
    parent = "bar",
    anchor_rect = hover_rect("clock"),
    anchor = "Bottom",
    gravity = "Bottom",
    offset = { y = 4 },
    grab = false,
    visible = hover("clock"),
    background = "#1e1e2e", radius = 6, padding = 6,
    child = text { content = "Thursday, 24 September" },
}

clock goes inside the bar panel.

Anchor to a node’s geometry

Bind geometry. It reads zero before the first layout, so map it to a 1×1 fallback. With no click to open it, it needs grab = false:

local battery = text { content = "87%", padding = 8, geometry = geometry("battery") }

local details = popup {
    id = "battery_details",
    parent = "bar",
    anchor_rect = geometry("battery"):map(function(rect)
        return rect.width > 0 and rect or { x = 0, y = 0, width = 1, height = 1 }
    end),
    anchor = "Bottom",
    gravity = "Bottom",
    grab = false,
    visible = state("battery_open", false),
    child = text { content = "2 h 10 min left" },
}

Gotchas

TrapFix
anchor_rect with a zero width or height is refused (a geometry before first layout, a hand-built rect)Fall back to { x = 0, y = 0, width = 1, height = 1 }
A dropdown reopens on the next click after an outside click closed itSet its visible state to false in on_dismiss
A popup that is visible at startup, or opened from a keybind, never opensgrab = true needs a click; open it from on_click, or set grab = false
A popup whose parent is hidden does not openShow the parent first; the popup opens on the next pass
A submenu reopens after its menu closedClear the submenu’s state wherever the menu closes, on_dismiss included
width = "Fill" or "50%" is refusedpx, or omit it to size to the content
anchor_rect from a click in a popup is placed wrong on the barA rect is in its own surface’s coordinates; anchor a popup only to rects from its parent
An open tooltip does not grow with its text on an old compositorxdg_popup below version 3 cannot reposition; it keeps its opening size until it closes

See also: surfaces, panel, input, signals.

Source: popup spec, popup, instances.

lock

The session lock screen: one ext_session_lock_surface_v1 per connected output, covering it for as long as the compositor holds the session locked. Declaring a lock does not lock; mantle.lock:lock() does, and only a correct password typed into its secure field unlocks. The lock’s state (active, authenticating, attempts, error, unlocking) and actions are on the lock capability. Rules every role shares are in surfaces.

mantle.lock:set_unlock_animation(250)

local up = mantle.lock:map(function(lock) return lock ~= nil and lock.active and not lock.unlocking end)

local hint = mantle.lock:map(function(lock)
    if lock == nil then return "" end
    if lock.error ~= "" then return string.format("%s (%d)", lock.error, lock.attempts) end
    if lock.authenticating then return "Checking…" end
    return lock.active and "Enter your password" or "Locking…"
end)

local lock_screen = lock {
    id = "lock",
    background = "#11111b",
    child = function(output)
        return column {
            width = "Fill", height = "Fill", align_v = "Center", spacing = 12,
            opacity = up:map(function(on) return on and 1 or 0 end),
            animate = { opacity = { duration = 200, from = 0 } },
            children = {
                text { content = output, foreground = "#6c7086", align_h = "Center" },
                rect {
                    width = 320, align_h = "Center", padding = 8, radius = 18, background = "#1e1e2e",
                    children = {
                        textfield {
                            width = "Fill",
                            height = 20,
                            placeholder = "Password",
                            mask_character = "•",
                            secure_submit = { capability = "lock", action = "authenticate" },
                        },
                    },
                },
                text { content = hint, foreground = "#a6adc8", align_h = "Center" },
            },
        }
    end,
}

action("lock", function() mantle.lock:lock() end)

return { lock_screen }

mantle call lock from a keybind (action) locks the session. Each output gets its own card; the field is typable as soon as the compositor gives the lock the keyboard, with no click. After a correct password the lock stays up 250 ms while the card fades out, then the session unlocks.

Properties

A lock takes id, child and the common and box node properties, minus the ones the protocol owns.

PropertyTypeDefaultBehaviour
idstringRequiredThe surface’s identity across reloads, unique among surfaces. A panel’s or lock’s per-output instances are "{id}@{output}"; monitor = "Active" keeps the bare id
childNode|fun(output: string): Node?|BoundNoneThe root’s content. A function runs per output instance with its connector name; nil leaves that instance empty (per-output child)
widthnilNoneRefused: the lock covers each output
heightnilNoneRefused, as width
visiblenilNoneRefused: the session lock decides when it shows

monitor and anchor are refused too: the protocol owns coverage and lifetime. The root is the output’s size; give children "Fill" to cover it. A reload that renames id while the session is locked is refused with a warning in mantle log. A config declares at most one lock; a second is refused at evaluation.

When a lock is refused

The compositor keeps the session locked if the shell dies, so a lock screen with no way to type a password leaves only a VT switch. mantle.lock:lock() checks the lock screen before it asks the compositor. A refusal leaves the session unlocked and puts the reason in mantle.lock’s error and in mantle.rescue.

ConditionResult
The config declares no lockRefused
No lock instance holds exactly one shown secure field, with secure_submit = { capability = "lock", action = "authenticate" }Refused. A hidden field does not count; a second shown secure field of any target refuses too
Another client already holds the session lockThe compositor denies it; reported in error and mantle.rescue
While locked, a reload leaves no lock instance with that single fieldThe reload is refused and the lock screen on screen stays
The compositor ends a held lock by its own mechanismThe session is unlocked; the reason goes to mantle.rescue only

The field’s keystrokes go to PAM and never reach Lua.

How do I…

TaskAnswer
Lock from a keybindaction("lock", ...) as in the example, then mantle call lock
Show “wrong password”Read error and attempts from mantle.lock, as the example’s hint does
Show that PAM is checkingRead authenticating
Animate the lock screen outset_unlock_animation with the animation’s length, and drive opacity or scale from unlocking (lock capability)
Animate it inDrive the same property from active; animate.from covers the first frame
Show the desktop wallpaper behind itAn image in the per-output child, keyed by output (per-output content)
Put a clock on itmantle.system:map(function(system) return system and os.date("%H:%M", system.time) or "" end)
Add an unlock button beside the fieldA button { submit = true } sends the field like Enter (pointer)
Lock before suspendInvoke lock from your idle or suspend handler (idle)

Gotchas

TrapFix
mantle call lock does nothingRead mantle.lock’s error: the config declares no lock, or its tree lacks exactly one shown secure field
Two secure fields in one lock tree make the lock refuseOne shown secure field per lock tree; hide the others
A reload while locked is ignoredIt removed the lock’s password field; the running lock screen stays. Fix the file
Renaming the lock’s id while locked is refusedSave the rename again after unlocking
visible, width, height, monitor or anchor on a lock is refusedRemove them; the lock always covers every output
The card’s exit animation is cut offThe session unlocks when the set_unlock_animation time ends; make it at least the animation’s length

See also: lock capability, secure fields, surfaces, animation.

Source: lock spec, at most one lock, session lock, authenticate check, rename while locked.

Nodes

Nodes are the UI tree inside a surface. Each constructor (row { ... }, text { ... }) takes a property table and returns it tagged with its kind. This page covers layout and the properties every kind shares; each kind’s page covers what it adds. How a box looks is on paint, motion on animation, clicks and typing on input.

A bar with a left group, a centred clock and a right group:

local clock = state("clock", "12:00")

local bar = panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    exclusive = 32,
    width = "Fill",
    height = 32,
    child = row {
        width = "Fill",
        height = "Fill",
        padding = { left = 8, right = 8 },
        background = "#1E1E2ECC",
        children = {
            row { width = "Fill", align_v = "Center", spacing = 6, children = {
                text { content = "left", foreground = "#CDD6F4" },
            } },
            text { content = clock, align_v = "Center", foreground = "#CDD6F4" },
            row { width = "Fill", align_h = "End", align_v = "Center", spacing = 6, children = {
                text { content = "right", foreground = "#CDD6F4" },
            } },
        },
    },
}

return { bar }

The two side rows are "Fill", so they split what the clock leaves equally, and the clock sits at the exact centre whatever its width. The right row packs its children at its end.

Kinds

Every kind accepts the common properties. Box kinds also accept the box properties. Any other key raises an error: a typo such as aling_v asks “did you mean align_v?”, and a key close to nothing lists what the kind accepts.

KindPageBoxChildrenOwn properties
rectrect✓Stackedchildren
row, columnrow and column✓Flowchildren, spacing, scroll
buttonbutton✓Stackedchildren, on_click, on_drag, on_wheel, submit
listlistFlow, from datasource, itemfn, key, limit, direction, spacing, scroll
texttextLeafcontent, font, font_size, foreground, text_align, elide, wrap, max_lines, on_link
iconiconLeafname, size, foreground
imageimageLeafsource, fit, async, retain, transition, source_blur
capturecaptureLeafoutput, fit, live, region, paint_cursor
shadershaderLeafsource, progress, params
textfieldtextfieldLeafplaceholder, font_size, foreground, text_align, autofocus, on_change, on_submit, on_cancel, on_navigate, secure_submit, mask_character

The four surface roles (panel, window, popup, lock) are node kinds too: they take the common and box properties and stack their one child (surfaces).

Values

RuleDetail
TypesA property table’s Type column is the editor stubs’ LuaCATS type. Bound means it also takes a signal; Length is a size; Edges is { top, right, bottom, left } with missing edges 0; Axes is { x, y } with a missing axis at the property’s default; Color is a colour; Animations is per-property tweens, keyed by the node’s own properties (RectAnimations on a rect). A range after the type is checked
SignalsA property whose Type includes Bound takes a signal; id and callbacks do not. hover, scroll and geometry take the signal handle itself. A signal inside a table property is refused: derive the whole table
nilA signal reading nil leaves its property absent, at its default. Capabilities read nil until their first push, so binding one never fails layout
NumbersFinite. A value outside a property’s range is an error, not a clamp
Colours"#RRGGBB" or "#RRGGBBAA" (colours)
StringsCapped at 64 KB
Arrayschildren, list.source and text runs take at most 10000 elements. A nil hole in children is an error; in list.source and runs it ends the array
TablesA table property (padding, anchor, shadow_offset, transition, an animate entry, a run, …) refuses a key it does not take, so { topp = 4 } names topp
Callbacks and booleansEvery on_* takes only a function, and every boolean property (visible, submit, …) only true or false. on_click = "x" or submit = 1 is an error. For a conditional handler write cond and fn or nil: false is refused too

Layout model

A pass is one resolve of a surface: the engine re-runs the node tree’s signals, re-lays it out and repaints. It happens after a signal the surface reads changes (signals). Layout is flexbox, solved by taffy on every pass. Each container either flows its children along one axis or stacks them on top of each other.

KindChildrenMain axis
rowFlow left to rightHorizontal
columnFlow top to bottomVertical
listFlow, generated from sourcedirection: vertical by default
rect, button, every surfaceStack: each child gets the whole content box and aligns in it on its own. Later children paint over earlier onesNone
text, icon, image, capture, shader, textfieldNone (leaves)None

A stacking parent’s content size is the union of its children, so a rect is how you layer a badge over an icon or a label over an image.

Of the leaves, only text and icon measure themselves. image, capture, shader and textfield have no intrinsic size: without width and height they are 0 × 0 and draw nothing.

Sizes

width and height take the same values.

ValueSize
OmittedContent: text and icon measure themselves, containers wrap their children, other leaves are 0
NumberPixels, [0, 8192]
"Fill"Along the parent’s main axis: an equal share of the space the fixed and content-sized siblings leave. Across it, or in a stacking parent: the whole slot, whatever align_h/align_v say
"NN%" ("50%", "12.5%")A fraction of the parent’s content box (inside its padding). It needs a parent with a definite size on that axis

There is no "Content" literal; omit the property instead.

Children never shrink. Fixed and content-sized children that overflow a row keep their sizes and spill out, cut by the parent’s clip, and "Fill" siblings get 0. A "Fill" child along the main axis of a content-sized parent also gets 0: there is no remainder to share. Across the axis, "Fill" in a content-sized parent takes the largest sibling’s size.

min_width, min_height, max_width and max_height are pixels [0, 8192] (not "Fill" or percents). They clamp every size, content, fixed and "Fill" alike, as in CSS: a "Fill" capped by max_width leaves the rest to its "Fill" siblings. A floor above a ceiling wins. Content past a ceiling overflows; a scroll on the same node scrolls it (scroll).

Spacing, padding and margin

PropertyMeaning
paddingInside the node’s box, around its children or text
marginOutside the box; part of the room the node takes in its parent
spacingGap between visible children of a row, column or list. Negative values overlap them

padding and margin take a number for all four edges or { top, right, bottom, left } with missing edges 0. Neither is range-checked, so negatives are accepted. A hidden child adds no gap.

Alignment

align_h and align_v take "Start", "Center", "End" or "Stretch", default "Start", and act by axis:

Wherealign_h / align_v does
A child in a stacking parentPlaces the child in the parent’s content box on that axis
A child in a flow, across its main axisPlaces the child across the row’s height or the column’s width
A child in a flow, along the main axisIgnored: the parent packs that axis
A row’s own align_h, a column’s own align_v (a list’s along its direction)Packs its children along the main axis ("Stretch" packs like "Start"). The same value also places the container itself in its parent

"Stretch" across an axis fills the slot and overrides a fixed size on that axis. To space items out along a row, use "Fill" children as spacers.

text_align on text and textfield is separate: it places lines inside the node’s own box, and matters only when that box is wider than the text.

Common properties

PropertyTypeDefaultBehaviour
widthLength|Bound, [0, 8192]ContentSee sizes
heightLength|Bound, [0, 8192]ContentSee sizes
max_widthnumber|Bound, [0, 8192]NonePixel ceiling, CSS max-width. Content past it overflows; a scroll on the same node scrolls it (sizes)
max_heightnumber|Bound, [0, 8192]NonePixel ceiling, as max_width
min_widthnumber|Bound, [0, 8192]NonePixel floor, CSS min-width; wins over a lower max_width
min_heightnumber|Bound, [0, 8192]NonePixel floor, as min_width
marginnumber|Edges|Bound0Outside the box; part of the room the node takes in its parent. A number sets all four edges; not range-checked (spacing)
paddingnumber|Edges|Bound0Inside the box, around its children or text. A number sets all four edges; not range-checked (spacing)
align_h"Start"|"Center"|"End"|"Stretch"|Bound"Start"See alignment
align_v"Start"|"Center"|"End"|"Stretch"|Bound"Start"See alignment
visibleboolean|Boundtruefalse removes the node from layout, paint and spacing and freezes its subtree (showing and hiding)
opacitynumber|Bound, [0, 1]1Multiplied down the tree. At 0 the node still takes space and input
znumber|Bound0Sibling paint and hit order. Higher paints later and hits first; ties keep declaration order. Layout and focus ignore it; animate refuses it
scalenumber|Axes|Bound, [0, 64]1About origin; a missing axis is 1. Paint only: layout and geometry see the unscaled box; hit-testing follows the painted one
rotatenumber|Bound, [-8192, 8192]0Degrees clockwise about origin. Paint only
translateAxes|Bound, [-8192, 8192]{ x = 0, y = 0 }Pixel offset per axis, a missing one 0, applied after scale and rotate. Paint only
originAxes|Bound, [0, 1]{ x = 0.5, y = 0.5 }Pivot for scale and rotate as box fractions; a missing axis is 0.5
shadow_colorColor|Bound"#000000"A drop shadow (shadows). Draws when alpha > 0 and shadow_blur, shadow_offset or shadow_spread is set
shadow_blurnumber|Bound, [0, 8192]0CSS box-shadow blur radius in px
shadow_offsetAxes|Bound, [-8192, 8192]{ x = 0, y = 0 }Shadow offset in px per axis. Follows the node’s transform
shadow_spreadnumber|Bound, [-8192, 8192]0Px the shadow grows per side; negative shrinks it. On non-box content it scales the shadow about the box centre
content_blurnumber|Bound, [0, 8192]0Gaussian sigma in px over this node’s painted subtree, CSS filter: blur() (blurs). Clipped like a shadow
animateAnimations|BoundNonePer-property tweens and an exit block (animation). Only a node already on screen animates, unless the entry has from
idstringNoneUnique among siblings; matches this node across passes (identity). Never a signal
hoverBoundNoneA hover(name) signal the engine sets while the pointer is over this node or its children (hover)
geometryBoundNoneA geometry(name) signal the pass writes this node’s surface-local rect into (geometry)
cursorCursor|Bound"pointer" on a button with a handler or submit and on a link, "text" on a textfield, else the arrowOne of the cursor names. The innermost node under the pointer that sets one wins
on_hoverfun(hovered: boolean)NoneCalled on each hover edge from pointer Enter, Motion or Leave; layout changes under a still pointer do not call it. Refused without hover on the same node

scale, rotate and translate act like CSS transform: the subtree draws moved, while layout, siblings and geometry see the untransformed box. A node scaled to 0 takes no input. Tween them for motion that skips re-layout.

Cursor names

cursor takes the CSS cursor names that the Wayland cursor-shape protocol (wp_cursor_shape_v1) also uses. Any other string is refused.

GroupNames
Generaldefault, context-menu, help, pointer, progress, wait
Selectioncell, crosshair, text, vertical-text
Drag and dropalias, copy, move, no-drop, not-allowed, grab, grabbing
Resizee-resize, n-resize, ne-resize, nw-resize, s-resize, se-resize, sw-resize, w-resize, ew-resize, ns-resize, nesw-resize, nwse-resize, col-resize, row-resize
Otherall-scroll, zoom-in, zoom-out

The compositor draws the shape from its cursor theme.

Box properties

rect, row, column, button and the four surface roles also take background, radius, corner_shape, border_color, border_width, clip, mask, blur, backdrop_blur and shadow_mode. They are documented on paint. Leaves and list take none of them: wrap one in a rect for a background, border or rounded clip.

Identity and reconciliation

Each pass walks the declared tree and matches it against the nodes on screen, one parent at a time. A matched node keeps its state: running tweens, a held image, a capture stream, a text field’s draft, and its resolved properties until a signal they read is written. An unmatched old node is removed, after its animate.exit if it has one (exit).

ChildMatches
With an idThe old sibling with the same id, wherever it moved. No such sibling: a new node
Without an idThe old id-less siblings, in order
Either, with a different kindNothing: the old one is removed and a new one built

An id is a plain UTF-8 string, unique among its siblings (a duplicate is refused), never a signal. In a list, key supplies it. Give a node a stable id when:

  • Siblings before it come and go. A position shift pairs it with the wrong old node.
  • It holds state across changes: an image with retain or transition, a capture, a textfield.
  • It replaces another node of the same kind. Two switched views that are both id-less columns match each other: the new view is the old node with new properties, so no exit or entry plays.

Showing, hiding and switching

visible = false takes a node out of layout, paint and input, with no gap. Its subtree stays in memory, frozen until it shows again. Use it for a section toggled in place; for views that replace each other, bind the parent’s children (switching views). opacity = 0 still takes space and input.

Switching views with ids

Views swapped through a children signal, each with its own id, so the outgoing one fades out while the incoming one fades in. The parent is a rect, so the two overlap during the swap instead of stacking. The shot switches tab to "bluetooth":

local tab = state("tab", "wifi")

local function page(name, label)
    return column {
        id = name, -- a new id per view: the old view leaves and fades instead of being reused
        padding = 12,
        opacity = 1, -- `from` needs the property set
        animate = { opacity = { duration = 150, from = 0 }, exit = { duration = 150, opacity = 0 } },
        children = { text { content = label } },
    }
end

local views = {
    wifi = function() return page("wifi", "Wi-Fi networks") end,
    bluetooth = function() return page("bluetooth", "Bluetooth devices") end,
}

local body = rect {
    width = 300,
    children = tab:map(function(current) return { views[current]() } end),
}

return body

How do I…

TaskAnswer
Split a bar into left, centre and rightThe bar at the top: two "Fill" rows around a content-sized middle
Centre somethingrect: centre something
Put a badge over an iconrect: a stacking parent with the badge aligned to a corner
Push items to the far end of a rowrow and column: a "Fill" spacer
Show a progress barThe meter: a percentage-width rect in a "Fill" track
Truncate long textwidth (or "Fill") plus elide = "End"; see text
Make something clickableWrap it in a button with on_click
Build rows from data, or a gridlist
Scroll a long listlist: scroll a long list
Show an app’s iconicon
Round an image’s cornersimage: round an image’s corners
Switch between tabsSwitching views with ids
Toggle a section in placevisible = signal; see showing and hiding
Read where a node ended upgeometry = geometry("name") (geometry)
Press feedback that does not re-lay outTween scale or translate (animation)

Gotchas

TrapFix
width = "Content" is refusedOmit the property; content size is the default
An image, capture, shader or textfield does not appearThey have no intrinsic size. Give width and height, or "Fill" in a sized parent
A "Fill" child is 0 wideIts parent is content-sized along that axis, or fixed siblings already overflow. Size the parent
"50%" resolves to 0The parent has no definite size on that axis
Items in a button or rect overlapThey stack their children; put a row inside for side by side
A switched view snaps in without its entry or exit animationSame kind at the same position is reused, not replaced. Give each view its own id
duplicate id errorSibling ids, and list keys, must be unique
A signal inside a table property (padding = { top = sig }) raises an errorMap the whole table: padding = sig:map(function(v) return { top = v } end)
on_click = cond and fn raises expected a functionA false cond yields false: write cond and fn or nil
children = { a, cond and b, c } raises children[1]: expected a node table, counting from 0 like the rest of the pathA false or nil entry is a hole. Build the array with table.insert, or a signal of the whole array
A node table, or a children array, changed in place after the config ran does not updateA node reads each children or child table once and keeps it while it holds that table. Bind the property to a signal, or :set a new table
opacity = 0 hides a node but it still takes clicksUse visible = false

See also: surfaces (where a tree lives), signals (live properties), paint, animation, input, capabilities.

Source: node vocabulary, layout solver, pass and reconciliation, property resolution, geometry parsers, transforms, hit testing and cursors (names from the cursor-icon crate), check.

rect

A plain box that stacks its children: each one gets the whole content box and places itself with align_h/align_v. Reach for it for a filled shape, a background behind something, or layering one node over another. Side by side needs a row or column instead.

A bell icon with an unread badge in its top-right corner:

local unread = state("unread", 3)

local bell = rect {
    width = 28,
    height = 28,
    children = {
        icon { name = "notification-symbolic", size = 20, foreground = "#CDD6F4",
               align_h = "Center", align_v = "Center" },
        rect {
            visible = unread:map(function(n) return (n or 0) > 0 end),
            align_h = "End",
            align_v = "Start",
            padding = { left = 4, right = 4 },
            radius = 7,
            background = "#F38BA8",
            children = { text { content = unread:map(tostring), font_size = 10, foreground = "#11111B" } },
        },
    },
}

return bell

The badge comes second, so it paints over the icon.

Properties

rect takes the common and box properties, plus:

PropertyTypeDefaultBehaviour
childrenNode[]|BoundNoneArray of node tables, up to 10000; a nil or false entry is an error. Stacked in order: later children paint over earlier ones. Bind a signal of an array to switch views

Without width and height, a rect is the union of its children, and 0 × 0 with none.

How do I…

TaskAnswer
Layer a badge over an iconThe example above
Centre somethingBelow
Draw a divider linerect { width = "Fill", height = 1, background = "#45475A" }
Round an image’s cornersimage: a rect with radius and clip = "Rounded"
Dim everything behind a dialogA full-size rect with a translucent background, the dialog as its child (paint)
Overlap two views while they swapMake the parent a rect (switching views)

Centre something

A stacking parent places each child on its own, so align_h and align_v of "Center" centre it. Here a card sits in the middle of a full-screen dimmed layer.

local dialog = rect {
    width = "Fill",
    height = "Fill",
    background = "#00000080",
    children = {
        column {
            align_h = "Center",
            align_v = "Center",
            padding = 24,
            radius = 12,
            background = "#1E1E2E",
            children = { text { content = "Centred", font_size = 16 } },
        },
    },
}

Inside a row, set align_h = "Center" on the row instead: it packs its children, and they ignore their own align_h.

Gotchas

TrapFix
Children of a rect sit on top of each otherThat is stacking. Use a row or column to lay them out side by side
An empty rect draws nothingWith no children and no size it is 0 × 0. Give it width and height
A rect takes no clicksOnly a button does
A background tween snaps in instead of fadingAn absent background has no colour to tween from. Start from a transparent one, such as "#89B4FA00" (animation)

See also: row and column, button, paint.

Source: vocabulary, children, box paint.

row and column

Boxes that flow their children along one axis: a row left to right, a column top to bottom. They are the main layout tools; everything else is placed inside one. The two accept the same properties and differ only in their main axis. For children built from data, use a list.

A meter: a "Fill"-wide track with a percentage-wide fill that follows a signal.

local volume = state("volume", 0.45)

local meter = row {
    width = "Fill",
    height = 6,
    radius = 3,
    background = "#313244",
    children = { rect {
        width = volume:map(function(v) return string.format("%d%%", math.floor((v or 0) * 100 + 0.5)) end),
        height = "Fill",
        radius = 3,
        background = "#89B4FA",
        animate = { width = 150 },
    } },
}

return column { width = 240, children = { meter } }

Properties

row and column take the common and box properties, plus:

PropertyTypeDefaultBehaviour
childrenNode[]|BoundNoneArray of node tables, up to 10000; a nil or false entry is an error. Laid out in order along the main axis. Bind a signal of an array to switch views
spacingnumber|Bound0Px between visible children; negative values overlap them. Not range-checked
scrollBoundNoneA scroll(name) signal; makes the node a scrolling viewport along its main axis (scroll)

How the container packs its children:

AxisSet byEffect
Main (row: horizontal, column: vertical)The container’s own align_h (row) or align_v (column)"Start", "Center", "End" pack the children; "Stretch" packs like "Start". The children’s own value on this axis is ignored
CrossEach child’s align_v (row) or align_h (column)Places that child across the row’s height or the column’s width; "Stretch" fills it

"Fill" children along the main axis share what the others leave, and no child shrinks; see sizes.

How do I…

TaskAnswer
Show a progress barThe meter above
Push items apartBelow
Split a bar into three groupsThe bar: two "Fill" rows around a content-sized middle
Centre items in a rowalign_h = "Center" on the row itself
Make children equal widthGive each width = "Fill"
Scroll overflowing contentBound the axis (height or max_height on a column), then scroll = scroll("name") (scroll)
Overlap items, like stacked avatarsNegative spacing

Push items apart

A "Fill" child takes the space its siblings leave, so a bare rect makes a spacer:

local header = row {
    width = 300,
    spacing = 8,
    children = {
        text { content = "Wi-Fi", font_size = 14, foreground = "#CDD6F4", align_v = "Center" },
        rect { width = "Fill" }, -- takes the space left over, pushing what follows to the end
        text { content = "Connected", foreground = "#A6ADC8", align_v = "Center" },
    },
}

return header

Gotchas

TrapFix
align_h = "Center" on a child of a row does nothingThe row packs its main axis: set align_h on the row, or use "Fill" spacers
align_v = "Center" on a row leaves its children at the topA row’s own align_v places the row in its parent. Set align_v on each child, as in push items apart
A "Fill" child of a content-sized row is 0 wideThe row has no leftover space to share. Give the row a width or "Fill"
direction = "Horizontal" on a column is refuseddirection is a list property. Use a row
A scroll row or column never scrollsIts size on the main axis is content-sized, so nothing overflows. Set width/height or a max_*

See also: list, rect, layout model.

Source: vocabulary, layout solver, spacing and alignment parsers, scroll.

button

A box that takes clicks, left-button drags and the wheel; no other kind has these handlers. It stacks its children like a rect, so put a row inside for an icon beside a label. Which button wins, when a click cancels and when a drag ends are on input.

A volume chip: left click mutes, the wheel changes the level.

local muted = state("muted", false)
local volume = state("volume", 0.5)

local mute_button = button {
    padding = { left = 10, right = 10, top = 6, bottom = 6 },
    radius = 8,
    background = muted:map(function(m) return m and "#F38BA8" or "#313244" end),
    on_click = function(_, which)
        if which == "left" then muted:set(not muted:get()) end
    end,
    on_wheel = function(_, steps)
        volume:set(math.max(0, math.min(1, volume:get() + steps * 0.05)))
    end,
    children = { row { spacing = 6, align_v = "Center", children = {
        icon { name = "audio-volume-high-symbolic", size = 16, foreground = "#CDD6F4", align_v = "Center" },
        text { content = volume:map(function(v) return string.format("%d%%", math.floor(v * 100 + 0.5)) end),
               align_v = "Center" },
    } } },
}

return mute_button

Properties

button takes the common and box properties, plus the ones below. rect in the callbacks is the button’s surface-local laid-out box { x, y, width, height }, before transforms.

PropertyTypeDefaultBehaviour
childrenNode[]|BoundNoneArray of node tables, up to 10000; a nil or false entry is an error. Stacked in order: later children paint over earlier ones. Bind a signal of an array to switch views
on_clickfun(rect: Rect, button: "left"|"right"|"middle")NoneOn release over the same button that was pressed, with the same mouse button. rect is the button’s surface-local box, before transforms
on_dragfun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end")NoneLeft-button drag. pointer is button-local and unclamped. "start" on press, "end" on release (before on_click) or when the pointer leaves the surface
on_wheelfun(rect: Rect, steps: number)NoneVertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins
submitboolean|BoundfalseA click also submits the armed secure field, like Enter. Works without on_click and runs before it

A button with none of on_click, on_drag, on_wheel and submit = true ignores the pointer: clicks fall through to what is under it, and it sets no cursor.

How do I…

TaskAnswer
Toggle something on clickThe example above
Open a menu on right clickCheck button == "right" and open a popup at rect (input)
Make a slideron_drag for the value, on_wheel for steps (input)
Show hover feedbackBind background or shadow_* to a hover signal and tween it with animate (paint)
Submit a password with a buttonsubmit = true (secure fields)
Change the cursorcursor = "grab" or any cursor name
Make a whole row clickableMake the button the row’s parent, width = "Fill", with a row inside

Gotchas

TrapFix
Icon and label overlapA button stacks its children. Put a row inside
A click lands on the node behind the buttonThe button has no handler and no submit = true, so it is transparent to the pointer
A click is lost when the button moves or resizes on pressA click cancels if the laid-out box moved between press and release. Give press feedback with scale or translate, not width or margin
on_click never fires for a link inside the buttonA text with on_link takes the click on a link run first
A press on a textfield inside the button does not clickPresses on a field go to the field

See also: input, rect, surfaces: popup.

Source: vocabulary, pointer dispatch, hit testing, takes_pointer.

text

One paragraph drawn in the fonts chain or a named family: labels, clocks, notification bodies. It sizes to its content, wraps and elides inside a bounded width, and mixes bold, italic, colour and links through runs. For typing, use a textfield.

Two notification cards. In the first, the title elides and the body wraps to two lines, eliding the second; the second card’s short texts fit.

local function card(icon_name, title, body)
    return row {
        width = "Fill",
        padding = 12,
        spacing = 10,
        radius = 12,
        background = "#313244",
        children = {
            icon { name = icon_name, size = 32, foreground = "#CDD6F4", align_v = "Center" },
            column { width = "Fill", align_v = "Center", spacing = 2, children = {
                text { content = title, width = "Fill", font_size = 14, elide = "End" },
                text { content = body, width = "Fill", foreground = "#A6ADC8",
                       wrap = "Word", max_lines = 2, elide = "End" },
            } },
        },
    }
end

return column { width = 340, spacing = 8, children = {
    card("dialog-information-symbolic", "Firmware update ready for the USB-C dock",
        "3 packages can be installed. Restart to finish the kernel upgrade and load the new graphics driver."),
    card("battery-caution-symbolic", "Battery low", "12% left"),
} }

The middle column is "Fill" so the texts have a bounded width; the icon keeps its 32 px.

Properties

text takes the common properties, plus:

PropertyTypeDefaultBehaviour
contentstring|TextRun[]|Bound""A string, or an array of up to 10000 runs, drawn as one paragraph
fontstring|BoundThe fonts chainFamily placed before the fonts chain. "" raises; an unknown family falls back to the chain
font_sizenumber|Bound, [1, 8192]12Each line is 1.2 × font_size tall
foregroundColor|Bound"#FFFFFF"A colour; a run’s color overrides it
text_align"Start"|"Center"|"End"|Bound"Start"Aligns lines inside the node’s own box; Start/End follow each line’s reading direction. Matters only when the box is wider than the text
wrap"None"|"Word"|Bound"None""Word" breaks at words, mid-word when one word is too wide. Needs a bounded width (width, "Fill" or a stretched cross axis)
max_linesnumber|Bound0Line cap under wrap = "Word"; 0 is unlimited, a negative value is refused. Ignored without wrap
elide"None"|"End"|Bound"None""End" ends an over-long line with an ellipsis; under wrap it applies to the last kept line
on_linkfun(href: string)NoneClick on a run with an href; the engine never opens it. Takes the click from any ancestor button; plain text passes it through

Runs

Each run in a content array is a table:

FieldValuesDefaultBehaviour
textStringRequiredA run without it is refused; an empty one is skipped
bold, italicBooleanfalseUses the family’s bold or italic face when one exists
underlineBooleanfalseUnderline in the run’s colour
colorColourThe node’s foreground
hrefStringNoneHanded to on_link on click; the pointer shows "pointer" over it. "" is no link

A run also takes kind = "text", so a notification body’s text spans pass through unchanged. Drop its image spans, which have no text. A nil hole ends the array.

local body = text {
    width = 280,
    wrap = "Word",
    content = {
        { text = "Update ready. " },
        { text = "3 packages", bold = true },
        { text = " can be installed. " },
        { text = "Release notes", underline = true, color = "#89B4FA", href = "https://example.org/notes" },
    },
    on_link = function(href) process.detach("xdg-open", { href }) end,
}

Size

A text node measures its content: one line per paragraph line, 1.2 × font_size each, as wide as the widest line. wrap and elide need a box narrower than the text, so give the node a width, "Fill", or a stretched cross axis (a text in a fixed-width column wraps at the column’s width). In a content-sized row, the text measures one line and overflows instead.

How do I…

TaskAnswer
Truncate a long titlewidth (or "Fill") plus elide = "End"
Show at most two lineswrap = "Word", max_lines = 2, elide = "End" and a bounded width, as in the card
Bold one wordA run with bold = true
Make a clickable linkA run with href plus on_link on the node (process.detach to open it)
Use an icon font glyphfont = "Symbols Nerd Font" (any installed family) with the glyph as content
Centre text in a fixed-width boxtext_align = "Center" with a width
Show a live valueBind content to a signal: content = volume:map(function(v) return v and tostring(v) or "" end)

Gotchas

TrapFix
A wrap = "Word" text runs off the edge on one lineWrapping needs a bounded width: set width, "Fill", or put it in a fixed-width column. A content-sized row offers none
elide = "End" never ellipsizesSame cause: the box is as wide as the text. Bound the width
max_lines has no effectIt applies only under wrap = "Word"
text_align = "Center" does nothingThe box is exactly as wide as the text. Give it a width, or centre the node with align_h
font = "" raisesOmit font to use the chain
A link run is not clickableLinks need on_link on the same text; without it the click goes to the button around it
content = 42 raisescontent takes a string or runs: tostring(n)

See also: textfield, fonts, icon.

Source: vocabulary, content parsers, measure, line height, link hit testing.

icon

A square icon from the desktop icon theme, or from a file path. Reach for it for app icons, status glyphs and tray items. Symbolic (SVG) icons take a tint. For photos and artwork at their own aspect ratio, use an image.

The focused window’s icon and title. mantle.applications maps a window’s app_id to its desktop entry, whose icon is a theme name:

local focused_icon = computed({ mantle.applications, mantle.workspaces }, function(apps, workspaces)
    local client = workspaces and workspaces.active_client
    if apps == nil or client == nil then return "" end
    local index = apps.by_app_id[client.class] or apps.by_app_id[string.lower(client.class)]
    return index and apps.entries[index].icon or ""
end)

local app_badge = row {
    spacing = 8, padding = { left = 8, right = 12, top = 6, bottom = 6 }, radius = 8, background = "#313244",
    children = {
        icon { name = focused_icon, size = 20, align_v = "Center" },
        text { content = mantle.workspaces:map(function(w)
            return w and w.active_client and w.active_client.title or ""
        end), max_width = 200, elide = "End", align_v = "Center", foreground = "#CDD6F4" },
    },
}

return app_badge

Properties

icon takes the common properties, plus:

PropertyTypeDefaultBehaviour
namestring|Bound""An icon theme name ("firefox", "audio-volume-high-symbolic"), looked up at the drawn size, or an absolute image path, used as is. "" or a name the theme lacks draws nothing
sizenumber|Bound12The box is size × size px; not range-checked
foregroundColor|BoundThe file’s own coloursColour for the SVG’s currentColor (CSS color), which tints symbolic icons. Full-colour icons ignore it

An explicit width or height overrides that axis of the square; the icon draws at the shorter side, centred.

The theme is gtk-icon-theme-name from $XDG_CONFIG_HOME/gtk-4.0/settings.ini, else gtk-3.0/settings.ini, else hicolor. It is read once per Renderer process, so a theme change shows after a shell restart, not a reload. Files load as PNG, JPEG, WebP, GIF, SVG or SVGZ.

How do I…

TaskAnswer
Show an app’s iconThe example above
Tint a symbolic iconforeground = "#CDD6F4" on a -symbolic name
Show a tray item’s iconname = item.icon_name or item.icon_path: both spellings work (tray)
Show a notification’s app iconname = notification.app_icon (notifications)
Make an icon buttonPut the icon in a button
Put a badge on an iconLayer them in a rect

Gotchas

TrapFix
An icon draws nothingThe theme and its fallbacks lack the name; mantle log warns once with the theme. Check /usr/share/icons/<theme>, or pass an absolute path
foreground does not change a colour iconOnly SVGs that use currentColor (symbolic icons) take it
The wrong theme’s icons appearThe theme comes from GTK settings, read at Renderer start. Set gtk-icon-theme-name and restart the shell
An icon is smaller than its boxIt draws at the shorter side of width/height. Keep them equal, or use size alone
An icon given as a relative path draws nothingA relative name is a theme name. Use mantle.config_dir .. "/icons/x.svg"

See also: image, capabilities.

Source: vocabulary, content parsers, theme lookup, decode, icon draw.

image

A picture from a file: wallpapers, album art, avatars, thumbnails. It can decode off-thread, hold the previous picture while a new one loads, and cross-fade or run a shader between them. For theme icons, use an icon.

A wallpaper that crossfades when the path changes:

local path = state("wallpaper", "/usr/share/backgrounds/a.jpg")

return { panel {
    id = "wallpaper",
    layer = "Background",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "Fill",
    height = "Fill",
    child = image {
        id = "wallpaper_image", -- keeps the node, and so the held picture, across source changes
        source = path,
        async = true,
        transition = { duration = 600, easing = "InOutCubic" },
        width = "Fill",
        height = "Fill",
    },
} }

Properties

image takes the common properties, plus:

PropertyTypeDefaultBehaviour
sourcestring|Bound""A file path (mantle.config_dir .. "/img/a.png"), never a theme name; "" draws nothing. PNG, JPEG, WebP, GIF, SVG or SVGZ; animated GIFs loop
fit"cover"|"contain"|"stretch"|Bound"cover""cover" fills the box and crops, "contain" fits inside it, "stretch" distorts to it
asyncboolean|Boundfalsefalse decodes in the frame that first draws it. true decodes on a worker and draws nothing until ready; use it for many or large images
retainboolean|BoundfalseKeep drawing the last picture while a new source decodes, and on a failed decode. Needs async = true and a stable id
transitionTransition|BoundNoneCross from the held picture to each newly decoded source. Implies retain; needs async = true and a stable id. Unknown keys are refused. See transition
source_blurnumber|Bound, [0, 8192]0Blur sigma in px, baked into the pixels once at decode (three box passes approximating a Gaussian); see blurs. Animated GIFs ignore it. Under async, a change blanks the image until the re-decode lands; retain does not cover it

transition

FieldValuesDefaultBehaviour
durationms, [1, 60000]RequiredLength of the cross
easingAn easing"InOutQuad"Drives u_progress
shaderAbsolute .frag pathBuilt-in cross-dissolveReplaces the dissolve. Recompiled when the file changes
params{ name = number | { 2 to 4 numbers } }{}Uniforms for that shader, as on a shader node. Refused without shader

The first picture appears without a transition. A transition shader gets everything a shader node gets, plus:

NameWhat
mantle_from(uv), mantle_to(uv)Outgoing and incoming picture at a box coordinate, premultiplied, already placed by fit; transparent outside the picture
u_from_rect, u_to_rectEach picture’s (x, y, w, h) in box fractions; may pass 0..1 under "cover"

u_progress is the eased progress 0..1. A transition shader that fails to build logs once and the node falls back to the cross-dissolve.

How do I…

TaskAnswer
Crossfade a wallpaperThe example above: async, transition and a stable id
Wipe instead of fadetransition = { duration = 700, shader = mantle.config_dir .. "/shaders/wipe.frag" } with a .frag that mixes mantle_from and mantle_to
Round an image’s cornersBelow
Make a circular avatarThe same, with radius half the size (paint)
Show many thumbnails without stutterasync = true on each, in a list
Blur a wallpaper oncesource_blur = 20
Show a file that ships with the configsource = mantle.config_dir .. "/img/logo.png"

Round an image’s corners

An image has no radius. Put it in a box with radius and clip = "Rounded" (clip):

local cover = rect {
    width = 96,
    height = 96,
    radius = 12,
    clip = "Rounded", -- cut the image to the corners
    children = {
        image { source = "/usr/share/backgrounds/a.jpg", fit = "cover", async = true, width = "Fill", height = "Fill" },
    },
}

Gotchas

TrapFix
The image does not appearIt has no intrinsic size. Give width and height
An image flashes blank when its source changes despite retainretain needs async = true and a node that survives: give it a stable id
The shell stutters while images loadInline decode blocks drawing. Set async = true
source = "firefox" draws nothingsource is a path. Use icon for theme names
A relative source draws nothingIt resolves against the Renderer’s working directory, not the config. Build paths from mantle.config_dir
radius on an image is refusedIt is not a box. Wrap it, as above
transition.params is refusedparams needs a shader

See also: icon, shader, paint, animation.

Source: vocabulary, content parsers, transition, shader stage, decode.

capture

A live preview of one output (monitor) through ext-image-copy-capture-v1, else wlr-screencopy. Reach for it for an overview, a monitor picker or a screenshot preview. Without either protocol it draws nothing and logs one warning.

A rounded preview of the first screen at up to 30 frames per second:

local first_output = mantle.screens:map(function(screens)
    return screens and screens[1] and screens[1].name or ""
end)

local preview = rect {
    width = 320,
    height = 180,
    radius = 8,
    clip = "Rounded",
    background = "#000000",
    children = {
        capture { output = first_output, live = 30, fit = "contain", width = "Fill", height = "Fill" },
    },
}

mantle.screens lists the connected outputs by connector name.

Properties

capture takes the common properties, plus:

PropertyTypeDefaultBehaviour
outputstring|Bound""Connector name, e.g. "DP-1"; "" draws nothing. An unknown name draws nothing and warns once. Changing it starts a fresh capture
fit"cover"|"contain"|"stretch"|Bound"cover"As on image
liveboolean|number|Boundfalsefalse: capture on show and on each output change. true: every frame, one in flight. A number: at most that many fps, (0, 1000]. Hiding the node or unmapping its surface drops the capture; showing starts a fresh one
regionRect|Bound, [0, 8192]The whole outputPart of the output in its logical px, placed by fit as the whole frame. Every key is required and in that range; the size is non-zero
paint_cursorboolean|BoundfalseInclude the pointer in the frame

It has no intrinsic size: without width and height it draws nothing. A hidden node or unmapped surface drops its capture and starts a fresh one when it shows again. A live capture gets a new frame only when the screen changes. A capture that fails pauses, with one warning, until the output list changes.

A region prefers wlr-screencopy, which crops at the source. Through ext-image-copy-capture-v1 the engine crops instead, and on a rotated or flipped output it cannot: it draws the whole output and logs one warning.

How do I…

TaskAnswer
Preview a monitorThe example above
Preview every monitorA list over mantle.screens, key = the screen’s name, one capture per item
Show a part of the screenregion = { x = 0, y = 0, width = 960, height = 540 }
Keep CPU lowLeave live = false for a still, or cap it: live = 10
Include the mouse pointerpaint_cursor = true
Round the cornersWrap it in a rect with radius and clip = "Rounded", as above

Gotchas

TrapFix
Nothing drawsGive it a size; check output against mantle.screens names; check mantle log for a missing-protocol warning
The preview is frozenlive is false, which captures once. Set true or a frame rate
live = 0 is refusedUse false for a single frame
A region shows the whole outputThe output is rotated or flipped and only ext-image-copy-capture-v1 is offered
region = { width = 100, height = 100 } is refusedAll four keys are required

See also: image, surfaces.

Source: vocabulary, content parsers, capture.

shader

Runs a fragment shader from the config over the node’s box: a glow, an animated gradient, a procedural pattern. It reads no textures and takes no input. To run a shader between two pictures, use an image transition.

A band that glows in over 400 ms when pulse_on turns true:

local pulse_on = state("pulse_on", false)

local glow = shader {
    width = 200,
    height = 40,
    source = mantle.config_dir .. "/shaders/glow.frag",
    progress = pulse_on:map(function(on) return on and 1 or 0 end),
    params = { tint = { 0.54, 0.71, 0.98 } },
    animate = { progress = 400 },
}

return glow

shaders/glow.frag in the config directory:

uniform vec3 tint;

void main() {
    // Distance from the horizontal centre line, 0 at the middle, 1 at the edges.
    float edge = abs(v_uv.y - 0.5) * 2.0;
    float alpha = (1.0 - edge) * u_progress;
    fragColor = vec4(tint * alpha, alpha); // premultiplied
}

Properties

shader takes the common properties, plus:

PropertyTypeDefaultBehaviour
sourcestring|Bound""Absolute .frag path; relative is refused, "" draws nothing. Compiling, errors and reloads: the .frag file
progressnumber|Bound, [-8192, 8192]0Becomes u_progress. There is no clock uniform: animate this for motion; the wide range lets a spring overshoot
paramstable<string, number|number[]>|Bound{}Uniforms by name: a finite number for float, 2-4 numbers for vec2-vec4. Missing ones are 0. Not tweened

It has no intrinsic size: without width and height it draws nothing. opacity, transforms, shadow_* and content_blur apply to it.

The .frag file

GLSL ES 3.00 without the header. The engine prepends #version 300 es, precision highp float and the declarations below, then compiles the file as written. Error line numbers count from the file’s first line.

NameTypeWhat
v_uvin vec2Box coordinate, 0..1, top-left origin, y down
fragColorout vec4Premultiplied RGBA. The engine multiplies it by the node’s opacity afterwards
u_progressfloatThe node’s progress
u_sizevec2The node’s size in logical px
uniform float, vec2, vec3, vec4 of your ownSet from params by name, 0 when params leaves one out. A params name with no uniform is ignored; a wrong component count is padded or truncated and logged once

Write void main(). params never sets a uniform named u_* or mantle_*. A uniform the shader reads of any other type, such as an int or a sampler2D, refuses the whole shader.

EventResult
Compile or link failsLogged once, draws nothing until the file changes
A .frag under the config directory is savedThe config reloads, which recompiles it. A file elsewhere recompiles at the surface’s next pass
mantle checkPasses: it has no GPU and compiles no GLSL. The first compile is in the running shell
The shader hangs the GPUThe session hangs. It is config code, as trusted as process.run

How do I…

TaskAnswer
Fade an effect in and outBind progress to 0 or 1 and tween it with animate, as above
Loop an animationanimate = { progress = { keyframes = { 0, 1 }, duration = 2000, loops = "Infinite" } } (keyframes)
Pass a colourA vec3 or vec4 uniform, params = { tint = { r, g, b } } in 0..1
Work in pixelsv_uv * u_size is the fragment’s position in logical px
Click a shaderWrap it in a button
Round its cornersWrap it in a rect with radius and clip = "Rounded" (clip)

Gotchas

TrapFix
Draws nothing, and check passedRead mantle log for the compile error. Check the node has a size and an absolute source
The shader is staticThere is no time uniform. Animate progress
A uniform int refuses the shaderDeclare it float and pass the integer as a number
Colours glow too bright where alpha is lowfragColor is premultiplied: multiply RGB by alpha
A params change jumpsparams is not tweened. Drive the change through progress

See also: image transitions, animation, paint.

Source: vocabulary, content parsers, params, shader stage, .frag reloads.

list

A row or column whose children come from data: one itemfn(item) call per element of source. Reach for it for anything with a count you do not know up front: workspaces, notifications, search results, a thumbnail grid. For a fixed set of children, a row or column is simpler.

A scrolling thumbnail grid: a vertical list of two-image rows, decoded off-thread.

local paths = state("wallpapers", { "/usr/share/backgrounds/a.jpg", "/usr/share/backgrounds/b.jpg",
    "/usr/share/backgrounds/c.jpg", "/usr/share/backgrounds/d.jpg" })

-- Two per row: a vertical list of rows, keyed by the paths they hold.
local rows = paths:map(function(all)
    local out = {}
    for i = 1, #all, 2 do out[#out + 1] = { all[i], all[i + 1] } end
    return out
end)

local grid = list {
    width = 420,
    height = 300,
    spacing = 8,
    scroll = scroll("thumbs"),
    source = rows,
    key = function(pair) return table.concat(pair, "\n") end,
    itemfn = function(pair)
        local tiles = {}
        for i, path in ipairs(pair) do
            tiles[i] = image { source = path, async = true, fit = "cover", width = 206, height = 116 }
        end
        return row { spacing = 8, children = tiles }
    end,
}

return grid

Keyed workspace buttons from a capability: workspaces cookbook.

Properties

list takes the common properties, plus the ones below. It takes no box properties: wrap it in a rect or column for a background.

PropertyTypeDefaultBehaviour
sourceany[]|BoundEmptyArray; bind a signal to rebuild on change. Missing or nil (a capability before its first push) is an empty list; a nil hole ends it. More than 10000 items without limit is an error
itemfnfun(item: any): NodeRequiredBuilds a node for every built item, visible or not
keyfun(item: any): stringNoneUnique UTF-8 key per item; replaces the node’s id. Duplicates are refused. Without it items match by position
limitinteger|BoundNoneBuild at most this many items; above 10000 acts as 10000, 0 builds none
direction"Vertical"|"Horizontal"|Bound"Vertical"Lays out as a column or a row
spacingnumber|Bound0Px between visible items along direction; negative values overlap them
scrollBoundNoneA scroll(name) signal; makes the list a scrolling viewport along direction (scroll)

A list packs and aligns exactly like the row or column its direction names: its own align_v (vertical) or align_h (horizontal) packs the items.

When items rebuild

A list keeps the items it built until something that build read changes. A pass that finds nothing changed calls no itemfn and reads none of the items’ signals; it lays the kept items out again, about a third of the cost of building them. A change one item read builds that item alone; a change the list itself read builds every item, scrolled out of view or not.

ChangeBuilds again
A write to source, or to a signal under a map or computed bound to itEvery item
A write to a signal key read with :get()Every item
A new source, itemfn or key value, a new limit, or a reloadEvery item
A write to a signal an item’s itemfn call read with :get(), or bound to a property of that item at any depth, such as its hoverThat item
A write to anything else, even on the same surfaceNothing

key carries each item’s state (tweens, a held image, a text field’s draft) onto its rebuilt node, and across reorders. Cap a long list with limit (a launcher’s top 50 matches), or hide it while closed so it freezes.

The engine sees signal reads only. An itemfn, key or item map that reads the clock, a mutable variable or a source table changed in place keeps what it read until a signal it read is written. A delay or pulse builds the items that read it again on every pass while one is pending or open. What to read instead: what a node reads again.

How do I…

TaskAnswer
Lay out a gridThe example above: a list of rows, several items per row
Scroll a long listBelow
Keep items’ animations when the order changesGive key a stable per-element string (an id from the data)
Show only the top N matcheslimit = 50
Lay items out horizontallydirection = "Horizontal"
Filter as the user typesBind source to a map of the query, as the textfield example does
Show an empty stateA sibling with visible = items:map(function(all) return not all or #all == 0 end)

Scroll a long list

Bound the size on the scrolling axis, then bind a scroll signal. max_height lets the list shrink to fit a few items and scroll past 200 px.

local names = {}
for i = 1, 40 do names[i] = "Item " .. i end

local items = list {
    width = 240,
    max_height = 200, -- grows with its items up to 200 px, then scrolls
    spacing = 2,
    scroll = scroll("items"),
    source = names,
    itemfn = function(name)
        return text { content = name, width = "Fill", padding = 6 }
    end,
}

Gotchas

TrapFix
A 2000-item list makes every update slowEvery item is laid out on every pass, and built whenever anything it read changes, visible or not. Cap it with limit, filter the source, or hide the list while it is closed
A list rebuilds though nothing it shows changedIts itemfn or key is a new function each time the builder around it runs, as inside a function child that reads a signal. Define them once, outside the builder
A relative time (“3 min ago”) in an item stops updatingitemfn read the clock with os.time(). Bind the text to a map of mantle.system instead (what a node reads again)
A list of more than 10000 elements is refusedSet limit, or page the source
duplicate key errorkey must return a different string for every element
key returning a number is refusedReturn a string: tostring(item.id)
Items lose their state when one is added at the topWithout key they match by position. Add key
background on a list is refusedA list is not a box. Wrap it

See also: row and column, signals, input: scroll.

Source: vocabulary, list parser, layout as row or column.

textfield

A single-line text input: a search box, a launcher query, a password. The engine holds what the user types (the draft); Lua sees it only through callbacks and cannot set it. Focus, editing keys, the draft’s lifetime and password fields are on input.

A launcher: the field filters a list as the user types, the arrow keys move a selection, Enter launches.

local apps = { "Firefox", "Files", "Terminal", "Text Editor", "Settings" }
local query = state("query", "")
local selected = state("selected", 1)

local matches = query:map(function(q)
    local out = {}
    for _, name in ipairs(apps) do
        if name:lower():find((q or ""):lower(), 1, true) then out[#out + 1] = name end
    end
    return out
end)

local launcher = column { width = 320, padding = 12, spacing = 8, background = "#1E1E2E", radius = 12, children = {
    rect { width = "Fill", padding = { left = 10, right = 10 }, radius = 8, background = "#313244", children = {
        textfield {
            width = "Fill",
            height = 36,
            font_size = 14,
            foreground = "#CDD6F4",
            placeholder = "Search…",
            autofocus = true,
            on_change = function(text) query:set(text); selected:set(1) end,
            on_navigate = function(key)
                if key == "down" then selected:set(math.min(#matches:get(), selected:get() + 1))
                elseif key == "up" then selected:set(math.max(1, selected:get() - 1)) end
            end,
            on_submit = function() print("launch", matches:get()[selected:get()]) end,
        },
    } },
    list {
        width = "Fill",
        source = matches,
        key = function(name) return name end,
        itemfn = function(name)
            return rect {
                width = "Fill", padding = { left = 10, right = 10, top = 6, bottom = 6 }, radius = 8,
                background = selected:map(function(index)
                    return matches:get()[index] == name and "#45475A" or "#00000000"
                end),
                children = { text { content = name, foreground = "#CDD6F4" } },
            }
        end,
    },
} }

return { panel { id = "launcher", layer = "Top", anchor = { top = true },
    keyboard_interactivity = "OnDemand", child = launcher } }

The panel needs keyboard_interactivity for the field to get keys (panel).

Properties

textfield takes the common properties, plus:

PropertyTypeDefaultBehaviour
placeholderstring|Bound""Shown while the field is empty, focused or not. Never submitted
font_sizenumber|Bound, [1, 8192]12Size of the text and placeholder
foregroundColor|Bound"#FFFFFF"Colour of the text and placeholder
text_align"Start"|"Center"|"End"|Bound"Start"Aligns the text inside the field’s box
autofocusboolean|BoundfalsePlain fields only: take the keyboard, empty, when the surface gets it or the field appears, calling on_change(""). The first in document order wins; never steals from a field already typing or one a press just left
on_changefun(text: string)NoneFull text after every edit
on_submitfun(text: string)NoneEnter with the full text; the field stays focused and clears. Never fires on a secure_submit field
on_cancelfun(cleared: boolean)NoneEscape; cleared says whether it removed text. A plain field clears (firing on_change("") only if there was text), gives up focus, then calls this. A secure_submit field scrubs and stays armed. Without it Escape clears and keeps focus
on_navigatefun(key: "up"|"down"|"left"|"right"|"page_up"|"page_down"|"tab"|"backtab")NoneKeys a single-line field does not use, for moving a list selection; repeats while held. "left"/"right" only when the caret cannot move that way and Shift is up
secure_submit{ capability: string, action: string }|BoundNoneMakes the field masked; keys never reach Lua. Both non-empty UTF-8 strings: only lock/authenticate, polkit/authenticate and network/connect; any other pair or key is an error (secure fields)
mask_characterstring|Bound"•"Drawn per typed character in a secure_submit field. Only the first character counts; "" hides the length

The field has no intrinsic size: give it width and height. It draws one line of text and a caret, vertically centred, in the fonts chain; there is no font property. It reads wl_keyboard, not an input method, so there is no CJK composition and no dead keys.

A field with none of on_change, on_submit and secure_submit never takes focus. The draft follows the node, so give the field a stable id when siblings before it come and go (identity).

How do I…

TaskAnswer
Filter a list as the user typesThe example above: on_change sets a state, the list’s source maps it
Move a selection with the arrow keyson_navigate, as above; pair it with scroll(name):reveal to keep the row in view (input)
Focus the field when a panel opensautofocus = true and a panel with keyboard_interactivity
Close on a second Escapeon_cancel(cleared): close only when cleared is false
Ask for a passwordsecure_submit = { capability = "lock", action = "authenticate" } (secure fields)
Submit a password from a buttonA button with submit = true
Style the box around the fieldWrap it in a rect with background, radius and border_*; the field draws only text and caret
Debounce a searchsignals

Gotchas

TrapFix
The field does not appearIt has no intrinsic size. Give width and height
Typing does nothingThe surface needs keyboard focus (keyboard_interactivity on a panel), and the field needs on_change, on_submit or secure_submit
on_cancel or on_navigate alone never firesNeither makes the field focusable. Add on_change or on_submit
You cannot set or clear the draft from LuaThe draft is the engine’s. Enter and Escape clear it; removing the node drops it
on_submit never fires on a password fieldA secure_submit field sends to its capability instead
font on a textfield is refusedFields use the fonts chain
background on a textfield is refusedIt is not a box. Wrap it in a rect

See also: input, list, text, surfaces: panel.

Source: vocabulary, content parsers, secure_submit, plain fields, focus.

Paint

How a node looks: fills, gradients, corners, borders, clipping, masks, shadows and the four blurs. Layout and per-kind properties are on Nodes; easing any of these values is on Animation.

column {
    padding = 16,
    spacing = 8,
    background = "#1E1E2EF2",
    radius = 12,
    border_width = 1,
    border_color = "#FFFFFF1A",
    shadow_color = "#00000099",
    shadow_blur = 18,
    shadow_offset = { x = 0, y = 8 },
    children = {
        text { content = "Battery", font_size = 14, foreground = "#CDD6F4" },
        text { content = "82% · 3 h 10 min left", foreground = "#A6ADC8" },
    },
}

A card: a translucent rounded fill, a hairline border and a soft shadow below it.

Terms

TermMeaning
Box kindA node that paints a box: rect, row, column, button and the four surface roles (panel, window, popup, lock)
RepaintMantle redraws the changed part of a surface’s buffer; an unchanged surface is not redrawn
Offscreen passThe subtree is drawn into a temporary texture, filtered or masked, then composited back. Costs a texture and an extra draw
LayerThe offscreen pass that content_blur and some shadows use. Unlike other offscreen passes, Mantle keeps it and reuses it while the subtree does not change
GlassA box with backdrop_blur
SigmaA Gaussian blur’s standard deviation in logical px. The blur reaches about 3 sigma

Who takes what

A property on a kind that does not take it is refused, naming the closest property the kind takes or, with none close, listing them all.

PropertiesTaken by
shadow_color, shadow_blur, shadow_offset, shadow_spread, content_blur, opacityEvery node, including text, icon, image, list, textfield
background, radius, corner_shape, border_color, border_width, clip, mask, shadow_mode, backdrop_blur, blurBox kinds only
source_blurimage only
foreground (text, icon, textfield), z, scale, rotate, translate, origin, visibleAlso affect paint; documented on Nodes

Every property can be a signal. A signal nested inside a table (a gradient stop, one border edge) is refused, so derive the whole table with :map. A malformed value fails the pass instead of drawing a default: the previous scene stays and the error goes to mantle log (runtime).

Colours

Colours are strings "#RRGGBB" or "#RRGGBBAA", hex digits in either case. There are no named colours and no short #RGB form.

Box properties

PropertyTypeDefaultBehaviour
backgroundColor|Gradient|BoundNoneA colour or gradient. Absent draws nothing; "#00000000" is an explicit transparent fill. A gradient snaps under animate
maskMask|BoundNoneMultiplies the alpha of this node and its subtree; see Mask
radiusnumber|Bound, [0, 8192]0Corner radius px. Above half the shorter side it clamps, so radius = 999 makes a pill or circle
corner_shape"Round"|"Scoop"|Bound"Round""Scoop" cuts each corner inward as a quarter circle centred on the corner point; fill, clip, glass, shadow and the blur region follow
border_colorColor|BorderColors|BoundNoneA string sets all four edges; a missing edge has none. An edge draws only with both a colour and a width
border_widthnumber|Edges|Bound, [0, 8192]0Px per edge; a number sets all four, a missing edge is 0. Borders draw inside the box and take no layout space
blurboolean|BoundfalseAsk the compositor to blur the desktop behind this box; see Blurs. Never inferred from a translucent background
backdrop_blurnumber|Bound, [0, 8192]0Gaussian sigma in px over what this surface already painted under the box, CSS backdrop-filter; see Blurs
shadow_mode"Box"|"Content"|Bound"Box""Box": CSS box-shadow of the box shape. "Content": CSS drop-shadow of everything painted. See Shadows
clip"Box"|"Rounded"|"None"|Bound"Box""Box" cuts children to the rectangle, "Rounded" also to radius, "None" leaves them on the parent’s clip. See Clip

A uniform border (same width and colour on all four edges) on a round corner follows radius. A per-edge border, or any border on a scoop, draws as four straight rectangles with square corners:

local function tile(label, props)
    props.width, props.height, props.radius = 88, 56, 14
    props.background = "#313244"
    props.children = { text { content = label, foreground = "#CDD6F4", align_h = "Center", align_v = "Center" } }
    return rect(props)
end

return row {
    spacing = 12,
    children = {
        tile("Round", { border_width = 2, border_color = "#89B4FA" }),
        tile("Scoop", { corner_shape = "Scoop", border_width = 2, border_color = "#89B4FA" }),
        tile("Per-edge", { border_width = { bottom = 3 }, border_color = "#89B4FA" }),
    },
}

Gradients

background and mask take a gradient table.

background = {
    gradient = "Linear",
    angle = 90,
    stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } },
}
KeyRule
gradient"Linear", "Radial" or "Conic"
angleDegrees clockwise from the top, as in CSS. Linear default 180 (top to bottom), Conic default 0 (starts at twelve o’clock). Radial refuses it
stopsAt least 2 { position, colour } pairs. Positions in [0, 1], never descending; two equal positions make a hard edge
ShapeGeometry
LinearAlong angle through the centre, long enough that the corners take the end stops (CSS)
RadialAn ellipse from the centre out to the box’s edges, not its corners
ConicA turn around the centre, starting at angle
local stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } }

local function swatch(label, fill)
    return column {
        spacing = 6,
        children = {
            rect { width = 96, height = 64, radius = 8, background = fill },
            text { content = label, foreground = "#A6ADC8" },
        },
    }
end

return row {
    spacing = 12,
    children = {
        swatch("Linear, 90", { gradient = "Linear", angle = 90, stops = stops }),
        swatch("Radial", { gradient = "Radial", stops = stops }),
        swatch("Conic", { gradient = "Conic", stops = stops }),
    },
}

Clip

clip decides what a box cuts its children to.

ValueChildren are cut toCost
"Box"The box’s rectangleFree (a scissor)
"Rounded"The box’s radius and corner_shape. With radius = 0 it is "Box"An offscreen pass every repaint of the box
"None"Whatever the parent cuts to, so children and their shadows can overflow this boxFree

A rounded clip draws in the order fill, children, border, so the border stays on top of children that reach the arc.

Mask

mask multiplies the alpha of the node’s own fill and border and of its whole subtree.

FormAlpha taken from
A gradient tableThe gradient’s colours’ alpha, laid over the box. RGB is ignored
{ source = "/path.png" }The image’s alpha, stretched over the box. A file that fails to load leaves the node unmasked
Either, plus invert = trueThe complement: kept and cut swap

Name exactly one of source or a gradient. A masked box draws its subtree offscreen every repaint and always cuts children to its box (to radius too under clip = "Rounded"), even with clip = "None".

local items = {}
for i = 1, 12 do
    items[i] = rect { width = "Fill", padding = 10, radius = 8, background = "#313244",
        children = { text { content = "Row " .. i, foreground = "#CDD6F4" } } }
end

return column {
    width = 200,
    height = 240,
    spacing = 6,
    scroll = scroll("feed"),
    mask = {
        gradient = "Linear",
        stops = { { 0, "#00000000" }, { 0.08, "#000000" }, { 0.92, "#000000" }, { 1, "#00000000" } },
    },
    children = items,
}

A scrolling list whose rows fade out at the top and bottom edges.

Shadows

A shadow draws when shadow_color has alpha above 0 and at least one of shadow_blur, shadow_offset or shadow_spread is set. The terms are CSS’s box-shadow.

PropertyValuesDefault
shadow_colorColour"#000000"
shadow_blurCSS blur radius in px [0, 8192]; the Gaussian’s sigma is half of it0
shadow_offset{ x, y } px, each [-8192, 8192], missing axis 0{ x = 0, y = 0 }
shadow_spreadpx [-8192, 8192] the shape grows (negative shrinks) per side. On a non-box shadow it scales the shadow about the box centre instead0
shadow_modeBox kinds only. "Box": CSS box-shadow, cast by the box’s shape and cut out under the box. "Content": CSS drop-shadow, cast by everything the node and its subtree paint"Box"

Non-box nodes (text, icon, image, …) have no box to cast, so their shadow is always the content’s: text gets a glyph-shaped shadow. The same unfilled, bordered box in each mode:

local function card(mode)
    return column {
        padding = 14,
        radius = 12,
        border_width = 1,
        border_color = "#89B4FA",
        shadow_mode = mode,
        shadow_color = "#000000",
        shadow_blur = 4,
        shadow_offset = { x = 5, y = 6 },
        children = { text { content = mode, font_size = 20, foreground = "#CDD6F4" } },
    }
end

return row {
    padding = 24,
    spacing = 24,
    background = "#585B70",
    children = { card("Box"), card("Content") },
}

"Box" casts the rounded box and cuts the shadow out under it; "Content" casts the border ring and the glyphs.

CaseHow it draws
Box mode on a round box, any fillOne gradient quad around the box. On a translucent box it is cut out under the box, so it never shows through the fill
An opaque box (solid colour fill with alpha 1, no mask, no content_blur, opacity 1), either modeThe same gradient quad; the box covers what is under it
Content mode on anything else, any non-box node, an opaque scoopAn offscreen layer: the subtree is drawn, blurred and tinted shadow_color
Box mode on a translucent scoopA layer of the scoop’s silhouette, cut out under the box

Blurs

Four properties blur four different things. Sigmas are in logical px, [0, 8192], 0 is off. source_blur is a fast box approximation; the others are Gaussian.

PropertyReadsWhen it runsCostPick it for
blur = true (box kinds)The desktop behind the surface: other windows and the wallpaper, not this surface’s own pixelsContinuously, in the compositorThe compositor’sA translucent bar or panel over windows
backdrop_blur = sigma (box kinds)What this surface has already painted under the box: ancestors, earlier siblings, lower z. Never the desktopEvery repaint that touches the box or what it reads, on the GPUA copy and a blur per repaint; not cachedGlass over the surface’s own wallpaper, image or animated content
content_blur = sigma (every node)The node’s own subtreeOn repaint, on the GPU, into an offscreen layerA blur when the subtree changes; an unchanged layer is reused. Large sigmas downsample firstA blurred or blur-in element, tweened with animate
source_blur = sigma (image)The image file’s pixelsOnce, on the CPU, when the source decodesNothing per frameA static blurred picture on a surface that repaints often

blur = true. Mantle sends the compositor a region, through ext-background-effect-v1, made of every blur = true box on the surface: rounded to radius (or scooped), cut by ancestor clips, moved by transforms, and dropped while the node is invisible or at opacity 0. It ignores mask. The compositor decides strength, noise, xray and whether to blur at all; a compositor without the protocol or its blur capability gives nothing, and no error. It is never inferred from a translucent background; the background alpha only decides how much of the blurred desktop shows through.

source_blur. The blur is baked into the decoded pixels, which are stored cropped to the box under fit = "cover", so it is exact there. Under "contain" or "stretch" the stored pixels are rescaled and the blur with them. Animated GIFs ignore it. Changing it re-decodes; under async = true the image draws nothing until that lands, and retain does not cover it (the source did not change). See image.

panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 36,
    exclusive = true,
    background = "#1E1E2E99",
    blur = true,
    child = row { width = "Fill", padding = { left = 12, right = 12 }, children = { clock } },
}

A bar whose 60% fill tints the compositor-blurred desktop behind it.

rect {
    width = 320,
    height = 180,
    children = {
        image { source = "/usr/share/backgrounds/default.png", width = "Fill", height = "Fill", async = true },
        row {
            align_h = "Center",
            align_v = "Center",
            padding = { left = 14, right = 14, top = 6, bottom = 6 },
            radius = 999,
            background = "#FFFFFF1F",
            border_width = 1,
            border_color = "#FFFFFF33",
            backdrop_blur = 12,
            children = { text { content = "12:45", font_size = 18, foreground = "#FFFFFF" } },
        },
    },
}

A frosted pill: the image is painted first, so the pill’s backdrop_blur blurs the image under its rounded shape, and the fill tints it. The same pattern over a full-screen image frosts a lock screen’s wallpaper.

Combining effects

One node paints in this order, each step over the last:

  1. Backdrop (backdrop_blur): replaces the pixels under the box with their blur.
  2. Shadow, when it is a gradient quad or a silhouette.
  3. Body: fill, children in z order, border. With a mask or a clip = "Rounded" the body goes through an offscreen pass.
  4. Layer: for content_blur or a layered shadow, the body is drawn offscreen, its shadow cast from it, then the body blurred.
  5. Transform (scale, rotate, translate) wraps all of the above.
CombinationWhat happensDo this
mask and backdrop_blur on one nodeThe mask fades the fill, border and subtree, not the node’s own glass or box shadowPut the glass on a child of the masked node
content_blur and backdrop_blur on one nodeThe glass stays sharp; only the fill, border and subtree blurExpected
backdrop_blur inside a parent with mask, content_blur or a Content-mode shadowThe glass sees only what that parent has drawn so far, not what is under the parentMove the glass out of the effect parent, or accept it
backdrop_blur inside clip = "Rounded" without a maskThe glass sees what is under the parent, as without the clipNothing to do
backdrop_blur on a surface rootNothing is under it on the surface, so it blurs transparencyUse blur = true for the desktop
blur = true and backdrop_blur on one boxThe compositor blurs the desktop; the backdrop blurs this surface’s pixels. Neither sees the otherPick by what is underneath: desktop or own content
Shadow and content_blur on one nodeThe shadow is cast from the sharp content, then the content is blurredExpected
Box-mode shadow on a translucent boxOne gradient quad, cut out under the box; children do not castshadow_mode = "Content" to cast from what is painted
Content-mode shadow on a masked nodeCast from the masked resultExpected
Content-mode shadow or content_blur over an image, icon, capture, image mask or glassThe layer is redrawn every repaint instead of reusedKeep those out of animated layers, or accept the cost
Anything under a glass changesThe glass repaints, and so does everything in the area it reads (3 sigma past its box)Keep glass away from constantly animating content, or keep sigma small
Shadow or content_blur near the parent’s edgeCut at the parent’s clip, like any child paintGive the parent padding, or clip = "None" on it
opacity on a node with effectsMultiplied into every draw once; layers and clips composite at full alpha, so nothing fades twiceExpected
opacity < 1 on a group whose children overlapEach child fades on its own, so overlaps show through each other (not CSS group opacity)For a group fade, give the parent a uniform mask (e.g. both stops "#00000080"); it costs an offscreen pass
A transform on a node with a glass or shadowThe backdrop, shadow and body move together; the glass reads under its transformed positionExpected

How do I…

TaskAnswer
Frosted glass panel over windowsGlass sheet below, or the blur bar
Frost a picture inside my own surfaceThe frosted pill: an image, then a sibling with backdrop_blur
Card with a shadowThe card at the top; lift on hover below
Pill buttonPill button
Gradient borderGradient ring
Fade a list’s edgesThe edge-fade mask
Circular avatarAvatar
Dim the background behind a modalScrim
Tint a gradient from a signalMap the whole table; see Gotchas

Frosted glass panel

panel {
    id = "sheet",
    layer = "Top",
    anchor = { top = true, right = true },
    margin = 8,
    child = column {
        width = 280,
        padding = 16,
        spacing = 8,
        radius = 16,
        background = "#1E1E2EB3",
        border_width = 1,
        border_color = "#FFFFFF2E",
        blur = true,
        children = { text { content = "Wi-Fi", font_size = 14, foreground = "#CDD6F4" } },
    },
}

The compositor blurs the desktop under the rounded sheet only; the rest of the surface stays clear. A faint light border separates glass from glass.

Card that lifts on hover

local lifted = hover("card_hover")
column {
    hover = lifted,
    padding = 16,
    radius = 12,
    background = "#313244",
    shadow_color = "#00000099",
    shadow_blur = lifted:map(function(on) return on and 36 or 12 end),
    shadow_offset = lifted:map(function(on) return { x = 0, y = on and 20 or 6 } end),
    animate = { shadow_blur = 200, shadow_offset = 200 },
    children = { text { content = "Hover me" } },
}

hover drives the shadow and animate eases it. Leave room around the card: the parent clips the shadow.

Pill button

local hovered = hover("save_hover")
button {
    hover = hovered,
    padding = { left = 16, right = 16, top = 6, bottom = 6 },
    radius = 999,
    background = hovered:map(function(on) return on and "#89B4FA59" or "#89B4FA33" end),
    border_width = 1,
    border_color = "#89B4FA66",
    animate = { background = 150 },
    on_click = function() print("saved") end,
    children = { text { content = "Save", foreground = "#CDD6F4" } },
}

A radius past half the height makes the ends round whatever the label’s width.

Gradient border

rect {
    padding = 2,
    radius = 14,
    background = { gradient = "Linear", angle = 135, stops = { { 0, "#CBA6F7" }, { 1, "#89B4FA" } } },
    children = {
        column {
            padding = 14,
            radius = 12,
            background = "#1E1E2E",
            children = { text { content = "Pro", foreground = "#CDD6F4" } },
        },
    },
}

border_color takes only flat colours, so paint the gradient as an outer fill and cover all but a 2px ring with an opaque inner box. Keep the inner radius the outer radius minus the ring width.

Circular avatar

rect {
    width = 64,
    height = 64,
    radius = 32,
    clip = "Rounded",
    border_width = 2,
    border_color = "#89B4FA",
    children = { image { source = "/var/lib/AccountsService/icons/user", width = "Fill", height = "Fill" } },
}

clip = "Rounded" cuts the image to the circle, and the border paints over the image’s edge.

Dim the background behind a modal

panel {
    id = "modal",
    layer = "Overlay",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "Fill",
    height = "Fill",
    exclusive = "Ignore",
    keyboard_interactivity = "OnDemand",
    child = rect {
        width = "Fill",
        height = "Fill",
        children = {
            rect { width = "Fill", height = "Fill", background = "#11111B99" },
            column {
                align_h = "Center",
                align_v = "Center",
                width = 360,
                padding = 24,
                spacing = 8,
                radius = 16,
                background = "#1E1E2EE0",
                blur = true,
                shadow_color = "#00000080",
                shadow_blur = 32,
                children = {
                    text { content = "Log out?", font_size = 18, foreground = "#CDD6F4" },
                    text { content = "Unsaved work in open apps will be lost.", foreground = "#A6ADC8" },
                },
            },
        },
    },
}

A full-screen panel whose first child is a translucent scrim and whose second is the dialog. Dim with a colour rather than blur = true on the scrim: the compositor’s blur does not fade with opacity, so a fading scrim would blur at full strength until it hits 0.

Gotchas

TrapFix
A shadow is cut off at one edgeThe parent clips it. Pad the parent, or set clip = "None" on it
backdrop_blur shows no desktop behind a translucent panelIt only reads this surface’s pixels. Use blur = true
blur = true does nothingThe compositor lacks ext-background-effect-v1 or its blur capability. No error is raised
A blur = true box fades out but its blur stays at full strengthThe blur region ignores opacity until it reaches 0. Dim with a translucent colour, or let the blurred box pop
A per-edge border or a border on a scoop has square cornersOnly a uniform border follows radius, and a scoop’s border is always square
A border covers contentBorders take no layout space. Add padding at least the border’s width
clip = "Rounded" changes nothingIt needs a non-zero radius, and only clips children
Children still clipped with clip = "None" and a maskA mask always cuts to its box
A gradient or a per-edge border_color jumps instead of easing under animateOnly single colours ease; see Animation
Rounded corners, scoops and masks still take clicks in the cut-away areaHit-testing uses the rectangle. Shrink the button or accept it
A signal inside a gradient stop or border edge is refusedMap the whole table: background = accent:map(function(c) return { gradient = "Linear", stops = { { 0, c }, { 1, "#00000000" } } } end)

See also: nodes, surfaces, animation, input, glossary.

Source: allowlist, parsers, paint style, paint order, canvas, effects, shapes, blur region, compositor push.

Animation

animate makes a node’s properties move to a new value instead of snapping: on a hover, a level or a toggle, as a node enters or leaves the tree, or in a loop like a spinner. A tween is one property moving from the value on screen to the value a new pass resolves. The engine runs every tween on its own surface’s compositor frames, so a panel on a 60 Hz output moves at 60 Hz beside one at 165 Hz; no Lua runs between the pass that starts a tween and its last frame.

local open = hover("tray")

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    child = row {
        hover = open,
        height = 28,
        radius = 14,
        background = "#313244",
        width = open:map(function(on) return on and 160 or 28 end),
        spacing = open:map(function(on) return on and 6 or 0 end),
        animate = { width = { duration = 200, easing = "OutCubic" }, spacing = 200 },
        children = {
            icon { name = "network-wireless-symbolic", size = 16, margin = 6 },
            icon { name = "bluetooth-active-symbolic", size = 16, margin = 6 },
        },
    },
}

How a tween starts

animate is a table from property names to entries. When a pass resolves a different value for a named property, the node moves from the value on screen to the new one. A pass that re-resolves the same value leaves a running tween alone, so an unrelated signal does not restart the motion. animate itself may be a signal (animate = shown:map(...)), but the entries inside it are plain values: a signal nested in an entry does not resolve.

SituationResult
Number, "NN%" size, "#rrggbb[aa]" colour, number edge table { top, right, bottom, left }, { x, y } tableTweens against a new value of the same shape. A missing edge or axis reads as 0 (1 for scale, 0.5 for origin)
"Fill", booleans, strings that are not colours, per-edge colour tables, gradients, or a change of shape (2 to { x = 2 }, "50%" to "Fill")Snaps
New node, or a property the node did not set last passStarts at the entry’s from, else snaps. from needs the node to set the property itself
Target changes mid-flightEased and keyframe motion start over from the value on screen. A spring keeps its velocity (spring)
Property removed from animateIts tween stops and the property snaps to the resolved value
Hidden subtree (visible = false)Tweens freeze and request no frames; they settle when it shows again
z, animate, or a name the node kind does not acceptRefused: the pass fails with an error naming the entry

What can animate

Any property the node’s kind accepts (nodes) can be named; the value’s shape decides whether it moves. The ones that do:

ShapeProperties
Numberwidth, height, min_*, max_*, padding, margin, opacity, scale, rotate, shadow_blur, shadow_spread, content_blur; on boxes radius, border_width, backdrop_blur; spacing on row, column, list; font_size on text and textfield; size on icon; progress on shader
"NN%"width, height
Colourbackground, border_color (single colour), shadow_color, foreground
{ top, right, bottom, left }padding, margin, border_width as tables
{ x, y }translate, scale, origin, shadow_offset

An image crossfading between sources uses its own transition property, not animate (image).

Range clamp

Every frame’s value is clamped to the property’s range, which catches overshoot from Back, Elastic, a Bezier with y outside [0, 1], or a spring. Only margin, translate, rotate, progress, shadow_offset and shadow_spread may go negative. padding, spacing and icon size have no range as plain values but tween within [0, 8192].

Layout cost

Tween onEach frame
opacity, colours, radius, translate, scale, rotate, origin, progress, shadow_*, content_blur, backdrop_blurRepaints; no layout pass
Anything else: width, height, margin, padding, spacing, font_size, …Lays the surface out again

Slide with translate and grow on hover with scale when surrounding nodes should stay put. Both skip layout; width and margin lay out the surface on every animation frame.

Entry keys

An entry is a bare number (a duration in ms with the default easing) or a table. Every entry picks one of three motions: eased (duration), keyframes (keyframes + duration) or spring (spring).

KeyValuesRules
durationWhole ms, [1, 60000]Required unless spring is set. With keyframes it is the default length of each segment
easingA name, { x1, y1, x2, y2 }, or { steps = n }Default "InOutQuad". Not with spring
delayWhole ms, [0, 60000]Holds the start value first, like CSS transition-delay. Offsets a keyframe run once, not each loop
fromA value of the property’s shapeStart value for a property with nothing on screen yet. Refused with keyframes
spring{ stiffness, damping }stiffness in (0, 100000], damping in (0, 10000], both required. Refuses duration, easing, keyframes and loops
keyframesAt least 2 framesSee keyframes
loopsWhole count [1, 10000] or "Infinite"Default 1. Only with keyframes

A duration or delay that is not a number ("200") is refused rather than read as absent.

Easing names. A name is case-sensitive; an unknown one is refused with the list.

FamilyNames
LinearLinear
Quad, Cubic, Quart, QuintInQuad, OutQuad, InOutQuad, InCubic, OutCubic, InOutCubic, InQuart, OutQuart, InOutQuart, InQuint, OutQuint, InOutQuint
Sine, Expo, CircInSine, OutSine, InOutSine, InExpo, OutExpo, InOutExpo, InCirc, OutCirc, InOutCirc
Back, Elastic, BounceInBack, OutBack, InOutBack, InElastic, OutElastic, InOutElastic, InBounce, OutBounce, InOutBounce

In starts slow, Out ends slow, InOut does both. Back and Elastic overshoot, and the range clamp above catches it; Bounce stays inside the range.

Table easingMeaning
{ x1, y1, x2, y2 }CSS cubic-bezier. x1 and x2 in [0, 1]; y is free, so a curve may overshoot
{ steps = n }n equal jumps, whole n in [1, 1000], like CSS steps(n, end): the target lands only at the end

The same 600 ms width change under six easings. OutBack passes the target and comes back:

local go = state("go", false)

local function race(label, easing)
    return row {
        spacing = 8,
        children = {
            text { content = label, width = 80, font_size = 12, foreground = "#a6adc8" },
            rect {
                height = 12,
                radius = 6,
                background = "#89b4fa",
                width = go:map(function(on) return on and 200 or 12 end),
                animate = { width = { duration = 600, easing = easing } },
            },
        },
    }
end

return column {
    spacing = 6,
    children = {
        race("Linear", "Linear"),
        race("InOutQuad", "InOutQuad"),
        race("OutCubic", "OutCubic"),
        race("OutBack", "OutBack"),
        race("OutBounce", "OutBounce"),
        race("steps = 4", { steps = 4 }),
    },
}

Spring

A spring has no duration: stiffness and damping decide how it settles. Use one for a target that changes mid-flight, like a held volume key or a pointer-following highlight. The spring carries its velocity into the new motion; an eased tween restarts from a standstill and lags behind. A spring that replaces an eased tween starts at rest.

DampingBehaviour
< 2 * sqrt(stiffness)Overshoots and rings
= 2 * sqrt(stiffness)Critical: the fastest settle with no overshoot
> 2 * sqrt(stiffness)Crawls in without crossing the target

There is no mass: it would only rescale the other two. A spring stops within a thousandth of its travel and never runs longer than 60 s.

The same translate change on three springs of stiffness = 400, where critical damping is 40. The underdamped knob passes the others’ resting point and swings back:

local go = state("go", false)

local function knob(label, damping)
    return row {
        spacing = 8,
        children = {
            text { content = label, width = 130, font_size = 12, foreground = "#a6adc8" },
            rect {
                width = 176,
                height = 16,
                radius = 8,
                background = "#313244",
                children = {
                    rect {
                        width = 16,
                        height = 16,
                        radius = 8,
                        background = "#cba6f7",
                        translate = go:map(function(on) return { x = on and 100 or 0 } end),
                        animate = { translate = { spring = { stiffness = 400, damping = damping } } },
                    },
                },
            },
        },
    }
end

return column {
    spacing = 8,
    children = {
        knob("damping = 12, rings", 12),
        knob("damping = 40, critical", 40),
        knob("damping = 120, crawls", 120),
    },
}

Keyframes

A keyframes entry walks a list of values instead of easing to the resolved one. While it runs, it owns the property: the value the pass resolves is ignored.

RuleDetail
FramesA bare value, or { value = v, duration = ms, easing = e } overriding the entry’s duration and easing for the segment that arrives at it
First frameWhere the run starts; its own duration and easing are never read
JumpA frame with duration = 0 (allowed only on a frame) cuts straight to its value
HoldA segment between two equal values holds still for its duration
ListAt least 2 frames, no holes ({ [1] = 0, [3] = 1 } is refused), at least one segment that takes time
EndA counted run holds its last frame as long as the entry stays. An "Infinite" run never ends
ContinuityThe same list on the next pass is the same run; any change to the frames, timing or loops starts a new run from the first frame

To replay a finished run, take the entry away and put it back. pulse does both in one expression: it reads true for a window after its source changes.

local taps = state("taps", 0)
-- Three 120 ms segments: `duration` times each one, so the run takes 360 ms.
local BOUNCE = { scale = { duration = 120, easing = "OutQuad", keyframes = { 1, 1.25, 0.9, 1 } } }

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    padding = 4, -- room for the overshoot: a scaled node paints past its box
    child = button {
        width = 32,
        height = 32,
        radius = 8,
        background = "#313244",
        on_click = function() taps:set(taps:get() + 1) end,
        -- pulse is true for 400 ms after each tap: the entry appears, plays once, then goes.
        animate = pulse(taps, 400):map(function(on) return on and BOUNCE or {} end),
        children = { icon { name = "starred-symbolic", size = 16, foreground = "#CDD6F4", align_h = "Center", align_v = "Center" } },
    },
}

An endless spinner needs no signal. A hidden spinner stops requesting frames by itself:

local busy = state("busy", true)
local SPIN = { rotate = { duration = 1000, easing = "Linear", keyframes = { 0, 360 }, loops = "Infinite" } }

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    padding = 4, -- room for the corners as it turns
    child = icon {
        name = "view-refresh-symbolic",
        size = 16,
        foreground = "#CDD6F4",
        visible = busy,
        animate = SPIN,
    },
}

Exit

animate.exit animates a child after its parent stops returning it: a notification removed from a list, or a card dropped from children. The block holds one timing for every property, and the values to ease to.

exit = { duration = 150, easing = "InQuad", opacity = 0, translate = { y = 16 } }
RuleDetail
Keysduration or spring, plus optional easing and delay, all as in entry keys. Every other key is a property name and its target value
CheckedOn every pass while the node is still in the tree, so a typo fails before the node leaves. A block with no targets is a legal no-op; one with targets needs duration or spring
Start valueThe value on screen. A property never set starts at its identity: 1 for opacity and scale, 0.5 for origin, "0%" for a percent, the target colour at alpha 0 for a colour, 0 otherwise
Running tweensStop where they are. The exit block alone decides how long the node lives
What movesEverything painted: opacity, colours, radius, translate, scale, rotate, origin, shadow_*, blurs, progress, and pixel width/height. margin, padding and spacing change nothing visible
While leavingPainted at its last rect and scroll offset, above live siblings of the same z. It takes no space in the flow (siblings close up at once), though a content-sized parent keeps room for its last rect until it is gone. It takes no pointer or keyboard input and no geometry writes. Its subtree is frozen: a resized box does not reflow its children, and text keeps the string it was fitted to
IdentityA leaving node is never matched again. Returning the same id builds a new node beside it
ScopeOnly the dropped child runs its block; descendants leave with it and their own blocks never run
Not triggered byvisible = false, a surface closing, or a child dropped while an ancestor was hidden

Hiding a surface skips the exit, so drop the child from children and hold the surface open with delay until the exit has played. The card below slides up and fades in on show; the shot plays the hide, down and out over 150 ms:

local shown = state("osd_shown", false)
-- Keep the surface mapped 150 ms past `shown`, so the card's exit can play.
local mapped = computed({ shown, delay(shown, 150) }, function(now, was)
    return now == true or was == true
end)

local card = rect {
    width = 240,
    height = 48,
    radius = 12,
    background = "#1e1e2ee6",
    opacity = 1,
    translate = { y = 0 },
    children = { text { content = "Volume 42%", align_h = "Center", align_v = "Center", foreground = "#CDD6F4" } },
    animate = {
        opacity = { duration = 200, from = 0 },
        translate = { duration = 200, easing = "OutCubic", from = { y = 16 } },
        exit = { duration = 150, easing = "InQuad", opacity = 0, translate = { y = 16 } },
    },
}

return panel {
    id = "osd",
    layer = "Overlay",
    anchor = { bottom = true },
    width = 240,
    height = 64, -- room for the exit's 16 px slide
    visible = mapped,
    child = column {
        height = "Fill",
        children = shown:map(function(on) return on and { card } or {} end),
    },
}

How do I…

TaskAnswer
Grow a button on hoverscale = hover("b"):map(function(on) return on and 1.1 or 1 end), hover = hover("b") and animate = { scale = { spring = { stiffness = 400, damping = 40 } } }
Show a loading spinnerThe spinner under keyframes
Bounce on clickThe pulse example under keyframes
Slide an on-screen display in and outThe example under exit
Fade a popup in and outFade a tooltip
Stagger a list’s entranceStagger
Slide a notification out when dismissedSlide out

Fade a tooltip popup in and out

A popup closing skips the exit, so switch the card out of children and let delay hold the popup open while it fades.

local over = hover("clock")
-- Stay mapped 120 ms after the pointer leaves, so the card's exit can play.
local mapped = computed({ over, delay(over, 120) }, function(now, was)
    return now == true or was == true
end)

local card = rect {
    width = 180,
    height = 32,
    radius = 6,
    background = "#1e1e2e",
    opacity = 1,
    animate = { opacity = { duration = 120, from = 0 }, exit = { duration = 120, opacity = 0 } },
    children = { text { content = "Thursday, 24 September", padding = 8 } },
}

return {
    panel { id = "bar", layer = "Top", anchor = { top = true }, child = text { content = "12:30", padding = 8, hover = over } },
    popup {
        id = "clock_tooltip",
        parent = "bar",
        anchor_rect = hover_rect("clock"),
        anchor = "Bottom",
        gravity = "Bottom",
        grab = false,
        visible = mapped,
        child = rect {
            children = over:map(function(on) return on and { card } or {} end),
        },
    },
}

Stagger a list’s entrance

Give each item a delay that grows with its index. delay holds the from value, so a card waits invisible for its turn.

local go = state("go", false)
local titles = { "Battery low", "Update ready", "Download complete" }

local function card(index, title)
    local wait = (index - 1) * 80
    return rect {
        width = 200,
        padding = 10,
        radius = 8,
        background = "#1e1e2e",
        opacity = 1,
        translate = { x = 0 },
        animate = {
            opacity = { duration = 200, delay = wait, from = 0 },
            translate = { duration = 200, delay = wait, easing = "OutCubic", from = { x = -24 } },
        },
        children = { text { content = title, foreground = "#cdd6f4" } },
    }
end

return column {
    spacing = 6,
    children = go:map(function(on)
        local cards = {}
        for index, title in ipairs(on and titles or {}) do
            cards[index] = card(index, title)
        end
        return cards
    end),
}

Slide a notification out

Removing an item from a keyed list makes it leave. The remaining cards close up at once; only the leaving one moves.

local notes = state("notes", { "Battery low", "Update ready", "Download complete" })

local function dismiss(title)
    local kept = {}
    for _, other in ipairs(notes:get()) do
        if other ~= title then kept[#kept + 1] = other end
    end
    notes:set(kept)
end

local function card(title)
    return button {
        width = 280,
        padding = 12,
        radius = 12,
        background = "#1e1e2e",
        border_width = 1,
        border_color = "#45475a",
        on_click = function() dismiss(title) end,
        animate = { exit = { duration = 200, easing = "InCubic", opacity = 0, translate = { x = 300 } } },
        children = { text { content = title, foreground = "#cdd6f4" } },
    }
end

return panel {
    id = "notifications",
    layer = "Overlay",
    anchor = { top = true, right = true },
    width = 300,
    height = 400,
    child = list { spacing = 8, source = notes, itemfn = card, key = function(title) return title end },
}

Gotchas

TrapFix
A node’s first value snaps; nothing fades inGive the entry from
from does nothingThe node must set the property too: opacity = 1 beside opacity = { from = 0, ... }
An exit never playsExit runs only when the parent stops returning the child. Switch children, and keep the surface up with delay
A held key makes an eased value trail behindUse a spring; it keeps its velocity through each new target
A keyframe run plays once and never againSame list, same run. Toggle the entry off and on, for example with pulse
A pulse-driven run is cut shortRemoving the entry snaps the property. Make the window at least delay plus every segment’s duration times loops
A second click inside the pulse window does not replay the runThe click only extends the window; the entry never leaves, so the run does not restart
Sliding with margin stutters on a large surfaceTween translate: it skips layout
width will not overshoot below 0 with OutBackThe property’s range clamps every frame. Use margin or translate for motion that must go negative

See also: signals (pulse, delay, hover), nodes (properties and identity), input (hover and clicks that drive motion), paint (what the painted properties draw).

Source: animate, easing, spring, keyframes, leaving nodes, range clamps.

Input

Pointer and keyboard input: clicks, drags and the wheel on a button, hover, scrolling containers, and typing into a textfield, including password fields whose keys never reach Lua. There is no key-handler property and no touch input; keys reach a config only through a focused textfield. A handler usually writes a named state, and the next pass shows the result.

local clicks = state("clicks", 0)

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    child = button {
        padding = 8,
        background = "#313244",
        on_click = function(rect, which)
            if which == "left" then clicks:set(clicks:get() + 1) end
        end,
        children = { text { content = clicks:map(function(n) return "Clicked " .. n end) } },
    },
}

Hit testing

Every pointer event asks which nodes lie under the pointer, from the surface down.

RuleDetail
TransformsA node is hit where it is painted, after scale, rotate and translate
StackingSiblings are asked topmost first: higher z, then later in declaration order
ClippingA point outside a node reaches none of its children, unless the node has clip = "None"
Skippedvisible = false subtrees and nodes playing an exit. opacity = 0 is still hit
EdgesHalf-open: two buttons sharing an edge never both take it
RectsEvery rect argument and hover_rect value is the node’s surface-local { x, y, width, height } laid-out box, before transforms

Pointer

Only a button takes clicks, drags and the wheel. For each event the innermost button with a handler for that event wins; a button without one is transparent, so a handle inside a draggable track leaves the track draggable.

HandlerArgumentsContract
on_click(rect, button)button is "left", "right" or "middle"Fires on release over the same button that was pressed, with the same mouse button. Other mouse buttons are ignored
on_drag(rect, pointer, phase)pointer is { x, y } relative to the button, unclamped; phase is "start", "move" or "end"Left button only. See below
on_wheel(rect, steps)steps is a number of wheel notchesVertical wheel only. See below
submit = true—Sends the armed secure field on click, like Enter; works without on_click and runs before it

Click. A press arms the click and the release fires it. Leaving the button and coming back before release still clicks; the pointer leaving the surface cancels. The click also cancels if the button’s laid-out box moved between press and release, so give press feedback with scale or translate rather than width or margin. A press on a textfield never clicks the button around it, and a link in a text (on_link) takes the click before any button around it.

Drag. A left press on an on_drag button calls "start" at once, so clicking a slider track also seeks. Every pointer motion on that surface then calls "move", wherever the pointer is. "end" comes on the left release, when the pointer leaves the surface, or when the surface closes. rect stays the box from the press for the whole drag. On release, "end" fires first and the click (if the button also has on_click) after it; a leave ends the drag and cancels the click.

Wheel. steps is positive away from the user (scroll up) and negative toward. One notch is 1; high-resolution wheels send fractions of a notch, and touchpads send distance divided by one notch’s 39 px. Horizontal motion never reaches on_wheel. The innermost on_wheel button or scroll container under the pointer takes the whole event, with no chaining to a parent.

local level = state("level", 0.5)
local function clamp(value) return math.max(0, math.min(1, value)) end

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    child = button {
        width = 200,
        height = 12,
        radius = 6,
        clip = "Rounded",
        background = "#45475a",
        -- A press is "start", so clicking the track also seeks.
        on_drag = function(rect, pointer, phase) level:set(clamp(pointer.x / rect.width)) end,
        on_wheel = function(_, steps) level:set(clamp(level:get() + steps * 0.05)) end,
        children = {
            rect {
                height = "Fill",
                background = "#89b4fa",
                width = level:map(function(value) return string.format("%d%%", math.floor(value * 100 + 0.5)) end),
            },
        },
    },
}

Hover

hover(name) returns a read-only boolean signal, false until the pointer first arrives. Bind it to a node’s hover and the engine writes it as the pointer moves; read the same signal anywhere else to react. The name is the identity: every hover("wifi") call returns the same signal, and it survives reloads.

APIContract
hover = hover(name)Any node kind. true while the pointer is over the node or any of its children (hit-tested, so clipping and stacking apply). Pointer leaving the surface, or the surface closing, turns every hover off. When layout moves nodes under a still pointer, hover follows
on_hover(inside)Called with true/false on each crossing caused by the pointer. Layout moving nodes under a still pointer updates hover but does not call it. Refused unless the same node has hover
hover_rect(name)Read-only signal of the node’s rect from the last time its hover turned on. It keeps that rect after the pointer leaves, reads { x = 0, y = 0, width = 1, height = 1 } before the first hover, and is updated before on_hover runs. Use it as a tooltip popup’s anchor_rect
cursorThe pointer shape over a node: one of the cursor names, defaults in common properties. The innermost node that sets one wins, and an explicit one beats a kind’s default
local over = hover("wifi")

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    child = button {
        padding = 6,
        radius = 6,
        hover = over,
        background = over:map(function(on) return on and "#45475a" or "#313244" end),
        on_hover = function(inside) log.debug("wifi hovered:", inside) end,
        on_click = function() process.detach("nm-connection-editor", {}) end,
        children = { icon { name = "network-wireless-symbolic", size = 16 } },
    },
}

Scroll

scroll(name) returns a read-only signal holding a scroll offset in px, 0 at first. Bind it to the scroll property of a row, column or list and the wheel moves that container’s children. Like hover, the name is the identity and survives reloads.

RuleDetail
AxisA column or vertical list scrolls with the vertical wheel, a row or horizontal list with the horizontal one only
DistanceOne wheel notch is 39 px; a touchpad scrolls the distance it reports
BoundLayout clamps the offset to [0, content − viewport] and writes the clamped value back. The container needs a bounded size on its axis (fixed, "Fill" or max_*); one sized by its content has nothing to scroll
CostWhile only scroll properties read the signal, the wheel moves the laid-out children without a layout pass. A map or :get() of it, or a scroll inside a list item, costs a pass per wheel event
:reveal(index)On the next pass, scrolls the least distance that shows the index-th visible child (1-based; a list’s items in source order). An index past the end does nothing; below 1 raises. Only a scroll signal has it

Text fields

A textfield is a single-line text input. The engine holds what the user types (the draft); Lua sees it only through callbacks and cannot set it. The field with focus is the one keys go to. It has no size of its own, so give it width and height (nodes).

A field takes the keyboard only when both hold:

ConditionDetail
The surface has keyboard focusA panel needs keyboard_interactivity = "OnDemand" or "Exclusive" (keyboard focus); a popup shown under the focused surface shares its keys
The field can use keysIt has secure_submit, on_change or on_submit. A field with none of them (even with on_cancel or on_navigate) never takes focus, and a press on it acts like a press on empty space

A press on the field focuses it and puts the caret under the pointer. autofocus focuses it without a press.

PropertyContract
on_change(text)Every edit that changes the text, with the whole draft. Caret moves call nothing
on_submit(text)Enter, with the whole draft (possibly ""). The draft then clears and on_change("") follows; the field keeps focus. A held Enter does not repeat
on_cancel(cleared)Escape. The draft clears, the field drops focus, on_change("") fires if there was text, then on_cancel gets whether text was removed. Without on_cancel, Escape clears and the field keeps focus
on_navigate(key)"up", "down", "page_up", "page_down", "tab", "backtab", and "left"/"right" when the caret cannot move that way and Shift is up. Repeats while held. The draft is untouched
autofocustrue: take the keys, with an empty draft and a call to on_change(""), when the surface gains keyboard focus or the field appears under it. The first visible such field in document order wins. It never takes over from a field that is already typing, and never re-takes a field the user just clicked away from
secure_submit, mask_characterSee secure fields
placeholder, font_size, foreground, text_alignAppearance; see textfield
KeyPlain fieldSecure field
TextInserts at the caret, replacing a selection; on_changeAppends
Enteron_submit, then clearsSends
EscapeClears; with on_cancel, also drops focusClears, stays armed, on_cancel
Backspace, DeleteOne character, or the selectionBackspace only
Ctrl+Backspace, Ctrl+DeleteOne wordNothing
Left, Right, Home, EndMove the caret; Ctrl+Left/Right by word; Shift selects. Left/Right with nowhere to go (and no Shift) call on_navigateNothing
Ctrl+ASelects allNothing
Up, Down, Page Up, Page Down, Tab, Shift+Tabon_navigate ("backtab" for Shift+Tab)Nothing
Any other Ctrl chordLeft to the compositorSame

Selection and clipboard. Dragging or Shift+clicking with the pointer selects too. There is no clipboard in a field, and Tab does not move focus between fields; it only reaches on_navigate. Every key but Enter repeats while held.

Draft lifetime. Clicking elsewhere, or the surface losing the keyboard, stops typing but keeps the draft; clicking the field again resumes it. Enter and Escape clear it. An autofocus arm starts it empty. It is dropped when the field’s node leaves the tree or its surface closes.

local APPS = { "firefox", "foot", "nautilus", "pavucontrol", "thunderbird", "zed" }
local query = state("query", "")
local selected = state("selected", 1)
local LIST = scroll("results")
local open = state("launcher_open", true)

local results = query:map(function(needle)
    local found = {}
    for _, app in ipairs(APPS) do
        if fuzzy(app, needle) then found[#found + 1] = app end
    end
    return found
end)

local function pick(index)
    selected:set(index)
    LIST:reveal(index)
end

local search = textfield {
    width = "Fill",
    height = 32,
    placeholder = "Search apps",
    autofocus = true,
    on_change = function(text)
        query:set(text)
        pick(1)
    end,
    on_submit = function()
        local app = results:get()[selected:get()]
        if app then process.detach(app, {}) end
    end,
    -- First Escape clears the text; a second one, on an empty field, closes.
    on_cancel = function(cleared)
        if not cleared then open:set(false) end
    end,
    on_navigate = function(key)
        local step = ({ up = -1, down = 1 })[key]
        if step then pick(math.max(1, math.min(#results:get(), selected:get() + step))) end
    end,
}

local function app_row(app)
    return row {
        width = "Fill",
        padding = 6,
        background = computed({ results, selected }, function(found, index)
            return found[index] == app and "#45475a" or nil
        end),
        children = { text { content = app } },
    }
end

return panel {
    id = "launcher",
    layer = "Overlay",
    keyboard_interactivity = "OnDemand",
    width = 320,
    visible = open,
    child = column {
        width = "Fill",
        children = {
            search,
            list { width = "Fill", height = 120, scroll = LIST, source = results, itemfn = app_row, key = function(app) return app end },
        },
    },
}

Secure fields

secure_submit = { capability, action } turns a textfield into a password field. Its keys go to a native buffer and leave the Renderer as one message to that target; no Lua value ever holds the secret or its length.

TargetEffect
{ capability = "lock", action = "authenticate" }Checked by PAM; success unlocks the session. A lock surface needs exactly one reachable field with this target (lock)
{ capability = "polkit", action = "authenticate" }Answers the current polkit request (polkit)
{ capability = "network", action = "connect" }The password for the network being joined (network)
Any other pairRefused when the field is laid out, so no password is typed into nowhere
RuleDetail
ArmingWhen the surface gains keyboard focus, the sole visible secure field in it (and in popups shown under it) is armed with no click. With two or more, a press picks one. A field revealed later under existing focus arms if none is armed
KeysTyped text appends, Backspace removes one character, Escape clears the buffer, stays armed and calls the field’s on_cancel(cleared). There is no caret, selection or on_navigate; on_change and on_submit never fire
SendingEnter, or a click on a submit = true button, sends the buffer and wipes it. An empty buffer is sent only to network/connect, where it joins an open network
FocusA click on anything but a field keeps the field armed, so a submit button works. Focusing another field, plain or secure, or the keyboard leaving the surface, disarms it and wipes the buffer
PriorityWhile a secure field is armed, plain fields in the same focus take no keys
mask_characterDrawn once per typed character. Default "•"; only the first character counts; "" draws nothing and hides the length. Only secure fields draw it. An empty field shows its placeholder
return lock {
    id = "lock",
    child = column {
        width = "Fill",
        height = "Fill",
        align_h = "Center",
        align_v = "Center",
        spacing = 12,
        background = "#11111b",
        children = {
            textfield {
                width = 280,
                height = 40,
                placeholder = "Password",
                mask_character = "•",
                secure_submit = { capability = "lock", action = "authenticate" },
            },
            button {
                padding = 8,
                radius = 8,
                background = "#89b4fa",
                submit = true,
                children = { text { content = "Unlock", foreground = "#11111b" } },
            },
        },
    },
}

How do I…

TaskAnswer
Make a sliderThe example under pointer
Move a selection through a list with the arrow keysThe launcher under text fields: on_navigate plus scroll(name):reveal
Close a search box on a second EscapeThe same launcher: on_cancel(cleared) closes only when cleared is false
Ask for a passwordThe lock example under secure fields
Show a tooltip on hoverTooltip, with hover_rect as the anchor
Open a menu on right clickBelow
Reorder a list by draggingBelow

Right-click menu. on_click reports the mouse button and the button’s rect, which is what a popup’s anchor_rect wants. The click is a pointer release, so the popup may take its grab. The menu hangs from the button, not the click point: no handler reports the pointer position of a click.

local menu_open = state("context_open", false)
local menu_at = state("context_at", { x = 0, y = 0, width = 1, height = 1 })

local function item(label, run)
    return button {
        width = "Fill",
        padding = 6,
        radius = 4,
        on_click = function()
            menu_open:set(false)
            run()
        end,
        children = { text { content = label } },
    }
end

local files = button {
    padding = 8,
    on_click = function(rect, which)
        if which == "right" then
            menu_at:set(rect)
            menu_open:set(true)
        else
            process.detach("nautilus", {})
        end
    end,
    children = { text { content = "Files" } },
}

return {
    panel { id = "bar", layer = "Top", anchor = { top = true }, child = files },
    popup {
        id = "files_menu",
        parent = "bar",
        anchor_rect = menu_at,
        anchor = "Bottom",
        gravity = "Bottom",
        visible = menu_open,
        on_dismiss = function() menu_open:set(false) end,
        child = column {
            width = 140,
            padding = 4,
            background = "#1e1e2e",
            children = {
                item("New window", function() process.detach("nautilus", { "--new-window" }) end),
                item("Downloads", function() process.detach("nautilus", { os.getenv("HOME") .. "/Downloads" }) end),
            },
        },
    },
}

Drag to reorder. Each row is an on_drag button in a keyed list. The drag keeps the row’s box from the press, so pointer.y divided by the row pitch counts the rows moved. The dragged row jumps slot by slot rather than following the pointer; to make it follow, also bind its translate to the drag offset. There is no drag-and-drop between surfaces or applications, and no drag image.

local items = state("order", { "Music", "Mail", "Files", "Terminal" })
local STEP = 32 -- row height 28 + spacing 4
local started_at = 1

local function index_of(name)
    for index, other in ipairs(items:get()) do
        if other == name then return index end
    end
end

local function row_for(name)
    return button {
        width = 200,
        height = 28,
        padding = 6,
        background = "#313244",
        cursor = "grab",
        on_drag = function(_, pointer, phase)
            if phase == "start" then
                started_at = index_of(name)
                return
            end
            -- `pointer` is relative to the row's box at the press, so the offset counts rows moved.
            local order = { table.unpack(items:get()) }
            local target = math.max(1, math.min(#order, started_at + math.floor(pointer.y / STEP)))
            local now = index_of(name)
            if target ~= now then
                table.insert(order, target, table.remove(order, now))
                items:set(order)
            end
        end,
        children = { text { content = name } },
    }
end

return panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true },
    child = list { spacing = 4, source = items, itemfn = row_for, key = function(name) return name end },
}

Gotchas

TrapFix
A textfield is invisible or cannot be clickedIt has no intrinsic size. Give it width and height
A field in a panel shows no caret and takes no keysSet the panel’s keyboard_interactivity to "OnDemand" (or "Exclusive" for a modal)
A field with only on_navigate/on_cancel ignores clicksAdd on_change or on_submit
The mouse wheel does nothing over a scrolling rowRows scroll on the horizontal axis. Use a column, or an on_wheel button that moves the row
A scroll container never scrollsBound its size on the scroll axis; content-sized means nothing overflows
on_hover is refusedAdd hover = hover("name") on the same node
A container’s hover stays on while the pointer is over a childHover covers the whole subtree. Give the child its own hover for innermost-only behaviour
A click is lost when the button grows on pressThe release must land on the same laid-out box. Animate scale instead
A tooltip or menu anchored to a scaled button is offRects are laid-out boxes before transforms. Anchor on an untransformed parent
Lua needs to prefill or clear a fieldNot possible: the draft belongs to the engine. autofocus re-arms empty; Escape and Enter clear
A typed password shows up in on_changeIt cannot: a secure_submit field never calls it. Plain fields also stop taking keys while a secure field is armed

See also: nodes (button, textfield, list), surfaces (keyboard_interactivity, popups, lock), signals (state the handlers write), animation (press and hover motion), capabilities (lock, polkit, network).

Source: pointer, wheel, keyboard, text fields, secure fields, hit testing, hover, scroll.

Processes

Run other programs from Lua: read a command’s output, launch an app the user keeps, or hold a long-running program across reloads. Call them from event handlers (on_click, on_change, a timer) or a module’s top level. Storage, timers, actions and the utilities are on scripting; Supervisor, Renderer and push are in the glossary.

A temperature from an HTTP API that refreshes on click, with process.run, json, log and a named state:

local temperature = state("temperature", "--")

local function refresh()
    local body = {}
    process.run("curl", { "-fsS", "--max-time", "5",
        "https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41&current_weather=true" },
        function(line, stream)
            if stream == "stdout" then body[#body + 1] = line end
        end,
        function(code)
            local data = code == 0 and json.decode(table.concat(body)) or nil
            if type(data) == "table" and data.current_weather then
                temperature:set(string.format("%.0f°", data.current_weather.temperature))
            else
                log.warn("weather: no reading; curl exited with", code)
            end
        end)
end
refresh()

return panel {
    id = "weather", layer = "Top", anchor = { top = true },
    child = button {
        on_click = refresh,
        children = { text { content = temperature, font_size = 14 } },
    },
}

Which one do I use

process.runsession_processprocess.detach
OutputLine by line to out_cbInherited stdio: mantle logNone: /dev/null
Exitexit_cb(code)running and exit_code signalsNone
InstancesOne per callOne per name; start is a no-op while it runsOne per call
OwnerThe evaluation that started itThe SupervisorNobody: own session, reparented to init
ReloadKilled, exit_cb(nil)Keeps runningUnaffected
Renderer crashKilled, no exit_cbKeeps runningUnaffected
Shell stopsKilledStopped with stop_signal, SIGKILL after 5 sUnaffected
Typical usescurl, getent, a poll every N seconds, a --follow streamA screen recorder, a daemon the shell ownsApps, xdg-open, a terminal
  • Need the output or the exit code: process.run. A long-running one, like tail -f or a subscribe loop, restarts with each save, which is how to follow a stream.
  • One instance that must outlive a save or a crash: session_process.
  • A program the user keeps after the shell: process.detach.

What else survives a reload, a crash and a stop: runtime.

process.run

Spawns a helper, streams its output line by line, and reports its exit.

PartContract
Signatureprocess.run(cmd, args, out_cb, exit_cb) → handle
cmdProgram name, looked up on PATH. No shell: no globbing, pipes, ~, $VAR or quoting
argsList of already-split strings; "a b" is one argument. Numbers coerce
out_cb(line, stream)Once per line, newline stripped. stream is "stdout" or "stderr". Invalid UTF-8 is replaced; a final line without a newline still arrives
exit_cb(code)Once, after both streams close and the process exits. code is the exit status, or nil when a signal ended it or the spawn failed
Handlehandle:kill(): SIGTERM to the whole process group, SIGKILL 100 ms later. exit_cb still fires. A no-op after exit
stdiostdin /dev/null, so a prompt fails instead of hanging; stdout and stderr piped
EnvironmentInherited from the shell. The working directory is the shell’s and unspecified: use absolute paths
LifetimeUntil the next reload, failed ones included, which kills its group as kill() does and calls exit_cb(nil) at once, before the new evaluation runs; no out_cb follows. A Renderer replacement or shell exit reaps its group without calling exit_cb
LimitsA line over 64 KiB is cut there and the rest of that line dropped, with one warning per stream. Callbacks run outside the CPU budget. A raise in either callback is logged as a warning

The call returns immediately. A spawn failure (command not on PATH) reaches Lua only as exit_cb(nil) with no output; the reason is logged as a warning.

process.detach

Starts a program that stops being the shell’s: its own session, reparented to init, stdio on /dev/null. It outlives reloads, Renderer replacement and the shell itself.

return panel {
    id = "dock", layer = "Top", anchor = { bottom = true },
    child = button {
        padding = 8,
        on_click = function()
            process.detach("xdg-open", { os.getenv("HOME") or "/" })
        end,
        children = { text { content = "Home folder" } },
    },
}
PartContract
Signatureprocess.detach(cmd, args) → nothing
cmd, argsAs process.run
Output, exit codeNone. A failed spawn is logged as a warning and otherwise silent

session_process

Declares one named, long-running program that the Supervisor holds. It survives reloads and Renderer replacement, and its state comes back as signals, so there is no pid file or poll. Use process.run when you need the output.

-- SIGINT lets the recorder finish the file; shutdown uses it too.
local recorder = session_process { name = "recorder", stop_signal = "INT" }
local recording = recorder.running:map(function(running) return running == true end)

local function toggle()
    if recording:get() then
        recorder:stop()
    else
        local file = (os.getenv("HOME") or "") .. "/Videos/" .. os.date("%Y%m%d_%H%M%S") .. ".mp4"
        recorder:start("gpu-screen-recorder", { "-w", "screen", "-o", file })
    end
end

return panel {
    id = "rec", layer = "Top", anchor = { top = true, right = true },
    child = button {
        on_click = toggle,
        children = {
            text {
                content = computed({ recording, recorder.start_error }, function(on, err)
                    if err and err ~= "" then return "Recorder failed: " .. err end
                    return on and "● REC" or "Record"
                end),
                foreground = recording:map(function(on) return on and "#F38BA8" or "#CDD6F4" end),
            },
        },
    },
}

session_process { name, stop_signal? } returns a handle. name must be non-empty; declaring the same name again, on reload or from another module, returns the same handle and re-reads only stop_signal. stop_signal defaults to "TERM". Any other key raises.

Signal names drop the SIG prefix: TERM, INT, HUP, QUIT, USR1, USR2, KILL, STOP, CONT.

Handle signals, each nil until mantle.processes first pushes (and in mantle check):

SignalValue
runningWhether it is up. When false, the rest describe the last run
pidProcess id, also its group id; kept after exit
started_atUnix seconds the current or last run began
exit_codeLast run’s exit status; nil while running, before any run, or after a signal ended it
start_errorWhy the last start spawned nothing, e.g. cmd not on PATH; "" when it spawned

Handle methods (call with :):

MethodEffect
start(cmd, args?)Spawns in a new process group, no shell, stdio inherited so output lands in mantle log. A no-op while running. Clears the last run’s fields
signal(name)Sends one signal to the process only, not its group. A no-op when not running
stop()Sends stop_signal to the group, then SIGKILL to the group after 5 s. A no-op when not running

At shutdown the Supervisor runs stop() on every running program and waits for each. The whole state is also readable as mantle.processes (capabilities).

How do I…

TaskAnswer
Fetch JSON over HTTPThe example at the top
Poll a command every N secondsBelow
Follow a long-running command’s outputBelow
Run a recorder that survives reloadssession_process
Stop a childhandle:kill(), or save: a reload kills them all
Open an app or URLprocess.detach
Read a fileprocess.run("cat", { path }, ...), collecting lines, or persistent_table for JSON settings

Poll a command every N seconds

A reload kills the df in flight and clears the timer, and the top-level call starts one fresh chain.

local disk = state("disk_usage", "")

local function poll()
    local lines = {}
    process.run("df", { "--output=pcent", "/" }, function(line, stream)
        if stream == "stdout" then lines[#lines + 1] = line end
    end, function(code)
        if code == 0 and lines[2] then disk:set(lines[2]:match("%d+%%") or "") end
    end)
    timer(30000, poll)
end
poll()

return panel {
    id = "disk", layer = "Top", anchor = { top = true },
    child = text { content = disk:map(function(value) return "/ " .. value end) },
}

Follow a long-running command’s output

A process.run child that never exits keeps calling out_cb. Start it at the top level: each save kills the old one and starts one fresh. The retry below runs after the child exits on its own; the one a reload’s exit_cb(nil) arms is cleared with the old timers.

local title = state("now_playing", "")

local function follow()
    process.run("playerctl", { "--follow", "metadata", "--format", "{{artist}} - {{title}}" },
        function(line, stream)
            if stream == "stdout" then title:set(line) end
        end,
        function() timer(5000, follow) end)
end
follow()

return panel {
    id = "media", layer = "Top", anchor = { top = true },
    child = text { content = title, elide = "End", max_width = 300 },
}

Gotchas

TrapFix
process.run("ls ~/*.png", {}) or process.run("ls", { "~/*.png" })No shell parses anything, so ~, globs and pipes stay literal. Split the arguments yourself, or run "sh", { "-c", "..." } explicitly
Calling json.decode(line) in out_cbOutput arrives one line at a time. Collect lines and decode once in exit_cb
Treating kill() as cancelexit_cb still fires, usually with nil. Tag requests with a counter and ignore stale ones
exit_cb never arrivesIt waits for stdout and stderr to close, and a backgrounded grandchild holding the pipes keeps them open. Redirect the grandchild’s output
A process.run child that must outlive a saveA reload kills it. Use session_process
A failure logged on every saveA reload’s kill calls exit_cb(nil). Report only a non-zero code
Invalid stop_signalThe Supervisor refuses the declaration with a warning, and start then does nothing. Use a name from the list above

See also: scripting (timer, json, log, persistent_table), runtime (reloads, logging), signals (state), processes capability (mantle.processes), cli (mantle log).

Source: process, spawn and reap, process registry, session process, processes controller.

Scripting

The imperative globals that are neither UI nor processes: keep settings on disk, schedule work, answer keybinds, and a few utilities. Reach for them from event handlers (on_click, on_change, callbacks) or a module’s top level. Running other programs is on processes; everything that renders goes through signals and nodes instead.

Terms used below (Supervisor, Renderer, generation, push) are in the glossary.

Which one do I use

ToUse
Run a command, launch an app, or keep a program runningprocesses
Remember a setting across restartspersistent_table
Do something later, repeat, or retrytimer
Run code from a keybind or scriptaction + mantle call
Parse JSONjson.decode
Write to the shell’s loglog.*
Rank search resultsfuzzy
Pull colours out of a wallpaperpalette.quantize
Set the font fallback chainfonts

What each one keeps across a reload, a crash and a restart: runtime.

persistent_table

A JSON object on disk, read as one signal per key and written one key at a time.

local state_home = os.getenv("XDG_STATE_HOME")
if not state_home or state_home == "" then
    state_home = (os.getenv("HOME") or "") .. "/.local/state"
end

local settings = persistent_table {
    path = state_home .. "/myshell",
    name = "settings.json",
    defaults = { clock_24h = true, recorder = { fps = 60, audio = "desktop" } },
}

-- nil until the first push, so pick the default here too.
local clock_24h = settings.clock_24h:map(function(value) return value ~= false end)

return panel {
    id = "clock", layer = "Top", anchor = { top = true },
    child = button {
        on_click = function() settings:set("clock_24h", not clock_24h:get()) end,
        children = {
            text { content = clock_24h:map(function(on) return on and "24h" or "12h" end) },
        },
    },
}
PartContract
Signaturepersistent_table { path, name, defaults? } → store. Another key raises
pathAbsolute directory; relative raises. Created if missing. Build it from os.getenv or mantle.config_dir
nameOne file name, no /; empty raises
defaultsFills missing top-level keys; stored values win. Nested tables are one value and do not merge. Re-sent every evaluation, so a new default lands on reload
store.<key>Signal for that key: nil before mantle.storage first pushes, then the stored value
store:set(key, value)Writes one key; nil deletes it until the next reload re-fills its default. The signal updates on the next push, not inside set
IdentityOne table per file: another call with the same path/name returns it, across reloads too
Raw statemantle.storage (capabilities)

On disk:

BehaviourDetail
Save1 s after the last write to that file, as pretty JSON through a temporary file and rename. A write still waiting at shell exit is lost
First runA missing file is created from defaults
Outside editsWatched with inotify. Another writer’s version replaces the in-memory one whole, dropping unsaved writes
Broken fileNot a JSON object, or unparsable: the last values stay, a warning is logged once, and nothing saves over it until it parses

timer

Runs a function once after a delay. Re-arm inside the callback to repeat.

local status = state("status", "checking")
local BACKOFF_MS = { 2000, 4000, 8000 }

local function check(attempt)
    attempt = attempt or 1
    process.run("ping", { "-c", "1", "-W", "2", "1.1.1.1" }, function() end, function(code)
        if code == 0 then
            status:set("online")
        elseif BACKOFF_MS[attempt] then
            status:set("retrying")
            timer(BACKOFF_MS[attempt], function() check(attempt + 1) end)
        else
            status:set("offline")
        end
    end)
end
check()

-- A repeating timer re-arms itself; calling it at the top level restarts the chain on reload.
local clock = state("clock", "")
local function tick()
    clock:set(os.date("%H:%M"))
    timer(60000 - (os.time() % 60) * 1000, tick)
end
tick()
PartContract
Signaturetimer(ms, fn) → handle
ms1 to 86400000 (one day), fractions allowed, monotonic clock; outside raises
fnCalled with no arguments under the 5 ms CPU budget. A raise or blown budget is logged as a warning
Handlehandle:cancel() disarms it. A no-op once fired or cancelled. Dropping the handle does not disarm
OrderTimers due at the same moment fire in the order they were armed; one may cancel another in the same batch
LifetimeEvery evaluation clears all timers, including chains armed from callbacks. Timers armed during an evaluation start only once its result is applied

For a delayed or blinking value, delay and pulse (signals) need no callback.

action

Names a function that mantle call <name> [args...] runs, usually from a compositor keybind. Write a state when the shell should look different; call an action when it should do something.

-- Keybind: mantle call volume.up 0.1
action("volume.up", function(step)
    local audio = mantle.audio:get()
    if not audio or not audio.volume then
        return "no output device"
    end
    local volume = math.min(1.5, audio.volume + (tonumber(step) or 0.05))
    mantle.audio:set_volume(volume)
    return string.format("%d%%", math.floor(volume * 100 + 0.5))
end)
PartContract
Signatureaction(name, fn) → nothing
nameAny non-empty string; nothing splits on .. Empty, or declared twice in one evaluation, raises
Arguments, return, failureAs values and arguments: each argument JSON-decoded when it parses, the return printed bare or as JSON. A return that is not convertible to JSON fails the call
Limits5 ms CPU budget
LifetimeCleared before every evaluation and again when one fails, so declare at the top level

CLI flags such as --pid: cli.

json.decode

json.decode(text) → value, or nil, message. It never raises on bad input.

JSONLua
null fieldAbsent key
null array elementA hole: ipairs stops there, # may still count past it
Top-level nullnil, same as a failure without the message
Non-UTF-8 inputnil, message

There is no json.encode; build JSON arguments with string.format.

log

log.error(...), log.warn(...), log.info(...), log.debug(...). Arguments join with tabs like print, and the line is stamped with time, level and the config subsystem. All levels print by default. Filter with MANTLE_LOG=config=warn (or config=off); read with mantle log.

fuzzy

fzf’s scorer for one candidate. Iterating, sorting and tiebreaking stay in Lua.

local APPS = { "Firefox", "Files", "GIMP", "Terminal", "System Monitor" }
local query = state("query", "")

local results = query:map(function(needle)
    local scored = {}
    for _, name in ipairs(APPS) do
        local score, start = fuzzy(name, needle)
        if score then
            scored[#scored + 1] = { name = name, score = score, start = start }
        end
    end
    table.sort(scored, function(left, right)
        if left.score ~= right.score then return left.score > right.score end
        if left.start ~= right.start then return left.start < right.start end
        return left.name < right.name
    end)
    local names = {}
    for index, entry in ipairs(scored) do names[index] = entry.name end
    return names
end)

return panel {
    id = "launcher", layer = "Overlay", keyboard_interactivity = "OnDemand",
    child = column {
        width = 320, padding = 12, spacing = 6, background = "#1E1E2E",
        children = {
            textfield { autofocus = true, placeholder = "Search", on_change = function(text) query:set(text) end },
            list { source = results, itemfn = function(name) return text { content = name } end },
        },
    },
}
PartContract
Signaturefuzzy(haystack, needle) → score, start
MatchInteger score (higher is better) and start, the 0-based byte offset of the needle’s first character at its earliest in-order hit (the best-scoring one for a one-character needle). For tiebreaks, not highlighting
No matchnil, nil, also for non-UTF-8 input
Empty needle0, 0
CaseSmart: an all-lowercase needle ignores case; one uppercase character makes the whole comparison exact. Don’t lowercase the query
Non-ASCIIA greedy scorer whose scores do not compare with the ASCII path’s

Tie order is the caller’s: table.sort is unstable, so end the comparator on a unique key.

palette.quantize

Median-cut dominant colours of an image, computed on a background thread.

local swatches = state("swatches", {})

palette.quantize("/usr/share/backgrounds/default.png", { depth = 3 }, function(found)
    swatches:set(found or {})
end)

return panel {
    id = "palette", layer = "Top", anchor = { bottom = true },
    child = row {
        children = swatches:map(function(list)
            local chips = {}
            for index, swatch in ipairs(list) do
                chips[index] = rect { width = math.max(4, 200 * swatch.share), height = 16, background = swatch.color }
            end
            return chips
        end),
    },
}
PartContract
Signaturepalette.quantize(path, opts?, cb) → handle
pathLocal raster image; no SVG or URL
opts.depth0 to 8, default 3: up to 2^depth colours, fewer when the image has fewer. Out of range raises
optsOnly depth and rescale; another key raises
opts.rescaleLongest edge in px before counting, default 128; 0 is full size. Negative raises. A cached freedesktop thumbnail that covers it is used instead of decoding
cb(swatches){ color = "#RRGGBB", share = 0..1 } entries, most common first. share counts only non-transparent pixels. nil on failure, with a logged warning. Runs outside the CPU budget; a raise is logged as a warning
Handlehandle:cancel() drops the callback; the work still finishes

fonts

fonts { family, ... } sets the fallback chain every text node uses. Each glyph takes the first family that covers it.

fonts { "Inter", "Symbols Nerd Font", "Noto Color Emoji" }
PartContract
ArgumentDense array of family-name strings. A hole, a named key or a non-string raises
ResolutionThrough fc-match. The first family that resolves is the primary and also loads its bold and italic faces; a family with no install is skipped (logged at -vvv)
DefaultWithout a call: sans-serif, Noto Sans CJK JP, Noto Color Emoji
Per nodeA text node’s font goes in front of the chain (nodes)
Uncovered glyphfontconfig is asked for any installed face that covers it
LifetimeRead once at startup. Last call wins; an edit needs a shell restart

How do I…

TaskAnswer
Fetch JSON over HTTPcurl through process.run, then json.decode the output
Poll a command every N secondsprocesses
Search as you typefuzzy; for a slow source, debounce the query with delay

Gotchas

TrapFix
Callbacks from before a reloadpalette callbacks run the old closures after a reload. Keep what they touch in named state
timer or action declared only inside a callbackEvery evaluation clears both, so they vanish on the next save. Declare actions at the top level; start timer chains from the top level too
Two modules declare the same actionRaises. Pick unique names
store.key:get() right after store:setStill the old value. The signal updates on the next push
store.key is nil at startupEvery key reads nil until mantle.storage pushes, even with defaults. Handle nil in every map
Storing a key named setstore.set is the method, so that key is unreadable

See also: processes, runtime (reloads, budgets, logging), signals (state, delay), storage capability (mantle.storage), cli (mantle call, mantle log).

Source: store, storage controller, timer, action, json, log, fuzzy, palette, fonts.

Capabilities

mantle.<name> reads one slice of the system (audio, network, battery, workspaces and the rest) as a read-only signal, and its action methods ask its backend to act. This page holds the rules every capability shares; each capability’s page holds its state, actions and backend.

button {
    on_click = function() mantle.audio:toggle_mute() end,
    on_wheel = function(_, steps)
        local audio = mantle.audio:get()
        if audio and audio.volume then
            mantle.audio:set_volume(audio.volume + steps * 0.05) -- clamped to [0, 1.5]
        end
    end,
    children = {
        text {
            content = mantle.audio:map(function(audio)
                if audio == nil or audio.volume == nil then
                    return "--" -- nil before the first push; no volume without a default sink
                end
                return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 0.5))
            end),
        },
    },
}

Reading and acting

A capability is a signal the backend writes: pass it, or a :map of it, to a property and the property stays live. Each push replaces the whole snapshot.

MemberContract
:get()The last pushed snapshot; nil before the first
:map(fn)Derived signal; fn must handle nil. A capability also works as a computed dependency
:on_change(fn)fn(current, previous) once per push, after it lands; previous is nil on the first. Runs under the 5 ms callback budget and may call actions, process.run or write state. A raise logs a warning and the next handler still runs. Every evaluation clears them before shell.lua registers its own
:<action>(...)One method per action on the capability’s page, e.g. mantle.audio:set_volume(0.5). Queues one command and returns nothing. Read the state it changes for the outcome. Call it with :; a . call raises

There is no :set on the state; mantle.brightness:set and mantle.storage:set are actions. mantle.idle has no actions; it takes methods instead.

Lifecycle

StageWhat happens
First read of mantle.<name>The Supervisor starts that backend, once. mantle.idle starts on its first method call, :get, :map and :on_change included
Before the first pushEvery read is nil. A missing backend may keep it nil for good
RunningA started backend runs for the Supervisor’s lifetime. Its state survives reloads and Renderer replacement: a new generation gets every last snapshot replayed (hydration)
Shared readersaudio and privacy share one PipeWire thread; workspaces and windows share one niri/Hyprland reader. Whichever is read first starts it
BusesOne system-bus connection for all. tray, notifications, mpris and idle each open their own session bus. Every D-Bus method call times out after 25 s
PushesOn backend events. system, sysinfo, updates, notification expiry, mpris’s position recheck and the brightness fallback also run timers
Renderer replacedThe departed generation’s Bluetooth discovery stops, its pending Wi-Fi prompt is cancelled, its files watches, idle thresholds and inhibits are dropped, and its process.run children are reaped (processes). An in-place reload keeps the generation
Missing backendLogged; the capability goes inert or stays nil. Each page’s Backend section says which. Only network retries: a failed NetworkManager connection is rebuilt on the next start, which each new generation sends
Built at bootlock, so the session can relock after a Renderer dies. The polkit controller also exists at boot, but its agent registers on the first read of mantle.polkit or a secure_submit naming it
Unknown namemantle.audioo is plain nil, so the :get() after it raises on that line

Actions

Arguments are positional, in the order each page’s Actions table lists them, and JSON-shaped: numbers, strings, booleans and tables. The Renderer checks the action name and marshalling; the Supervisor checks types and count.

MistakeResult
A state field read off the capability (mantle.audio.volume)Raises at the read: did you mean mantle.audio:get().volume?
A misspelled action (mantle.audio:set_volum(1))Raises at the read: did you mean mantle.audio:set_volume(...)?
Any other unknown nameRaises at the read, listing the actions the capability takes
Any other method but get, map and on_change on battery, privacy or systemRaises: they have no actions
A function or userdata argumentRaises at the call, naming its slot
Wrong type or argument countLogged (mantle log) and dropped
A float where an integer goesDropped: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer
Arguments to an action that takes noneDropped: mantle.network:scan(1) is refused

A trailing nil counts as omitted, so an optional last argument can be passed as nil.

ConventionRule
TargetsPass the ID from the snapshot (sinks[].id, feed[].id, players[].id, windows[].id). IDs are opaque: compare them, never build them
Volume1.0 is 100%
PercentagesIntegers 0 to 100
IndicesZero-based

lua-meta/mantle.lua is generated from the same Rust types as the pages. On the LuaLS library path, mantle.audio:get(). completes fields and mantle.audio: offers every action, and a wrong argument type is a warning.

Capability list

NameWhat it gives youNote
applicationsDesktop entries, window app_id index, launching
audioOutput and input volume and mute, devices, per-app streams, Bluetooth codecs
batteryCharge, state, time estimatesCheck present first
bluetoothAdapter power, devices, discovery, pairing prompts
brightnessScreen backlight percentagenil without a backlight
filesListings of watched foldersLists nothing until watch
idleWho holds the session awake; idle thresholds and inhibitsMethods, not actions
keyboardActive layout, lock keys, keyboard backlightnil until the compositor reports a layout or a lock key changes
lockLock held, authentication progress, last failureNo unlock: only a correct password unlocks
mprisMedia players, metadata, position
networkConnectivity, Wi-Fi scan, join progressSecured joins take the key through secure_submit
notificationsThe newest 20 notifications, do-not-disturbMantle is the notification daemon
polkitThe pending authentication requestMantle is the polkit agent; the password goes through secure_submit
powerPower profiles, on battery, power draw
privacyApps using the camera, microphone or screen capture
processesPrograms declared with session_processUse session_process, not its actions
storageEach persistent_table fileUse persistent_table, not its actions
sysinfoCPU, memory, swap, temperaturesnil until configure
systemWall and monotonic clocks, once a second
trayTray items, artwork, menusMantle hosts the StatusNotifierWatcher
updatesPending packages, install progress, reboot neededNo schedule until configure
windowsEvery toplevel: title, app ID, workspace, output, state
workspacesPer-output workspaces, specials, focused windowniri or Hyprland only

Renderer members

Five members come from the Renderer, not a backend, so they are never nil and start nothing.

MemberKindContract
mantle.screensSignalConnected outputs, one Screen each. Starts as {}, so a loop runs zero times before the first output arrives. An output with no known size is left out
mantle.rescueSignal{ is_rescue, error_log }. is_rescue turns true when an evaluation raises, a scene fails to apply, a live update fails, a reload renames the lock surface while locked, or the session lock is refused or torn down; error_log holds the reason, ready to draw. The next reload that applies clears it, and so does a later pass after a failed startup apply or live update
mantle.versionPlain table{ major, minor, patch } integers, for guarding newer API
mantle.config_dirPlain stringDirectory shell.lua was loaded from, for naming files shipped beside it
mantle.pidPlain integerThe Supervisor’s pid, as mantle list shows it. mantle stop --pid with it ends this shell

Screen

FieldTypeMeaning
namestringConnector name, e.g. "eDP-1", as a surface’s monitor takes it; "output-N" below wl_output v4
xintegerLeft edge in compositor space: xdg_output’s logical position, else wl_output’s
yintegerTop edge, on the same terms as x
widthintegerLogical pixels, already divided by scale; the mode’s pixels when no logical size is known
heightintegerLogical pixels, on the same terms as width
scaleintegerInteger scale factor, e.g. 2 on HiDPI
fractional_scalenumberReal scale, e.g. 1.5: mode width over logical width; scale without both
refreshnumberRefresh rate in Hz; 0 without a current mode, e.g. a virtual output
orientationOrientationThe wl_output transform
modelstringMonitor model, e.g. "DELL U2720Q"; stable across connector renames. Empty when unadvertised
description?stringThe compositor’s human label; format varies (Hyprland’s has the serial). Absent below wl_output v4

Orientation

ValueMeaning
"normal"No transform
"90", "180", "270"Rotated that many degrees counter-clockwise
"flipped"Mirrored around a vertical axis, no rotation
"flipped_90", "flipped_180", "flipped_270"Mirrored, then rotated that many degrees counter-clockwise

How do I…

TaskAnswer
Show a value that may not have arrived yetGuard nil in the map: battery label
Change volume or brightness with the wheelon_wheel plus :get() and an action, as in the example above; brightness
Show an OSD when volume changes:on_change writing state: Volume OSD
Show a clockos.date over mantle.system.time: system
Give each monitor its own bar and workspacesA function child gets the connector name; match it in workspaces.outputs: workspaces
Show CPU and memory useconfigure once at top level, then map: sysinfo
Name or iconify the focused appworkspaces.active_client.class through applications.by_app_id: applications
Play or pause whatever is playingcontrol on players[1].id: mpris
Show a microphone or camera indicatorprivacy
Keep the screen awake (caffeine)idle
Know whether an action workedWatch the state it changes: a failed Wi-Fi join

Gotchas

TrapFix
attempt to index a nil value in a :map at startupGuard the whole payload before its fields
An optional field is nilA JSON null arrives as an absent key. Fields marked ? need their own guard (audio.volume with no default sink)
local ok = mantle.audio:set_volume(...) is always nilBind the state the action changes; read mantle log for dropped commands
An action silently does nothingWrong argument type or count, often a float where an integer goes (brightness:set(50.0)). Check mantle log
on_change fires at startup with previous == nilThat push is learned state, not a change; return early. A replacement Renderer gets every snapshot replayed the same way. An in-place reload keeps the last value, so its next push has a real previous
on_change fires with nothing visibly changedEvery push carries the whole snapshot. Compare the fields you care about

See also: signals for :map, computed and named state; input for click and wheel handlers; installation for what each backend needs.

Source: namespace, capability, idle, screens, lazy start and dispatch, argument decoding, payload and action types under supervisor/src/capabilities/.

applications

Installed desktop entries, indexed by window app_id.

text {
    content = computed({ mantle.workspaces, mantle.applications }, function(workspaces, applications)
        local client = workspaces and workspaces.active_client
        if client == nil or applications == nil then
            return ""
        end
        local index = applications.by_app_id[client.class]
        return index and applications.entries[index].name or client.class
    end),
}

State

mantle.applications:get() returns ApplicationsState, nil before the first push. A field marked ? may be absent.

mantle.applications payload.

FieldTypeDescription
by_app_idtable<string, integer>Window app_id to its 1-based index: entries[by_app_id[app_id]]. Keys are exact StartupWMClass and desktop ids, then lowercased and last-dot-segment guesses.
entriesAppSummary[]Installed entries, sorted by name (byte order). A change under an applications directory rescans 250 ms after the last event.

AppSummary

One visible Type=Application desktop entry; display data only, argv stays private.

FieldTypeDescription
comment?stringComment=, unlocalized, e.g. "Web Browser"; nil without the key.
generic_name?stringGenericName=, unlocalized, e.g. "Text Editor"; nil without the key.
icon?stringIcon= as written, a theme name or absolute path, both accepted by icon { name }; nil without the key.
idstringDesktop file id, e.g. "org.telegram.desktop"; the argument of "launch".
keywordsstring[]Keywords= split on ;, for search; empty without the key.
namestringName=, unlocalized: Name[xx] is not read.

Actions

Call each as mantle.applications:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
refreshRescans installed desktop entries. The directories are watched, so only a failed watch (logged) needs this.
launchid: stringLaunches entries[].id, detached; Terminal=true entries run in $TERMINAL.
open_urlurl: stringOpens an http, https or mailto URL with xdg-open. One over 2048 bytes or holding whitespace or a control character is refused.

Backend

Reads applications/ under $XDG_DATA_HOME, then each $XDG_DATA_DIRS entry (default /usr/local/share:/usr/share), subdirectories included. The first file for a desktop id wins, so a copy under ~/.local/share/applications overrides the system one. Type=Application entries with Name and Exec are listed; NoDisplay=true and Hidden=true ones are not. inotify watches every directory, including ones created later.

How do I…

TaskAnswer
Hide an app from a launcherCopy its .desktop file to ~/.local/share/applications and add NoDisplay=true; the rescan drops it

Gotchas

TrapFix
launch of a terminal app does nothingTerminal=true needs $TERMINAL in the Supervisor’s environment, not an interactive shell’s. mantle log names the refusal

See also: App launcher recipe.

Source: supervisor/src/capabilities/applications/

audio

PipeWire: output and input volume and mute, device lists, per-app streams and Bluetooth codecs.

list {
    source = mantle.audio:map(function(audio)
        return audio and audio.sinks or {}
    end),
    key = function(sink) return tostring(sink.id) end,
    itemfn = function(sink)
        return button {
            padding = 6,
            background = sink.active and "#45475A" or "#1E1E2E",
            on_click = function() mantle.audio:set_default_sink(sink.id) end,
            children = { text { content = sink.name } },
        }
    end,
}

State

mantle.audio:get() returns AudioState, nil before the first push. A field marked ? may be absent.

mantle.audio’s payload.

FieldTypeDescription
appsAppStream[]Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures.
balance?numberDefault output balance, -1.0 (left) to 1.0 (right); nil for mono or an unknown channel map.
bluetoothBluetoothCodecs[]BlueZ audio devices PipeWire knows, with their codecs, ordered by device.
mutedbooleanDefault output mute; false with no default sink.
sinksAudioDevice[]Every output device.
source_mutedbooleanDefault input (microphone) mute; false with no default source.
source_volume?numberDefault input volume, 1.0 is 100%; set_source_volume caps at 1.0, another client may not. nil with no source or before its first volume report.
sourcesAudioDevice[]Every input device.
volume?numberDefault output volume, 0.0 to 1.5 (1.0 is 100%), loudest channel; louder writes by other clients are pulled back to 1.5. nil with no sink or before its first volume report.

AppStream

One app’s playback or recording stream. Streams without a pid are left out.

FieldTypeDescription
binary?stringapplication.process.binary, e.g. "firefox".
icon?stringXDG icon name from application.icon-name, else media.icon-name, e.g. "firefox".
idintegerPipeWire node id, the first argument of set_app_volume and set_app_muted.
mutedbooleanStream mute; false until volume is known.
name?stringapplication.name, if the client set one.
pidintegerOwning process id, from application.process.id.
process_name?string/proc/<pid>/comm, or nil if it was unreadable when the stream’s properties were read.
recordingbooleanA capture stream, such as a call’s microphone, rather than playback.
volume?numberStream volume, 1.0 is 100%; nil until PipeWire reports the stream’s Props.

AudioDevice

One sinks or sources entry.

FieldTypeDescription
activebooleanThis is the default output or input; with no default known, the lowest id is.
bus?stringdevice.bus, e.g. "pci", "usb", "bluetooth".
form_factor?stringdevice.form-factor, e.g. "headset".
icon?stringdevice.icon-name theme name, e.g. "audio-card-analog".
idintegerPipeWire node id, the argument of set_default_sink/set_default_source; not reboot-stable.
namestringnode.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name.
port?stringThe active card route’s port.type, e.g. "headphones", "hdmi", "mic".

BluetoothCodecs

One BlueZ audio device’s codec choices, joined to mantle.bluetooth by MAC.

FieldTypeDescription
active?integerindex of the active profile; nil before PipeWire reports it or when it is not in codecs.
codecsCodecProfile[]Available profiles that name a codec, ordered by index.
deviceintegerPipeWire device id, the first argument of set_bluetooth_profile.
macstringMAC address from the bluez_card.* name, _ turned to :.

CodecProfile

One entry of BluetoothCodecs::codecs.

FieldTypeDescription
codecstringCodec from the profile name, else its English description, e.g. "AAC", "LDAC", "mSBC".
descriptionstringPipeWire’s description, e.g. "High Fidelity Playback (A2DP Sink, codec AAC)".
indexintegerProfile index, the second argument of set_bluetooth_profile.

Actions

Call each as mantle.audio:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_volumevolume: numberSets master output volume, clamped to [0.0, 1.5].
set_mutedmuted: booleanSets master output mute.
toggle_muteToggles master output mute.
set_balancebalance: numberSets default output balance, -1.0 (left) to 1.0 (right), clamped; the louder side keeps its level.
set_default_sinkid: integerMakes this sinks[].id the default output.
set_default_sourceid: integerMakes this sources[].id the default input.
set_source_volumevolume: numberSets default input volume, clamped to [0.0, 1.0].
set_source_mutedmuted: booleanSets default input mute.
toggle_source_muteToggles default input mute.
set_app_volumeid: integer, volume: numberSets an apps[].id stream’s volume, clamped to [0.0, 1.0].
set_app_mutedid: integer, muted: booleanSets an apps[].id stream’s mute.
set_bluetooth_profiledevice: integer, index: integerSwitches a bluetooth[].device to one of its codecs[].index.

Backend

PipeWire’s native API, on one thread shared with privacy.

PipeWire objectFeeds
Audio/Sink, Audio/Source nodes and the default metadata’s default.audio.sink/sourcesinks, sources, volume, muted, balance, source_volume, source_muted
Stream/Output/Audio, Stream/Input/Audio nodesapps, minus the streams its field lists
bluez_card.* devices and their profilesbluetooth

The first push waits until PipeWire has reported every object and its volume, so a machine with no audio hardware still gets one push of empty lists. An unreachable PipeWire is logged and audio stays nil. Nothing reconnects: a PipeWire restart freezes audio at its last push until the Supervisor restarts.

How do I…

Change volume on the scroll wheel, mute on middle click

A handler reads with :get(): it needs the value now, not a binding (input).

button {
    on_wheel = function(_, steps)
        local audio = mantle.audio:get()
        if audio == nil or audio.volume == nil then
            return
        end
        mantle.audio:set_volume(math.max(0, math.min(1, audio.volume + steps * 0.05)))
    end,
    on_click = function(_, which)
        if which == "middle" then
            mantle.audio:toggle_mute()
        end
    end,
    children = {
        text {
            content = mantle.audio:map(function(audio)
                if audio == nil or audio.volume == nil then
                    return "--"
                end
                return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 0.5))
            end),
        },
    },
}

Show an OSD when volume changes

on_change writes named state that a panel binds, and a timer hides the panel again:

local osd_text = state("osd_text", "")
local osd_visible = state("osd_visible", false)
local hide_timer

mantle.audio:on_change(function(audio, previous)
    if previous == nil or audio.volume == nil then
        return -- the first push is learned state, not a change
    end
    if audio.volume == previous.volume and audio.muted == previous.muted then
        return
    end
    osd_text:set(audio.muted and "Muted" or string.format("Volume %d%%", math.floor(audio.volume * 100 + 0.5)))
    osd_visible:set(true)
    if hide_timer then
        hide_timer:cancel()
    end
    hide_timer = timer(2000, function() osd_visible:set(false) end)
end)

local osd = panel {
    id = "osd",
    layer = "Overlay",
    anchor = { bottom = true },
    margin = { bottom = 80 },
    visible = osd_visible,
    padding = 12,
    radius = 12,
    background = "#1E1E2ECC",
    child = text { content = osd_text, font_size = 16 },
}

See also: Volume OSD recipe.

Source: supervisor/src/capabilities/audio/

battery

UPower’s display device: charge, state and time estimates.

text {
    content = mantle.battery:map(function(battery)
        local seconds = battery and battery.present and battery.time_to_empty
        if not seconds then
            return ""
        end
        return string.format("%d:%02d left", seconds // 3600, seconds % 3600 // 60)
    end),
}

State

mantle.battery:get() returns BatteryState, nil before the first push. A field marked ? may be absent.

mantle.battery’s payload. No battery, or no UPower, reads present = false and defaults.

FieldTypeDescription
percentintegerUPower’s Percentage, rounded to 0 to 100; a spurious 0 while not draining keeps the last value.
presentbooleanUPower’s display device is a present battery. Check it before drawing the other fields.
stateBatteryStatusWhat the battery is doing; see BatteryStatus.
time_to_empty?integerSeconds until flat, or nil while UPower has no estimate.
time_to_full?integerSeconds until full, or nil while UPower has no estimate.

BatteryStatus

battery.state: UPower’s Device.State by name, e.g. b.state == "PendingCharge".

ValueDescription
"Unknown"No answer: UPower unreachable, an unknown state number, or a display device that is not a battery.
"Charging"Taking current from an adapter.
"Discharging"Draining.
"Empty"Flat.
"FullyCharged"Charged and holding.
"PendingCharge"On mains, neither draining nor taking current: a charge limit, weak charger or thermal pause.
"PendingDischarge"Waiting to discharge.

Actions

None: read-only, so any method but get, map and on_change raises.

Backend

ContractBehavior
SourceUPower’s DisplayDevice, the composite of every battery
UpdatesRe-reads every field on each PropertiesChanged; no timer, since UPower already polls the hardware
No battery or no UPowerpresent = false, percent = 0, state = "Unknown", no time estimates

How do I…

Show a battery label with every nil state handled

text {
    foreground = mantle.battery:map(function(battery)
        return (battery and battery.present and battery.percent <= 15) and "#F38BA8" or "#CDD6F4"
    end),
    content = mantle.battery:map(function(battery)
        if battery == nil then
            return "…"
        elseif not battery.present then
            return ""
        end
        return string.format("%d%%%s", battery.percent, battery.state == "Charging" and " +" or "")
    end),
}

See also: Battery indicator recipe; power for on-battery and power draw.

Source: supervisor/src/capabilities/battery/

bluetooth

BlueZ: adapter power, discovery, connected, paired and discovered devices, and pairing prompts.

list {
    source = mantle.bluetooth:map(function(bluetooth)
        return bluetooth and bluetooth.connected_devices or {}
    end),
    key = function(device) return device.mac end,
    itemfn = function(device)
        local battery = device.battery >= 0 and string.format(" %d%%", device.battery) or ""
        return button {
            on_click = function() mantle.bluetooth:disconnect(device.mac) end,
            children = { text { content = device.name .. battery } },
        }
    end,
}

State

mantle.bluetooth:get() returns BluetoothState, nil before the first push. A field marked ? may be absent.

FieldTypeDescription
availablebooleanBlueZ has an adapter; false without one or without bluetoothd.
connected_devicesConnectedDevice[]Paired, connected devices. Unordered and may reshuffle on any push: sort before drawing.
discoverablebooleanOther devices can find this adapter. BlueZ turns it off after DiscoverableTimeout (180 s by default).
discovered_devicesDiscoveredDevice[]Unpaired devices BlueZ knows, unordered. Kept after stop_discovery; BlueZ expires unseen temporary ones after TemporaryTimeout (30 s by default).
discoveringbooleanThe adapter is scanning, whichever client started it.
enabledbooleanThe adapter is powered.
paired_devicesPairedDevice[]Paired devices that are not connected. Unordered like connected_devices.
pairing_request?PairingRequestThe pairing question to show, or nil. Answer with answer_pairing.

ConnectedDevice

FieldTypeDescription
batteryintegerBattery percentage, or -1 when the device reports none.
busy?DeviceActionSame as DiscoveredDevice::busy.
categorystringFrom the class of device: "keyboard", "mouse", "headphones", "headset", "phone", "computer" or "generic".
macstringMAC address, e.g. "00:1A:7D:DA:71:11"; every bluetooth action takes it.
namestringThe device’s advertised name, or empty.

DeviceAction

What the shell is doing to a device, as its busy.

One of "pairing", "connecting", "disconnecting".

DiscoveredDevice

FieldTypeDescription
blockedbooleanBlueZ refuses to pair with or connect to the device until it is unblocked.
busy?DeviceActionThe action this shell is running on the device, or nil; another client’s never shows.
macstringMAC address, the argument of pair.
namestringAdvertised name, often empty when the device broadcasts only an address.
pairedbooleanAlways false.

PairedDevice

FieldTypeDescription
blockedbooleanBlueZ refuses every connection to or from the device until it is unblocked.
busy?DeviceActionSame as DiscoveredDevice::busy.
categorystringSame set as ConnectedDevice.category.
macstringMAC address, the argument of connect and forget.
namestringThe device’s advertised name, or empty.

PairingKind

What a pairing_request asks; see PairingRequest.kind.

One of "confirm", "authorize", "service", "display".

PairingRequest

What the pairing agent is asking the user.

FieldTypeDescription
code?stringSix-digit passkey for "confirm", passkey or PIN for "display", else nil.
kindPairingKind"confirm": does the device show code? "authorize": may it pair? "service": may a paired, untrusted device connect? "display": type code on the device; nothing to answer.
macstringThe device’s MAC address.
namestringThe device’s advertised name, or empty.

Actions

Call each as mantle.bluetooth:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_enabledenabled: booleanPowers the adapter on or off.
set_discoverablediscoverable: booleanMakes the adapter findable by other devices, or not.
start_discoveryClears discovered_devices and scans. The request holds, so a scan starts once the adapter powers on and pauses while a pair runs.
stop_discoveryStops discovery; discovered_devices stays.
pairmac: stringPairs a discovered device, then trusts and connects it.
connectmac: stringTrusts and connects a paired device.
disconnectmac: stringDisconnects a connected device.
forgetmac: stringRemoves a device from BlueZ, unpairing it.
answer_pairingmac: string, accept: booleanAccepts or rejects the pairing_request for mac; a yes within 750 ms of it appearing is ignored.

Backend

BlueZ on the system bus.

ContractBehavior
Stateorg.bluez’s ObjectManager plus property changes. An adapter added later is picked up; a bluetoothd started after the Supervisor is not. Without BlueZ, available is false, the lists stay empty and every action does nothing
AgentMantle registers the default DisplayYesNo agent at /org/mantle/Bluez/Agent1. A confirmation, authorization or displayed code becomes pairing_request only while the adapter is discoverable or Mantle is pairing that device. A "service" request asks only for a paired device. One request shows at a time: a second is rejected, unless the first only displays a code and the second needs an answer. PIN and passkey entry are rejected
Discoverystart_discovery clears discovered_devices; stop_discovery keeps it
Battery, categoryBattery1 gives battery; the Class major and minor bits give category
CodecsPipeWire owns them: audio.bluetooth lists each device’s profiles and set_bluetooth_profile switches one

Gotchas

TrapFix
A device pairing from its own side gets rejectedMantle only prompts for invited devices. Set set_discoverable to true while pairing
A device that needs a PIN typed on the computer fails to pairThe agent rejects PIN and passkey entry. Pair it with bluetoothctl, which brings its own agent

Source: supervisor/src/capabilities/bluetooth/

brightness

The screen backlight percentage; nil without a backlight.

Scroll to change the brightness by 5% a notch:

button {
    on_wheel = function(_, steps)
        local brightness = mantle.brightness:get()
        if brightness then
            local percent = brightness.percent + math.floor(steps * 5) -- math.floor returns an integer
            mantle.brightness:set(math.max(1, math.min(100, percent)))
        end
    end,
    children = {
        text {
            content = mantle.brightness:map(function(brightness)
                return brightness and string.format("☀ %d%%", brightness.percent) or ""
            end),
        },
    },
}

State

mantle.brightness:get() returns BrightnessState, nil before the first push. A field marked ? may be absent.

mantle.brightness’s payload; the capability stays nil on a machine with no backlight.

FieldTypeDescription
percentintegerScreen backlight, 0 to 100: the last requested level (sysfs brightness), not the mid-fade one.

Actions

Call each as mantle.brightness:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
setpercent: integerSets the screen backlight, 0 to 100; higher clamps to 100.

Backend

ContractBehavior
DeviceOne /sys/class/backlight device with max_brightness > 0, chosen on the first read: firmware, then platform, then raw, then by name. External monitors are not covered
No deviceStays nil for good; set is logged and ignored
UpdatesA udev backlight watch re-reads sysfs brightness and pushes on change. If the watch cannot start, a 30 s poll replaces it
Writeslogind’s Session.SetBrightness, so no udev rule or group is needed. logind refuses it from an inactive session; the refusal is logged

Gotchas

TrapFix
set to 0 writes raw 0, which turns the backlight off on many panelsClamp the floor to 1, as the example does. With max_brightness under 50, 1 also rounds to raw 0: clamp higher

See also: keyboard for the keyboard backlight.

Source: supervisor/src/capabilities/brightness/

files

Live file listings of watched folders.

local folder = (os.getenv("HOME") or "") .. "/Pictures/Wallpapers"
mantle.files:watch(folder, { "jpg", "png" })

list {
    source = mantle.files:map(function(files)
        local listing = files and files.folders[folder]
        return listing and listing.entries or {}
    end),
    key = function(entry) return entry.path end,
    itemfn = function(entry)
        return text { content = entry.name }
    end,
}

State

mantle.files:get() returns FilesState, nil before the first push. A field marked ? may be absent.

mantle.files payload.

FieldTypeDescription
folderstable<string, Folder>One entry per "watch", keyed by its path minus trailing slashes; nil until watched.

FileEntry

FieldTypeDescription
modifiedintegerModification time in Unix seconds; 0 when unavailable.
namestringFile name, e.g. "sunrise.jpg".
pathstringAbsolute path.

Folder

FieldTypeDescription
entriesFileEntry[]Files (and symlinks to files) directly inside, minus dotfiles, filtered by extension and sorted case-insensitively by name. Relisted 200 ms after the last change.
error?stringWhy listing failed, e.g. "No such file or directory (os error 2)"; nil on success. A missing or deleted folder is not watched for reappearing.
readybooleanfalse until the first listing lands, then true even when empty or failed.

Actions

Call each as mantle.files:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
watchpath: string, extensions?: string[]Keeps folders[path] listing an absolute folder. extensions match case-insensitively, dot optional; omitted means every file.
unwatchpath: stringStops watching path and removes it from folders.

Backend

One inotify watch per folder, on the folder only: subfolders are neither listed nor watched. A new generation drops every watch, so call watch at top level and each evaluation asks again; repeating a watch with the same extensions only re-pushes the listing.

Gotchas

TrapFix
watch("~/Pictures") does nothingOnly absolute paths pass; ~ is not expanded. Build the path from os.getenv("HOME")
folders[path] is nil after a watchThe key drops trailing slashes: watch("/walls/") lands at folders["/walls"]
A folder created after watch never listsA missing folder records error and stays unwatched. unwatch, then watch again once it exists

See also: process.run to read a file’s contents.

Source: supervisor/src/capabilities/files/

idle

Idle inhibitors, plus threshold and inhibit methods.

mantle.idle reads like the others (:get, :map, :on_change) but has no actions. Its thresholds take Lua callbacks, which cannot cross to the Supervisor, so it has methods instead. Any method, :get included, starts it.

local dimmed = state("dimmed", false)

mantle.idle:register_threshold(300, function()
    dimmed:set(true)
end, function()
    dimmed:set(false)
end)

State

mantle.idle:get() returns IdleState, nil before the first push. A field marked ? may be absent.

mantle.idle’s payload.

FieldTypeDescription
inhibitedbooleanSomething holds the session awake: a logind inhibitor (this shell’s included), a ScreenSaver client or a Wayland inhibitor. No threshold fires while true.
inhibitorsIdleInhibitor[]Holders other than this shell, ScreenSaver clients included. The compositor’s hold has an empty who; draw why then.

IdleInhibitor

One holder blocking idle.

FieldTypeDescription
whostringFree-text holder name, e.g. "mpv"; draw it, never match it.
whystringFree-text reason, e.g. "Playing video"; often empty.

Actions

None: read-only, so any method but get, map and on_change raises.

Methods

MethodContract
:register_threshold(seconds, on_idle, on_resume)Runs on_idle after seconds (1 to 4294967, whole) without input and on_resume when input returns. Returns an integer handle. If this idle period already passed seconds for another registration, on_idle runs at once
:cancel_threshold(handle)Drops one registration. An unknown or cancelled handle is a no-op
:inhibit(reason)Takes one hold on a logind idle block inhibitor. Counted: two calls need two releases
:release_inhibit()Releases one hold; with none held, a no-op
EventThresholdsInhibit holds
In-place reloadDropped before the config evaluates again; top-level register_threshold calls re-register. The same seconds keeps its timer and does not re-run on_idle this idle periodKept
Renderer replacementDroppedDropped

Backend

PartBehaviour
Thresholdsext_idle_notifier_v1 on the Supervisor’s own Wayland connection. Missing protocol, or setup over 5 s: thresholds never fire, logged once
InhibitEvery hold, from any generation, shares one logind Inhibit("idle", "block") fd, closed when the last hold goes
ScreenSaverHosts org.freedesktop.ScreenSaver when the name is free. A browser’s video hold arrives here, directly or through xdg-desktop-portal, and takes the same fd. A client that leaves the bus loses its holds
GateMantle, not logind, acts on idle, so it honours inhibitors itself. While logind’s BlockInhibited names idle, idled thresholds get on_resume and none fire; on release, ones still idle get on_idle again
Compositor holdsA Wayland idle inhibitor shows when the shortest threshold’s input-only twin fires and the normal notification does not, so it needs a registered threshold and an idle seat. It sets inhibited and adds one holder with an empty who

How do I…

Keep the screen awake (caffeine)

The hold survives reloads, so named state records it:

local caffeine = state("caffeine", false)

button {
    on_click = function()
        if caffeine:get() then
            mantle.idle:release_inhibit()
        else
            mantle.idle:inhibit("caffeine")
        end
        caffeine:set(not caffeine:get())
    end,
    children = {
        text {
            content = computed({ caffeine, mantle.idle }, function(held, idle)
                if held then
                    return "awake (held)"
                elseif idle and idle.inhibited then
                    return "awake (another app)"
                end
                return "idle allowed"
            end),
        },
    },
}

Gotchas

TrapFix
register_threshold inside on_change, a timer or a click handlerEach call adds a registration that lives until the next reload. Register once at top level, or keep the handle and cancel_threshold it
An inhibit hold that never endsHolds survive reloads and are counted. Record the hold in a state and release once per inhibit
A threshold never fires while a video playsSomething holds idle off and inhibited is true. Draw inhibitors to show who

See also: Lock screen recipe, which locks after an idle threshold.

Source: supervisor/src/capabilities/idle/

keyboard

Lock keys, the active layout and the keyboard backlight.

button {
    on_click = function()
        local keyboard = mantle.keyboard:get()
        if keyboard and keyboard.layout_count > 1 then
            mantle.keyboard:switch_layout((keyboard.active_layout_index + 1) % keyboard.layout_count)
        end
    end,
    children = {
        text {
            content = mantle.keyboard:map(function(keyboard)
                if keyboard == nil then
                    return ""
                end
                return keyboard.active_layout .. (keyboard.caps_lock and " ⇪" or "")
            end),
        },
    },
}

State

mantle.keyboard:get() returns KeyboardState, nil before the first push. A field marked ? may be absent.

mantle.keyboard’s payload. Lock keys read false when no source resolves.

FieldTypeDescription
active_layoutstringLayout display name, e.g. "English (US)"; empty before the compositor answers or without one.
active_layout_indexinteger0-based position of the active layout, as switch_layout takes it.
backlight_pctintegerKeyboard backlight, 0 to 100, or -1 without a backlight device or readable level. Refreshes on hardware hotkeys and set_backlight only, not on other software writes.
caps_lockbooleanCaps Lock is on.
layout_countintegerConfigured layout count; below 2 there is nothing to switch.
num_lockbooleanNum Lock is on.
scroll_lockbooleanScroll Lock is on.

Actions

Call each as mantle.keyboard:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_backlightpercent: integerSets the keyboard backlight, 0 to 100; higher clamps to 100.
switch_layoutindex: integerSwitches to the 0-based configured layout index.

Backend

PartSourceWithout it
Lock keysEV_LED events from the first /dev/input device with a Caps Lock LED; a replugged keyboard is reopenedsysfs *::capslock, *::numlock, *::scrolllock read once, then frozen; with none of those, false
BacklightReads sysfs *::kbd_backlight, writes through logind’s SetBrightnessbacklight_pct = -1; set_backlight is logged and ignored
LayoutThe event stream workspaces reads: niri’s layout events, or Hyprland’s devices for the keyboard marked main (the one typed on last)active_layout = "", layout_count = 0; switch_layout is logged and ignored

The first push comes when keyboard starts, with whatever layout the compositor has reported.

Gotchas

TrapFix
caps_lock never changesThe Supervisor cannot read /dev/input, so the sysfs fallback was read once. Add the user to the input group
backlight_pct misses a change another program madeIt refreshes only on hardware hotkeys and set_backlight. Change it through set_backlight
switch_layout on niri with an index above 255 does nothingniri takes a u8; the call is logged and dropped

See also: brightness for the screen backlight.

Source: supervisor/src/capabilities/keyboard/

lock

The session lock: whether it is held, authentication progress and the last failure.

The lock screen itself is a lock surface; this capability locks the session and reports the password attempt.

button {
    on_click = function() mantle.lock:lock() end,
    children = { text { content = "Lock" } },
}

State

mantle.lock:get() returns LockState, nil before the first push. A field marked ? may be absent.

mantle.lock’s payload.

FieldTypeDescription
activebooleanThe Renderer confirmed the session locked; a requested lock stays false until then.
attemptsintegerRejected passwords since this lock was confirmed; reset by the next lock.
authenticatingbooleanA password is with PAM. A second submit is refused while true.
errorstringLast failure to draw: PAM’s verdict ("authentication failed", "too many attempts", or a PAM or worker error) or a refused lock’s reason. Cleared by a correct password, lock, a confirmed lock, and unlock.
unlockingbooleanPAM said yes and the lock is still up: the window for an out-animation.

Actions

Call each as mantle.lock:<action>(arguments...); ? marks an argument you may omit.

mantle.lock actions. There is no unlock; only a correct password unlocks.

ActionArgumentsDescription
lockLocks the session; a no-op while active.
set_unlock_animationms?: integerKeeps the lock up ms after a correct password for an out-animation. Clamped to 600; omitted is 0.

Backend

ContractBehavior
OwnershipThe Supervisor decides lock and unlock; the Renderer holds and paints ext_session_lock_v1. Built at boot, unlike other capabilities
TriggersThe lock action, and logind’s Lock signal (loginctl lock-session). logind’s Unlock is logged and ignored. Each lock and unlock sets logind’s LockedHint
UnlockOnly a successful PAM conversation, run in a re-exec’d worker with the mantle PAM service from /etc/pam.d or /usr/lib/pam.d, else login. An exchange that takes over 30 s fails
Unlock animationset_unlock_animation delays the release by up to 600 ms; the value persists across reloads
CrashA dead Renderer never unlocks; the replacement retakes the lock. $XDG_RUNTIME_DIR/mantle/session-locked carries the lock across a Supervisor restart
Refused or lostSets mantle.rescue with the reason
ReloadAn edit that would recreate a lock surface is refused while locked (lock surface)

How do I…

TaskAnswer
Show “wrong password”error and attempts: lock surface example
Animate the lock screen outset_unlock_animation with the animation’s length, then drive the fade from unlocking: Lock screen recipe

Gotchas

TrapFix
Removing set_unlock_animation from the config keeps the old delayThe value outlives reloads. Call set_unlock_animation with no argument to reset it to 0
A 1 s out-animation is cut shortThe delay clamps to 600 ms. Keep the animation within it

See also: Lock screen recipe; idle to lock after inactivity.

Source: supervisor/src/capabilities/lock/

mpris

MPRIS: media players with track metadata, playback state and position.

local player = mantle.mpris:map(function(mpris)
    return mpris and mpris.players[1]
end)

button {
    on_click = function()
        local current = player:get()
        if current then
            mantle.mpris:control(current.id, "play_pause")
        end
    end,
    children = {
        text {
            max_width = 240,
            elide = "End",
            content = player:map(function(current)
                return current and (current.play_state .. ": " .. current.title) or ""
            end),
        },
    },
}

State

mantle.mpris:get() returns MprisState, nil before the first push. A field marked ? may be absent.

FieldTypeDescription
playersPlayerState[]Every controllable MPRIS player except playerctld, longest-running first, so players[1] stays put; empty when none runs.

PlayerState

FieldTypeDescription
album_art_pathstringCover art as an existing absolute path, or empty; a remote artUrl is not fetched.
artiststringArtists joined with ", "; empty when unset.
desktop_entrystringThe player’s .desktop basename, e.g. "firefox", for app matching; empty when unset.
idstringBus-name suffix after org.mpris.MediaPlayer2., e.g. "spotify"; every action takes it.
identitystringDisplay name, e.g. "Spotify"; empty if unanswered.
lengthintegerTrack length in microseconds, or -1 when unknown, as for a live stream.
play_statestring"Playing", "Paused" or "Stopped"; keeps the last value when a read fails, empty if none.
positionintegerPlayback offset in microseconds as of position_updated_at, not polled while playing: add elapsed time. -1 when unknown.
position_updated_atintegerCLOCK_MONOTONIC microseconds when position was read. No Lua clock shares this epoch (not mantle.system.monotonic); only compare it with itself.
titlestringTrack title; empty when unset, normal between tracks.
urlstringxesam:url as sent, e.g. a file:// path or an https:// page; empty when unset.

Actions

Call each as mantle.mpris:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
controlid: string, cmd: PlayerCommandSends a playback command to players[].id.
seekid: string, position_us: integerSeeks to an absolute position in microseconds, clamped to [0, length] (only >= 0 when length is -1).
seek_relativeid: string, offset_us: integerSeeks by a signed offset in microseconds, unclamped; past the end may skip to the next track.

PlayerCommand

One of "play", "pause", "play_pause", "next", "previous".

Backend

ContractBehavior
DiscoverySession bus ListNames once, then NameOwnerChanged for org.mpris.MediaPlayer2.*. Skips playerctld and any player reporting CanControl = false
PushesOn a PlaybackStatus or Metadata change and on Seeked. A status change re-reads Position 100 ms later. Nothing polls
Seekseek calls SetPosition with the cached mpris:trackid. A player without one gets a relative Seek from a live Position read

How do I…

TaskAnswer
Show a live progress barStamp each new position_updated_at with mantle.system.monotonic in on_change, then add the seconds since: Media player

Gotchas

TrapFix
position stands still while playingIt is the offset at position_updated_at, not polled. Extrapolate, as above
position_updated_at compared with mantle.system.monotonic gives nonsenseDifferent clocks and units: CLOCK_MONOTONIC microseconds against seconds since system started. Only compare it with itself

See also: Media player recipe.

Source: supervisor/src/capabilities/mpris/

network

NetworkManager: connectivity, Wi-Fi and wired state, scanned access points and join progress.

text {
    content = mantle.network:map(function(network)
        if network == nil then
            return ""
        elseif not network.connected then
            return "offline"
        elseif network.ssid == "Ethernet" then
            return "wired"
        end
        return string.format("%s %d%%", network.ssid or "", network.strength)
    end),
}

State

mantle.network:get() returns NetworkState, nil before the first push. A field marked ? may be absent.

mantle.network’s payload.

FieldTypeDescription
available_networksAccessPointInfo[]NetworkManager’s visible networks, re-read on every change: one per SSID, at most 20, ordered associated, then saved, then strongest. {} without Wi-Fi hardware.
connect_error?JoinErrorThe last failed connect, or nil before any or after a success. Kept until the next connect, cancel_connect or abort_connect; check its ssid before showing it.
connectedbooleanA connection carries the default route; false means offline.
connecting_ssid?stringThe SSID connect is joining, or nil; clears on a verdict or abort_connect.
ethernet_enabledbooleanA wired device is activated; set_ethernet_enabled’s read-back, unlike carrier.
ethernet_ip?stringThe first activated wired device’s IPv4 address without prefix, or nil.
ethernet_presentbooleanAt least one wired device exists, cable or not.
ethernet_speedintegerThat wired device’s link speed in Mb/s; 0 when unknown or none is activated.
networking_enabledbooleanNetworkManager networking is on (NetworkingEnabled).
password_ssid?stringThe SSID whose connect waits for a password from a network/connect secure field, or nil. Also set after a rejected key; cleared when a join starts or by cancel_connect.
scanningbooleanA scan is in flight, from the moment scan is accepted.
ssid?string"Ethernet" when the default route is wired, else the associated SSID, else nil. An association still getting an address has an ssid while connected is false.
strengthintegerThe associated network’s strength, 0 to 100; 0 without a Wi-Fi association.
wifi_enabledbooleanWi-Fi radio power (WirelessEnabled); can be true with no Wi-Fi hardware, see wifi_present.
wifi_ip?stringThe Wi-Fi device’s IPv4 address without prefix, or nil.
wifi_presentbooleanA Wi-Fi device exists.

AccessPointInfo

One scanned network in available_networks.

FieldTypeDescription
activebooleanThe Wi-Fi device is associated with this SSID.
bandstring"2.4 GHz", "5 GHz", "6 GHz", or empty for a frequency outside those bands.
savedbooleanA saved NetworkManager profile names this SSID, so connect asks for no password.
securebooleanNeeds a key: WEP, WPA or RSN.
ssidstringNetwork name, "" for hidden networks; one entry per SSID, from its strongest access point.
strengthintegerSignal strength, 0 to 100.

JoinError

A failed join, as connect_error.

FieldTypeDescription
messagestringDisplay text, such as "wrong password" or "network not found".
ssidstringThe network the join was for.

Actions

Call each as mantle.network:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_networking_enabledenabled: booleanTurns NetworkManager networking on or off.
set_wifi_enabledenabled: booleanPowers the Wi-Fi radio.
set_ethernet_enabledenabled: booleanfalse disconnects every wired device; true activates each one’s autoconnect profile, and a device without one stays down.
scanRequests a Wi-Fi scan; a no-op without Wi-Fi hardware.
connectssid: string, hidden: booleanJoins a network. Without a saved profile, a secured, hidden or out-of-range one sets password_ssid and waits for a key.
cancel_connectDrops the password request password_ssid names; a join already running continues.
abort_connectStops the join connecting_ssid names, deleting a profile the join created.
forgetssid: stringDeletes every saved profile for this SSID.
disconnect_wifiDisconnects Wi-Fi; NetworkManager does not autoconnect it again until the next join.

Backend

ContractBehavior
UpdatesEvery manager, device-list, device-state, access-point, association and saved-profile change re-reads the whole state from NetworkManager. A hotplugged adapter rescans the device set
DevicesOnly the first Wi-Fi device is tracked. Wired fields describe the first activated wired device
TogglesNetworking through Enable, Wi-Fi through WirelessEnabled
ScanRequestScan. scanning turns true on the call and false when LastScan moves or NetworkManager refuses
Access pointsThe associated one’s strength is live. The others’ are read when they appear and after each scan, when NetworkManager updates them
ConnectA saved profile or an open network in range joins at once. Anything else sets password_ssid and waits for the key from a secure_submit = { capability = "network", action = "connect" } field (secure fields); the key never reaches Lua
Join verdictWatched for up to 45 s. A rejected key sets password_ssid again. A new network’s profile, key included, is saved when the join starts and stays after a rejection; a key retyped for a saved profile reaches disk only once NetworkManager accepts it
Abortabort_connect deletes a profile the join created, else deactivates the join
MissingStays nil. The next generation’s first read retries

How do I…

Ask for a Wi-Fi password

Show the field while password_ssid is set. Escape clears a secure field and keeps it armed, then calls its on_cancel, the place to call cancel_connect:

local asking = mantle.network:map(function(network)
    return network ~= nil and network.password_ssid ~= nil
end)

return column {
    visible = asking,
    spacing = 6,
    children = {
        text {
            content = mantle.network:map(function(network)
                return network and network.password_ssid and ("Password for " .. network.password_ssid) or ""
            end),
        },
        textfield {
            width = 240,
            height = 24,
            placeholder = "Password",
            secure_submit = { capability = "network", action = "connect" },
            on_cancel = function() mantle.network:cancel_connect() end,
        },
    },
}

Know whether a join worked

A failed connect lands in connect_error:

text {
    foreground = "#F38BA8",
    content = mantle.network:map(function(network)
        local failure = network and network.connect_error
        return failure and (failure.ssid .. ": " .. failure.message) or ""
    end),
}

Gotchas

TrapFix
available_networks has a row with ssid == ""Hidden networks broadcast no name; they merge into one nameless row. Skip it and join hidden networks with connect(ssid, true)
A hidden network asks for a password even when openIts security is unknown until it answers. Enter on the empty field joins it as open

Source: supervisor/src/capabilities/network/

notifications

The notification server: the newest 20 notifications and do-not-disturb.

button {
    on_click = function()
        local notifications = mantle.notifications:get()
        if notifications then
            mantle.notifications:set_dnd(not notifications.dnd)
        end
    end,
    children = {
        text {
            content = mantle.notifications:map(function(notifications)
                return (notifications and notifications.dnd) and "DND on" or "DND off"
            end),
        },
    },
}

State

mantle.notifications:get() returns NotificationsState, nil before the first push. A field marked ? may be absent.

mantle.notifications’s payload.

FieldTypeDescription
dndbooleanDo-not-disturb: mutes non-critical sounds only. Hiding popups is the config’s call.
feedNotification[]The newest 20 of up to 100 queued notifications, newest first, expired ones included; a replacement keeps its place. An entry past 20 stays dismissable by id.

Notification

One notifications.feed entry.

FieldTypeDescription
actionsNotificationAction[]Buttons in sender order, at most 8, excluding default and inline-reply.
app_icon?stringApplication icon for icon { name = ... }: a theme name such as "firefox" or an absolute path, or nil.
app_namestringSending application, truncated to 64 bytes.
bodyNotificationSpan[]Parsed body markup; the raw body is truncated to 512 bytes first.
desktop_entry?stringSender’s desktop id, e.g. "org.telegram.desktop", for mantle.applications.by_app_id; nil when absent or containing /.
expiredbooleanThe timeout ran out: drop it from popups, keep it in history until dismissed. Never true for critical or expire_timeout = 0; a replacement resets it.
has_default_actionbooleanClicking the card may :invoke_action(id, "default").
has_replybooleanThe sender accepts mantle.notifications:reply(id, text).
idintegerServer id, from 1; a replacement keeps the id it replaces.
image_path?stringAttached picture (album art, avatar) as an existing absolute path, or nil. Never a theme name.
reply_placeholder?stringPlaceholder for an empty reply field, e.g. "Reply to Alice", capped at 64 bytes; nil when unset.
summarystringTitle as sent, truncated to 128 bytes. Not markup-parsed: the spec makes it plain text.
timestampintegerArrival time, Unix seconds; age is mantle.system.time - timestamp. A replacement restamps it.
transientbooleanPopup-only: removed on expiry instead of retired to history.
urgencyUrgency"normal" when the sender set none. "critical" never expires and plays sound through DND.

NotificationAction

One action button.

FieldTypeDescription
icon_name?stringTheme icon name (the key) when the sender set action-icons, else nil. Never a path.
keystringOpaque key for :invoke_action(id, key).
labelstringButton label, capped at 64 bytes. An empty label falls back to the key unless icon_name is set.

NotificationSpan

One body-markup run: styled text or an image.

FieldTypeDescription
bold?booleanWhether the run was inside <b>.
href?string<a href> target, or nil when not a link.
image_path?stringExisting absolute path under an icon root; images elsewhere are dropped.
italic?booleanWhether the run was inside <i>.
kind"text"|"image"Which variant this is; each other field belongs to one variant.
text?stringUnescaped text; empty runs are omitted.
underline?booleanWhether the run was inside <u>.

Urgency

Notification urgency, also the set_sound tier.

One of "low", "normal", "critical".

Actions

Call each as mantle.notifications:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
dismissid: integerRemoves a queued notification.
invoke_actionid: integer, key: stringInvokes an actions[].key, or "default"; removes the notification unless it is resident.
replyid: integer, text: stringSends reply text to a notification with has_reply; removes it unless it is resident.
set_soundurgency: Urgency, path: stringSets an urgency tier’s sound: an existing file under /usr/share, /usr/local/share, /opt or $XDG_DATA_HOME, else ignored. Only Ogg Vorbis and 16-bit PCM WAV play.
set_dndenabled: booleanGates non-critical notification sounds.
set_quietenabled: booleanMutes non-critical sounds like set_dnd, without changing dnd.
set_app_mutedapp: string, muted: booleanSilences every sound from an app, critical included, matched exactly on app_name or desktop_entry.
hold_expiryseconds: integerPauses every expiry countdown for seconds, capped at 300; 0 releases the hold.

Backend

Mantle is the org.freedesktop.Notifications server on the session bus.

ContractBehavior
NameRequested with DoNotQueue. If mako, dunst or another daemon owns it, the server stays off for the run and feed stays empty
Retention100-entry FIFO; feed shows the newest 20
ExpiryA negative expire_timeout means 5 s. Critical and 0 never expire
Close reasonsNotificationClosed sends 1 expired, 2 dismiss or reply, 3 CloseNotification or invoke_action, 4 evicted from the FIFO. reply also emits NotificationReplied(id, text)
Body markupKeeps <b>, <i>, <u>, <a href>, <img src>. Other tags lose their markup and keep their text; script and style lose both
ImagesA path in image-path or <img> must be a regular file under /usr/share/icons, /usr/share/pixmaps, $XDG_DATA_HOME/icons or ~/.icons; an image-path without / and every action icon are theme names. Raw image-data (8-bit RGB or RGBA, at most 128 px a side) is spooled as notifications/notif-<id>.png
SoundNothing plays for a tier until set_sound registers a file for it, except a client’s own sound-file. Order: suppress-sound or set_app_muted silences, critical included; else the client’s sound-file; else sound-name from the freedesktop theme, only for a registered tier; else the tier’s file. Every file sits under /usr/share, /usr/local/share, /opt or $XDG_DATA_HOME and is Ogg Vorbis or 16-bit WAV, at most 4 MiB and 30 s

How do I…

List notifications and dismiss one on click

list {
    spacing = 6,
    source = mantle.notifications:map(function(notifications)
        return notifications and notifications.feed or {}
    end),
    key = function(item) return tostring(item.id) end,
    itemfn = function(item)
        return button {
            width = 320,
            padding = 8,
            radius = 8,
            background = "#1E1E2E",
            on_click = function() mantle.notifications:dismiss(item.id) end,
            children = {
                column {
                    children = {
                        text { content = item.summary, font_size = 13, elide = "End", width = "Fill" },
                        text { content = item.app_name, font_size = 11, foreground = "#A6ADC8" },
                    },
                },
            },
        }
    end,
}

See also: Notification popups recipe.

Source: supervisor/src/capabilities/notifications/

polkit

The pending polkit authentication request, its progress and the last failure.

The password never reaches Lua: a secure field with secure_submit = { capability = "polkit", action = "authenticate" } sends it straight to the Supervisor.

column {
    visible = mantle.polkit:map(function(polkit)
        return polkit ~= nil and polkit.active
    end),
    spacing = 8,
    children = {
        text {
            content = mantle.polkit:map(function(polkit)
                return polkit and polkit.message or ""
            end),
        },
        textfield {
            width = 280,
            height = 24,
            placeholder = "Password",
            secure_submit = { capability = "polkit", action = "authenticate" },
        },
        text {
            foreground = "#F38BA8",
            content = mantle.polkit:map(function(polkit)
                return polkit and polkit.error or ""
            end),
        },
        button {
            on_click = function() mantle.polkit:cancel() end,
            children = { text { content = "Cancel" } },
        },
    },
}

State

mantle.polkit:get() returns PolkitState, nil before the first push. A field marked ? may be absent.

mantle.polkit’s payload. Every other field is empty while active is false.

FieldTypeDescription
action_idstringAction being authorized, e.g. org.freedesktop.systemd1.manage-units.
activebooleanpolkitd is waiting for the user to authenticate.
authenticatingbooleanA password is with PAM. A second submit is refused while true.
errorstringDrawable reason for the last failure, e.g. "authentication failed". The prompt stays open to retry.
icon_namestringThemed icon name, or empty when the caller set none.
messagestringThe action’s prompt, e.g. "Authentication is required to ...", in en_US: the locale the agent registers with.

Actions

Call each as mantle.polkit:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
cancelDismisses the prompt; the requesting program sees the request cancelled.

Backend

On first read, registers as the authentication agent for $XDG_SESSION_ID’s session, at /org/mantle/PolicyKit1/AuthenticationAgent with locale en_US.UTF-8. If another agent already answers, it stays off for the run. polkitd accepts an answer only from uid 0, so the PAM worker hands the password to polkit’s root helper at /run/polkit/agent-helper.socket. The agent is polkit.rs.

Gotchas

TrapFix
A second program’s prompt never showsOne request at a time: a request arriving while another is on screen is cancelled, and its program sees the cancel. Finish or cancel the first
Prompts go to another agentpolkit-gnome, hyprpolkitagent or similar registered first. Stop it and restart Mantle
The prompt stays open after a wrong passwordBy design: error says why, and the next submit retries

See also: secure fields; FAQ for prompts that never appear.

Source: supervisor/src/capabilities/polkit.rs

power

Power profiles, mains or battery, and battery power draw.

list {
    direction = "Horizontal",
    source = mantle.power:map(function(power)
        return power and power.profiles or {}
    end),
    itemfn = function(name)
        return button {
            padding = 6,
            background = mantle.power:map(function(power)
                return (power and power.active_profile == name) and "#89B4FA" or "#313244"
            end),
            on_click = function() mantle.power:set_profile(name) end,
            children = { text { content = name } },
        }
    end,
}

State

mantle.power:get() returns PowerState, nil before the first push. A field marked ? may be absent.

mantle.power’s payload. Profile fields are nil without power-profiles-daemon, the rest without UPower; a failed read is also nil. With neither service the payload is an empty table.

FieldTypeDescription
active_profile?stringActive platform profile, e.g. "balanced".
energy_rate?numberUPower’s display-device EnergyRate in watts; direction is mantle.battery.state.
on_battery?booleanUPower’s OnBattery: running on battery rather than mains.
profiles?string[]Available profiles in daemon order, e.g. {"power-saver", "balanced", "performance"}.

Actions

Call each as mantle.power:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
set_profilename: stringSwitches to one of profiles. Not validated here; a rejected name is logged and active_profile stays.

Backend

HalfSourceMissing
active_profile, profilespower-profiles-daemon: org.freedesktop.UPower.PowerProfiles, else net.hadess.PowerProfilesBoth fields absent; set_profile is logged and ignored
on_battery, energy_rateUPower’s OnBattery and its DisplayDevice’s EnergyRateBoth fields absent

Every OnBattery, EnergyRate or ActiveProfile change re-reads all four fields. With neither service the push is an empty table.

Gotchas

TrapFix
power-profiles-daemon started after the capability never shows upThe daemon is looked up once, on the first read. Restart mantle after installing it
energy_rate has no signIt is a magnitude in watts. Read mantle.battery’s state for the direction

See also: battery for charge and time estimates.

Source: supervisor/src/capabilities/power/

privacy

Apps using the camera, microphone or screen capture right now.

rect {
    width = 8,
    height = 8,
    radius = 4,
    background = "#F38BA8",
    visible = mantle.privacy:map(function(privacy)
        return privacy ~= nil and (#privacy.microphone_users > 0 or #privacy.camera_users > 0)
    end),
}

State

mantle.privacy:get() returns PrivacyState, nil before the first push. A field marked ? may be absent.

FieldTypeDescription
camera_usersPrivacyUser[]One entry per process holding a /dev/videoN open; empty when none is. Only devices present when privacy started are watched.
microphone_usersPrivacyUser[]Apps with a running PipeWire audio capture, one per name. Idle streams and sink-monitor captures are absent; a muted microphone still counts.
screencast_usersPrivacyUser[]Apps with a running PipeWire screen-capture stream, one per name. wlr-screencopy tools such as wf-recorder and grim never appear.

PrivacyUser

One app using a camera, microphone or screen capture.

FieldTypeDescription
app_namestringPipeWire application.name, else /proc/<pid>/comm, else "pid 1234" (or "node 56"); never empty.

Actions

None: read-only, so any method but get, map and on_change raises.

Backend

SourceFeeds
Running Stream/Input/Audio PipeWire nodesmicrophone_users
Running Stream/Output/Video PipeWire nodesscreencast_users
/proc/*/fd links to a /dev/videoN, rescanned on each inotify open or close of the devicecamera_users. A PipeWire Video/Source from the same pid only supplies the name

The PipeWire half shares audio’s thread. privacy pushes its first /proc scan as soon as it starts. Without PipeWire the microphone and screencast lists stay empty and camera_users keeps its first scan: the update loop ends with the PipeWire thread.

Gotchas

TrapFix
A webcam plugged in after privacy started never showsThe device list is read once at start. Restart the Supervisor
grim or wf-recorder never shows in screencast_userswlr-screencopy bypasses PipeWire; only portal screen captures appear

Source: supervisor/src/capabilities/privacy/

processes

Programs declared with session_process: running state, start time and last exit.

Declare programs with session_process: it sends these actions and exposes each field as a signal. Read mantle.processes directly to see every declared program in one table.

text {
    content = mantle.processes:map(function(processes)
        local recorder = processes and processes.sessions.recorder
        return (recorder and recorder.running) and "● REC" or ""
    end),
}

State

mantle.processes:get() returns ProcessesState, nil before the first push. A field marked ? may be absent.

mantle.processes payload.

FieldTypeDescription
sessionstable<string, SessionProcess>One entry per session_process name; an undeclared name is nil.

SessionProcess

One declared program: its current run, or what is left of its last one.

FieldTypeDescription
exit_code?integerExit status of the last run; nil while running, before any run, or when a signal killed it.
pid?integerProcess id, also its process group id; kept after exit, nil before a spawn or after a failed start.
runningbooleanWhether it is up now. Otherwise the fields below describe the last run.
start_errorstringWhy the last start failed to spawn, e.g. a cmd not on PATH; empty when it spawned.
started_at?integerUnix seconds when the run began; nil before a spawn or after a failed start.

Actions

Call each as mantle.processes:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
declarename: string, stop_signal?: SignalNameRegisters name (required before start) and sets its stop signal, default TERM. Redeclaring updates the signal without touching a running program.
startname: string, cmd: string, args?: string[]Runs cmd with args (no shell) as its own process group. No-op while running or when name is undeclared.
signalname: string, signal: SignalNameSends signal to the program’s process (not its group); no-op when not running.
stopname: stringSends the declared stop signal to the process group, then KILL if it is still up 5 s later; no-op when not running.

SignalName

A signal name without the SIG prefix.

One of "TERM", "INT", "HUP", "QUIT", "USR1", "USR2", "KILL", "STOP", "CONT".

Backend

The Supervisor spawns and owns each program, so it outlives reloads and Renderer replacement. One task per program holds its child and sends every signal, so a signal never reaches a recycled pid. When to use process.run or process.detach instead: processes.

Source: supervisor/src/capabilities/processes/

storage

Each persistent_table JSON file, keyed by absolute path.

Declare files with persistent_table: it sends these actions and exposes each key as a signal. Saving, outside edits and a broken file are covered there.

State

mantle.storage:get() returns StorageState, nil before the first push. A field marked ? may be absent.

mantle.storage payload.

FieldTypeDescription
filestable<string, any>Each declared persistent_table’s contents, keyed by its absolute file path; nil until declared. Another writer’s change to the file replaces it, unsaved writes included.

Actions

Call each as mantle.storage:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
openpath: string, defaults?: table<string, any>Loads an absolute JSON file into files[path], filling missing top-level keys from defaults. persistent_table sends this; stored values win over defaults.
setpath: string, key: string, value?: anySets key in a declared file, nil deleting it; saved 1 s after the last write.

Backend

Plain JSON files at absolute paths, watched with inotify. A set pushes at once; the save follows 1 s later. Another writer’s change, another shell’s included, gets missing defaults refilled and pushes.

Source: supervisor/src/capabilities/storage/

sysinfo

CPU, memory and swap use, CPU and GPU temperatures. nil until configure sets intervals.

mantle.sysinfo:configure({ cpu_interval = 2, ram_interval = 5 })

text {
    content = mantle.sysinfo:map(function(sysinfo)
        if sysinfo == nil then
            return ""
        end
        return string.format("CPU %d%%  RAM %d%%", sysinfo.cpu_percent, sysinfo.ram_percent)
    end),
}

State

mantle.sysinfo:get() returns SysinfoState, nil before the first push. A field marked ? may be absent.

mantle.sysinfo’s payload; nil until configure sets an interval and a reading changes a field. Pushes only on a change.

FieldTypeDescription
cpu_percentintegerCPU utilization across all cores, 0 to 100, rounded down; 0 until two samples form a delta.
ram_percentintegerPhysical memory in use (MemTotal - MemAvailable), 0 to 100, rounded down.
swap_percentintegerSwap in use, 0 to 100, rounded down; also 0 without swap.
temp_coresinteger[]CPU temperatures in whole Celsius: per core (coretemp) or per CCD (k10temp), else one package or acpitz reading; empty without a sensor. An unreadable sensor is skipped.
temp_gpuintegeramdgpu, nouveau or nvidia hwmon temperature in whole Celsius, or -1 without a readable one.

Actions

Call each as mantle.sysinfo:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
configureintervals: SysinfoConfigureSets poll intervals; every one starts at 0, so nothing is read until this. The first reading lands on the next wall-clock second (CPU: one interval after it).

SysinfoConfigure

sysinfo:configure’s table. Absent keys keep their interval; one wrong-typed key drops the call.

FieldTypeDescription
cpu_interval?integerSeconds between CPU reads; 0 (the default) stops them.
ram_interval?integerSeconds between memory and swap reads; 0 (the default) stops them.
temp_interval?integerSeconds between temperature reads; 0 (the default) stops them.

Backend

FieldRead fromInterval
cpu_percent/proc/stat’s cpu line, the delta between two readscpu_interval
ram_percent, swap_percent/proc/meminforam_interval
temp_coreshwmon k10temp Tccd* or coretemp Core * sensors, else that chip’s first sensor, else acpitz’stemp_interval
temp_gpuThe first sensor of hwmon amdgpu, nouveau or nvidiatemp_interval

The chips are picked once, when sysinfo starts; a driver loaded later needs a Supervisor restart. A reading pushes only when it changed a field. Intervals live in the Supervisor, so they outlast reloads until the next configure. Each read runs on the wall-clock second, first at the next one after configure, so it lands with mantle.system.time’s tick and the two can share one layout pass.

Gotchas

TrapFix
sysinfo stays nilNothing is read until configure. With only temp_interval set on a machine without sensors, every reading equals the defaults and nothing pushes
ram_percent stays 0 while cpu_percent movesEach field updates on its own interval, and an unset one is 0 (off). Set ram_interval too

Source: supervisor/src/capabilities/sysinfo/

system

Wall and monotonic clocks, pushed once a second until configure sets the interval.

mantle.system:configure({ interval = 60 }) -- the text below shows no seconds

text {
    content = mantle.system:map(function(system)
        return system and os.date("%a %H:%M", system.time) or ""
    end),
}

State

mantle.system:get() returns SystemState, nil before the first push. A field marked ? may be absent.

mantle.system’s payload, pushed on each tick of interval.

FieldTypeDescription
monotonicintegerSeconds since system was first used, as of the last push; excludes suspend. Take durations from it, since NTP moves time.
timeintegerUnix epoch seconds, as os.date takes them.

Actions

Call each as mantle.system:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
configuresettings: SystemConfigureSets the push interval, 1 second until this. Each push lands on a wall-clock multiple of it, and one lands at once.

SystemConfigure

system:configure’s table. An absent interval keeps the current one.

FieldTypeDescription
interval?integerSeconds between pushes, each on a multiple of it since the epoch, so 60 lands on every minute; 1 is the default and 0 stops them.

Backend

The Supervisor’s own clocks; nothing external. Pushes land on wall-clock multiples of interval since the epoch (default 1): 60 lands on each minute’s :00; 3600 on UTC hours, not local ones in a half-hour timezone. A push due during suspend lands on resume, and a clock step (NTP, settimeofday) pushes at once and re-aligns. 0 stops pushes; time and monotonic keep their last values. The interval lives in the Supervisor, so it outlasts reloads until the next configure, and every reader shares it.

See also: Clock bar recipe.

Source: supervisor/src/capabilities/system/

tray

StatusNotifierItem: registered tray items with artwork, status and menus.

list {
    direction = "Horizontal",
    spacing = 4,
    source = mantle.tray:map(function(tray)
        return tray and tray.items or {}
    end),
    key = function(item) return item.id end,
    itemfn = function(item)
        return button {
            on_click = function(_, which)
                if which == "left" and not item.item_is_menu then
                    mantle.tray:activate(item.id, 0, 0) -- screen x, y; most apps ignore them
                end
            end,
            children = { icon { name = item.icon_name or item.icon_path or "", size = 16 } },
        }
    end,
}

State

mantle.tray:get() returns TrayState, nil before the first push. A field marked ? may be absent.

FieldTypeDescription
itemsTrayItem[]Registered items in registration order, oldest first; updates never reorder them.

One tray.items[].menu entry.

FieldTypeDescription
childrenMenuItem[]Submenu entries, empty for a leaf. An app that fills submenus lazily sends them only after menu_will_show.
enabledbooleanfalse for a greyed-out entry; draw it, but clicking does nothing.
icon_name?stringTheme icon name, or nil. Icon pixmaps are not carried.
idintegerDBusMenu id, the second argument of activate_menu_item and menu_will_show.
label?stringEntry text as sent, or nil. _ mnemonic markers remain ("_Quit"); strip them to draw.
menu_typestring"standard" (the default) or "separator", as the application sent it.
toggle_state?integer0 off, 1 on, -1 indeterminate or unreported; nil exactly when toggle_type is.
toggle_type?string"checkmark", "radio", or nil for an entry that is not a toggle.

TrayItem

FieldTypeDescription
attention_icon_name?stringArtwork to draw while status == "NeedsAttention", paired with attention_icon_path like the base icon; both nil when unset.
attention_icon_path?stringFile half of the attention artwork.
icon_name?stringTheme icon name for icon { name = ... }. At most one of it and icon_path is set.
icon_path?stringIcon file for image { source = ... }: one from the item’s IconThemePath, or its pixmap spooled to a PNG.
idstringItem identity for every tray action, e.g. "1.234/StatusNotifierItem". Opaque.
item_is_menubooleanLeft click should open menu instead of activate.
menu?MenuItem[]Top-level menu entries, or nil when the item exports no DBusMenu or its first fetch failed.
namestringSNI Title, or its Id when the title is empty.
overlay_icon_name?stringBadge to draw over the icon’s corner, paired with overlay_icon_path; both nil when unset.
overlay_icon_path?stringFile half of the badge.
statusstring"Active", "Passive" (the item asks to be hidden) or "NeedsAttention", as the item sent it.
tooltip?stringTooltip title and text joined by a newline, or nil when both are empty.

Actions

Call each as mantle.tray:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
activateid: string, x: integer, y: integerLeft-click activation at screen coordinates x, y; a no-op when item_is_menu.
secondary_activateid: string, x: integer, y: integerMiddle-click activation at screen coordinates x, y.
scrollid: string, delta: integer, orientation: stringScrolls the icon by delta; orientation is "vertical" or "horizontal", passed verbatim.
activate_menu_itemid: string, menu_item_id: integerClicks the item’s MenuItem.id.
menu_will_showid: string, submenu_id: integerTells the application submenu submenu_id is opening, then refetches the menu unless it answers that nothing changed.

Backend

Mantle hosts org.kde.StatusNotifierWatcher at /StatusNotifierWatcher on the session bus and registers itself as a host.

ContractBehavior
NameRequested without DoNotQueue: if another watcher owns it, Mantle queues behind it
AdoptionAt start, adopts items already on the bus at /StatusNotifierItem, /StatusNotifierItem/1 or /org/chromium/StatusNotifierItem/1, for apps that never re-register
RemovalAn item leaves, and its spooled PNGs are deleted, when its bus name loses its owner
IconIconName found in the item’s IconThemePath, then IconName as a theme name, then the largest valid pixmap: square, 1 to 128 px, exactly w × h × 4 ARGB bytes, spooled as a PNG under tray/
BoundsStrings 256 bytes; menus 1024 nodes, depth 32
Menuscom.canonical.dbusmenu. menu_will_show sends AboutToShow, activate_menu_item sends Event("clicked")

Gotchas

TrapFix
activate does nothing on some itemsThe item set item_is_menu, and Mantle skips Activate for it. Open menu on left click
A submenu is emptySome apps fill submenus only after AboutToShow. Send menu_will_show with the submenu’s id before drawing it

See also: System tray with menu recipe.

Source: supervisor/src/capabilities/tray/

updates

Pending package upgrades (pacman, optionally AUR), install progress and whether a reboot is due.

mantle.updates:configure({ interval = 3600 })

button {
    on_click = function() mantle.updates:check() end,
    children = {
        text {
            content = mantle.updates:map(function(updates)
                if updates == nil or updates.checking then
                    return "…"
                end
                return updates.count > 0 and (updates.count .. " updates") or "up to date"
            end),
        },
    },
}

State

mantle.updates:get() returns UpdatesState, nil before the first push. A field marked ? may be absent.

mantle.updates’s payload.

FieldTypeDescription
aur_error?stringWhy AUR packages are missing: the last check’s AUR query failed, or aur is on with no aur_helper; nil otherwise. packages still holds the repos’ answer.
aur_helper?stringAUR helper found at start, "paru" or "yay", or nil; used only once configure sets aur.
check_error?stringWhy the last check failed, or nil after a success. A check never modifies the system.
checkingbooleanA check is running.
consecutive_check_failuresintegerCheck failures in a row; a success resets it to 0.
countintegerAlways #packages.
install_current_packagestringPackage being installed; empty before the first step line.
install_current_stepinteger1-based number of the package being installed, e.g. pacman’s (2/5); 0 before the first.
install_error?stringWhy the package manager could not be run or waited on, or nil. Its own failures are install_exit_code.
install_exit_code?integerPackage manager’s exit code for the last install (0 success); nil while running, before one, or when a signal killed it.
install_finished_at?integerUnix seconds when the last install’s process ended, whatever its status; nil while running, before one, or when it failed to spawn.
install_logstring[]The last 200 lines of install output, stdout and stderr interleaved, newest last; cleared when an install starts.
install_total_stepsintegerPackages in the transaction; 0 until the first step line, so draw progress as indeterminate.
installingbooleanAn install is running; the install_* fields describe the latest run.
last_successful_check?integerUnix seconds of the last successful check (or the checked_at seed), else nil.
package_manager?stringPackage manager, e.g. "pacman", from the first push, which comes at start; nil when none is supported, and then every action is ignored.
packagesUpdateCandidate[]Pending upgrades. A failed check keeps the last good list.
reboot_requiredboolean/run/mantle-reboot-required exists, watched live. Mantle never writes it; anything you set up may, a pacman hook for example, and /run empties on reboot.

UpdateCandidate

One installed package with a newer version.

FieldTypeDescription
download_sizeintegerBytes to fetch; 0 when already cached.
installed_sizeintegerBytes the new version occupies installed; not a delta.
namestringPackage name.
new_versionstringVersion on offer.
old_versionstringInstalled version.
repository?stringSource repository, e.g. "extra" or "aur"; empty in a seeded list that lacks it.

Actions

Call each as mantle.updates:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
checkChecks for upgrades now, even when dormant; ignored while checking.
configureconfig: UpdatesConfigureSets the check schedule and AUR use, and seeds a remembered check.
installRuns a full upgrade through pkexec: pacman -Syu --noconfirm, or aur_helper when aur is on; dnf upgrade -y --refresh; apt-get update then apt-get upgrade --with-new-pkgs. Ignored while installing. Does not recheck afterwards.

UpdatesConfigure

configure’s table. One wrong-typed key drops the whole call.

FieldTypeDescription
aur?booleanAlso check the AUR and install through aur_helper. Sends every foreign package name to aur.archlinux.org and builds without PKGBUILD review.
checked_at?integerPersisted Unix seconds of the last successful check. Seeds last_successful_check only while that is nil, so a restart need not recheck at once.
intervalintegerSeconds between scheduled checks, the first at once unless last_successful_check is younger; 0 checks only on check.
packages?UpdateCandidate[]Persisted packages from that check, seeded on the same terms; ignored without checked_at.

Backend

The first manager that owns / wins, each on PATH at start: pacman with packages in /var/lib/pacman/local, else apt-get with packages in /var/lib/dpkg/status, else dnf. With none, package_manager is nil and every action is ignored. The dnf and apt backends are verified in containers only, untested on a Fedora or Ubuntu install for now.

Partpacman (Arch)dnf (Fedora)apt (Debian, Ubuntu)
Checkcurl fetches each repo database pacman-conf lists into $XDG_RUNTIME_DIR/mantle/pacman, skipping one the mirror reports unchanged. pacman -Qu, -Sp and -Si read it against /var/lib/pacman/localdnf repoquery --refresh --upgrades, then --installed for old_version; metadata goes to your cache (~/.cache/libdnf5)apt-get update into $XDG_RUNTIME_DIR/mantle/apt with its hooks cleared, then apt-get -s upgrade --with-new-pkgs and apt-cache show for sizes
AURWith aur = true, pacman -Qm names the foreign packages, one curl POST to the AUR RPC (30 s timeout) covers them, and vercmp orders the versions. aur_helper is paru, else yayNone: aur_helper is nil, aur only sets aur_errorSame as dnf
Installenv LC_ALL=C pkexec pacman -Syu --noconfirm, or env LC_ALL=C <aur_helper> -Syu --noconfirm --sudo pkexec with aur onpkexec env LC_ALL=C dnf upgrade -y --refreshpkexec env LC_ALL=C DEBIAN_FRONTEND=noninteractive sh -c "apt-get update && apt-get -y ... upgrade --with-new-pkgs"; a changed config file keeps your copy
Progresspacman’s (2/5) upgrading name lines; nothing while downloading, since pacman draws no progress without a tty[ 4/12] Upgrading name-... lines. The total also counts removals of the old versions and, on dnf5, two setup steps; dnf5 cuts a long name to fit its columnEach Setting up name line against the 4 upgraded, 1 newly installed summary; 0 while downloading

Every check runs as you and leaves the system’s package databases untouched. pkexec asks the session’s polkit agent; the polkit rule makes that one approval per run for wheel users of pacman, and dnf and apt install in one pkexec call, so one prompt. reboot_required mirrors /run/mantle-reboot-required through an inotify watch on /run, whatever the manager.

What packages holds differs by manager:

Fieldpacmandnfapt
repositoryThe repo, or "aur"The repo idThe suites carrying the new version, comma-joined: "noble-updates,noble-security"
download_size0 once cachedThe package sizeThe .deb’s size even when cached
installed_sizeFrom -Si, rounded to ~5 KiBExactExact to the KiB
Left outIgnorePkg entriesexcludepkgs entriesNew dependencies the upgrade pulls in; a package whose upgrade would remove another, and Ubuntu’s phased updates not yet offered, are held back, listed and installed by neither. sudo apt full-upgrade takes those
DuplicatesNoneOne entry per architecture when two of one package upgradeNone

How do I…

Remember the last check across restarts

Save each successful check in a persistent_table, and send configure only once the file has loaded, so its seed lands before the first scheduled check.

local dir = (os.getenv("HOME") or "") .. "/.local/state/myshell"
local cache = persistent_table { path = dir, name = "updates.json" }
local file = dir .. "/updates.json"

mantle.storage:on_change(function(storage, previous)
    local saved = storage.files[file]
    if saved and not (previous and previous.files[file]) then
        mantle.updates:configure({
            interval = 3600,
            checked_at = saved.checked_at,
            packages = saved.packages,
        })
    end
end)

mantle.updates:on_change(function(updates, previous)
    local at = updates.last_successful_check
    if at and at ~= (previous and previous.last_successful_check) then
        cache:set("checked_at", at)
        cache:set("packages", updates.packages)
    end
end)

return text {
    content = mantle.updates:map(function(updates)
        return updates and tostring(updates.count) or ""
    end),
}

Gotchas

TrapFix
packages still lists everything after a successful installinstall does not recheck. Invoke check from on_change when installing falls with install_exit_code == 0
install ends at once with install_exit_code 127No polkit agent answered pkexec. Read mantle.polkit and draw its prompt (polkit), or run another agent. 126 means the prompt was dismissed

Source: supervisor/src/capabilities/updates/

windows

Open toplevel windows with title, app ID, workspace, output and state flags.

list {
    source = mantle.windows:map(function(windows)
        return windows and windows.windows or {}
    end),
    key = function(window) return window.id end,
    itemfn = function(window)
        return button {
            on_click = function() mantle.windows:focus(window.id) end,
            children = {
                text { content = window.title, foreground = window.focused and "#89B4FA" or "#CDD6F4" },
            },
        }
    end,
}

State

mantle.windows:get() returns WindowsState, nil before the first push. A field marked ? may be absent.

mantle.windows payload; nil with no niri, Hyprland or wlr-foreign-toplevel backend.

FieldTypeDescription
sourcestring"niri", "hyprland", or "wlr_foreign_toplevel".
windowsWindowEntry[]Sorted by workspace_id, then backend order; windows without one last.

WindowEntry

One toplevel window. nil optional fields are ones the backend does not report.

FieldTypeDescription
app_idstringWayland app_id (Hyprland’s class); empty when unset.
floating?booleanWhether the window floats rather than tiles; nil on wlr.
focusedbooleanWhether the window has keyboard focus.
fullscreen?booleanWhether the window is fullscreen; nil on niri.
idstringOpaque, backend-shaped id for the windows actions; compare it, never parse it.
maximized?booleanWhether the window is maximized; nil on niri.
minimized?booleanWhether the window is minimized; nil except on wlr.
output?stringConnector name; nil when unknown. On wlr, the earliest-entered output the window is still on.
titlestringWindow title; empty when unset.
workspace_id?integerWorkspaceEntry.id; nil on wlr and on Hyprland special workspaces.

Actions

Call each as mantle.windows:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
focusid: stringFocuses a window.
closeid: stringAsks the compositor to close the window.
set_fullscreenid: string, fullscreen: booleanSets fullscreen on or off; no-op on niri.
set_minimizedid: string, minimized: booleanSets minimized on or off; wlr only.
set_maximizedid: string, maximized: booleanSets maximized on or off; no-op on niri.

Backend

niri and Hyprland share the workspaces reader; any other compositor needs zwlr_foreign_toplevel_manager_v1 (backend table).

BackendReportsWrites
nirifloatingfocus, close
Hyprlandfloating, fullscreen, maximizedfocus, close, set_fullscreen, set_maximized
wlr foreign-toplevelfullscreen, maximized, minimizedEvery action

A flag a backend does not report is nil; an action it lacks is logged at debug level and dropped.

Gotchas

TrapFix
if window.fullscreen == false never matches on niriThe flag is nil there. Test truthiness, or branch on source
output is nil for a window on a monitor plugged in after startupwlr binds outputs once, at connect. Restart the Supervisor after a hotplug if a dock sorts by output

See also: workspaces for the focused window and per-output workspaces.

Source: supervisor/src/capabilities/windows/

workspaces

Workspaces per output, special workspaces and the focused window.

panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    height = 28,
    child = function(output) -- one instance per monitor, named by connector
        return text {
            content = mantle.workspaces:map(function(workspaces)
                for _, entry in ipairs(workspaces and workspaces.outputs or {}) do
                    if entry.name == output then
                        for _, workspace in ipairs(entry.workspaces) do
                            if workspace.id == entry.active_workspace then
                                return "workspace " .. workspace.idx
                            end
                        end
                    end
                end
                return ""
            end),
        }
    end,
}

State

mantle.workspaces:get() returns WorkspacesState, nil before the first push. A field marked ? may be absent.

mantle.workspaces payload; nil without niri or Hyprland.

FieldTypeDescription
active_client?ActiveClientThe focused window, or nil when none has focus. One per session, not per output.
compositorstring"niri" or "hyprland".
outputsOutputWorkspaces[]One entry per output, sorted by connector name.
overview_open?booleanWhether niri’s overview is open; nil on Hyprland, which has none.
special?SpecialWorkspace[]Hyprland special workspaces, sorted by name. nil on niri; empty means none exist.

ActiveClient

The focused window.

FieldTypeDescription
classstringWayland app_id, e.g. "firefox"; the key of applications.by_app_id. Empty when unset.
is_floatingbooleanWhether the window floats rather than tiles.
is_fullscreen?booleanWhether the window is fullscreen (maximized is false); nil on niri, which does not report it.
titlestringWindow title; empty when unset.

OutputWorkspaces

One output’s workspaces.

FieldTypeDescription
active_workspaceintegerWorkspaceEntry::id shown on this output.
focused_workspace?integerWorkspaceEntry::id with focus, present only on the focused output.
namestringConnector name, e.g. "eDP-1", as in mantle.screens and a surface’s monitor.
workspacesWorkspaceEntry[]Workspaces on this output, sorted by WorkspaceEntry::idx.

SpecialWorkspace

One Hyprland special workspace.

FieldTypeDescription
app_id?stringapp_id of its representative window, chosen as WorkspaceEntry::app_id is.
namestringFull name, "special:scratch" or "special"; the argument of "toggle_special".
populatedbooleanWhether at least one window sits on it.
shown_on?stringConnector showing it, or nil while hidden.

WorkspaceEntry

One workspace. Draw idx, send id.

FieldTypeDescription
app_id?stringapp_id of a window here: Hyprland’s most recently focused one with an app_id; on niri the focused one, else the lowest id, nil if that one has no app_id. nil when empty.
idintegerStable id, the argument of "focus". Hyprland’s workspace number; opaque on niri.
idxintegerLabel number: niri’s 1-based position on the output, renumbered on reorder; Hyprland’s workspace number, equal to id up to 255, where it saturates.
name?stringWorkspace name; nil when unnamed, or on Hyprland when the name is just the number.
populatedbooleanWhether a window sits here.

Actions

Call each as mantle.workspaces:<action>(arguments...); ? marks an argument you may omit.

ActionArgumentsDescription
focusid: integerFocuses a WorkspaceEntry.id. Hyprland creates a missing number; niri ignores it.
toggle_specialname: stringShows or hides a special[].name on Hyprland, creating an unknown one; no-op on niri.

Backend

The Supervisor picks the compositor once, from $HYPRLAND_INSTANCE_SIGNATURE, then $NIRI_SOCKET (compositor.rs). One reader feeds both workspaces and windows.

CapabilityniriHyprlandNeither
workspacesIPC event stream.socket2.sock events, then one re-read per burst over .socket.sock; a title change alone patches in placenil for the run
windowsSame event streamSame re-readzwlr_foreign_toplevel_manager_v1 on its own Wayland connection; nil if the protocol is missing or setup takes over 5 s

Hyprland’s refusal of a write logs at debug level only (MANTLE_LOG=debug); niri’s is not logged.

How do I…

Draw workspace buttons

Draw idx, send id (list builds one button per entry):

list {
    direction = "Horizontal",
    spacing = 4,
    source = mantle.workspaces:map(function(workspaces)
        local output = workspaces and workspaces.outputs[1]
        return output and output.workspaces or {}
    end),
    key = function(workspace) return tostring(workspace.id) end,
    itemfn = function(workspace)
        local active = mantle.workspaces:map(function(workspaces)
            local output = workspaces and workspaces.outputs[1]
            return output ~= nil and output.active_workspace == workspace.id
        end)
        return button {
            padding = { left = 8, right = 8 },
            radius = 6,
            background = active:map(function(is_active) return is_active and "#89B4FA" or "#313244" end),
            on_click = function() mantle.workspaces:focus(workspace.id) end,
            children = { text { content = tostring(workspace.idx) } },
        }
    end,
}

Gotchas

TrapFix
Labels show large or odd numbers on niriDraw idx, send id. niri’s id is opaque
The strip differs between compositorsHyprland lists no empty workspace but the active one, and focus on a missing number creates it; niri keeps its own empty workspace and ignores an unknown id. Branch on compositor
Actions do nothing on Hyprland older than 0.56Writes use 0.56’s Lua dispatch syntax; older versions refuse them while reads still work. Update Hyprland; MANTLE_LOG=debug shows the refusal

See also: Workspaces recipe.

Source: supervisor/src/capabilities/workspaces/

Cookbook

Complete widgets to copy, simplest first. Each recipe is a whole shell.lua: save it in a config directory, run mantle check -c <dir>, then start the shell. New to Mantle? The introduction builds a first bar step by step.

RecipeBuildsNew since the one above
Clock barA top bar with a centred clock that toggles to the date on click, with a tooltipcomputed, named state, a hover tooltip
Battery indicatorA bar pill with charge icon, percentage, time-left tooltip and a low-battery warningon_change, process.detach
Volume OSDA card that slides in with an icon and level bar whenever the volume changespulse, animate with from, monitor = "Active"
WorkspacesPer-monitor workspace pills for Hyprland and niri, with wheel switching and Hyprland special workspacesPer-output child, keyed list, capability actions
Lock screenA per-monitor lock screen with clock, password field, error hint, fade and idle locklock, a secure field, an idle threshold
Power menuA full-screen menu to lock, suspend, log out, restart or power off, with confirmationA full-screen overlay closed by an outside click
Notification popupsA corner stack of notification cards with formatted bodies, action buttons and dismissText runs, animate.exit, nested buttons
App launcherA search overlay that fuzzy-ranks installed apps, with keyboard selectiontextfield, fuzzy, scroll:reveal
System tray with menuTray icons with click, wheel and a right-click dropdown menu with submenusA grabbing popup, a flattened menu tree
Media playerA now-playing pill and a card with cover art, a seekable progress bar and controlsExtrapolated position, on_drag

To combine recipes, keep one bar panel and put each recipe’s bar widgets in its row. Copy every other surface across as it is.

Clock bar

A bar across the top of every monitor with a clock in the centre. Clicking the clock switches between the time and the full date, and hovering it shows the date in a tooltip.

local show_date = state("clock_show_date", false)
local clock_hover = hover("clock")

local label = computed({ mantle.system, show_date }, function(system, date)
    if system == nil then
        return "--:--" -- nil until the first push
    end
    return os.date(date and "%A %d %B" or "%H:%M", system.time)
end)

local tooltip_text = mantle.system:map(function(system)
    return system and os.date("%A, %d %B %Y", system.time) or ""
end)

local clock = button {
    align_v = "Center",
    padding = { left = 10, right = 10, top = 4, bottom = 4 },
    radius = 6,
    hover = clock_hover,
    background = clock_hover:map(function(on) return on and "#313244" or "#00000000" end),
    animate = { background = 120 },
    on_click = function() show_date:set(not show_date:get()) end,
    children = { text { content = label, font_size = 14, foreground = "#cdd6f4" } },
}

local bar = panel {
    id = "bar",
    layer = "Top",
    anchor = { top = true, left = true, right = true },
    width = "Fill",
    height = 32,
    exclusive = true,
    child = row {
        width = "Fill",
        height = "Fill",
        padding = { left = 8, right = 8 },
        background = "#1e1e2e",
        children = { rect { width = "Fill" }, clock, rect { width = "Fill" } },
    },
}

local tooltip = popup {
    id = "clock_tooltip",
    parent = "bar",
    anchor_rect = hover_rect("clock"),
    anchor = "Bottom",
    gravity = "Bottom",
    offset = { y = 6 },
    grab = false,
    visible = clock_hover,
    padding = 8,
    radius = 8,
    background = "#1e1e2e",
    border_width = 1,
    border_color = "#45475a",
    child = text { content = tooltip_text, foreground = "#cdd6f4" },
}

return { bar, tooltip }

How it works

  • mantle.system pushes the time once a second, and os.date formats it (system).
  • computed combines the clock with a named state the click toggles (derived signals).
  • Both "Fill" spacers take an equal share of the row, so the clock sits at the exact centre (sizes).
  • The background is on the row, not the panel, so the whole bar takes clicks (input region).
  • hover and hover_rect drive a non-grabbing popup as a tooltip (hover).

Variations

ChangeEdit
Seconds"%H:%M:%S"
12-hour clock"%I:%M %p"
Clock on the rightDrop the second spacer: children = { rect { width = "Fill" }, clock }
One monitor onlymonitor = "DP-1" on the panel
Bottom baranchor = { bottom = true, left = true, right = true } and anchor = "Top", gravity = "Top", offset = { y = -6 } on the tooltip

Battery indicator

A bar pill with a battery icon and percentage that turns red when low, a tooltip with the charge state and time left, and a desktop notification when the charge drops past 15%. On a desktop with no battery the pill hides itself.

local LOW = 15
local pill_hover = hover("battery")

local STATES = {
    Charging = "Charging",
    Discharging = "On battery",
    FullyCharged = "Fully charged",
    PendingCharge = "Plugged in, not charging",
    Empty = "Empty",
}

local function duration(seconds)
    return string.format("%d h %02d min", seconds // 3600, seconds % 3600 // 60)
end

-- Adwaita-style names: battery-level-0 to battery-level-100 in steps of 10.
local glyph = mantle.battery:map(function(battery)
    if battery == nil or not battery.present then
        return "battery-missing-symbolic"
    end
    local step = math.floor(battery.percent / 10 + 0.5) * 10
    if battery.state == "FullyCharged" or (step == 100 and battery.state == "Charging") then
        return "battery-level-100-charged-symbolic"
    end
    return string.format("battery-level-%d%s-symbolic", step, battery.state == "Charging" and "-charging" or "")
end)

local colour = mantle.battery:map(function(battery)
    local low = battery and battery.present and battery.percent <= LOW and battery.state == "Discharging"
    return low and "#f38ba8" or "#cdd6f4"
end)

local details = mantle.battery:map(function(battery)
    if battery == nil or not battery.present then
        return "No battery"
    end
    local line = string.format("%d%% · %s", battery.percent, STATES[battery.state] or battery.state)
    if battery.state == "Discharging" and battery.time_to_empty then
        line = line .. "\n" .. duration(battery.time_to_empty) .. " left"
    elseif battery.state == "Charging" and battery.time_to_full then
        line = line .. "\n" .. duration(battery.time_to_full) .. " to full"
    end
    return line
end)

-- Warn once each time the charge falls past LOW while draining.
mantle.battery:on_change(function(battery, previous)
    if previous == nil or not battery.present then
        return
    end
    if battery.state == "Discharging" and battery.percent <= LOW and previous.percent > LOW then
        process.detach("notify-send", { "-u", "critical", "Battery low", battery.percent .. "% remaining" })
    end
end)

local pill = row {
    align_v = "Center",
    spacing = 4,
    padding = { left = 8, right = 10, top = 3, bottom = 3 },
    radius = 12,
    background = pill_hover:map(function(on) return on and "#45475a" or "#313244" end),
    hover = pill_hover,
    visible = mantle.battery:map(function(battery) return battery ~= nil and battery.present end),
    children = {
        icon { name = glyph, size = 16, foreground = colour, align_v = "Center" },
        text {
            content = mantle.battery:map(function(battery)
                return battery and battery.present and battery.percent .. "%" or ""
            end),
            foreground = colour,
            align_v = "Center",
        },
    },
}

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        width = "Fill",
        height = 32,
        exclusive = true,
        child = row {
            width = "Fill",
            height = "Fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = { rect { width = "Fill" }, pill },
        },
    },
    popup {
        id = "battery_tooltip",
        parent = "bar",
        anchor_rect = hover_rect("battery"),
        anchor = "Bottom",
        gravity = "Bottom",
        offset = { y = 6 },
        grab = false,
        visible = pill_hover,
        padding = 8,
        radius = 8,
        background = "#1e1e2e",
        border_width = 1,
        border_color = "#45475a",
        child = text { content = details, foreground = "#cdd6f4", wrap = "Word" },
    },
}

How it works

  • mantle.battery is nil before its first push and reads present = false on a desktop; every map checks both (battery).
  • state names UPower’s charge state; time_to_empty and time_to_full are optional and need their own guard.
  • The icon is picked by name from the icon theme and tinted with foreground (icon).
  • on_change compares with the previous push to fire only on the downward crossing (on_change); process.detach runs notify-send (process.detach).
  • The tooltip is a non-grabbing popup anchored to hover_rect (hover).

Variations

ChangeEdit
Charge as a bar instead of an iconA 24 × 10 rect track with a child rect { width = battery.percent .. "%", height = "Fill" } (sizes)
Cycle the power profile on clickMake the pill a button whose on_click picks the next entry of mantle.power:get().profiles and calls set_profile (power)
Show the wattageAdd text { content = mantle.power:map(function(power) return power and power.energy_rate and string.format("%.1f W", power.energy_rate) or "" end) }
Different thresholdLOW = 20
Hide the pill on mains at full chargevisible returns battery ~= nil and battery.present and battery.state ~= "FullyCharged"

Volume OSD

A card near the bottom of the focused monitor that shows the volume for a moment whenever it changes, whether from a media key, wpctl or a mixer. It fades and slides in, then out.

-- The last change worth showing. A fresh table on every set, so each change counts as new.
local osd = state("volume_osd", { volume = 0, muted = false })

mantle.audio:on_change(function(audio, previous)
    if previous == nil or audio.volume == nil then
        return -- the first push is learned state, not a change
    end
    if audio.volume ~= previous.volume or audio.muted ~= previous.muted then
        osd:set({ volume = audio.volume, muted = audio.muted })
    end
end)

local shown = pulse(osd, 1500) -- true for 1.5 s after each change
local mapped = pulse(osd, 1700) -- keeps the surface up while the card fades out

local function percent(entry)
    return math.floor(math.min(entry.volume, 1) * 100 + 0.5)
end

local glyph = osd:map(function(entry)
    if entry.muted or entry.volume == 0 then
        return "audio-volume-muted-symbolic"
    end
    local level = percent(entry)
    return level < 34 and "audio-volume-low-symbolic"
        or level < 67 and "audio-volume-medium-symbolic"
        or "audio-volume-high-symbolic"
end)

return {
    panel {
        id = "volume_osd",
        layer = "Overlay",
        monitor = "Active",
        anchor = { bottom = true }, -- no left/right: centred, width measured
        margin = { bottom = 80 },
        visible = mapped,
        child = row {
            width = 280,
            height = 48,
            spacing = 12,
            padding = { left = 16, right = 16 },
            align_v = "Center",
            radius = 24,
            background = "#1e1e2ee6",
            border_width = 1,
            border_color = "#45475a",
            opacity = shown:map(function(on) return on and 1 or 0 end),
            translate = shown:map(function(on) return { y = on and 0 or 12 } end),
            animate = {
                opacity = { duration = 150, from = 0 },
                translate = { duration = 200, easing = "OutCubic", from = { y = 12 } },
            },
            children = {
                icon { name = glyph, size = 20, foreground = "#cdd6f4", align_v = "Center" },
                rect {
                    width = "Fill",
                    height = 6,
                    radius = 3,
                    align_v = "Center",
                    background = "#45475a",
                    children = {
                        rect {
                            height = "Fill",
                            radius = 3,
                            background = osd:map(function(entry) return entry.muted and "#6c7086" or "#89b4fa" end),
                            width = osd:map(function(entry) return percent(entry) .. "%" end),
                            animate = { width = { duration = 120 } },
                        },
                    },
                },
                text {
                    content = osd:map(function(entry) return entry.muted and "Muted" or percent(entry) .. "%" end),
                    width = 44,
                    text_align = "End",
                    align_v = "Center",
                    font_size = 13,
                    foreground = "#cdd6f4",
                },
            },
        },
    },
}

Bind the volume keys to anything that changes the default sink, for example wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+; the OSD follows the push.

How it works

  • on_change reacts to each audio push and skips the first one, which is learned state (on_change, audio).
  • It writes a fresh table into a named state; pulse reads true for a while after each change (pulse).
  • A longer second pulse keeps the surface mapped while the card fades, since hiding a surface plays no exit (delay is the general form).
  • monitor = "Active" shows it on the output the compositor picks, usually the focused one, and a bottom-only anchor centres it (panel monitor, OSD).
  • The fill is a "NN%" width inside a fixed track (sizes); translate and opacity animate without re-laying out (animation).
  • The glyph comes from the icon theme by name (icon).

Variations

ChangeEdit
Brightness tooA second on_change on mantle.brightness writing { volume = brightness.percent / 100, muted = false } into the same state
Show above 100%Drop math.min from percent, and size the fill math.floor(entry.volume / 1.5 * 100) .. "%": the track is then 150%
Top of the screenanchor = { top = true }, margin = { top = 80 } and from = { y = -12 }
Every monitorRemove monitor = "Active"
Longer on screenpulse(osd, 3000) and pulse(osd, 3200)

Workspaces

A bar on every monitor listing that monitor’s workspaces, on Hyprland and niri alike. The active workspace is a wide pill, occupied ones are brighter, a click focuses one and the wheel steps through them. On Hyprland, special workspaces get their own toggles.

-- The workspaces of one output, `nil` until the compositor answers.
local function output_of(workspaces, name)
    for _, output in ipairs(workspaces and workspaces.outputs or {}) do
        if output.name == name then
            return output
        end
    end
end

local function focus_step(name, step)
    local output = output_of(mantle.workspaces:get(), name)
    if output == nil then
        return
    end
    for index, workspace in ipairs(output.workspaces) do
        if workspace.id == output.active_workspace then
            local target = output.workspaces[index + step]
            if target then
                mantle.workspaces:focus(target.id)
            end
            return
        end
    end
end

local function strip(name)
    return button {
        align_v = "Center",
        -- Wheel up is positive; step towards lower numbers.
        on_wheel = function(_, steps) focus_step(name, steps > 0 and -1 or 1) end,
        children = {
            list {
                direction = "Horizontal",
                spacing = 4,
                align_v = "Center",
                source = mantle.workspaces:map(function(workspaces)
                    local output = output_of(workspaces, name)
                    local items = {}
                    for index, workspace in ipairs(output and output.workspaces or {}) do
                        items[index] = {
                            id = workspace.id,
                            label = tostring(workspace.idx), -- draw idx, send id
                            active = workspace.id == output.active_workspace,
                            populated = workspace.populated,
                        }
                    end
                    return items
                end),
                key = function(item) return tostring(item.id) end,
                itemfn = function(item)
                    return button {
                        width = item.active and 36 or 22,
                        height = 20,
                        radius = 10,
                        background = item.active and "#89b4fa" or (item.populated and "#45475a" or "#313244"),
                        animate = { width = { duration = 180, easing = "OutCubic" }, background = 180 },
                        on_click = function() mantle.workspaces:focus(item.id) end,
                        children = {
                            text {
                                content = item.label,
                                align_h = "Center",
                                align_v = "Center",
                                font_size = 11,
                                foreground = item.active and "#1e1e2e" or "#cdd6f4",
                            },
                        },
                    }
                end,
            },
        },
    }
end

-- Hyprland's scratchpads; `special` is nil on niri, so this list is empty there.
local specials = list {
    direction = "Horizontal",
    spacing = 4,
    align_v = "Center",
    source = mantle.workspaces:map(function(workspaces)
        return workspaces and workspaces.special or {}
    end),
    key = function(special) return special.name end,
    itemfn = function(special)
        local short = special.name:gsub("^special:?", "") -- "special:scratch" -> "scratch"
        return button {
            padding = { left = 8, right = 8, top = 2, bottom = 2 },
            radius = 10,
            background = special.shown_on and "#f9e2af" or "#313244",
            on_click = function() mantle.workspaces:toggle_special(special.name) end,
            children = {
                text {
                    content = short ~= "" and short or "special",
                    font_size = 11,
                    foreground = special.shown_on and "#1e1e2e" or "#cdd6f4",
                },
            },
        }
    end,
}

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        width = "Fill",
        height = 32,
        exclusive = true,
        child = function(output) -- one instance per monitor, named by connector
            return row {
                width = "Fill",
                height = "Fill",
                spacing = 12,
                padding = { left = 8, right = 8 },
                background = "#1e1e2e",
                children = { strip(output), specials },
            }
        end,
    },
}

How it works

  • child = function(output) builds one bar per monitor and passes its connector name (per-output child).
  • outputs[].name matches that connector; active_workspace is the id shown there (workspaces).
  • The list rebuilds its buttons from the mapped array, and key keeps each button’s tweens when workspaces come and go (list).
  • Labels draw idx and clicks send id: niri’s ids are opaque (workspaces gotchas).
  • on_wheel on the outer button reads the live state with :get() inside the handler (pointer).
  • The width tween makes the active pill grow in place (animation).

Variations

ChangeEdit
Always show workspaces 1 to 5 on HyprlandPad items with { id = n, label = tostring(n), active = false, populated = false } for missing numbers; focus creates them
App icons instead of numbersCarry app_id = workspace.app_id into items and draw icon { name = item.app_id or "", size = 14 }. Where the icon name differs from the app_id, read entries[by_app_id[app_id]].icon from mantle.applications
Named workspaceslabel = workspace.name or tostring(workspace.idx), with min_width and side padding instead of a fixed width
Dots onlyDrop the text and set width = item.active and 20 or 8, height = 8
Show the focused window’s titleAdd text { content = mantle.workspaces:map(function(workspaces) return workspaces and workspaces.active_client and workspaces.active_client.title or "" end), elide = "End", max_width = 400 }
Vertical barAnchor left, set width = 40, height = "Fill", use a column and direction = "Vertical"

Lock screen

A lock screen on every monitor with a large clock, the date and a password card that reports checking and wrong passwords. It fades in when the session locks and out after a correct password. It locks from a keybind (mantle call lock) and after five minutes idle.

local FADE_MS = 250

mantle.lock:set_unlock_animation(FADE_MS)
mantle.idle:register_threshold(300, function() mantle.lock:lock() end, function() end)
action("lock", function() mantle.lock:lock() end)

-- Up while locked and not yet unlocking: drives the fade both ways.
local up = mantle.lock:map(function(lock) return lock ~= nil and lock.active and not lock.unlocking end)

local time = mantle.system:map(function(system) return system and os.date("%H:%M", system.time) or "" end)
local date = mantle.system:map(function(system) return system and os.date("%A, %d %B", system.time) or "" end)

local hint = mantle.lock:map(function(lock)
    if lock == nil then
        return ""
    elseif lock.authenticating then
        return "Checking…"
    elseif lock.error ~= "" then
        return lock.attempts > 1 and string.format("Wrong password (%d tries)", lock.attempts) or "Wrong password"
    end
    return "Type your password and press Enter"
end)

local failed = mantle.lock:map(function(lock) return lock ~= nil and lock.error ~= "" and not lock.authenticating end)

local lock_screen = lock {
    id = "lock",
    background = "#11111b",
    child = function(output)
        return column {
            width = "Fill",
            height = "Fill",
            align_h = "Center",
            align_v = "Center",
            spacing = 16,
            background = { gradient = "Linear", angle = 160, stops = { { 0, "#1e1e2e" }, { 1, "#11111b" } } },
            opacity = up:map(function(on) return on and 1 or 0 end),
            animate = { opacity = { duration = FADE_MS, from = 0 } },
            children = {
                text { content = time, font_size = 96, foreground = "#cdd6f4", align_h = "Center" },
                text { content = date, font_size = 20, foreground = "#a6adc8", align_h = "Center" },
                rect { height = 32 },
                text { content = os.getenv("USER") or "", font_size = 16, foreground = "#cdd6f4", align_h = "Center" },
                rect {
                    width = 320,
                    align_h = "Center",
                    padding = { left = 16, right = 16 },
                    radius = 22,
                    background = "#1e1e2e",
                    border_width = 2,
                    border_color = failed:map(function(bad) return bad and "#f38ba8" or "#45475a" end),
                    animate = { border_color = 150 },
                    children = {
                        textfield {
                            width = "Fill",
                            height = 44,
                            font_size = 16,
                            foreground = "#cdd6f4",
                            text_align = "Center",
                            placeholder = "Password",
                            secure_submit = { capability = "lock", action = "authenticate" },
                        },
                    },
                },
                text {
                    content = hint,
                    foreground = failed:map(function(bad) return bad and "#f38ba8" or "#6c7086" end),
                    align_h = "Center",
                },
            },
        }
    end,
}

return { lock_screen }

How it works

  • Declaring a lock does not lock; mantle.lock:lock() does, and only a correct password in the one secure field unlocks (lock surface, lock capability).
  • secure_submit sends keystrokes straight to PAM; Lua never sees the password, so the field has no on_change or on_submit (secure fields).
  • child = function(output) gives every monitor its own copy (per-output child).
  • set_unlock_animation keeps the lock up for FADE_MS after success, while unlocking fades the card out (animation).
  • error, attempts and authenticating drive the hint and the red border.
  • action exposes mantle call lock to a keybind, and an idle threshold locks after 300 s without input (action, idle).

Variations

ChangeEdit
Wallpaper behind itWrap the column in a rect { width = "Fill", height = "Fill" } whose first child is image { source = "/path/to/wallpaper.jpg", width = "Fill", height = "Fill" }, and drop the gradient (image)
Blurred wallpaperAdd source_blur = 24 to that image (blurs)
Unlock buttonA button { submit = true, ... } beside the field sends it like Enter (pointer)
Clock on one monitor onlyvisible = output == "DP-1" on the clock texts
Lock before suspendAn action that sets a suspend_pending state and calls mantle.lock:lock(); a mantle.lock:on_change that sees active turn true with it set clears it and runs systemctl suspend through process.detach. Suspending straight away can sleep before the lock draws

Power menu

A full-screen overlay with lock, suspend, log out, restart and power off. The last three ask for a second click before they run, and a click outside the buttons closes the menu. A bar button opens it, and so does mantle toggle power_menu_open from a keybind.

local open = state("power_menu_open", false)
local pending = state("power_menu_pending", "") -- the action waiting for its second click

local function close()
    open:set(false)
    pending:set("")
end

local function log_out()
    local workspaces = mantle.workspaces:get()
    local compositor = workspaces and workspaces.compositor
    if compositor == "niri" then
        process.detach("niri", { "msg", "action", "quit", "--skip-confirmation" })
    elseif compositor == "hyprland" then
        process.detach("hyprctl", { "dispatch", "exit" })
    end
end

local ACTIONS = {
    { key = "lock", label = "Lock", glyph = "system-lock-screen-symbolic",
      run = function() mantle.lock:lock() end },
    { key = "suspend", label = "Suspend", glyph = "weather-clear-night-symbolic",
      run = function() process.detach("systemctl", { "suspend" }) end },
    { key = "logout", label = "Log out", glyph = "system-log-out-symbolic", confirm = true, run = log_out },
    { key = "reboot", label = "Restart", glyph = "system-reboot-symbolic", confirm = true,
      run = function() process.detach("systemctl", { "reboot" }) end },
    { key = "poweroff", label = "Power off", glyph = "system-shutdown-symbolic", confirm = true,
      run = function() process.detach("systemctl", { "poweroff" }) end },
}

local function action_button(action)
    local over = hover("power_" .. action.key)
    local armed = pending:map(function(key) return key == action.key end)
    return button {
        width = 120,
        height = 120,
        radius = 20,
        hover = over,
        background = computed({ over, armed }, function(hovered, is_armed)
            if is_armed then return "#f38ba8" end
            return hovered and "#45475a" or "#313244"
        end),
        animate = { background = 120, scale = { duration = 120, easing = "OutCubic" } },
        scale = over:map(function(hovered) return hovered and 1.05 or 1 end),
        on_click = function()
            if action.confirm and pending:get() ~= action.key then
                pending:set(action.key)
                return
            end
            close()
            action.run()
        end,
        children = {
            column {
                align_h = "Center",
                align_v = "Center",
                spacing = 10,
                children = {
                    icon {
                        name = action.glyph,
                        size = 36,
                        align_h = "Center",
                        foreground = armed:map(function(is_armed) return is_armed and "#1e1e2e" or "#cdd6f4" end),
                    },
                    text {
                        content = armed:map(function(is_armed) return is_armed and "Click again" or action.label end),
                        align_h = "Center",
                        foreground = armed:map(function(is_armed) return is_armed and "#1e1e2e" or "#cdd6f4" end),
                    },
                },
            },
        },
    }
end

local buttons = {}
for index, action in ipairs(ACTIONS) do
    buttons[index] = action_button(action)
end

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        width = "Fill",
        height = 32,
        exclusive = true,
        child = row {
            width = "Fill",
            height = "Fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = {
                rect { width = "Fill" },
                button {
                    align_v = "Center",
                    padding = 6,
                    radius = 6,
                    on_click = function() open:set(true) end,
                    children = { icon { name = "system-shutdown-symbolic", size = 16, foreground = "#f38ba8" } },
                },
            },
        },
    },
    panel {
        id = "power_menu",
        layer = "Overlay",
        monitor = "Active",
        anchor = { top = true, bottom = true, left = true, right = true },
        width = "Fill",
        height = "Fill",
        exclusive = "Ignore",
        visible = open,
        child = rect {
            width = "Fill",
            height = "Fill",
            background = "#11111bcc",
            opacity = 1, -- `from` needs the property set
            animate = { opacity = { duration = 150, from = 0 } },
            children = {
                button { width = "Fill", height = "Fill", on_click = close }, -- outside click
                row { align_h = "Center", align_v = "Center", spacing = 16, children = buttons },
            },
        },
    },
}

How it works

  • process.detach runs systemctl and the compositor’s quit command as programs that outlive the shell, so a shutdown is never cut short by the shell exiting (process.detach).
  • Log out branches on mantle.workspaces’ compositor field (workspaces).
  • Lock goes through the lock capability, which needs a declared lock screen (lock screen, lock).
  • One named state, pending, holds the armed action; a second click on the same button runs it (named state).
  • A full-size button under the row closes the menu; the row is declared after it, so it is on top (close an overlay).
  • hover drives the tint and a scale tween, which does not re-lay out the row (hover, animation).

Variations

ChangeEdit
No confirmationRemove confirm = true from the entries
HibernateAdd { key = "hibernate", label = "Hibernate", glyph = "drive-harddisk-symbolic", confirm = true, run = function() process.detach("systemctl", { "hibernate" }) end }
Hyprland with a Lua configprocess.detach("hyprctl", { "dispatch", "hl.dsp.exit()" })
Vertical listcolumn instead of row, and width = 240, height = 56 on each button with a row inside
Open from a keybindBind mantle toggle power_menu_open

Notification popups

Notification cards stacked in the top-right corner, newest first. Each card shows the app, the summary, the formatted body and the sender’s action buttons; clicking it runs the default action, the × dismisses it, and hovering the stack pauses every countdown.

local MAX_CARDS = 4
local stack_hover = hover("notification_stack")

-- Cards to show: not expired, not silenced by do-not-disturb, newest first.
local cards = mantle.notifications:map(function(notifications)
    local out = {}
    for _, item in ipairs(notifications and notifications.feed or {}) do
        local quiet = notifications.dnd and item.urgency ~= "critical"
        if not item.expired and not quiet and #out < MAX_CARDS then
            out[#out + 1] = item
        end
    end
    return out
end)

-- A text span is a text run as it is; links also get a colour. Image spans are skipped.
local function body_runs(spans)
    local runs = {}
    for _, span in ipairs(spans) do
        if span.kind == "text" then
            -- A copy: the span belongs to the pushed snapshot every other reader shares.
            local run = {}
            for field, value in pairs(span) do run[field] = value end
            if run.href then
                run.underline, run.color = true, "#89b4fa"
            end
            runs[#runs + 1] = run
        end
    end
    return runs
end

local function artwork(item)
    if item.image_path then
        return rect {
            width = 40,
            height = 40,
            radius = 8,
            clip = "Rounded",
            children = { image { source = item.image_path, fit = "cover", width = "Fill", height = "Fill" } },
        }
    end
    return icon { name = item.app_icon or "dialog-information-symbolic", size = 32 }
end

local function action_buttons(item)
    local buttons = {}
    for index, action in ipairs(item.actions) do
        buttons[index] = button {
            width = "Fill",
            padding = 6,
            radius = 6,
            background = "#313244",
            on_click = function() mantle.notifications:invoke_action(item.id, action.key) end,
            children = {
                text { content = action.label, align_h = "Center", elide = "End", foreground = "#cdd6f4" },
            },
        }
    end
    return row { width = "Fill", spacing = 6, visible = #buttons > 0, children = buttons }
end

local function card(item)
    local critical = item.urgency == "critical"
    local runs = body_runs(item.body)
    return button {
        width = "Fill",
        padding = 12,
        radius = 12,
        background = "#1e1e2ef2",
        border_width = 1,
        border_color = critical and "#f38ba8" or "#45475a",
        opacity = 1, -- `from` needs the property set
        translate = { x = 0 },
        animate = {
            opacity = { duration = 150, from = 0 },
            translate = { duration = 200, easing = "OutCubic", from = { x = 40 } },
            exit = { duration = 150, opacity = 0 },
        },
        on_click = function()
            if item.has_default_action then
                mantle.notifications:invoke_action(item.id, "default")
            else
                mantle.notifications:dismiss(item.id)
            end
        end,
        children = {
            row {
                width = "Fill",
                spacing = 10,
                children = {
                    artwork(item),
                    column {
                        width = "Fill",
                        spacing = 4,
                        children = {
                            row {
                                width = "Fill",
                                spacing = 6,
                                children = {
                                    text { content = item.app_name, width = "Fill", elide = "End", font_size = 11, foreground = "#a6adc8" },
                                    button {
                                        padding = { left = 4, right = 4 },
                                        radius = 4,
                                        on_click = function() mantle.notifications:dismiss(item.id) end,
                                        children = { text { content = "×", font_size = 14, foreground = "#a6adc8" } },
                                    },
                                },
                            },
                            text {
                                content = { { text = item.summary, bold = true } },
                                width = "Fill",
                                elide = "End",
                                font_size = 13,
                                foreground = "#cdd6f4",
                            },
                            text {
                                content = runs,
                                visible = #runs > 0,
                                width = "Fill",
                                wrap = "Word",
                                max_lines = 4,
                                elide = "End",
                                foreground = "#bac2de",
                                on_link = function(href) mantle.applications:open_url(href) end,
                            },
                            action_buttons(item),
                        },
                    },
                },
            },
        },
    }
end

return {
    panel {
        id = "notifications",
        layer = "Overlay",
        anchor = { top = true, right = true },
        margin = { top = 8, right = 8 },
        width = 380,
        visible = cards:map(function(shown) return #shown > 0 end),
        child = column {
            width = "Fill",
            hover = stack_hover,
            -- Pause every countdown while the pointer is over the stack.
            on_hover = function(inside) mantle.notifications:hold_expiry(inside and 300 or 0) end,
            children = {
                list {
                    width = "Fill",
                    spacing = 8,
                    source = cards,
                    key = function(item) return tostring(item.id) end,
                    itemfn = card,
                },
            },
        },
    },
}

How it works

  • feed is the newest 20, expired ones included; the map keeps the live ones and caps them (notifications).
  • dnd only mutes sounds, so hiding popups during it is the config’s filter; critical ones still show.
  • key by id keeps each card’s node when a newer one arrives above it, so only the new one animates in; a dismissed card fades out through animate.exit (identity, exit).
  • A body’s text spans pass to text as runs unchanged; on_link hands a clicked href to open_url (text runs, applications).
  • The × is a button inside the card’s button: the innermost one with a handler takes the click (pointer).
  • on_hover on the stack calls hold_expiry, so a card cannot expire while being read (hover).
  • The panel is anchored to two edges, so it measures its content and grows with the stack (corner stack).

Variations

ChangeEdit
Play soundsOnce at top level: mantle.notifications:set_sound("normal", "/usr/share/sounds/freedesktop/stereo/message.oga")
Bottom-right corneranchor = { bottom = true, right = true }, margin = { bottom = 8, right = 8 }
Hide everything under do-not-disturblocal quiet = notifications.dnd
Relative timeAdd text { content = mantle.system:map(function(system) return system and math.floor((system.time - item.timestamp) / 60) .. " min ago" or "" end) }
Mute one appmantle.notifications:set_app_muted("discord", true)

App launcher

A centred search overlay for installed apps. Typing ranks desktop entries with fzf’s scorer, the arrow keys move the selection, Enter or a click launches, and Escape or a click outside closes it. Open it from a compositor keybind with mantle toggle launcher_open.

local MAX_RESULTS = 50

local open = state("launcher_open", false)
local query = state("launcher_query", "")
local selected = state("launcher_selected", 1)
local results_scroll = scroll("launcher_results")

local function close()
    open:set(false)
    query:set("")
    selected:set(1)
end

-- Best score of the name, generic name and keywords; nil when none match.
local function score_of(entry, needle)
    local best, best_start = fuzzy(entry.name, needle)
    for _, field in ipairs({ entry.generic_name or "", table.concat(entry.keywords, " ") }) do
        local score, start = fuzzy(field, needle)
        if score and (best == nil or score > best) then
            best, best_start = score, start
        end
    end
    return best, best_start
end

local matches = computed({ mantle.applications, query }, function(applications, needle)
    local scored = {}
    for _, entry in ipairs(applications and applications.entries or {}) do
        local score, start = score_of(entry, needle)
        if score then
            scored[#scored + 1] = { entry = entry, score = score, start = start }
        end
    end
    table.sort(scored, function(left, right)
        if left.score ~= right.score then return left.score > right.score end
        if left.start ~= right.start then return left.start < right.start end
        return left.entry.id < right.entry.id -- unique, so the order is stable
    end)
    local out = {}
    for index = 1, math.min(#scored, MAX_RESULTS) do
        out[index] = scored[index].entry
    end
    return out
end)

local rows = computed({ matches, selected }, function(entries, current)
    local out = {}
    for index, entry in ipairs(entries) do
        out[index] = { entry = entry, selected = index == current }
    end
    return out
end)

local function launch(entry)
    if entry then
        mantle.applications:launch(entry.id)
    end
    close()
end

local function move(step)
    local count = #matches:get()
    if count == 0 then
        return
    end
    selected:set((selected:get() - 1 + step) % count + 1) -- wraps at both ends
    results_scroll:reveal(selected:get())
end

local search = rect {
    width = "Fill",
    padding = { left = 12, right = 12 },
    radius = 10,
    background = "#313244",
    children = {
        textfield {
            id = "search",
            width = "Fill",
            height = 40,
            font_size = 16,
            foreground = "#cdd6f4",
            placeholder = "Search apps",
            autofocus = true,
            on_change = function(text)
                query:set(text)
                selected:set(1)
                results_scroll:reveal(1)
            end,
            on_navigate = function(key)
                if key == "down" or key == "tab" then move(1) end
                if key == "up" or key == "backtab" then move(-1) end
            end,
            on_submit = function() launch(matches:get()[selected:get()]) end,
            on_cancel = function(cleared)
                if not cleared then close() end -- first Escape clears, second closes
            end,
        },
    },
}

local results = list {
    width = "Fill",
    max_height = 400,
    spacing = 2,
    scroll = results_scroll,
    source = rows,
    key = function(row_data) return row_data.entry.id end,
    itemfn = function(row_data)
        local entry = row_data.entry
        return button {
            width = "Fill",
            padding = 8,
            radius = 8,
            background = row_data.selected and "#45475a" or "#00000000",
            on_click = function() launch(entry) end,
            children = {
                row {
                    width = "Fill",
                    spacing = 12,
                    children = {
                        icon { name = entry.icon or "application-x-executable", size = 32, align_v = "Center" },
                        column {
                            width = "Fill",
                            align_v = "Center",
                            children = {
                                text { content = entry.name, width = "Fill", elide = "End", font_size = 14, foreground = "#cdd6f4" },
                                text {
                                    content = entry.comment or entry.generic_name or "",
                                    visible = (entry.comment or entry.generic_name) ~= nil,
                                    width = "Fill",
                                    elide = "End",
                                    font_size = 11,
                                    foreground = "#a6adc8",
                                },
                            },
                        },
                    },
                },
            },
        }
    end,
}

return {
    panel {
        id = "launcher",
        layer = "Overlay",
        monitor = "Active",
        anchor = { top = true, bottom = true, left = true, right = true },
        width = "Fill",
        height = "Fill",
        exclusive = "Ignore",
        visible = open,
        keyboard_interactivity = "Exclusive", -- hiding destroys the surface, so no binding needed
        child = rect {
            width = "Fill",
            height = "Fill",
            background = "#11111b80",
            children = {
                -- Catches clicks outside the card.
                button { width = "Fill", height = "Fill", on_click = close },
                column {
                    width = 560,
                    align_h = "Center",
                    margin = { top = 160 },
                    padding = 12,
                    spacing = 8,
                    radius = 16,
                    background = "#1e1e2e",
                    border_width = 1,
                    border_color = "#45475a",
                    children = {
                        search,
                        results,
                        text {
                            content = "No matches",
                            visible = matches:map(function(entries) return #entries == 0 end),
                            align_h = "Center",
                            padding = 12,
                            foreground = "#6c7086",
                        },
                    },
                },
            },
        },
    },
}

Bind a key to mantle toggle launcher_open, for example bind = SUPER, Space, exec, mantle toggle launcher_open on Hyprland or Mod+Space { spawn "mantle" "toggle" "launcher_open"; } on niri.

How it works

  • applications.entries holds every visible desktop entry and follows installs and removals; launch takes its id and runs it detached (applications).
  • fuzzy scores one candidate; ranking, the tiebreak and the cap stay in Lua (fuzzy).
  • computed joins the capability with the query, and a second one marks the selected row (derived signals).
  • The textfield owns the typed text and reports it through on_change; on_navigate gets the arrow and Tab keys (textfield, text fields).
  • scroll(name):reveal(index) keeps the selected row in view inside the max_height list (scroll, list).
  • "Exclusive" hands the panel the keyboard when it maps, and autofocus gives it to the field (keyboard focus).
  • A full-size transparent button under the card closes it on an outside click (close an overlay).

Variations

ChangeEdit
Pointer focus on Hyprland"OnDemand" instead of "Exclusive", so other surfaces keep taking clicks (panel gotchas)
Fewer rowsMAX_RESULTS = 8 and drop max_height
Debounce typingRank against delay(query, 80) instead of query (debounce a search)
No dimmed backdropRemove the root rect’s background

System tray with menu

Tray icons in the bar. A left click activates the app, a middle click sends its secondary action, the wheel scrolls it and a right click opens its menu in a dropdown. Submenus expand in place, check marks and radio dots follow the app, and disabled entries are drawn greyed out.

local menu_open = state("tray_menu_open", false)
local menu_anchor = state("tray_menu_anchor", { x = 0, y = 0, width = 1, height = 1 })
local menu_item = state("tray_menu_item", "") -- the tray item whose menu is open
local expanded = state("tray_menu_expanded", {}) -- open submenu ids, as strings

local function close_menu()
    menu_open:set(false)
end

local function open_menu(item, rect)
    menu_item:set(item.id)
    expanded:set({})
    menu_anchor:set(rect)
    menu_open:set(true)
end

local function artwork(item)
    local name = item.icon_name
    local path = item.icon_path
    if item.status == "NeedsAttention" and (item.attention_icon_name or item.attention_icon_path) then
        name, path = item.attention_icon_name, item.attention_icon_path
    end
    if path then
        return image { source = path, width = 16, height = 16, fit = "contain", align_v = "Center" }
    end
    -- `foreground` tints symbolic icons, which are dark by default; colour icons ignore it.
    return icon { name = name or "application-x-executable", size = 16, foreground = "#cdd6f4", align_v = "Center" }
end

local tray_items = list {
    direction = "Horizontal",
    spacing = 2,
    align_v = "Center",
    source = mantle.tray:map(function(tray)
        local shown = {}
        for _, item in ipairs(tray and tray.items or {}) do
            if item.status ~= "Passive" then -- Passive asks to be hidden
                shown[#shown + 1] = item
            end
        end
        return shown
    end),
    key = function(item) return item.id end,
    itemfn = function(item)
        return button {
            padding = 4,
            radius = 6,
            on_click = function(rect, which)
                if item.menu and (which == "right" or item.item_is_menu) then
                    open_menu(item, rect)
                elseif which == "left" then
                    mantle.tray:activate(item.id, 0, 0)
                elseif which == "middle" then
                    mantle.tray:secondary_activate(item.id, 0, 0)
                end
            end,
            on_wheel = function(_, steps)
                mantle.tray:scroll(item.id, steps > 0 and 1 or -1, "vertical")
            end,
            children = { artwork(item) },
        }
    end,
}

-- The open item's menu as flat rows, depth-first, with open submenus inlined.
local function flatten(entries, depth, open, out)
    for _, entry in ipairs(entries or {}) do
        out[#out + 1] = { entry = entry, depth = depth }
        if #entry.children > 0 and open[tostring(entry.id)] then
            flatten(entry.children, depth + 1, open, out)
        end
    end
    return out
end

local menu_rows = computed({ mantle.tray, menu_item, expanded }, function(tray, id, open)
    for _, item in ipairs(tray and tray.items or {}) do
        if item.id == id then
            return flatten(item.menu, 0, open, {})
        end
    end
    return {}
end)

-- "_Quit" -> "Quit"; "__" is a literal underscore.
local function strip_mnemonic(label)
    return ((label or ""):gsub("__", "\0"):gsub("_", ""):gsub("%z", "_"))
end

local function marker(entry)
    if #entry.children > 0 then
        return "›"
    elseif entry.toggle_state == 1 then
        return entry.toggle_type == "radio" and "●" or "✓"
    end
    return ""
end

local function menu_row(row_data)
    local entry = row_data.entry
    local indent = 8 + row_data.depth * 12
    if entry.menu_type == "separator" then
        return rect {
            width = "Fill",
            padding = { top = 4, bottom = 4, left = indent, right = 8 },
            children = { rect { width = "Fill", height = 1, background = "#45475a" } },
        }
    end
    local mark = marker(entry)
    local row_hover = hover("tray_menu_row_" .. entry.id .. "_" .. row_data.depth)
    return button {
        width = "Fill",
        padding = { top = 6, bottom = 6, left = indent, right = 8 },
        radius = 6,
        hover = row_hover,
        background = row_hover:map(function(on) return on and entry.enabled and "#313244" or "#00000000" end),
        opacity = entry.enabled and 1 or 0.4,
        on_click = function()
            if #entry.children > 0 then
                local key = tostring(entry.id)
                local open = {}
                for id, value in pairs(expanded:get()) do open[id] = value end
                open[key] = not open[key] or nil
                expanded:set(open)
                if open[key] then
                    mantle.tray:menu_will_show(menu_item:get(), entry.id)
                end
            elseif entry.enabled then
                mantle.tray:activate_menu_item(menu_item:get(), entry.id)
                close_menu()
            end
        end,
        children = {
            row {
                width = "Fill",
                spacing = 8,
                children = {
                    icon { name = entry.icon_name or "", size = 14, foreground = "#cdd6f4", visible = entry.icon_name ~= nil, align_v = "Center" },
                    text { content = strip_mnemonic(entry.label), width = "Fill", elide = "End", foreground = "#cdd6f4" },
                    text { content = mark, visible = mark ~= "", foreground = "#a6adc8" },
                },
            },
        },
    }
end

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        width = "Fill",
        height = 32,
        exclusive = true,
        child = row {
            width = "Fill",
            height = "Fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = { rect { width = "Fill" }, tray_items },
        },
    },
    popup {
        id = "tray_menu",
        parent = "bar",
        anchor_rect = menu_anchor,
        anchor = "Bottom",
        gravity = "BottomLeft",
        offset = { y = 4 },
        constraint_adjustment = { "SlideX", "FlipY" },
        visible = menu_open,
        on_dismiss = close_menu,
        width = 260,
        padding = 6,
        radius = 10,
        background = "#1e1e2e",
        border_width = 1,
        border_color = "#45475a",
        child = list {
            width = "Fill",
            max_height = 480,
            scroll = scroll("tray_menu"),
            source = menu_rows,
            -- The same entry at two depths is two rows.
            key = function(row_data) return row_data.entry.id .. ":" .. row_data.depth end,
            itemfn = menu_row,
        },
    },
}

How it works

  • tray.items arrive in registration order with their whole DBusMenu tree in menu (tray).
  • on_click gets the button’s rect and the mouse button; the rect goes straight into the popup’s anchor_rect (pointer, popup).
  • The popup grabs the pointer, so an outside click dismisses it and on_dismiss clears the state (dismissal).
  • The menu tree is flattened into one list with an indent per depth, so a submenu opens in place rather than in a second popup (list, nested menus for the other way).
  • menu_will_show lets apps that fill submenus lazily send them before the row expands.

Variations

ChangeEdit
Show hidden (Passive) itemsReturn tray and tray.items or {} from the source map
TooltipsGive each button hover = hover("tray_" .. item.id) and add a grab = false popup showing item.tooltip (tooltip)
Bigger iconssize = 20 and width = 20, height = 20
Open the menu rightwards, for a tray on the leftgravity = "BottomRight"
Attention badgeStack an icon { name = item.overlay_icon_name } over the artwork in a rect, aligned "End" both ways

Media player

A now-playing pill in the bar for any MPRIS player (Spotify, mpv, a browser tab). Click it for a card with cover art, title, artist, a seekable progress bar and previous, play/pause and next buttons. It prefers whichever player is playing and hides when none runs.

local card_open = state("media_open", false)
local card_anchor = state("media_anchor", { x = 0, y = 0, width = 1, height = 1 })
-- Where `position` was last reported, in `mantle.system.monotonic` seconds.
local position_mark = state("media_position_mark", { key = "", at = 0 })

-- The playing player, else the longest-running one.
local function pick(mpris)
    local players = mpris and mpris.players or {}
    for _, candidate in ipairs(players) do
        if candidate.play_state == "Playing" then
            return candidate
        end
    end
    return players[1]
end

local player = mantle.mpris:map(pick)

local function report_key(current)
    return current.id .. ":" .. current.position_updated_at
end

-- Positions are not polled: stamp each new report so the bar can add the time since.
mantle.mpris:on_change(function(mpris)
    local current = pick(mpris)
    local system = mantle.system:get()
    if current and system then
        local key = report_key(current)
        if key ~= position_mark:get().key then
            position_mark:set({ key = key, at = system.monotonic })
        end
    end
end)

-- Live position in microseconds, or -1 when unknown.
local position = computed({ player, position_mark, mantle.system }, function(current, mark, system)
    if current == nil or current.position < 0 then
        return -1
    end
    local elapsed = 0
    -- An unstamped report, such as one before the first clock push, adds nothing.
    if current.play_state == "Playing" and system and mark.key == report_key(current) then
        elapsed = (system.monotonic - mark.at) * 1000000
    end
    local now = current.position + elapsed
    return current.length > 0 and math.min(now, current.length) or now
end)

local function clock(microseconds)
    local seconds = math.max(0, microseconds // 1000000)
    return string.format("%d:%02d", seconds // 60, seconds % 60)
end

local function control(command)
    local current = player:get()
    if current then
        mantle.mpris:control(current.id, command)
    end
end

local function control_button(glyph, command, size)
    return button {
        align_v = "Center",
        padding = 8,
        radius = 20,
        background = command == "play_pause" and "#89b4fa" or "#313244",
        on_click = function() control(command) end,
        children = { icon { name = glyph, size = size, foreground = command == "play_pause" and "#1e1e2e" or "#cdd6f4" } },
    }
end

local play_glyph = player:map(function(current)
    return current and current.play_state == "Playing" and "media-playback-pause-symbolic" or "media-playback-start-symbolic"
end)

local pill = button {
    align_v = "Center",
    padding = { left = 10, right = 10, top = 4, bottom = 4 },
    radius = 12,
    background = "#313244",
    visible = player:map(function(current) return current ~= nil end),
    on_click = function(rect, which)
        if which == "middle" then
            control("play_pause")
        else
            card_anchor:set(rect)
            card_open:set(not card_open:get())
        end
    end,
    on_wheel = function(_, steps) control(steps > 0 and "previous" or "next") end,
    children = {
        row {
            spacing = 6,
            children = {
                icon { name = play_glyph, size = 14, foreground = "#cdd6f4", align_v = "Center" },
                text {
                    max_width = 240,
                    elide = "End",
                    foreground = "#cdd6f4",
                    content = player:map(function(current)
                        if current == nil or current.title == "" then return "" end
                        return current.artist ~= "" and current.artist .. " — " .. current.title or current.title
                    end),
                },
            },
        },
    },
}

local progress = button {
    width = "Fill",
    height = 6,
    radius = 3,
    clip = "Rounded",
    background = "#45475a",
    -- Seek on release, to where the pointer let go.
    on_drag = function(rect, pointer, phase)
        local current = player:get()
        if phase == "end" and current and current.length > 0 then
            local fraction = math.max(0, math.min(1, pointer.x / rect.width))
            mantle.mpris:seek(current.id, math.floor(fraction * current.length))
        end
    end,
    children = {
        rect {
            height = "Fill",
            background = "#89b4fa",
            width = computed({ player, position }, function(current, now)
                if current == nil or current.length <= 0 or now < 0 then return "0%" end
                return string.format("%.1f%%", now / current.length * 100)
            end),
        },
    },
}

local card = column {
    width = 320,
    padding = 16,
    spacing = 12,
    children = {
        row {
            width = "Fill",
            spacing = 12,
            children = {
                rect {
                    width = 64,
                    height = 64,
                    radius = 8,
                    clip = "Rounded",
                    background = "#313244",
                    children = {
                        image {
                            width = "Fill",
                            height = "Fill",
                            fit = "cover",
                            source = player:map(function(current) return current and current.album_art_path or "" end),
                        },
                    },
                },
                column {
                    width = "Fill",
                    align_v = "Center",
                    spacing = 2,
                    children = {
                        text {
                            content = player:map(function(current) return current and current.title or "" end),
                            width = "Fill", elide = "End", font_size = 14, foreground = "#cdd6f4",
                        },
                        text {
                            content = player:map(function(current) return current and current.artist or "" end),
                            width = "Fill", elide = "End", foreground = "#a6adc8",
                        },
                        text {
                            content = player:map(function(current) return current and current.identity or "" end),
                            width = "Fill", elide = "End", font_size = 11, foreground = "#6c7086",
                        },
                    },
                },
            },
        },
        progress,
        row {
            width = "Fill",
            children = {
                text { content = position:map(function(now) return now < 0 and "" or clock(now) end), font_size = 11, foreground = "#a6adc8" },
                rect { width = "Fill" },
                text {
                    content = player:map(function(current) return current and current.length > 0 and clock(current.length) or "" end),
                    font_size = 11,
                    foreground = "#a6adc8",
                },
            },
        },
        row {
            align_h = "Center",
            spacing = 12,
            width = "Fill",
            children = {
                control_button("media-skip-backward-symbolic", "previous", 16),
                control_button(play_glyph, "play_pause", 20),
                control_button("media-skip-forward-symbolic", "next", 16),
            },
        },
    },
}

return {
    panel {
        id = "bar",
        layer = "Top",
        anchor = { top = true, left = true, right = true },
        width = "Fill",
        height = 32,
        exclusive = true,
        child = row {
            width = "Fill",
            height = "Fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = { rect { width = "Fill" }, pill, rect { width = "Fill" } },
        },
    },
    popup {
        id = "media_card",
        parent = "bar",
        anchor_rect = card_anchor,
        anchor = "Bottom",
        gravity = "Bottom",
        offset = { y = 6 },
        visible = computed({ card_open, player }, function(open, current) return open and current ~= nil end),
        on_dismiss = function() card_open:set(false) end,
        radius = 14,
        background = "#1e1e2e",
        border_width = 1,
        border_color = "#45475a",
        child = card,
    },
}

How it works

  • players is longest-running first; the map prefers one that is playing (mpris).
  • position is a snapshot, not polled. on_change stamps each new report with mantle.system.monotonic, and a computed adds the seconds since (system, derived signals).
  • The fill is a "NN%" width in a rounded, clipped track; on_drag on the track seeks on release (pointer, clip).
  • album_art_path is a local file or "", and an image with source = "" draws nothing over the placeholder rect (image).
  • The card is a grabbing popup anchored to the pill’s click rect; it also closes when the last player quits.

Variations

ChangeEdit
Always the first playerpick returns mpris and mpris.players[1]
Seek 10 s back and forwardTwo more buttons whose on_click calls seek_relative with the player’s id and -10000000 or 10000000
Show the app iconicon { name = current.desktop_entry } from the player’s desktop_entry
Hide browsersSkip players whose desktop_entry is "firefox" or "chromium" in pick
No popup, controls in the barPut the three control_buttons in the bar row and drop the popup

FAQ

Symptoms whose cause lives on another page: find the symptom, then follow the link. A trap that stays within one page is in that page’s Gotchas table.

First steps when something is wrong

StepCommandTells you
1mantle checkSyntax and top-level errors, with file and line. Node and layout errors as laid out with every capability nil, then with sample data (what check covers)
2mantle logEvaluation errors, layout errors, errors raised in callbacks, failed mantle calls and refused mantle set/toggle writes (output and logging)
3MANTLE_DUMP_LAYOUT=<id>@<output> mantle -vvvEvery visible node’s kind and rect on that surface after each pass (how do I)

Nothing shows

SymptomCauseFix
No surface appears at all after startingThe startup evaluation raised, so there is no scene. The error is in the logEvaluation, reload and generations
mantle check passes, but a surface is empty or lays out wrongcheck lays out on a 1920x1080 output with every capability nil, then with one sample value each, so a branch that needs a particular value, or a smaller output, went unchecked. The running shell reports those in mantle logWhat check covers, then the layout dump and the layout model
A node shows before its data arrivesThe map returns nil for visible, which counts as absent, and visible defaults to truesignals gotchas
Two bars on screenTwo shells are running, one per mantle startcli gotchas
The shell vanishes and comes back only after 30 sThe Renderer died three times within 60 s, so the next respawn waitsFix the error in mantle log (limits)

A save or a click does nothing

SymptomCauseFix
Saving a file leaves the old UI on screen, and mantle.rescue.is_rescue is trueThe reload failed: an evaluation or apply error keeps the previous scene, sets mantle.rescue and logs the error. The next reload that applies clears itFind out why a reload did nothing, error banner
After a broken save, mantle call says the action does not exist, timers stop and on_change goes quietA failed reload drops every action, timer, handler and idle threshold the last evaluation registeredEvaluation, reload and generations
An on_change, timer, process.run, palette or idle callback does nothingIts error, a blown CPU budget included, is a warning. Read mantle logOutput and logging
mantle.<cap>:<action>(...) returns nil and nothing changesActions are fire and forget; a wrong argument type or count is dropped with a log lineactions
A keybind running mantle set or mantle toggle does nothingIt was refused (an undeclared name, a bare toggle on a non-boolean). The compositor discards the error; mantle log keeps itcli gotchas
Saving a .json or an image beside shell.lua does not reloadOnly .lua and .frag changes reload; a byte-identical save and an unreadable directory (changes inside it will not reload) do not eitherEvaluation, reload and generations
An edit to fonts { ... } does nothingThe font chain is read when the Renderer startsRestart the shell (fonts)
A textfield shows no caret and takes no keysThe panel does not take keyboard focustext fields, panel

Values are wrong or stale

SymptomCauseFix
A map raises attempt to index a nil value at startup, or a capability reads nilEvery capability reads nil until its first push, for mantle check’s first pass, and for good when its backend is missingThe one rule, capabilities
A text never updatesIt holds a :get() snapshot, not the signalThe one rule
A setting is lost after the shell restartsNamed state lives in the Renderer and dies with itpersistent_table
A switched view keeps old state, or snaps in without its animationvisible = false freezes the subtree in place; two id-less views of the same kind are reusedSwitching views, nodes

Errors in the log

MessageCauseFix
surface 2 is a string, not a noderequire returned the module and its path into the surface listModules and require
exceeded the 5ms CPU budget for one evaluationA map, computed, handler or timer did too much workLimits and budgets
signal nesting exceeded its maximum depth of 32 levelsA derived chain reads itself or nests too deepErrors
a Signal resolved to another SignalA map returned a signalErrors
`margin.left` is a Signal handleA signal nested in a property table does not resolvesignals gotchas
`mantle` asked to write state ... and was refusedmantle set/toggle named an undeclared state, or wrote a value it refusesValues and arguments

Running processes

SymptomCauseFix
process.run prints nothing and exit_cb gets nilThe spawn failed, usually a command not on PATH. mantle log has the reasonprocess.run
A program runs twice after a saveA top-level process.detach launches again on every reloadsession_process, What survives a reload

Capabilities

A capability starts on the config’s first mantle.<name> read, so a config that never reads it runs no server, watcher or agent. The quoted log lines need -v or -vv.

SymptomCauseFix
A capability reads nil or stays inertIts backend is missing or started after the shell (requirements); sysinfo and updates wait for configure; or the config reads mantle.<name> only inside a callback that never ranStart the backend, then restart the shell: a reload does not retry. Call configure where the page says so. Read mantle.<name> at config top level
battery.present is falseUPower’s display device is not a present battery (desktop, or battery not detected), or UPower is not running (state "Unknown")Expected on desktops; otherwise start upower.service, then restart the shell; upower -d should list DisplayDevice
No tray iconsConfig never reads mantle.tray; another host (waybar, snixembed) owns org.kde.StatusNotifierWatcher; the app is XEmbed-only; or it registered at an unlisted path before the shell startedRead mantle.tray; stop the other host; restart the app so it registers again
Notifications not showingAnother daemon (mako, dunst, swaync) owns org.freedesktop.Notifications (another notification daemon already owns this name), or the config never reads mantle.notificationsStop and disable the other daemon, then restart the shell. busctl --user status org.freedesktop.Notifications names the owner
No notification soundDND or quiet on (critical still plays); the app is muted; the file is not Ogg Vorbis or 16-bit WAV, is over 4 MiB or 30 s, or lies outside the sound roots; sound-name with no tier sound registerednotifications backend
Polkit prompts not appearingConfig never reads mantle.polkit, so the agent never registers; another agent (polkit-gnome, hyprpolkitagent) registered first; $XDG_SESSION_ID unsetRead mantle.polkit; stop the other agent and restart the shell; start the session through logind
Polkit authentication always fails/run/polkit/agent-helper.socket is missing, so the helper cannot runCheck that the installed polkit provides that socket
Idle never firesA block-mode idle inhibitor is held (systemd-inhibit --list), a browser or player holds ScreenSaver, a Wayland surface inhibitor is up, or the config’s own inhibit is still held; or the compositor lacks ext_idle_notifier_v1mantle.idle.inhibited and inhibitors name the holder (empty who is the compositor)
Unlock refuses the right passwordPAM stack login in use and pam_nologin or pam_shells refusingInstall the mantle PAM stack (install)
Locked session with no lock screenThe Renderer died and its replacement could not retake the lock (could not take the session lock over)Switch VT and unlock through the compositor’s own mechanism
Keyboard layout switch does nothingNot niri or Hyprland; only one layout configured (layout_count 1); on Hyprland it sends switchxkblayout main <i> to the keyboard marked main, which likely fails on Hyprland 0.56+, whose socket parses LuaConfigure several layouts in the compositor; on Hyprland 0.56+, switch through a compositor keybind
Caps/Num Lock always falseNo readable /dev/input keyboard with LEDs and no sysfs LEDGive the user read access to the input device
brightness reads nilNo /sys/class/backlight device; external monitors are not coveredNone; brightness is backlight-only
Brightness writes ignoredSession not active (another VT), so logind refuses SetBrightnessSwitch back to the session
Pairing prompt never showsThe adapter is not visible and this shell did not start the pairing, or the device asks for a PIN or passkey entry (rejected)Make the adapter visible or pair from the shell

See also: runtime, cli, signals, processes, capabilities, glossary (rescue, hydration, generation).

Source: reload and rescue, apply, check, callback logging, log subsystems.

Glossary

The terms these pages use, each defined once. Engine-internal vocabulary (retained scene, dirty scope, capability roster) lives in CONTEXT.md.

Processes

TermMeaning
SupervisorThe long-lived mantle process. Owns capabilities, idle-notify, PAM, polkit, session processes, the file watcher and the control socket; spawns the Renderer and respawns it after a crash. See CLI.
RendererThe mantle-renderer process: the Lua VM, the scene, the Wayland client and painting. One per generation. See runtime.
GenerationOne Renderer process and its Lua VM. Only a Renderer replacement (after a crash) starts a new one; a reload does not.
Instance directory$XDG_RUNTIME_DIR/mantle/<pid>-<start ms>/, one per running mantle: control socket, log, lock file, icon spools. mantle list, log, set, toggle and call pick one. See which shell.
Check modemantle check: evaluates and lays out the config with no Wayland, no subprocesses and no state writes; it lays out once with every capability nil and once with a sample push each. See what check covers.
Session processA session_process program the Supervisor owns. Survives reloads and Renderer replacement; stopped at shutdown with its declared signal, then SIGKILL after 5 s. A process.run child, by contrast, dies at the next reload.

Reloads

TermMeaning
EvaluationOne run of shell.lua and the modules it requires, producing the surface list.
In-place reloadA re-evaluation in the same generation and VM after a saved .lua or .frag file or an output change, then one apply: the scene is reconciled, and surfaces whose fingerprint changed are destroyed or created. See runtime.
Surface fingerprintA declaration’s creation-time fields (panel: id, layer, anchor, monitor, namespace; other roles: id). A change rebuilds that surface; other edits update it live.
Evaluation-scoped registrationaction, on_change and idle-threshold callbacks, cleared before each evaluation because they close over its locals. The timers an evaluation arms go live only when its result applies. See what survives a reload.
RollbackA failed evaluation or apply keeps the previous scene and surfaces. A failed evaluation drops the timers, actions and change handlers it registered; a failed apply drops only its timers.
Rescuemantle.rescue, { is_rescue, error_log }: set by a failed evaluation, apply or live update, or a refused or lost session lock; cleared by the next reload that applies. A failed startup apply or live update also clears when a later pass applies.

Surfaces

TermMeaning
SurfaceA top-level declaration returned by shell.lua, with one role and one or more instances. See surfaces.
Surface rolepanel (layer-shell), window (xdg_toplevel), popup (xdg_popup) or lock (session lock).
Surface instanceOne mapped copy of a surface. Per-output panels and locks are keyed {id}@{output}; windows, popups and monitor = "Active" panels use the bare id.
Structural propertyA property read once per evaluation to make a structural decision, so it refuses a signal: any node’s id; a panel’s layer, anchor, monitor, namespace; a popup’s parent.
Lock surfaceThe lock declaration’s instance on one output, alive only while the session is locked.

Nodes and paint

TermMeaning
NodeAn element in a surface’s tree: row, text, button, list and the other kinds.
Node identityHow a node is matched across evaluations, scoped to its parent: sibling id or list key (which wins), else position among id-less siblings. An unmatched node is new and starts fresh.
Paint-only propertyA property whose change repaints without relayout (opacity, colours, radius, shadows, blurs, transforms, progress). Its tweens tick without a layout pass.
Shader nodeshader: a config .frag drawn as a node. Editing the file reloads.
Capture nodecapture: a live preview of an output.

Signals and state

TermMeaning
SignalA reactive value. Pass it to a property to keep that property live; :get() is a snapshot. See signals.
Derived signalA signal computed from others: :map, computed, delay, pulse. See derived signals.
Named stateA state(name, initial) signal, keyed by name. Survives reloads until a scalar initial changes; lost with the generation. mantle set and toggle write it.
Input signalAn engine-written, name-keyed signal: hover, hover_rect, scroll, geometry. Survives reloads like named state.
Change handlerAn on_change(fn) callback, run with the current and previous payload on each capability, rescue or screens push.
Persistent tablepersistent_table: a JSON file read as signals and written one key at a time.
Idle thresholdAn inactivity duration with idle and resume callbacks, registered on mantle.idle and cancellable by its handle.
Idle inhibitA hold that stops idle actions, shared by the config and org.freedesktop.ScreenSaver clients.

Animation

TermMeaning
TweenA property moving from its displayed value to a newly resolved one, advanced per frame without Lua. See animation.
SpringA tween driven by stiffness and damping instead of duration and easing; it keeps its velocity when the target moves.
KeyframesA property walked through a list of values, once or looped, driven by elapsed time rather than a resolved target.
Leaving nodeA removed child, painted at its last rect with no layout or input while its animate.exit runs.
Cross-dissolveAn image blending from the picture it holds to a newly decoded one over its transition.
Transition shadertransition.shader: a config fragment shader that draws an image’s cross-dissolve.

Capabilities

TermMeaning
CapabilityA backend owning one slice of platform state and its actions, read in Lua as mantle.<name>. See capabilities.
Capability startThe first mantle.<name> read (or secure_submit naming it) starts the backend for the Supervisor’s lifetime. mantle.idle starts on its first method call; lock and the polkit controller exist from boot.
SnapshotA capability’s full state, pushed on change. An equal payload is not pushed again, except for tray and notifications, whose icon files change in place.
PushA capability sending a new snapshot. Every property reading that capability re-resolves.
HydrationThe Supervisor replaying its last snapshots to a new generation. Before its first snapshot a capability reads nil.
ActionA capability method such as mantle.audio:set_volume(0.5), fire and forget; or action(name, fn), which exposes Lua to mantle call.
Mantle namespaceThe mantle table: capabilities, plus the Renderer’s screens, rescue, version and config_dir.
Secure submitA secret text field sending its buffer straight to a capability action, never through Lua. See secure fields.
Toplevel windowAnother application’s window, listed by the windows capability. Not the window surface role.
Do-not-disturbA notifications toggle that silences non-critical sounds. It does not filter the feed.

Changelog

User-facing changes to the Lua API and the mantle CLI. The format follows Keep a Changelog. Mantle is pre-release and unversioned, so everything since the rename from Obelisk sits under Unreleased.

Unreleased

Added

  • x86_64 release builds: a .deb, an .rpm and a tarball, built in Ubuntu 26.04 by the release workflow on a v* tag (install).
  • This site: guide, one page per node, surface and capability, cookbook, glossary. Every Lua example on it runs as a test, and the lua-meta stubs link each entry to its page.
  • Nodes: shader (a config fragment shader) and capture (a live output preview, with live capped at a frame rate and region to crop).
  • Paint: gradient backgrounds, mask, shadow_* with shadow_mode, content_blur, backdrop_blur, image.source_blur, clip = "None" and z (paint).
  • palette.quantize for an image’s dominant colours.
  • mantle.screens entries gain position, model, description, fractional_scale and orientation; mantle.screens and mantle.rescue gain on_change.
  • monitor = "Active" lets the compositor pick a panel’s output.
  • A plain textfield has a caret, a selection, grapheme-correct deletion, key repeat and Ctrl bindings; Left/Right reach on_navigate when the caret cannot move; Escape in a secure_submit field calls on_cancel.
  • Numbered verbosity (-v, -vv, -vvv), quiet by default; --profile prints its own reports (CLI).
  • --profile reports each capability’s snapshot pushes sent and deduped beside its size (CLI).
  • A bare mantle call lists the running config’s actions, and a bare mantle set or mantle toggle its states with their values (CLI).
  • mantle.system:configure({ interval = 60 }) pushes the clock every minute on the minute instead of every second, or never with 0; default 1 (system).
  • mantle stop [--pid | -c] stops a running shell and waits for it to exit; mantle.pid is the shell’s own pid, so a config can stop itself (CLI).
  • mantle.updates runs on Fedora through dnf (dnf5 or dnf4) and on Debian and Ubuntu through apt-get; package_manager is "dnf" or "apt" (updates).

Changed

  • mantle.updates checks through pacman, pacman-conf, curl and vercmp instead of linking libalpm, so building no longer needs libalpm and the binary starts off Arch. packages leaves out IgnorePkg entries, and installed_size rounds to the two decimals pacman prints (updates).
  • A geometry rect an animation moved schedules one pass when the animation settles, so a property bound to it catches up instead of waiting for an unrelated write.
  • A state:set of the value the state already holds re-resolves nothing, and a :map or computed re-resolves its readers only when its result changes. Scalars and plain-data tables compare by value (signals).
  • mantle.system pushes land on the wall-clock second after a resume or clock step too, rather than mid-second until a restart.
  • mantle.keyboard pushes once when it starts, so it is no longer nil without niri or Hyprland until a lock key or the backlight changes.
  • mantle.sysinfo reads on the wall-clock second, first at the next one after configure rather than one interval later, so its pushes can share mantle.system’s layout pass.
  • mantle.applications watches its directories and rescans after a change, so installs and removals appear without refresh (applications).
  • A reload kills every process.run child and calls its exit_cb(nil) before the new evaluation runs, so a top-level follower restarts instead of doubling (processes).
  • Lua’s warn now logs at warn level like log.warn, on by default; before, it printed nothing.
  • A text.content run’s kind must be "text", as a notification text span’s is; any other value fails the pass instead of being ignored.
  • lua-meta node, surface and global types come from the Rust types the engine parses with: a surface’s child declares the Bound it always took, a panel’s width/height the [0, 8192] it always enforced. A signal-bound popup parent fails every pass, not only the first.
  • A callback (on_*) that is not a function (write cond and fn or nil), a submit or autofocus that is not a boolean, and a nil or false entry in children, which dropped every child after it, fail the pass instead of being ignored.
  • A table property (padding, anchor, shadow_offset, min_size, anchor_rect, an animate entry, a text run, secure_submit, …) and the session_process, persistent_table and palette.quantize option tables refuse a key they do not take.
  • secure_submit takes only lock/authenticate, polkit/authenticate and network/connect.
  • Two surfaces with one id, or two different scalar seeds for one state name in one evaluation, fail the evaluation.
  • A list without source builds no items instead of raising.
  • A list keeps its items while nothing its last build read has changed, instead of calling itemfn for every item on every pass: a write elsewhere on its surface costs about half as much. itemfn, key and the maps inside items must read time and mutable data through signals, or they show what they read at the last build (when items rebuild).
  • A node reads each children or child table once and keeps it while it holds that table, so a pass’s property reads outside list builds cost about 40% less. A node table or children array changed in place is no longer seen; bind a signal or :set a new table (gotchas).
  • A node keeps the properties it resolved until a signal they read is written, instead of running every getter on its surface on every write: a clock tick re-reads the clock’s node, not the bar. A map, computed or function child that reads os.date() with no time, os.time(), a mutable variable or a file keeps its last answer until a signal it read changes. Derive time from mantle.system: mantle.system:map(function(s) return s and os.date("%H:%M", s.time) or "" end) (what a node reads again).
  • A write one list item read, such as its hover, builds that item alone instead of every item: 4.5 ms instead of 16 ms a pass on 500 rows (when items rebuild).
  • A wheel over a container whose scroll signal no getter, map or list build reads moves its children without a layout pass: 0.06 ms instead of 2.4 ms on 500 rows.
  • Breaking: each capability action is a method, and :invoke is gone: mantle.audio:set_volume(0.5) replaces mantle.audio:invoke("set_volume", 0.5). The editor stubs type each action’s own arguments, so mantle.audio:set_muted(0.5) is flagged. An unknown name raises, listing the actions the capability has, as does any action on a capability with none; a . call in place of : raises instead of sending a wrong argument (actions).
  • A state field read off a capability raises did you mean mantle.audio:get().volume?, and a misspelled action names the one it is close to.
  • A misspelled node or surface property raises “did you mean content?” instead of listing every property the kind takes; the list stays for a key close to none.
  • mantle set and mantle toggle wait for the shell and exit 1 with its reason when it refuses the write (CLI).
  • A tween advances on its own surface’s frame callbacks, so a surface animates at its output’s refresh rate instead of the fastest animating output’s (animation).
  • mantle check lays the config out on stand-in outputs and fails on a layout error, and says when the stubs mantle init wrote are out of date.
  • A pass that fails names every broken node, one per line, in mantle check, the log and mantle.rescue, instead of stopping at the first. It lists 20, then counts the rest (what check covers). A children entry that is not a node reads children[1]: expected a node table, counted from 0 like the rest of the path.
  • mantle check lays out a second time after one sample push per capability, every list one entry long, so an error in a list itemfn or a data-only branch fails the check; each error names its pass (what check covers).
  • Errors name files relative to the config directory (widgets/bar.lua:4, not a path Lua cut to ...2b41-2949-.../bar.lua:4), including required modules. A layout error’s path names the line that built each node (row[0] (shell.lua:7) > ...), a failing :map or computed the line that created it (signal created at shell.lua:3), and tracebacks drop the engine’s own frames. mantle check prints the config directory once instead of shell.lua: shell.lua failed to evaluate (CLI).
  • The .luarc.json from mantle init warns on unused locals and on the type-check, unbalanced, strict and global diagnostic groups in every file.
  • mantle.rescue is set when a reload, or a live update, fails to apply, and clears only when a scene applies. Errors raised in callbacks, failed spawns and failed mantle calls are warnings, and a missing icon or undecodable image warns once per name.
  • The editor stubs flag a misspelled property or table key and a percent that is not a whole "0%" to "100%", and type children as taking a signal, as the engine does.
  • The editor stubs type a capability’s :get() as T?, so an unguarded read of a field warns; mantle.screens and mantle.rescue stay non-nil. An animate key the node does not take (or z), and an unknown key in an animate entry, { steps = n }, session_process, persistent_table or palette.quantize options, are flagged. Each node’s animate is typed by its own alias (RectAnimations, TextAnimations, …); a wrapper that passes animate through types it with that alias. A capability or scroll(...) handle passes where a Signal or a scroll property is declared.
  • translate, scale, rotate and origin tweens repaint without relayout.
  • Hover callbacks fire on pointer entry.
  • A nil or non-signal computed dependency raises naming its index, computed() dependency 2 is nil; ...; before, a hole dropped every dependency after it. A named key in the list raises too.
  • timer’s ms is a number: timer(1.5, fn) runs, and timer(-1, fn) or a NaN raises timer(-1) is outside 1..=86400000 milliseconds, not mlua’s error converting Lua integer to u64.
  • mantle.idle:register_threshold outside 1..=4294967 seconds raises naming the range; before, a negative one got mlua’s conversion error and a huge one was clamped. cancel_threshold(-1) is a no-op like any unknown handle.
  • A nil in the returned surface list raises surface 2 is nil: ...; before, it dropped every surface after it. A named key in the list (return { bar, cfg = x }) raises instead of being ignored.
  • An equal capability snapshot is not pushed again, except tray and notifications.
  • A bad layer, corner_shape, keyboard_interactivity, popup anchor or gravity, constraint_adjustment entry or easing name fails with one wording that lists every choice: expected one of `Background`, `Bottom`, `Top`, `Overlay`, got ….

Roadmap

Ordering is intent, not a schedule. The docs hold what exists, DECISIONS.md why. Rust owns platform connections, validation, secrets, resource lifetimes, input and rendering; Lua owns composition, appearance and orchestration. A feature one config lacks is not an engine gap.

Next

Defects or missing pieces a config cannot work around.

ItemWhy / what’s leftADR
expected_revision is uncheckedThe socket drops a frame from another generation, but nothing reads the revision it claims, so it is no authorization guarantee. Settle stale-revision semantics before anything relies on it—
Keyboard focus and accessibilityOnly textfield holds focus; Tab reaches the config as on_navigate("tab"). Needs focusable controls, keyboard activation and an accessibility tree—
Blocking dofile / loadfileThe base library keeps both, and they read files on the Wayland thread outside the CPU budget, against ADR-0048’s intent. Remove them or route them through require’s resolver0048
HiDPIPaint scale is fixed at 1.0, so every surface on a scaled output is upscaled and soft. Needs set_buffer_scale (or fractional-scale plus viewporter) with a matching EGL resize and glyph raster scale—
Silent failuresAn invalid stop_signal is dropped with a log line; mantle set on a state a reload removed still passes. Each should reach the author—
Hyprland layout switchkeyboard/layout.rs sends switchxkblayout, which Hyprland 0.56’s Lua socket likely rejects; the other Hyprland writes already use hl.dsp.*—
Multi-prompt PAMThe worker relays every masked prompt, but LockState and secure_submit carry one password, answered to every prompt. Fingerprint, 2FA and expired passwords fail. Echo-on prompts stay refused0241

Later

Wanted, but each needs a consumer or a decision first.

ItemWhy / what’s leftADR
GreeterMantle as a greetd client under cage or sway. Needs multi-prompt PAM and a session-launch command—
DrawingGradients, mask, shadows and blurs exist; no config-facing paths, and no node masks another. Add the smallest set a real component needs; SVG covers static artwork, but its <text> draws nothing0254–0256
Large listsEvery item up to limit is laid out on every pass and built again whenever anything it read changes, visible or not. Virtualization would need key to be mandatory, which cannot be enforced0191, 0219
Output actionswindows has five actions; screens are read-only. Pick the actions, then settle niri/Hyprland differences and revert0119, 0247
Service depthMPRIS lacks stop, shuffle, repeat, rate and volume; audio has no per-channel levels or peak metering; UPower reads only DisplayDevice; network tracks only the first Wi-Fi device; Bluetooth pairing refuses PIN and passkey entry. Extend for concrete controls—
External IPCset/toggle answer only applied or refused; call returns only what the action returns. No generic state read or subscription0197
Process controlStart, stream and signal exist. No child stdin, cwd or env0175, 0188
Panel root sizingA panel spanning an axis sizes the surface but not its root node, while window and lock roots fill theirs (forced_root_size). Decide whether panel roots fill too—
Move transitionsA sibling closing a gap snaps. Needs the solver’s old and new rects per sibling—
Text field editingNo undo, paste or IME; the secure field edits only at its end. On RTL or mixed lines a click lands one cluster off and the caret does not move inside a ligature0236
Animated WebP and APNGOnly GIF animates; the others draw their first frame. AnimationDecoder covers both0233
LocalizationNo translation API; desktop entry Name, GenericName and Keywords are read unlocalized0112
Wayland and input extrasNo shortcut inhibition, per-surface idle inhibition, touch gestures, cross-app drag and drop, pointer buttons past left, right and middle, or a click position inside a button. logind and ScreenSaver inhibition work—
Window capturecapture takes an output. A window source would take windows ids0247, 0248
Native I/ONo HTTP, sockets, watched file contents or json.encode; JSON storage and folder watching exist. Native only for a measured latency or volume need—
KDE ConnectNo device or plugin model. A capability or a streaming helper, not unrestricted D-Bus—
Derived nested stub shapesGradient, GradientStop, Mask, Easing, Animation, Animations and Exit are still hand-written in nodes.lua’s header, since no struct holds their keys, so a parser change can leave them stale. Derive them with lua_shape! like Edges and Transition once their parsers fill one—
Dynamic topologyA reload rebuilds only what changed. Revisit only if dynamic windows need a different lifetime0216

Won’t do

ItemInsteadADR
Weather, currency or geolocation capabilitiesprocess.run with an HTTP CLI, then json.decode—
Native FFTStream Cava output into state and draw it—
Clipboard capabilityprocess.detach("wl-copy", { text }): a selection needs a process that stays alive to serve it0188
Video encodingA recorder under session_process, driven from config0175
Global input captureAn external input backend, streamed in—
Wallpaper capabilityA Background panel, an image with async/retain/transition, files for the folder, persistent_table for the choice0055
Rust widgets (sliders, calendars, launchers, settings)Lua components over existing nodes—
Framework settings schemapersistent_table with config-declared files—
Per-panel IPC commandsmantle set, toggle and call—
Deferred surface loaderWayland objects are created when shown; the 5 ms cap guards one signal resolve, not a whole evaluation0157
Shaders over a subtree or as a persistent filterimage.transition and the input-less shader node keep a stable contract. Fixed blur or shadow is content_blur and shadow_*0184, 0253, 0254
Display manager (PAM as root, sessions, seats)greetd; see Greeter—
X11 or i3The target is a Wayland session shell—

Documenting

How to write and check a page in this book. The book source is docs/, built by mdBook. The code is the only source of truth: verify every behavioural claim in renderer/src, supervisor/src or shared/src before writing it. DECISIONS.md records why things were built; it is history and may be stale, so cite an ADR only as a “why” pointer after the code confirms it.

Commands

CommandDoes
just docsServes the book at http://localhost:3000, rebuilt on save
just bookBuilds the book as CI publishes it, then checks every link and anchor
just stubsRegenerates every lua-meta/*.lua, every docs/capabilities/<name>.md and the generated property tables
cargo test -p renderer doc_examplesRuns every Lua block in docs/ and checks every screenshot (part of just check)
just shotsRe-renders the screenshots that changed, deletes orphans, lists what moved
just rustdocRustdoc for the crates, warnings as errors. Not this book

Lua examples are tests

Every fenced block whose info string is lua or starts lua, runs in cargo test. The test is every_lua_block_in_the_docs_evaluates_and_lays_out in renderer/src/check.rs. Each block goes through the same evaluation and both layout passes as mantle check: no Wayland, no subprocesses, every capability nil and then one sample push each, one 704x396 output named DP-1. The test fails on a Lua error, a check error or a layout error, and names the block by file:line. Blocks inside > quotes count too.

Info stringThe block
luaRuns and lays out. It returns surfaces as shell.lua does, or returns one node, which the test mounts in a panel with 24 px of padding
lua,shotA lua block the book shows a screenshot of (screenshots)
lua,fragmentOnly parses. For a snippet that needs context the page has not given, like a require of another file
lua,must-failMust fail to evaluate or lay out. For showing a mistake
lua,no-checkSkipped. Put the reason in an HTML comment on the line above

Prefer lua over fragment, and fragment over no-check. mdBook turns the comma into a second CSS class, so every tag still highlights as Lua.

A widget example needs no surface around it:

button {
    padding = 6,
    on_click = function() print("clicked") end,
    children = { text { content = "Click" } },
}

Evaluation accepts this width, but layout refuses it:

rect { width = "Wide", height = 10 }

A module file that only makes sense beside another one:

local clock = require("widgets.clock")
return panel { id = "bar", layer = "Top", child = clock }
return { require("bar") }  -- fails
local bar = require("bar")
return { bar }             -- works

Screenshots

A lua,shot block also renders, headless over EGL, and must match its committed image, docs/images/<section>/<page>-<n>.png, the page’s n-th shot. tools/book_links.py puts the image under the block on the site, so a page never links it. Tag the example a reader wants to see; a block has to return a node or surfaces.

PartRule
ImageEvery visible surface stacked top to bottom 8 px apart, each popup where its anchor_rect, anchor, gravity, offset and SlideX put it on its parent, at scale 1 over Catppuccin Mocha crust #11111b. Cropped to the painted pixels plus 16 px; a shot that paints nothing fails
StillDrawn with every tween finished
<!-- shot: frames=0..400/20 --> on the line aboveAn animated PNG: one frame per time, in ms after the last tween started. frames=0,50,120 lists them
docs/images/<section>/<page>.fakes.luaRuns before each shot on the page. fakes = { battery = {...} } is pushed as each capability’s first push, on_change included. __pointer = { surface = "bar", x = 40, y = 12 } rests the pointer there after the first layout, in that surface’s logical px (surface defaults to the first), so hover(), its rect and on_hover answer as for a real pointer. A __after function runs after the first layout, then the shot lays out again: that is how an OSD shows or a card leaves. A popup a click opens needs its anchor state set to the rect that click would pass
<!-- file: shaders/glow.frag --> on the line above any fenced blockThat block is written to that path in each shot’s config directory, mantle.config_dir, so a page’s shots load the file the page shows
PinnedFonts, icons and images come from renderer/fixtures/shots, os.time() is 2026-09-24 12:45 UTC and os.date reads UTC, $USER is user

A quoted absolute path whose file name is in renderer/fixtures/shots/images points at that file. An icon missing from fixtures/shots/icons fails the test with its name; copy it in from Adwaita and note it in fixtures/shots/NOTICE.

FailureFix
<name>.png differs by up to N per channelThe render moved. Compare <name>.new.png beside it. Intended: just shots. Not: fix the regression
is missing or a different sizeA new shot, or its size changed: just shots, then look at the image
no lua,shot block draws this imageA shot was removed or renumbered: just shots deletes it
An APNG passes on one GPU and differs by 100+ on anotherA moving edge lands on exactly half a pixel in some frame, and drivers round that differently. Pick frame times that miss it, such as 0..210/30 over 0..200/20

A render within 2 per channel of the committed image passes and is not rewritten, so another GPU driver never shows up in git. EGL is required: without it the test fails.

Capability pages

docs/capabilities/<name>.md is generated by supervisor/src/stubs.rs from the Rust payload types. Never edit it: write prose in docs/capabilities/intro/<name>.md and run just stubs.

Part of the intro fileLands on the page
Above <!-- reference -->After the one-line blurb, before the generated State and Actions tables
Below <!-- reference -->After the tables: How do I…, Gotchas, See also
No markerAll of it above the tables

cargo test fails while a generated page differs from what just stubs would write. intro/ stays out of SUMMARY.md.

Property tables

The property table on each docs/nodes/<kind>.md and docs/surfaces/<role>.md, the common one on nodes/index.md and the box one on guide/paint.md are generated by renderer/src/lua/nodes/stubs.rs from the typed fields in renderer/src/lua/nodes/properties.rs, the same fields lua-meta/nodes.lua and surfaces.lua come from and the engine parses through. just stubs rewrites what sits between two markers inside the page and leaves the rest alone:

<!-- Generated from renderer/src/lua/nodes/properties.rs by `just stubs`: edit the table there. -->
<!-- End of the generated table. -->
ColumnFrom the field
TypeIts Rust type’s LuaCATS, as the stub declares it; range after it
Defaultabsent
BehaviourThe Book: paragraph of its /// block, which may link relative to the page, else the rest of that block without (ADR-NNNN)

A page links to the table’s anchors, not to rows. Prose about a property goes around the table, or into its row if every page showing it should say it.

A table a property takes, such as Edges or Transition, is declared with lua_shape! on the struct its parser reads it as (renderer/src/lua/luacats.rs): its /// blocks are what lua-meta says about the shape and each key. The struct fixes the key names and field types; which keys are optional and how Lua spells one (as Option<T>, as S) are marked by hand beside them. One struct has one shape, so anchor_rect, whose x and y may be left out, is declared as region’s Rect with every key required. The book describes a shape in prose on the page.

Page shape

One or two sentences on what the page is for, a small complete example, reference tables, a How do I… table (Task | Answer), a Gotchas table (Trap | Fix), See also, then a Source: line of code links. Each fact lives on one page; link to it elsewhere. Links to code go out of docs/ as relative paths (../../renderer/src/... from docs/<dir>/x.md); the book turns them into GitHub links and fails the build on a missing target.