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. Your shell.lua returns surfaces such as bars, windows and popups. Each surface holds nodes for layout and drawing. Signals keep their properties up to date as capabilities report changes.

The video is a Mantle shell: demo/director types each save and records the result. just demo records it again; The demo covers changing it.

Your first shell

Install Mantle, then create a config directory:

mantle init

Open ~/.config/mantle/shell.lua and replace its contents with this bar:

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true, left = true, right = true },
    width = "fill",
    height = 32,
    exclusive_zone = true,
    background = "#1e1e2e",
    child = row {
        width = "fill",
        height = "fill",
        padding = { left = 12, right = 12 },
        children = {
            text { content = "Mantle", font_weight = 700, foreground = "#89b4fa", align_v = "center" },
            rect { width = "fill" },
            text {
                content = mantle.clock:map(function(clock)
                    return os.date("%H:%M", clock.time)
                end, "--:--"),
                font_weight = 700,
                foreground = "#cdd6f4",
                align_v = "center",
            },
        },
    },
}

PartWhat it does
panelPlaces a 32 px bar at the top of each output and reserves that space from windows
row and the "fill" rectPut the label on the left and push the clock to the right
mantle.clock:map(...)Updates the clock when the clock capability pushes time. Until the first push the signal holds the second argument, and the callback does not run

Check the config before starting a shell, then run it:

mantle check
mantle -d
mantle log -f

check evaluates and lays out the config without Wayland. It catches Lua and layout errors, but cannot test every value, callback or compositor response (what check covers). -d starts the shell detached; log -f follows its output. Change "Mantle" in shell.lua and save it. The bar reloads in place. If a save fails, the previous bar stays visible and the error goes to the log (reload behavior).

mantle init also writes .luarc.json so lua-language-server can complete the API and flag type mistakes (editor setup).

Where to go next

To buildStart with
A clock that changes on clickClock bar, then signals
An app launcher opened by a keyApp launcher, then CLI keybinds
A bar with workspace buttonsWorkspaces
A popup, window or lock screenSurfaces
Your own widget layoutNodes and paint
Help with a shell that shows nothingFAQ

Every cookbook recipe is a complete shell.lua you can run on its own. The glossary defines terms used throughout the book.

Source: init, starter, check, watcher, reload.

Installation

Install both Mantle binaries, create a config, check it, and start the shell. The introduction provides a first bar to put in shell.lua.

Install

Releases carry the .deb, .rpm and tarball. mantle-bin packages each release; mantle-git and source builds follow main.

RouteWhat to do
ArchInstall mantle-bin from the AUR for the latest release, or mantle-git to build main. Both install the PAM stack
Ubuntu, FedoraInstall a release package with sudo apt install ./mantle_<version>_amd64.deb or sudo dnf install ./mantle-<version>-1.x86_64.rpm. These packages install under /usr, with library dependencies and the PAM stack. They require Ubuntu 26.04 or Fedora 44 or later because the binaries need glibc 2.43
Release tarballsudo tar -xzf mantle-<version>-x86_64-linux.tar.gz -C / installs under /usr/local and leaves /etc alone, so an upgrade keeps your edits: copy the system files from /usr/local/share/mantle/ once. Install the required libraries yourself. It needs glibc 2.43
From sourcecargo build --workspace --release, then put target/release/mantle and target/release/mantle-renderer together on PATH, for example in ~/.local/bin. See build requirements
From a checkout, for developmentjust run [config] builds and runs a config (default share/starter). just swap builds an optimised pair into $CARGO_HOME/bin and restarts the running shell

The mantle executable starts mantle-renderer from its own directory. Keep the two binaries from the same build together (binaries).

Set up a config

mantle init
mantle check

init writes shell.lua and .luarc.json under ~/.config/mantle by default. It keeps files that already exist; mantle init --force replaces them. Use -c DIR to work in another directory (config selection). Then edit shell.lua or copy the first bar into it. Run mantle check after edits to catch Lua and layout errors before starting the shell.

The .luarc.json points lua-language-server at the lua-meta/ stubs. 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 and updates changed stubs. mantle check reports when those stubs need refreshing with mantle init. The editor config enables type, unbalanced, strict, global and unused-local warnings.

Run the shell

From a terminal inside your Wayland session:

mantle -d
mantle log -f

-d returns after starting the shell and prints its pid. log -f follows its output. Run mantle without -d to keep it in the foreground, or mantle stop to end a detached shell. The CLI explains logs, multiple running shells and command flags.

To start it with your compositor session, add one of these lines to that compositor’s config:

# niri
spawn-at-startup "mantle"
# Hyprland
exec-once = mantle

Start Mantle inside the compositor session so workspaces, windows and keyboard can find niri or Hyprland through that session’s environment.

Requirements

FeatureNeeds
SurfacesA Wayland compositor with wlr-layer-shell-v1; ext-session-lock-v1 for lock
window, popupxdg-shell; these surfaces are skipped when absent
captureext-image-copy-capture-v1, else wlr-screencopy-v1
compositor_blur = trueext-background-effect-v1; ignored when absent
Fontsfontconfig (fc-match)
Editor completionlua-language-server

A capability starts when the config first reads mantle.<name>. You need only the backends for capabilities your config uses. Each capability page says what happens when its backend is missing.

CapabilityBackend
notifications, traySession bus; no other notification daemon or tray host holding the name
mprisSession bus
appearancexdg-desktop-portal on the session bus; defaults without it
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, Hyprland, sway or mango for layouts
workspacesniri, Hyprland, sway or mango
windowsniri, Hyprland, sway or mango, else wlr-foreign-toplevel-management-v1
idleext-idle-notify-v1, logind
lockext-session-lock-v1, logind, the Mantle PAM stack (system files)
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, nvidia, i915 or xe for GPU temperature; lsblk for disks; nvtop, nvidia-smi or DRM sysfs for GPU telemetry; /proc/net/dev for network throughput
updatespacman and its curl, 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

clock, folders, storage and processes need nothing beyond the paths and programs the config names.

Build from source

A source build needs Rust 1.99 or later, a C compiler and pkg-config for vendored Lua, clang for PipeWire’s bindings, and development files for the linked libraries:

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

Use rustup where the distro’s cargo is older. Mantle is developed and run on Arch. The Fedora and Debian package 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.

System files

Packages install the files they need. The tarball puts them in /usr/local/share/mantle/, and a source build has them in packaging/. On Debian and Ubuntu, install the -debian variant of each PAM stack under the same name: they include common-* where the others include system-auth.

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

Gotchas

TrapFix
cargo run -p supervisor runs a stale Renderer and reports the mismatch as a config errorUse just run, which builds both binaries
Another notification daemon, tray host or polkit agent is runningStop it: Mantle takes those names only when 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 10`.
action("volume.up", function(step)
    local audio = mantle.audio:get()
    for _, sink in ipairs(audio and audio.sinks or {}) do
        if sink.active and sink.volume then
            local volume = math.max(0, math.min(100, sink.volume + (step or 5)))
            mantle.audio:set_volume(sink.id, volume)
            return string.format("%d%%", math.floor(volume + 0.5))
        end
    end
    error("no default output yet")
end)

return panel {
    id = "launcher",
    layer = "overlay",
    keyboard_interactivity = "on_demand",
    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; volume.up stands for an action your config defines. 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 10 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 input <surface> <verb> ...Sends synthetic pointer or keyboard input to one surface of the running config, for testing. See Injecting input
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 and the live task count 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, input, 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.

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, call, stopRequired. 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, call, inputThat 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 Lua CPU 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 a name the config does not declare, 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.

A write that changes the value runs the state’s on_change handlers before the shell repaints, the same as a write from Lua. They run after the command has its answer, so a raising handler is a warning in mantle log, not a refusal.

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 four times with the real layout code, on one 1920x1080 output plus one per output name a panel pins:

PassCapabilities readCatches
before capability datanil, as at start before the first pushA :get() 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 1, every boolean true, every enum its first variantTypos and bad properties in a list each or a branch that only shows with data
with alternate sample dataThe same push with every boolean false and every enum its last variantA branch taken only on false or a later variant, such as unmuted audio or a launcher that drops no_display entries
with empty sample listsThe first push with every list and map emptyAn empty-state branch, such as “No notifications”

In the sample passes, reading a field a capability’s state does not have raises, naming the nearest one: mantle.audio:get().sinks[1] has no `volum`: did you mean `volume`?. A running shell reads nil there, so a field removed by an engine update fails the check instead of hiding. An on_change handler runs on each push, and its error fails the pass; a running shell only logs it.

It prints <path>: ok, N surface(s) and one <role> <id> line per surface, preceded by anything the config printed, then one warning: line per outer shadow the surface cuts off by 4 px or more, naming the side and the room it needs. A warning leaves the exit status at 0, and a side where a panel meets the output edge is skipped. An error prints as <config dir>: <error> and exits 1; files in it are named relative to the config directory. A layout error leads with the line that built the failing node and its kind (text[1], with its index among children), then gives the path down to its parent and the surface instance. An error that only some passes hit ends with those passes on a line of its own, (<pass>, <pass>); one every pass hits, such as a typo, names none. 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 (a lone child has no index) 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:2: in main chunk
/home/me/.config/mantle: shell.lua:2: text[1]: no property `contnet`; did you mean `content`? (at panel (shell.lua:7) > row (shell.lua:7) on `bar@DP-1`)
/home/me/.config/mantle: shell.lua:9: text: invalid value for `content`: Signal getter on a `text` node failed: signal created at shell.lua:3: shell.lua:4: attempt to index a number value (local 'n') (at panel (shell.lua:9) on `bar@DP-1`)
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. A capability on_change does run on each sample push, and its error fails the check. At run time a raising handler logs ... (defined at file:line), and identical repeats fold into raised again (N times)
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 (false for an application’s no_display) 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 eachs 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.

Under an error line that starts with file.lua:N:, check prints that source line, trimmed, after |.

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

Injecting input

mantle input <surface> <verb> ... feeds events to one surface through the code real input runs: hit testing, hover, on_press, on_click, on_drag, on_wheel, focus and textfield editing. It never reaches another client, moves the real pointer or cursor, or takes the compositor’s keyboard focus.

VerbSends
move X YPointer motion to X, Y: logical pixels in the surface
press [button], release [button]A button edge at the last position; button is left (default), right or middle. Needs a move, click, drag or wheel on that surface first
leaveThe pointer leaving the surface: hover is written off, a held on_drag ends and an armed click is dropped
click X Y [button]move, press, release
drag X1 Y1 X2 Y2 [button]move to the start, press, move to the end, release: an on_drag runs "start", "move" and "end"
wheel X Y DYDY notches at X, Y, positive scrolling down. on_wheel gets the negated count, as for a real wheel
key <combo>One key press and its release: Return, Down, a, or with modifiers, ctrl+a, shift+Tab. Only ctrl and shift are modifiers; names are xkb keysyms in any case. on_key sees alt and super as not held
type <text>The text as one commit, as an input method or a paste delivers it, so max_length and on_change apply. Fails when no textfield in that surface is focused, or the text is empty or holds a control character

<surface> is the id the config declares. A surface with an instance per output is named id@output; the bare id works only while one instance is shown. A hidden surface is not found. Click a textfield to focus it, then type or key; each command is one request, and the scene re-resolves between requests, not between the steps of one.

A click or key can change which field or control that surface has focused. Focus on another surface is kept, unless the injection focuses something of its own: one field holds focus at a time. Pointer state stays where injected input left it, hover and pointer(name) included, until a leave or real pointer motion; a press with no release stays held the same way.

Refused, with an error on stderr and exit 1:

  • a lock surface;
  • a press or release over a secure_submit field or a submits_secure = true node, and any key or type while a secure_submit field holds focus, including the real keyboard’s;
  • ctrl+c and ctrl+v, which need the compositor’s clipboard;
  • an unknown, ambiguous or hidden surface, a pointer button with no position, a bad combo or a modifier other than ctrl and shift.

Injected presses carry no compositor serial. A popup with grab = true, or toplevel(id):move(), :resize() and :show_menu() called from the press, warn in mantle log and do nothing; the rest of the press runs.

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, input 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"'
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 Lua error in the config clears actions until an evaluation succeeds. Fix the config and save (runtime)
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/input client, state writes, input injection, check, init, log, levels.

Working with coding agents

A coding agent can edit a Mantle config, check it, watch it reload and drive the result, all from a shell, without a screenshot or a human at the keyboard. Every step below is a command from the CLI; none is agent-specific.

The loop

StepCommandTouches the session?
1. Discovermantle call, mantle set, mantle listReads only
2. ExtendEdit shell.luaNo
3. Validatemantle checkNo: no Wayland, no GPU
4. ApplySave; mantle log -fYes: live reload
5. Drivemantle call, toggle, set, inputYes

1. Discover

With a shell running:

$ mantle list
PID     UPTIME  DIR                    CONFIG
4242    3m      4242-1760000000000     /home/me/.config/mantle
$ mantle call
volume.up
$ mantle set
launcher_open	false
modal	""

mantle call with no name prints each action the config declares, one per line, sorted. mantle set (or toggle) with no name prints each state as name<TAB>JSON; strings are quoted, so the line can be passed back as mantle set <name> <value>. mantle list shows running shells, oldest first. After a failed reload, both lists describe what the shell still runs (reload rules).

Run mantle init once. It writes .luarc.json and, unless packaged stubs exist, the type stubs to $XDG_DATA_HOME/mantle/lua-meta, so lua-language-server shows the agent the exact API names, property names and capability fields instead of guesses.

2. Extend

Declare a state for something the UI shows and an action for something the shell does. Both are reachable from the command line, so the agent can test them before wiring a key:

local count = state("clicks", 0)

action("clicks.bump", function(by)
    local n = count:get() + (tonumber(by) or 1)
    count:set(n)
    log.info("clicks is now", n)
    return n
end)

return panel {
    id = "counter",
    layer = "top",
    anchor = { top = true, left = true },
    width = 120,
    height = 32,
    child = text { content = count:map(function(n) return "clicks: " .. n end) },
}

mantle call clicks.bump 2 prints the return value: a string bare, anything else as JSON, nothing for nil (call output). A raise prints `name` failed: <reason> on stderr and exits 1.

The Lua logging functions are log.error, log.warn, log.info and log.debug (log). They print at every level, as HH:MM:SS LEVEL config: message with the arguments tab-joined. print also lands in mantle log, unstamped.

3. Validate

$ mantle check
/home/me/.config/mantle/shell.lua: ok, 1 surface(s)
  panel   counter

mantle check runs the same evaluation and layout code as a start, with no Wayland, no GPU and no subprocesses, so it is safe while the real shell runs. It lays every surface out in four passes (capabilities nil, sample data, alternate sample data, empty lists), which catches the missing-data and empty-list branches an agent rarely reads back. It exits 0 when clean and 1 on an error, printing <config dir>: <error>, with file:line and, for a mistyped property, a suggestion:

/home/me/.config/mantle: shell.lua:16: text: no property `contnet`; did you mean `content`? (at panel (shell.lua:15) on `counter@DP-1`)

Details: What check covers. -c DIR checks another directory.

4. Apply

Saving any .lua or .frag file under the config directory reloads the running shell 200 ms after the last write. Other extensions do not. Read the result:

$ mantle log -f
12:01:07 NOTICE renderer/socket: shell reloaded

A reload that raises or whose scene is rejected is logged as an error, and the previous scene stays on screen (failure table). It also drops the old evaluation’s actions, so mantle call after a failure may not find a name that worked a minute ago. The fix is to correct the file and save again.

5. Drive

GoalCommand
Run an actionmantle call clicks.bump 3
Flip or set a statemantle toggle launcher_open, mantle set modal settings
Clickmantle input counter click 40 16
Drag, scrollmantle input bar drag 10 10 90 10, mantle input bar wheel 40 16 -3
Keyboardmantle input search key ctrl+a, mantle input search type "hello"

mantle input <SURFACE> <VERB> goes through the same hit testing, hover, on_click, on_drag and textfield code as a real pointer, in the surface’s logical pixels, and never moves the real cursor or takes compositor focus. <SURFACE> is the declared id, or id@output when one id has an instance per output. Lock surfaces and secure_submit fields refuse input. All verbs: Injecting input.

With several shells running, --pid <pid> (from mantle list) picks one, and -c <dir> picks the newest on that config. They cannot be combined. The selection rules cover the default.

Instructions to paste

For CLAUDE.md, AGENTS.md or the equivalent:

This repo is a Mantle shell config (Lua). Docs: https://anasgets111.github.io/mantle/
- Run `mantle init` once; read the stubs it points `.luarc.json` at for exact API names before guessing.
- Before saving a change to shell.lua, run `mantle check`; it must exit 0.
- After saving, run `mantle log` and look for "shell reloaded"; any error line means the old scene is still on screen.
- Add `action("name", fn)` for behavior you want to test. Run it with `mantle call name [args]`.
- Use `mantle set`/`toggle` and `mantle input <surface> <verb>` to exercise the UI. Do not run `mantle stop` or start a second shell.
- Use log.info/log.warn, not print, for debug output.

Source: CLI, check, log.

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, output = "active"
WorkspacesPer-monitor workspace pills on every supported compositor, 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.clock, show_date }, function(clock, date)
    return os.date(date and "%A %d %B" or "%H:%M", clock.time)
end, "--:--")

local tooltip_text = mantle.clock:map(function(clock)
    return os.date("%A, %d %B %Y", clock.time)
end, "")

local clock = rect {
    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_zone = 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.clock pushes the time once a second, and os.date formats it (clock).
  • 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 onlyoutput = "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",
    fully_charged = "Fully charged",
    pending_charge = "Plugged in, not charging",
    empty = "Empty",
}

local function duration(seconds)
    local hours, minutes = seconds // 3600, seconds % 3600 // 60
    return hours > 0 and string.format("%d h %02d min", hours, minutes) or string.format("%d min", minutes)
end

-- Adwaita-style names: battery-level-0 to battery-level-100 in steps of 10.
local glyph = mantle.battery:map(function(battery)
    if not battery.present then
        return "battery-missing-symbolic"
    end
    local step = math.floor(battery.percent / 10 + 0.5) * 10
    if battery.state == "fully_charged" 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, "battery-missing-symbolic")

local colour = mantle.battery:map(function(battery)
    local low = battery.present and battery.percent <= LOW and battery.state == "discharging"
    return low and "#f38ba8" or "#cdd6f4"
end, "#cdd6f4")

local details = mantle.battery:map(function(battery)
    if 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, "No battery")

-- 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.present end, false),
    children = {
        icon { name = glyph, size = 16, foreground = colour, align_v = "center" },
        text {
            content = mantle.battery:map(function(battery)
                return 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_zone = 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 reads present = false on a desktop, and a map gives before for the time before its first push (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 node with an on_click that 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.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.present and battery.state ~= "fully_charged", with before false

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. `n` makes every write differ: `:set` skips a table equal to the held one.
local osd = state("volume_osd", { volume = 0, muted = false, n = 0 })

local function default_sink(audio)
    for _, sink in ipairs(audio.sinks) do
        if sink.active then
            return sink
        end
    end
end

mantle.audio:on_change(function(audio, previous)
    local sink, before = default_sink(audio), previous and default_sink(previous)
    if before == nil or sink == nil or sink.id ~= before.id or before.volume == nil or sink.volume == nil then
        return -- the first push, or a new default, is learned state, not a change
    end
    if sink.volume ~= before.volume or sink.muted ~= before.muted then
        osd:set({ volume = sink.volume, muted = sink.muted, n = osd:get().n + 1 })
    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, 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",
        output = "active",
        anchor = { bottom = true }, -- no left/right: centred, width measured
        margin = { bottom = 68 },
        padding = { bottom = 12 }, -- room for the card's 12 px slide, which the surface would clip
        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 = "out_cubic", 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 follows the active sink. It skips the first push, a new default and a sink’s first volume report, which are learned state (on_change, audio).
  • It writes a fresh table, counted by n, 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).
  • output = "active" shows it on the output the compositor picks, usually the focused one, and a bottom-only anchor centres it (panel output, 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, muted = false } into the same state
Show above 100%Drop math.min from percent, and size the fill math.floor(entry.volume / 1.5) .. "%": the track is then 150%
Top of the screenanchor = { top = true }, margin = { top = 80 } and from = { y = -12 }
Every monitorRemove output = "active"
Longer on screenpulse(osd, 3000) and pulse(osd, 3200)

Workspaces

A bar on every monitor listing that monitor’s workspaces, on every supported compositor. 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[math.max(1, math.min(#output.workspaces, index + step))]
            if target then
                mantle.workspaces:focus(target.id)
            end
            return
        end
    end
end

local function strip(name)
    local wheel = 0 -- partial notches from a touchpad or high-resolution wheel
    return rect {
        align_v = "center",
        -- Wheel up is positive; step towards lower numbers, once per whole notch.
        on_wheel = function(_, steps)
            wheel = wheel + steps
            local notches = math.modf(wheel) -- whole notches; the fraction carries over
            if notches == 0 then return end
            wheel = wheel - notches
            focus_step(name, -math.tointeger(notches))
        end,
        children = {
            list {
                direction = "horizontal",
                spacing = 4,
                align_v = "center",
                items = 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.number or workspace.name), -- draw number, send id
                            active = workspace.id == output.active_workspace,
                            populated = workspace.populated,
                        }
                    end
                    return items
                end),
                key = function(item) return tostring(item.id) end,
                each = function(item)
                    return rect {
                        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 = "out_cubic" }, 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",
    items = mantle.workspaces:map(function(workspaces)
        return workspaces.special
    end),
    key = function(special) return special.name end,
    each = function(special)
        local short = special.name:gsub("^special:?", "") -- "special:scratch" -> "scratch"
        return rect {
            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_zone = 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 number (or name) and clicks send id: ids are opaque strings (workspaces gotchas).
  • on_wheel on the outer rect 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 = tostring(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.number), 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.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.active and not lock.unlocking end, false)

local time = mantle.clock:map(function(clock) return os.date("%H:%M", clock.time) end, "")
local date = mantle.clock:map(function(clock) return os.date("%A, %d %B", clock.time) end, "")

local hint = mantle.lock:map(function(lock)
    local last = lock.messages[#lock.messages]
    if lock.authenticating then
        return "Checking…"
    elseif last then
        return last.text
    elseif lock.error ~= "" then
        return lock.attempts > 1 and string.format("Wrong password (%d tries)", lock.attempts) or "Wrong password"
    end
    return lock.prompt and lock.prompt.text or "Type your password and press Enter"
end, "")

local failed = mantle.lock:map(function(lock) return lock.error ~= "" and not lock.authenticating end, false)

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",
                            masked = mantle.lock:map(function(lock) return not (lock.prompt and lock.prompt.echo) end, true),
                            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).
  • PAM starts when the session locks. The hint shows its latest messages line (a faillock countdown, say) or else its prompt text, and error, attempts and authenticating drive the rest of the hint and the red border.
  • masked follows prompt.echo, so a two-factor code shows as typed while a password stays masked. Either way it never reaches Lua (lock capability).
  • 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 clickable node with submits_secure = true beside the field sends it like Enter (pointer)
Fingerprint hintIn hint, right after the lock.messages branch, return the last of lock.fingerprint.messages when lock.fingerprint is not nil, so a wrong password’s error does not hide it. A match unlocks like a password (lock capability)
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)
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 rect {
        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 = "out_cubic" } },
        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_zone = true,
        child = row {
            width = "fill",
            height = "fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = {
                rect { width = "fill" },
                rect {
                    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",
        output = "active",
        anchor = { top = true, bottom = true, left = true, right = true },
        width = "fill",
        height = "fill",
        exclusive_zone = "ignore",
        visible = open,
        reset_on_close = { pending }, -- a reopened menu never starts armed
        child = rect {
            width = "fill",
            height = "fill",
            background = "#11111bcc",
            opacity = 1, -- `from` needs the property set
            animate = { opacity = { duration = 150, from = 0 } },
            children = {
                rect { 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).
  • reset_on_close = { pending } disarms the menu whenever it closes, whether a click, a run action or mantle toggle closed it (reset on close).
  • One named state, pending, holds the armed action; a second click on the same button runs it (named state).
  • A full-size rect 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.feed) 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] = rect {
            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 row {
        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
        scale = 1,
        animate = {
            opacity = { duration = 150, from = 0 },
            -- Grows in place: a slide would leave the surface and be clipped at its edge.
            scale = { duration = 200, easing = "out_cubic", from = 0.95 },
            move = { duration = 200, easing = "out_cubic" }, -- older cards slide down for a new one
            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,
        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" },
                            rect {
                                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,
                    items = cards,
                    key = function(item) return tostring(item.id) end,
                    each = 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 while animate.move slides the older ones down; a dismissed card fades out through animate.exit, except the last one, which leaves with its hidden panel (identity, move, 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 clickable rect inside the clickable card: 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; the hold lapses after 300 s of continuous hover (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.clock:map(function(clock) return math.floor((clock.time - item.timestamp) / 60) .. " min ago" 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, Escape clears the query, then closes on a second press, and 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)
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.entries) do
        local score, start = score_of(entry, needle)
        if not entry.no_display and 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 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_key = function(key)
                if key.name == "Down" or key.name == "Tab" then move(1) end
                if key.name == "Up" or key.name == "ISO_Left_Tab" 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,
    items = matches,
    key = function(entry) return entry.id end,
    each = function(entry)
        return row {
            width = "fill",
            padding = 8,
            radius = 8,
            -- Per row, so a move reruns this and builds no row.
            background = computed({ matches, selected }, function(entries, current)
                local picked = entries[current]
                return picked and picked.id == entry.id and "#45475a" or "#00000000"
            end),
            on_click = function() launch(entry) end,
            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",
        output = "active",
        anchor = { top = true, bottom = true, left = true, right = true },
        width = "fill",
        height = "fill",
        exclusive_zone = "ignore",
        visible = open,
        reset_on_close = { query, selected, results_scroll }, -- however it closed, it reopens fresh
        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.
                rect { 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 includes desktop entries used for window icon lookup; the launcher omits entries with no_display = true. It follows installs and removals; launch takes an entry’s 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 (derived signals).
  • Each row derives its own highlight from matches and selected. An arrow key reruns those 50 small functions and builds no row; folding the selection into items would call each for all 50 on every keypress (when items rebuild).
  • The textfield owns the typed text and reports it through on_change; on_key gets the arrow and Tab keys the field does not use (textfield, key handlers).
  • scroll(name):reveal(index) keeps the selected row in view inside the max_height list (scroll, list).
  • reset_on_close returns the query, the selection and the scroll to their start whenever the launcher closes, including by mantle toggle (reset on close).
  • "exclusive" hands the panel the keyboard when it maps, and autofocus gives it to the field (keyboard focus).
  • A full-size transparent rect under the card closes it on an outside click (close an overlay).

Variations

ChangeEdit
Pointer focus on Hyprland"on_demand" 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 (or opens the menu of a menu-only item), 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)
    mantle.tray:menu_will_show(item.id, 0)
end

local function artwork(item)
    local name = item.icon_name
    local path = item.icon_path
    if item.status == "needs_attention" 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",
    items = mantle.tray:map(function(tray)
        local shown = {}
        for _, item in ipairs(tray.items) 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,
    each = function(item)
        local wheel = 0 -- partial notches from a touchpad or high-resolution wheel
        return rect {
            padding = 4,
            radius = 6,
            on_click = function(rect, which)
                local cx = math.floor(rect.x + rect.width / 2)
                local cy = math.floor(rect.y + rect.height)
                if item.menu and (which == "right" or which == "left" and item.item_is_menu) then
                    open_menu(item, rect)
                elseif which == "right" then
                    mantle.tray:context_menu(item.id, cx, cy)
                elseif which == "left" then
                    mantle.tray:activate(item.id, cx, cy)
                elseif which == "middle" then
                    mantle.tray:secondary_activate(item.id, cx, cy)
                end
            end,
            on_wheel = function(_, steps)
                wheel = wheel + steps
                local notches = math.modf(wheel) -- whole notches; the fraction carries over
                if notches == 0 then return end
                wheel = wheel - notches
                mantle.tray:scroll(item.id, math.tointeger(notches), "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.items) 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 row {
        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,
        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_zone = 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 = "bottom_left",
        offset = { y = 4 },
        constraint_adjustment = { "slide_x", "flip_y" },
        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"),
            items = menu_rows,
            -- The same entry at two depths is two rows.
            key = function(row_data) return row_data.entry.id .. ":" .. row_data.depth end,
            each = 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).
  • menu_will_show notifies apps when a menu opens (id 0) or a submenu expands (entry.id), letting apps that build items lazily populate them.
  • Right-clicking an item with no DBusMenu calls context_menu at the button’s bottom centre so native menus still appear. The coordinates are the bar’s own: a layer surface cannot learn its screen position, so they match the screen only for a top bar on the output at the screen’s origin.

Variations

ChangeEdit
Show hidden ("passive") itemsReturn tray.items 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 = "bottom_right"
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.clock.monotonic` seconds.
local position_mark = state("media_position_mark", { key = "", at = 0 })
local selected_player = state("media_selected_player", "")

-- Prefer a playing player. When all are paused, keep the selected one while it remains registered.
local function pick(mpris)
    local players = mpris.players
    for _, candidate in ipairs(players) do
        if candidate.play_state == "playing" then
            return candidate
        end
    end
    for _, candidate in ipairs(players) do
        if candidate.id == selected_player:get() then
            return candidate
        end
    end
    return players[1]
end

local player = mantle.mpris:map(pick)

mantle.mpris:on_change(function(mpris)
    local players = mpris and mpris.players or {}
    if #players == 0 then
        card_open:set(false) -- so the next player to appear does not reopen the card
    end
    for _, candidate in ipairs(players) do
        if candidate.play_state == "playing" then
            selected_player:set(candidate.id)
            return
        end
    end
    for _, candidate in ipairs(players) do
        if candidate.id == selected_player:get() then
            return
        end
    end
    selected_player:set(players[1] and players[1].id or "")
end)

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)
    if current then
        local key = report_key(current)
        if key ~= position_mark:get().key then
            position_mark:set({ key = key, at = mantle.clock:now() })
        end
    end
end)

-- Live position in microseconds, or nil when unknown. `monotonic` is as of the last tick, up to
-- `interval` behind the `now()` stamp, so the difference is clamped at 0.
local position = computed({ player, position_mark, mantle.clock }, function(current, mark, clock)
    if current == nil or current.position == nil then
        return nil
    end
    local elapsed = 0
    if current.play_state == "playing" and mark.key == report_key(current) then
        elapsed = math.max(0, clock.monotonic - mark.at) * 1000000
    end
    local rate = current.rate or 1
    if rate <= 0 then rate = 1 end
    local now = current.position + elapsed * rate
    return current.length 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 rect {
        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 wheel = 0 -- partial notches from a touchpad or high-resolution wheel
local pill = row {
    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)
        wheel = wheel + steps
        local notches = math.modf(wheel) -- whole notches; the fraction carries over
        wheel = wheel - notches
        for _ = 1, math.abs(notches) do
            control(notches > 0 and "previous" or "next")
        end
    end,
    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 = rect {
    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 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 not current.length or current.length <= 0 or now == nil 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 and clock(now) or "" end), font_size = 11, foreground = "#a6adc8" },
                rect { width = "fill" },
                text {
                    content = player:map(function(current) return current and current.length 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_zone = 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 in registration order; the map prefers one that is playing and keeps it selected while paused (mpris).
  • position is a snapshot, not polled. on_change stamps each new report with mantle.clock:now(), and a computed adds the seconds since, from the ticking monotonic field, clamped at 0 (clock, 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 an existing local path or "". A remote cover arrives only as art_url; fetch it into a cache file with curl through process.run and draw that path. 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
Remote covers (Spotify)On an art_url change, process.run("curl", { "-sfLo", file, url }, function() end, function(code) if code == 0 then cover:set(file) end end) with file under $XDG_CACHE_HOME; draw cover when album_art_path is ""
No popup, controls in the barPut the three control_buttons in the bar row and drop the popup

Component libraries

Mantle ships nodes, not widgets. These libraries build full widget sets from them in plain Lua on the public API, each with a demo app that shows every component. Both are MIT-licensed and typed for LuaLS, so the editor completes and checks every opts table.

LibraryLookModuleNeeds
mantle-glassLiquid GlassglassMantle built from main after 0.4.0
mantle-materialMaterial 3 Expressivem3Mantle 0.4.0

To use one, copy or symlink its module folder (glass/ or m3/) into your config directory and require it. To run its demo, clone the repository and run mantle -c . inside it.

mantle-glass

A Liquid Glass component library: window chrome (traffic lights, toolbars, sidebar, inspector, tabs), controls, text fields, pickers, tables and outlines, menus, popovers, alerts, sheets, a menu bar and a Dock. Its glass materials refract and follow the desktop’s dark mode, accent, contrast and reduced-motion settings.

fonts { "Inter", "lucide", "Noto Sans" }
local glass = require("glass")
glass.core.window = "app" -- the app window's id, before building controls

return {
    glass.app_window {
        id = "app",
        title = "My app",
        child = glass.button("save", { label = "Save", kind = "default" }),
    },
}

mantle-material

A Material 3 Expressive component library: the full M3 catalogue (actions, communication, containment, navigation, selection and text inputs), dynamic colour from one seed, the Expressive motion springs, and adaptive layouts: window size classes, navigation that becomes a bar, rail or drawer by width, and list-detail panes.

fonts { "Roboto", "Noto Sans" }
local m3 = require("m3")
m3.theme.seed:set("#6750A4") -- optional

return {
    m3.app_window {
        id = "app",
        title = "My app",
        child = m3.button("save", { label = "Save", on_click = function() m3.overlay.notify("Saved") end }),
    },
}

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.clock:map(function(clock)
        return os.date("%H:%M", clock.time)
    end, ""), -- "" until the first push
    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, error, getmetatable, ipairs, load, next, pairs, pcall, print, rawequal, rawget, rawlen, rawset, require, select, setmetatable, tonumber, tostring, type, warn, xpcall, _G, _VERSION ("Lua 5.4"). warn logs like log.warn, on by default; warn("@off") / warn("@on") toggle itdofile, loadfile
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 (the Lua file searcher only), 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. Use require for Lua files in the config directory. dofile and loadfile are unavailable because they read files on the render thread. To read other files, use process.run or persistent_table for JSON. 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, list, textfieldNode constructorsnodes
state, computed, delay, pulse, geometrySignal constructorssignals
hover, hover_rect, scroll, focus_targetInput signals and the textfield focus handleinput
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 a property value is invalidThe node uses that property’s default and everything else comes up. mantle.rescue is set until the value is fixed. The error is logged
Startup evaluation succeeds but the scene rejects part of it (a getter raises, a malformed children)Each surface it rejects paints nothing; the others come up. mantle.rescue is set. The error is logged
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 whole reload is refused, even if one surface broke: 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 makes a property value invalidThe node uses that property’s default and the update applies. mantle.rescue is set until the value is fixed. Logged as an error, once until the message changes
A live update fails later (a pushed value breaks a map)Each surface it breaks keeps its last applied tree; the others, an error banner included, keep updating. mantle.rescue is set until a pass applies every surface. Logged as an error, once until the message changes
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

An error from an engine function, a method on an engine object (signal:reveal, mantle.idle:register_threshold) or a capability action keeps its leading file:line: through pcall. Only an unknown capability field omits it there; the logged and mantle check forms carry it.

A failed reload keeps the old scene, but not the old evaluation’s actions, timers, on_change handlers or idle thresholds (what survives):

FailureRegistered afterwards
Evaluation (a Lua error)Nothing
ApplyThe new evaluation’s actions, handlers and thresholds; 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.is_rescue end),
    background = "#f38ba8ff",
    padding = 8,
    child = text {
        content = rescue:map(function(state) return state.error_log 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 on_exit(nil) runs before the new evaluation; no on_line followsKilled with its process groupKilled
process.detach programUnaffectedUnaffectedUnaffected
timer, intervalCleared. The new evaluation’s timers start when its result is appliedClearedGone
action, mantle.<cap>:on_change, state:on_change, a derived :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 limit50 ms of thread CPU timeEach :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer or interval callback. Nested reads share the outermost deadlineThe call raises exceeded the 50ms CPU limit for one evaluation; a runaway loop?. pcall inside the callback does not hide it
Frame target2.5 ms of thread CPU timeThe same evaluationsNothing fails. The log gets one warning naming the evaluation’s CPU time and the computed where most of its Lua instructions ran, or that it ran outside any signal. Each site warns once until the next reload, and a handler with no site shares one warning per reload. A slow getter costs frames
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 eachs and function child buildersThe pass fails and every surface keeps its last applied tree
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 is invalid, as below
Numeric properties[0, 8192] logical px for most sizes and each padding edge. [-8192, 8192] for translate, rotate, shader progress, shadow offset and spread. opacity and origin [0, 1], scale [0, 64], font_size [1, 8192]. margin, spacing and icon size are unbounded (a tween still clamps them)Node and surface properties (nodes)A startup or live pass uses the property’s default; a reload and mantle check fail. Each names the property
Array length10,000children of one node, items of one list (items, and limit is clamped to it), runs in one text contentThe pass fails; a startup or live pass gives a content its default instead
delay, pulse duration[1, 60000] msdelay(signal, ms), pulse(signal, ms) (signals)Raises at the call
timer and interval delay[1, 86400000] ms (one day)timer(ms, fn), interval(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_request, 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, well under the 2.5 ms frame target: one check, one index, one format.
local battery_label = mantle.battery:map(function(battery)
    if not battery.present then
        return "" -- 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_request, 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 missing the 2.5 ms frame targetBuild 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 50ms CPU limitA runaway loop, or work far past the frame target. Move the heavy work to the top level or to process.run. The map should only index and format
The log warns that an evaluation went over the 2.5 ms frame targetThe named signal is slow but still works. Index and format inside it, and move the rest out
dofile or loadfile is nilUse require for Lua modules; read other files through process.run or persistent_table for JSON
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 evaluation clears actions, timers and handlers; a failed apply keeps the new actions. 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 minute = state("minute", 45)
local clock = minute:map(function(value) return string.format("12:%02d", value) end)

local function reading(label, value)
  return column { spacing = 2, children = {
    text { content = label, font_size = 12, foreground = "#a6adc8" },
    text { content = value, font_size = 20, font_weight = 700, foreground = "#cdd6f4" },
  } }
end

return row {
  spacing = 20,
  padding = 14,
  radius = 12,
  background = "#1e1e2e",
  children = {
    rect {
      padding = { left = 12, right = 12, top = 8, bottom = 8 },
      radius = 8,
      align_v = "center",
      background = "#89b4fa",
      on_click = function() minute:set(minute:get() + 1) end,
      children = { text { content = "Next minute", font_weight = 700, foreground = "#1e1e2e" } },
    },
    reading("Live", clock),
    reading("Snapshot", string.format("12:%02d", minute:get())),
  },
}

Click Next minute to update only the Live column. The screenshot captures that first click.

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 }Each signal is read again once it is written, as at the top level; nil drops a key but is refused as an array entry. Not inside children, a list’s items, a table with a metatable, or a table a signal returned

A capability (mantle.<name>, see capabilities) is a signal too. When a signal resolves to nil, its property counts as absent and takes the property’s default.

For values your config writes, start with named state. To transform or combine live values, use derived signals. The full API and error messages are in the reference.

Capabilities before their first push

A capability reads nil until its first snapshot arrives (hydration), and in mantle check’s first pass. lock and polkit push their idle state at startup; after a restart while locked, lock stays nil until the lock is taken again. :map(fn, before?) and computed(deps, fn, before?) skip fn and hold before until every capability dependency has pushed. Set before to what a guard would have returned ({}, 0, "", false); without it the signal reads nil. :get() and on_change’s previous still see nil.

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.

local open = state("details_open", false)

column { children = {
  rect {
    on_click = function() open:set(not open:get()) end,
    children = { text { content = "Details" } },
  },
  text { content = "More information", visible = open },
} }

The click writes open; visible = open reads it again. visible = open:get() would take only a snapshot.

RuleDetail
IdentityOne name, one signal. hover, pointer, scroll, geometry, elided, settled and image_status 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, as do a table seed and a scalar one
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
Surface closesKeeps its value. List it in the surface’s reset_on_close to return it to initial instead

on_change: react to a write

s:on_change(fn) runs fn(current, previous) after a write changes the state, whoever wrote it: a click, a timer, another handler, or mantle set/mantle toggle from outside the shell. Use it for an effect that must follow the state however it changed, such as another surface’s state or a command.

local mode = state("mode", "balanced")
local last = state("last_mode", "balanced")

-- `mantle set mode '"performance"'` from a keybind runs this too.
mode:on_change(function(_, previous) last:set(previous) end)

return rect {
  on_click = function() mode:set(last:get()) end,
  children = { text { content = last:map(function(m) return "Back to " .. m end) } },
}
RuleDetail
WhenAfter the write’s callback returns, before the next layout pass, so the pass paints what the handler wrote
Change onlyA write of the held value runs nothing, nor does a change undone before the handlers run. Declaring the state, and a reload re-seeding it, run nothing
ChainsA handler may write state, which runs that state’s handlers next. After 8 rounds the rest are dropped with a logged error, so two handlers undoing each other stop
FailureEach handler runs under the CPU budget. A raise is logged; the value stays written and the other handlers still run
ReloadHandlers are dropped before each evaluation, which registers them again. Several per state are allowed, and none can be removed: a call inside a callback adds another handler each time it runs, until the next reload
KindsState, capabilities, and a :map, computed or persistent_table key, which runs fn when a source’s write changes its value; a raise in its function is logged and the last value kept. nil on delay, pulse, hover, pointer, scroll, geometry, elided, settled and image_status

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.

Derived signals

Use :map(fn) to turn one signal’s value into another value. Use computed({ a, b }, fn) when the result needs several signals. Both return a signal you can bind to a property. Their functions should only calculate a value: write state or run commands from on_click, an on_change or a timer. The functions run under the CPU budget.

A derived colour:

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

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.clock, show_seconds }, function(system, seconds)
  return os.date(seconds and "%H:%M:%S" or "%H:%M", system.time)
end, "")

rect {
  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

rect {
  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) },
} }

elided: read text truncation

Bind elided(name) as a text node’s elided property. It starts false; after layout it is true when elide removes content or max_lines drops wrapped lines, including without an ellipsis. Wrapping alone, box or ancestor clipping, scrolling and occlusion do not count.

The result follows the shaped text, styled runs, font metrics and available width. Content changes, resizing and layout animations recompute it. A changed result schedules one follow-up pass over its readers, sharing geometry’s limit of one follow-up. Failed layouts keep the previous result. The property itself writes the signal and does not count as a reader.

Names persist across reloads. If several texts or output instances bind the same name, it is true when any visible binding is truncated. Hidden, leaving and removed bindings do not contribute; with no visible binding it becomes false.

Keep the expander visible while expanded so that restoring all lines does not remove the collapse button:

local clipped = elided("body")
local expanded = state("body_expanded", false)

return column { width = 240, children = {
    text { width = "fill", wrap = "word", elide = "end", elided = clipped,
        max_lines = expanded:map(function(open) return open and 0 or 2 end),
        content = "A longer message can wrap across several lines. Expand it to read the rest, then collapse it again." },
    rect {
        visible = computed({ expanded, clipped }, function(open, cut) return open or cut end),
        on_click = function() expanded:set(not expanded:get()) end,
        children = { text { content = expanded:map(function(open) return open and "Less" or "More" end) } },
    },
} }

image_status: read an image’s load

Bind image_status(name) as an image’s status. Two shown images on one name in one surface instance fail the pass, naming both. Across instances, such as a surface shown on several outputs, the name follows the first shown image by instance id (bar@DP-1 before bar@HDMI-A-1) whose state is known, else the first one. The value follows that image’s current source:

stateWhen
"empty"source is "", and before the first pass
"loading"The image’s new source has not finished decoding, including while retain or transition still draws the old picture. Paint starts the decode, so an image clipped or scrolled out of view stays here
"ready"Decoded. width and height are the file’s own size in pixels, absent if unreadable. When a cached thumbnail stands in, the size comes from reading the file’s header, a whole-file scan for a JPEG, paid only by an image that binds status
"failed"The file is missing or does not decode

An inline image (async = false) decodes in the frame that draws it, so "loading" lasts until that frame and moves to "ready" or "failed". A hidden image keeps its last status. Each change schedules one follow-up pass over the signal’s readers, as geometry does.

Fade a picture in once it has decoded:

local load = image_status("cover")

return image {
    width = 160, height = 90, async = true, status = load,
    source = "/usr/share/backgrounds/a.jpg",
    opacity = load:map(function(s) return s.state == "ready" and 1 or 0 end),
    animate = { opacity = 200 },
}

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 text’s elided result changing, or a node’s settled flippingOne follow-up pass over the instances that read it
An image’s image_status changingOne 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

Readers of a :map or computed result update only when the result changes. Scalars and plain data tables compare by value. A table holding a function, signal or metatable counts as changed on every run; comparison also stops after 256 table entries. For example, an HH:MM label mapped from a per-second clock push updates once a minute. Within one pass, several properties reading the same derived signal calculate it once.

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, each, 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.clock:map(function(s) return os.date("%H:%M", s.time) 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 items✓: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.

Reference

ExpressionReturnsContract
sig:get()valueThe current value, read once. nil before a capability’s first push (why)
sig:map(fn)signalfn(value), run again when read, at most once per layout pass. 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)
sig:scroll_to(px), sig:scroll_by(px)nothingscroll signals only; moves to an offset, or by a distance from where it is headed (input)
sig:on_change(fn)nothingState, :map, computed, persistent_table keys and capabilities; fn(current, previous) after each change (state, capabilities)
cap:<action>(...)nothingCapabilities only (capabilities)
computed({ a, b, ... }, fn, before?)signalfn(a_value, b_value, ...): the values in list order, not the signals. Signals and capabilities are read live; a string, number, boolean or table is a constant passed to fn unchanged. Until every capability among them has pushed, fn is skipped and the signal holds before (details). A nil, function, userdata or 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
pointer(name){ x, y } or nil signalBind it as a node’s pointer. The pointer’s position from that node’s top-left corner while it is over the node, nil otherwise (input)
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
elided(name)boolean signalBind it as a text node’s elided. Reports content removed by elide or max_lines; false until measured
settled(name)boolean signalBind it as any node’s settled. false while that node has an animate tween or move running; true at rest (animation)
image_status(name){ state, width?, height? } signalBind it as an image’s status. Its source’s load: "empty", "loading", "ready" with the file’s size, or "failed" (details)

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, :scroll_to and :scroll_by work 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
Elisionelided(name)Text fitting
Settledsettled(name)Layout, tween ticks and scroll runs
Image statusimage_status(name)Layout and paint
Hoverhover(name), hover_rect(name) (input)The pointer
Pointerpointer(name) (input)The pointer, while read
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 nilThat computed list entry is nil, often a misspelled variable
computed() dependencies: keyThe computed list has a named key; list the signals in fn’s order
delay: source must be a Signal or a `mantle` capability, got integer 5 / the same for pulseThe first argument is not a signal or capability
delay: ms must be within [1, 60000] / pulse: ms 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, pointer, scroll, geometry, elided, settled or image_status signal
attempt to call a nil value (method 'on_change'):on_change on a delay, pulse, hover, pointer, scroll, geometry, elided, settled or image_status signal, which has none
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 / index must be 1 or more: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 50ms CPU limit for one evaluationA map or computed body ran away (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 table a signal returned (see gotchas)

How do I…

TaskAnswer
Open a dropdown under a buttonDismissal
Switch tabsSwitching views with ids
Keep a toggle across shell restartsNamed state is lost with the Renderer; use persistent_table (scripting)
Run a side effect when a capability or a state changeson_change (state, capabilities), never a map

Derive from two capabilities

List every source in computed; before here is "..." (why).

local status = computed({ mantle.network, mantle.audio }, function(network, audio)
  local net = network.connected and "online" or "offline"
  local sink = audio.sinks[1]
  for _, each in ipairs(audio.sinks) do
    if each.active then sink = each end
  end
  local sound = not sink and "no output"
    or sink.muted and "muted" or string.format("%d%%", math.floor(sink.volume or 0))
  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
:get() errors with attempt to index a nil value at startupThe capability has not pushed yet. Guard there, or use :map/computed, which skip fn until the first push (details)
visible = cap:map(function(c) return c.on end) shows the node before hydrationThe signal holds nil until the first push, and visible defaults to true; pass before: cap:map(fn, false)
`margin.left` is a Signal handleThe signal sits where it is not read: in a table a signal returned, under a metatable, or 8 tables deep. Return plain values from the map, or move the signal up
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, output, 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, or a table and a scalar. Often a component’s name (a slider’s held drag) shares it. Declare it in one module and require that, or rename one
delay on a capability never updates, or pulse fires on every pushPayloads are tables, which never compare ==. Delay or pulse a scalar derived with :map
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.clock’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; one with the bare id id for output = "active"
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_zone = true,
    child = function(output)
        return row {
            width = "fill",
            height = "fill",
            padding = { left = 8, right = 8 },
            background = "#1e1e2e",
            children = {
                rect {
                    align_v = "center",
                    padding = { left = 10, right = 10, top = 3, bottom = 3 },
                    radius = 10,
                    background = "#313244",
                    children = { text { content = output, font_weight = 700, foreground = "#89b4fa" } },
                },
            },
        }
    end,
}

return { bar }

Properties every role takes

Every surface takes id (required), one child node and, except lock, visible; each role’s page has their table. The root is itself a box node, so it also takes the common and box node properties, except those that place a node in a parent (below): 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.

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 size: they cap a content-sized or spanned panel, a popup, and a window or lock root
marginA panel’s offset from its anchored edges. Refused on the other roles
align_h, align_vRefused: 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, output, 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, output, 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 output = "active" panel has no output name and refuses a function child. Example: per-output wallpaper.

Reset on close

Named state keeps its value while a surface is hidden, so a reopened menu shows what it showed last. List the state a panel, window or popup owns in reset_on_close to start it fresh instead: when the surface stops being shown, each state goes back to its declared initial and each scroll to the top.

local open = state("picker_open", true)
local query = state("picker_query", "")
local list_scroll = scroll("picker_list")

return panel {
  id = "picker",
  layer = "overlay",
  visible = open,
  reset_on_close = { query, list_scroll },
  child = textfield { width = 300, height = 32, on_change = function(text) query:set(text) end },
}
RuleDetail
Takesstate(...) and scroll(...) handles. A map, a capability, another signal kind or a plain value fails the evaluation, naming the surface and the entry
Closesvisible turns false, a reload removes the surface, its last output goes away, or (a popup) its parent closes
Does not closeA surface never shown, a failed reload, a reload that rebuilds it under the same id, being covered, losing the keyboard
Per-output panelResets when its last instance closes
Resets toThe initial the evaluation on screen declares, the value mantle toggle returns to; a scroll to offset 0
TimingThe turn the surface is unmapped, after it is gone, so the closed surface never paints the reset value
WriteAn ordinary write: readers re-resolve, and a state that changed runs its on_change. A state listed by several surfaces resets when any of them closes

A textfield’s draft is the engine’s and is not state; it follows its own lifetime.

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) with a background or a non-zero border_widthIts whole box, painted bounds under its own transform; a rotated or skewed box claims its rotated shape. #00000000 counts
text, icon, image, capture, textfieldIts box, rotated with it
Any node with on_click, on_double_click, on_drag, on_wheel or submits_secure = trueIts box, even with nothing painted
A shaderNothing; its alpha is unknown to the engine. Give it a handler for a hit area
A path with a fill, or a stroke with stroke_width above 0, and at least one L, Q or C commandIts whole box, not the drawn shape. Otherwise nothing; give it a handler for a hit area
A box’s mask childNothing, nor its subtree
A transparent containerNothing; its children are asked instead
The surface root itselfNothing, even with a background; with a handler, the whole surface
Anything on a layer = "background" panelOnly a node with a handler

A claiming box that clips its children ends the walk there; one that does not also claims the descendants sticking out of it, each cut to any clipping ancestor. visible = false subtrees claim nothing. To make an empty area catch clicks, put a rect { 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>
Start a surface fresh each time it opensReset on close
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 rect 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 child that fills the root; a panel’s omitted size fills the root on axes anchored at both edges
two surfaces declare an idSurface ids are unique across every role; rename one
A function child on a window or popup is refusedOnly panel (not output = "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 function now(format)
    return mantle.clock:map(function(clock) return os.date(format, clock.time) end, "")
end

local function workspace(active)
    return rect { width = active and 20 or 8, height = 8, radius = 4, background = active and "#89b4fa" or "#45475a" }
end

local function status(name)
    return icon { name = name, size = 16, foreground = "#cdd6f4", align_v = "center" }
end

local bar = panel {
    id = "bar",
    layer = "top",
    anchor = { top = true, left = true, right = true },
    height = 32,
    exclusive_zone = true,
    child = row {
        width = "fill",
        height = "fill",
        padding = { left = 14, right = 14 },
        background = "#1e1e2e",
        children = {
            row { spacing = 6, align_v = "center", children = { workspace(false), workspace(true), workspace(false), workspace(false) } },
            rect { width = "fill" },
            row {
                spacing = 8,
                align_v = "center",
                children = {
                    text { content = now("%H:%M"), font_weight = 700, foreground = "#cdd6f4" },
                    text { content = now("%a %d %b"), foreground = "#a6adc8" },
                },
            },
            rect { width = "fill" },
            row {
                spacing = 12,
                align_v = "center",
                children = {
                    status("network-wireless-symbolic"),
                    status("bluetooth-active-symbolic"),
                    status("audio-volume-high-symbolic"),
                    text { content = "82%", foreground = "#a6e3a1", align_v = "center" },
                },
            },
        },
    },
}

return { bar }

Both horizontal edges are anchored, so the omitted width fills the root (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}"; output = "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
outputstring"all"A connector name, "all" or "active": which outputs get an instance (output)
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)
exclusive_zoneboolean|integer|"ignore"|BoundfalseThe space reserved from other windows (exclusive zones)
keyboard_interactivity"none"|"on_demand"|"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)
on_escapefun()NoneEscape pressed while this surface or a popup under it has the keyboard and no focused field took it: a field with text to clear or an on_cancel keeps its own Escape. Once per press; the innermost shown popup declaring it wins, with no order promised among sibling popups. Never on a surface without keyboard_interactivity
reset_on_close(StateSignal<any>|ScrollSignal)[]{}state and scroll handles written back when the surface stops being shown: a state to its initial, a scroll to the top (reset on close)

output

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; the root fills it tooMeasured 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. On a spanned axis, omitted size also fills its root node. On an unspanned axis, omission measures the content. To keep a narrower box inside a spanned surface, put its size and background on a child. max_width/max_height cap the root.

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

exclusive_zoneReservesCovers 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

An integer zone counts from the output edge, so it must include the panel’s margin on that edge; true reserves the size alone.

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 APPS = {
    { icon = "web-browser", name = "Web Browser" },
    { icon = "folder", name = "Files" },
    { icon = "utilities-terminal", name = "Terminal" },
}

local function result(index, app)
    return row {
        width = "fill",
        padding = 8,
        spacing = 12,
        radius = 8,
        background = index == 1 and "#89b4fa26" or "#00000000",
        children = {
            icon { name = app.icon, size = 24, align_v = "center" },
            text { content = app.name, foreground = "#cdd6f4", align_v = "center" },
        },
    }
end

local results = {}
for index, app in ipairs(APPS) do
    results[index] = result(index, app)
end

local launcher = panel {
    id = "launcher",
    layer = "overlay",
    output = "active",
    anchor = { top = true, bottom = true, left = true, right = true },
    width = "fill",
    height = "fill",
    visible = open,
    keyboard_interactivity = "exclusive",
    background = "#11111b99",
    child = column {
        width = 420, align_h = "center", align_v = "center",
        padding = 12, spacing = 8, radius = 14, background = "#1e1e2e",
        children = {
            rect {
                width = "fill",
                height = 36,
                padding = { left = 12, right = 12 },
                radius = 8,
                background = "#313244",
                children = {
                    textfield {
                        width = "fill",
                        height = "fill",
                        placeholder = "Search apps",
                        autofocus = true,
                        on_change = function(query) end,
                        on_cancel = function() open:set(false) end,
                    },
                },
            },
            column { width = "fill", spacing = 2, children = results },
        },
    },
}

return { launcher }

See named state and text fields.

ModeBehaviour
"none"Never takes the keyboard
"on_demand"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_zone = "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 node with a pointer 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 percent = level:map(function(value) return string.format("%d%%", math.floor(value * 100)) end)

local osd = panel {
    id = "osd",
    layer = "overlay",
    output = "active",
    anchor = { bottom = true },
    margin = { bottom = 96 },
    visible = pulse(level, 1500),
    background = "#1e1e2ee6", radius = 14, padding = 14,
    child = row {
        spacing = 12,
        children = {
            icon { name = "audio-volume-high-symbolic", size = 20, foreground = "#89b4fa", align_v = "center" },
            rect {
                width = 200, height = 8, align_v = "center", radius = 4, background = "#45475a",
                children = { rect { width = percent, height = "fill", radius = 4, background = "#89b4fa" } },
            },
            text { content = percent, width = 36, foreground = "#cdd6f4", align_v = "center" },
        },
    },
}

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_zone = 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 54 px plus its 8 px margin; exclusive_zone = true would reserve only the 54 px (exclusive zones):

local function app(name, running)
    return column {
        spacing = 2,
        children = {
            icon { name = name, size = 32 },
            rect { width = 4, height = 4, radius = 2, align_h = "center", background = running and "#89b4fa" or "#00000000" },
        },
    }
end

local dock = panel {
    id = "dock",
    layer = "bottom",
    output = "DP-1",
    anchor = { bottom = true },
    margin = { bottom = 8 },
    exclusive_zone = 62,
    background = "#1e1e2e", radius = 14, padding = 8,
    border_width = 1, border_color = "#ffffff14",
    child = row { spacing = 12, children = {
        app("web-browser", true),
        app("folder", false),
        app("utilities-terminal", false),
    } },
}

return { dock }

Close an overlay on an outside click

A full-screen panel with a transparent rect 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 = "on_demand",
    child = rect {
        width = "fill",
        height = "fill",
        children = {
            rect { 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", {
    { icon = "dialog-information-symbolic", color = "#89b4fa", title = "Build finished", body = "mantle built in 42 s" },
    { icon = "battery-caution-symbolic", color = "#fab387", title = "Battery at 20%", body = "About 1 h left" },
})

local stack = panel {
    id = "toasts",
    layer = "overlay",
    anchor = { top = true, right = true },
    margin = { top = 8, right = 8 },
    child = list {
        items = items,
        spacing = 8,
        each = function(toast)
            return row {
                width = 320, padding = 12, spacing = 12, radius = 12, background = "#1e1e2e",
                children = {
                    icon { name = toast.icon, size = 20, foreground = toast.color, align_v = "center" },
                    column {
                        spacing = 2,
                        children = {
                            text { content = toast.title, font_weight = 700, foreground = "#cdd6f4" },
                            text { content = toast.body, font_size = 12, foreground = "#a6adc8" },
                        },
                    },
                },
            }
        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 needs a content-sized backgroundPut the size and background on a child; the omitted panel extent fills its root
"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 an output = "active" panel is refusedUse "fill" with anchors and margins, or px
exclusive_zone = true on a corner-anchored panel reserves nothingAnchor one edge, alone or with both perpendicular edges, or give a px count
exclusive_zone = 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 covering the root (input region)
On Hyprland, other surfaces stop taking clicks while an "exclusive" panel is mappedUse "on_demand" unless the panel must hold every key; it still takes focus when it maps
A panel on output = "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 TABS = {
    { name = "General", icon = "dialog-information-symbolic" },
    { name = "Display", icon = "display-brightness-symbolic" },
    { name = "Sound", icon = "audio-volume-high-symbolic" },
    { name = "Power", icon = "system-shutdown-symbolic" },
}

local function tab(entry)
    local selected = page:map(function(current) return current == entry.name end)
    return row {
        width = "fill",
        padding = { left = 10, right = 12, top = 8, bottom = 8 },
        spacing = 10,
        radius = 8,
        background = selected:map(function(on) return on and "#89b4fa26" or "#00000000" end),
        on_click = function() page:set(entry.name) end,
        children = {
            icon { name = entry.icon, size = 16, align_v = "center",
                   foreground = selected:map(function(on) return on and "#89b4fa" or "#a6adc8" end) },
            text { content = entry.name, foreground = "#cdd6f4", align_v = "center" },
        },
    }
end

local function switch(label, detail, on)
    return row {
        width = "fill",
        padding = 12,
        spacing = 12,
        children = {
            column { width = "fill", spacing = 2, align_v = "center", children = {
                text { content = label, foreground = "#cdd6f4" },
                text { content = detail, font_size = 12, foreground = "#a6adc8" },
            } },
            rect {
                width = 36, height = 20, radius = 10, align_v = "center",
                background = on and "#89b4fa" or "#45475a",
                children = { rect { width = 14, height = 14, radius = 7, margin = { left = 3, right = 3 },
                    align_h = on and "end" or "start", align_v = "center", background = on and "#ffffff" or "#bac2de" } },
            },
        },
    }
end

local function group(title, rows)
    local divided = {}
    for index, item in ipairs(rows) do
        if index > 1 then divided[#divided + 1] = rect { width = "fill", height = 1, margin = { left = 12, right = 12 }, background = "#313244" } end
        divided[#divided + 1] = item
    end
    return column { width = "fill", spacing = 10, children = {
        text { content = title, font_size = 12, font_weight = 700, foreground = "#89b4fa", margin = { left = 4 } },
        column { width = "fill", radius = 10, background = "#181825", children = divided },
    } }
end

local tabs = { text { content = "Settings", font_size = 15, font_weight = 700, foreground = "#cdd6f4", margin = { left = 10, top = 6, bottom = 10 } } }
for _, entry in ipairs(TABS) do tabs[#tabs + 1] = tab(entry) 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_request = function() open:set(false) end,
    child = row {
        width = "fill",
        height = "fill",
        background = "#1e1e2e",
        children = {
            column { width = 180, height = "fill", padding = 10, spacing = 2, background = "#181825", children = tabs },
            column { width = "fill", padding = 24, spacing = 18, children = {
                text { content = page, font_size = 22, font_weight = 700, foreground = "#cdd6f4" },
                group("Appearance", {
                    switch("Dark style", "Use dark colours in every window", true),
                    switch("Animations", "Ease panels and popups in and out", true),
                }),
                group("Clock", {
                    switch("Seconds", "Show seconds in the bar clock", false),
                    switch("24-hour time", "Show 13:00 instead of 1:00 PM", true),
                }),
            } },
        },
    },
}

return { settings }

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

Properties

Beyond the shared properties. Every field but id and on_close_request 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}"; output = "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
decorations"server"|"client"|Bound"server"Who draws the window’s frame: "server" asks the compositor for its decorations, "client" leaves the frame to the app. A compositor without zxdg_decoration_manager_v1 always leaves it to the app; toplevel(id):state().decoration says which was chosen
geometry_insetnumber|Edges|Bound, [0, 256]0Room around the frame for a client-drawn shadow; the compositor sizes and tiles by the frame alone. A number sets all four edges (client-side decoration)
on_close_requestfun()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
on_escapefun()NoneEscape pressed while this surface or a popup under it has the keyboard and no focused field took it: a field with text to clear or an on_cancel keeps its own Escape. Once per press; the innermost shown popup declaring it wins, with no order promised among sibling popups. Never on a surface without keyboard_interactivity
reset_on_close(StateSignal<any>|ScrollSignal)[]{}state and scroll handles written back when the surface stops being shown: a state to its initial, a scroll to the top (reset on close)
childNode|BoundNoneThe one root node; a function child is refused

By default the engine requests server-side decorations and draws none itself. decorations = "client" asks for none, and a compositor without zxdg_decoration_manager_v1 (or one that insists on its choice) decides regardless: the mode it chose is toplevel(id):state().decoration. Changing the property on a reload asks again.

Size

The window’s size is the compositor’s configure. The window node fills it on each axis where it has no width/height of its own; a set one sizes the node inside the window. Its child sizes to its content unless it sets "fill". A geometry_inset grows the surface past that size on each side (client-side decoration).

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_request = 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
Draw my own title barCustom title bar
Draw a shadow and rounded corners around my own frameClient-side decoration
Open a menu from itA popup with parent set to the window’s id

Custom title bar

An app that draws its own frame asks the compositor to run the pointer: toplevel(id) takes a window’s id and has three pointer methods, called from an on_press (or an on_drag "start"), three state methods, and :state() (window state).

MethodDoes
:move()Starts an interactive move, as dragging a title bar does
:resize(edge)Starts a resize from "top", "bottom", "left", "right", "top_left", "top_right", "bottom_left" or "bottom_right"; any other value raises
:show_menu()Opens the compositor’s window menu at the press position
:set_maximized(on)Asks for maximize, or restore with false; on must be a boolean
:set_minimized()Asks for minimize; nothing asks it back
:set_fullscreen(on)Asks for fullscreen, or restore with false; on must be a boolean

Compositors honour the pointer methods only for the serial of the press that started them, so a call from on_click, a timer or any other callback, on a hidden window, or on a press that landed on a different surface logs a warning and sends nothing. After a request the compositor owns the pointer and may send no release: the engine drops the drag and the armed click, so on_drag gets no "end" and on_click does not fire.

The state methods need no press and work from any callback. A request for a window that is not mapped yet is dropped; do not pair one with move() in the same press, as the move grab starts first.

local frame = toplevel("main")
local GRIP = 6

local function grip(edge, props)
    props.on_press = function(_, button)
        if button == "left" then frame:resize(edge) end
    end
    return rect(props)
end

return {
    window {
        id = "main",
        title = "Notes",
        child = column {
            width = "fill", height = "fill", background = "#1e1e2e",
            children = {
                grip("top", { width = "fill", height = GRIP, cursor = "n-resize" }),
                row {
                    width = "fill", height = 32, padding = { left = 12, right = 12 }, background = "#181825",
                    on_press = function(_, button)
                        if button == "left" then frame:move() else frame:show_menu() end
                    end,
                    children = { text { content = "Notes", foreground = "#cdd6f4", align_v = "center" } },
                },
                grip("bottom_right", { width = GRIP, height = GRIP, align_h = "end", cursor = "se-resize" }),
            },
        },
    },
}

Window state

toplevel(id):state() is a read-only signal of what the compositor last configured the window to be, so the app’s own frame can follow it. It is rewritten only when a configure changes a value. Before the first configure and after the window closes it holds the default: every flag false, no bounds, every capability true, decoration = "client". Reading it never requests a change; :set_maximized, :set_minimized and :set_fullscreen do.

KeyTypeMeaning
activated, maximized, fullscreen, resizingbooleanThe xdg_toplevel state flags
tiled{ left, right, top, bottom }The edges a tiling compositor tiled the window against
bounds{ width, height } or nilThe most room the compositor suggests, in logical pixels
capabilities{ window_menu, maximize, fullscreen, minimize }What the compositor supports; all true when it never says
decoration"server" or "client"The mode the compositor chose
local focused = toplevel("main"):state():map(function(s) return s.activated end)

return {
    window {
        id = "main",
        title = "Notes",
        decorations = "client",
        child = rect {
            width = "fill", height = "fill",
            background = focused:map(function(on) return on and "#1e1e2e" or "#313244" end),
        },
    },
}

Client-side decoration

A window that draws its own frame can also draw its shadow and rounded corners outside it. geometry_inset adds a band around the window’s frame: the buffer is the frame plus the band, the window node fills the whole buffer, and the compositor sizes, tiles and snaps by the frame alone (xdg_surface.set_window_geometry). Configure sizes, min_size and max_size are all the frame’s. The child fills the buffer too if it says "fill"; give the frame a margin of the band, and its shadows and radius draw into the band.

Compositors expect no shadow on a maximized, fullscreen or tiled edge. The engine does not guess: bind geometry_inset to the window state and zero those edges.

local frame = toplevel("main")
local SHADOW = 24

local function band(s)
    if s.maximized or s.fullscreen then return 0 end
    return {
        left = s.tiled.left and 0 or SHADOW, right = s.tiled.right and 0 or SHADOW,
        top = s.tiled.top and 0 or SHADOW, bottom = s.tiled.bottom and 0 or SHADOW,
    }
end
local inset = frame:state():map(band)
local floating = frame:state():map(function(s)
    local t = s.tiled
    return not (s.maximized or s.fullscreen or t.left or t.right or t.top or t.bottom)
end)

local GRIP = 8
local function grip(edge, cursor, props)
    props.background = "#00000000"
    props.cursor = cursor
    props.visible = floating
    props.on_press = function(_, button)
        if button == "left" then frame:resize(edge) end
    end
    return rect(props)
end

return {
    window {
        id = "main",
        title = "Notes",
        decorations = "client",
        geometry_inset = inset,
        child = rect {
            width = "fill", height = "fill",
            children = {
                column {
                    width = "fill", height = "fill", margin = inset, background = "#1e1e2e",
                    radius = floating:map(function(on) return on and 10 or 0 end),
                    shadows = { { color = "#00000080", blur = 18, offset = { x = 0, y = 4 } } },
                    children = {
                        row {
                            width = "fill", height = 32, padding = { left = 12 }, background = "#181825",
                            on_press = function(_, button)
                                if button == "left" then frame:move() else frame:show_menu() end
                            end,
                            children = { text { content = "Notes", foreground = "#cdd6f4", align_v = "center" } },
                        },
                    },
                },
                grip("top", "n-resize", { width = "fill", height = GRIP }),
                grip("bottom", "s-resize", { width = "fill", height = GRIP, align_v = "end" }),
                grip("left", "w-resize", { width = GRIP, height = "fill" }),
                grip("right", "e-resize", { width = GRIP, height = "fill", align_h = "end" }),
            },
        },
    },
}

The rect root stacks its children, so the grips lie over the band’s edges: a transparent background makes each one claim input (input region), and visible = floating removes them when the window is maximized, fullscreen or tiled. The shadow itself is not content and takes no pointer input. Corner grips are more of the same, sized GRIP * 2 and listed after the edges.

Confirm before closing

on_close_request 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_request = 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 = {
                    rect { padding = 8, background = "#f38ba8",
                        on_click = function() confirming:set(false); open:set(false) end,
                        children = { text { content = "Discard" } } },
                    rect { 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_request 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 window node 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 (custom title bar), or use compositor rules
decorations = "server" still leaves a bare windowThe compositor has no zxdg_decoration_manager_v1 or chose client-side; check toplevel(id):state().decoration
toplevel("main"):move() warns and does nothingCall it from on_press, not on_click; the press serial is gone by release

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 function item(icon_name, label, color)
    return row {
        width = "fill",
        padding = 8,
        spacing = 10,
        radius = 6,
        children = {
            icon { name = icon_name, size = 16, foreground = color, align_v = "center" },
            text { content = label, foreground = color, align_v = "center" },
        },
    }
end

local bar = panel {
    id = "bar",
    layer = "top",
    anchor = { top = true, left = true, right = true },
    width = "fill",
    height = 32,
    exclusive_zone = true,
    child = row {
        width = "fill", height = "fill", background = "#1e1e2e",
        children = {
            rect {
                padding = 8,
                on_click = function(rect)
                    menu_anchor:set(rect)
                    menu_open:set(not menu_open:get())
                end,
                children = { icon { name = "system-shutdown-symbolic", size = 16, foreground = "#f38ba8" } },
            },
        },
    },
}

local menu = popup {
    id = "menu",
    parent = "bar",
    anchor_rect = menu_anchor,
    anchor = "bottom",
    gravity = "bottom_right",
    offset = { x = 4, y = 4 },
    visible = menu_open,
    on_dismiss = function() menu_open:set(false) end,
    child = column {
        width = 170, padding = 4, radius = 10, background = "#1e1e2e",
        border_width = 1, border_color = "#45475a",
        children = {
            item("system-lock-screen-symbolic", "Lock", "#cdd6f4"),
            item("system-log-out-symbolic", "Log out", "#cdd6f4"),
            item("system-reboot-symbolic", "Restart", "#cdd6f4"),
            item("system-shutdown-symbolic", "Power off", "#f38ba8"),
        },
    },
}

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, on_dismiss, on_escape and reset_on_close 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}"; output = "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"|"top_left"|"top_right"|"bottom_left"|"bottom_right"|"center"|Bound"center"The point on anchor_rect the popup hangs from
gravity"top"|"bottom"|"left"|"right"|"top_left"|"top_right"|"bottom_left"|"bottom_right"|"center"|Bound"center"The direction it extends from that point: "bottom" hangs it below, "bottom_right" below and to the right
constraint_adjustment("slide_x"|"slide_y"|"flip_x"|"flip_y"|"resize_x"|"resize_y")[]|Bound{ "flip_y", "slide_x" }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
on_escapefun()NoneEscape pressed while this surface or a popup under it has the keyboard and no focused field took it: a field with text to clear or an on_cancel keeps its own Escape. Once per press; the innermost shown popup declaring it wins, with no order promised among sibling popups. Never on a surface without keyboard_interactivity
reset_on_close(StateSignal<any>|ScrollSignal)[]{}state and scroll handles written back when the surface stops being shown: a state to its initial, a scroll to the top (reset on close)
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 mouse button 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 = { "flip_x", "flip_y", "slide_x", "slide_y" }
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 = rect {
    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 = "top_right",
    gravity = "bottom_right",
    constraint_adjustment = { "flip_x", "slide_y" },
    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, prompt, messages, 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.active and not lock.unlocking end, false)

local function now(format)
    return mantle.clock:map(function(clock) return os.date(format, clock.time) end, "")
end

local hint = mantle.lock:map(function(lock)
    if lock.error ~= "" then return string.format("%s (%d)", lock.error, lock.attempts) end
    if lock.authenticating then return "Checking…" end
    if lock.prompt then return lock.prompt.text 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 = now("%H:%M"), font_size = 72, font_weight = 700, foreground = "#cdd6f4", align_h = "center" },
                text { content = now("%A, %d %B"), font_size = 16, foreground = "#a6adc8", align_h = "center" },
                text { content = output, font_size = 12, foreground = "#6c7086", align_h = "center", margin = { top = 24 } },
                rect {
                    width = 300, align_h = "center", padding = { left = 16, right = 16, top = 10, bottom = 10 },
                    radius = 999, background = "#1e1e2e", border_width = 1, border_color = "#45475a",
                    children = {
                        textfield {
                            width = "fill",
                            height = 20,
                            placeholder = "Password",
                            mask_character = "•",
                            masked = mantle.lock:map(function(lock) return not (lock.prompt and lock.prompt.echo) end, true),
                            secure_submit = { capability = "lock", action = "authenticate" },
                        },
                    },
                },
                text {
                    content = hint,
                    foreground = mantle.lock:map(function(lock) return lock.error ~= "" and "#f38ba8" or "#a6adc8" end, "#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}"; output = "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

output 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.clock:map(function(clock) return os.date("%H:%M", clock.time) end, "")
Add an unlock button beside the fieldA clickable node with submits_secure = true sends the field like Enter (pointer). Give it and the field an accessible_name so Tab and screen readers reach both (keyboard controls)
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, output 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 function workspace(active)
    return rect { width = active and 18 or 8, height = 8, radius = 4, background = active and "#89B4FA" or "#45475A" }
end

local function status(name)
    return icon { name = name, size = 16, foreground = "#CDD6F4" }
end

local bar = panel {
    id = "bar",
    layer = "top",
    anchor = { top = true, left = true, right = true },
    exclusive_zone = 32,
    width = "fill",
    height = 32,
    child = row {
        width = "fill",
        height = "fill",
        padding = { left = 12, right = 12 },
        background = "#1E1E2ECC",
        children = {
            row { width = "fill", align_v = "center", spacing = 6, children = {
                workspace(true), workspace(false), workspace(false),
            } },
            text { content = clock, align_v = "center", font_weight = 700, foreground = "#CDD6F4" },
            row { width = "fill", align_h = "end", align_v = "center", spacing = 12, children = {
                status("network-wireless-symbolic"), status("audio-volume-high-symbolic"), status("bluetooth-active-symbolic"),
            } },
        },
    },
}

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. Keys inside a property table (anchor = { lefft = true }) and choice values (align_h = "cenetr") get the same suggestion.

KindPageBoxChildren
rectrect✓Stacked
row, columnrow and column✓Flow
listlistFlow, from data
texttextLeaf
iconiconLeaf
imageimageLeaf
capturecaptureLeaf
pathpathLeaf
shadershaderLeaf
textfieldtextfieldLeaf

The four surface roles (panel, window, popup, lock) are node kinds too: they take the common and box properties but margin, align_h and align_v, 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, pointer, scroll and geometry take the signal handle itself. A table property takes a signal for any value inside it
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.items and text runs take at most 10000 elements. A nil hole in children is an error; in list.items and runs it ends the array
TablesA table property (padding, anchor, origin, 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, submits_secure, …) only true or false. on_click = "x" or submits_secure = 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 itemsdirection: vertical by default
rect, 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, path, 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. A parent with its own size gives every child its content box, even a child larger than it.

Of the leaves, only text and icon measure themselves. image, capture, path, shader and textfield have no intrinsic width: without width they are 0 wide and draw nothing, and all but textfield need height too. A textfield defaults to one line of font_size tall.

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 overflow it: they paint past a parent with no clip and are cut only when it sets one (a scroll viewport and a surface cut by default), 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. Negative margin is accepted; each padding edge must be within [0, 8192]. 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

The pointer handlers on_click, on_double_click, on_drag, on_wheel and submits_secure are here, so any node takes clicks: the innermost node under the pointer with a handler for the event wins, and a node without one lets the event through to the node around it (pointer).

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|Bound, [0, 8192]0Inside the box, around its children or text. A number sets all four edges; each edge is within [0, 8192] (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
shadowsShadowLayer[]|BoundNoneDrop shadows, the first on top (shadows). Each layer is { color, blur, offset, spread, inset, blend }; at most 16. A layer draws when alpha > 0 and blur, offset or spread is set
effectEffect|BoundNonePixel filters: blur (sigma in px, [0, 8192]) and the colour filters saturate, brightness, contrast ([0, 8], 1 is off), at the top level and in backdrop (whose mask fades the glass out; see Progressive blur), and a shader over the node’s subtree or its backdrop; see Blurs and Shader effects. backdrop and input = "backdrop" or "desktop" are for box kinds only
blend"normal"|"multiply"|"screen"|"overlay"|"darken"|"lighten"|"color_dodge"|"color_burn"|"hard_light"|"soft_light"|"difference"|"exclusion"|"hue"|"saturation"|"color"|"luminosity"|"plus_lighter"|"plus_darker"|Bound"normal"CSS mix-blend-mode onto what this surface painted under the node; see Blend modes
animateAnimations|BoundNonePer-property tweens, parent-relative layout move and an exit block (animation). An ancestor that shifts needs its own move. Only a node already on screen animates, unless a property entry has from
settledBoundNoneA settled(name) signal the engine sets to whether every animate tween on this node is at rest (settled)
idstringNoneUnique among siblings; matches this node across passes (identity). Never a signal
accessible_namestring|Bound""Spoken name for a control. A node with on_click, submits_secure, on_key or on_key_release becomes keyboard focusable when this is set. Give each textfield a name for screen readers
focus_targetFocusHandleNoneA focus_target(name) handle. :request() from an on_click, an on_key, or an edit typed or committed into a field (on_change, on_submit, on_cancel) moves keyboard focus here, if this node is a visible focusable control on that callback’s keyboard-focused surface or a popup under it. A request from a key callback shows the focus outline, one from a click does not. Inert on a node that takes no focus. Any other value fails the pass
autofocusboolean|BoundfalseTakes keyboard focus when the surface gets the keyboard or this node appears; the first in document order wins, field or control. A plain textfield arms under existing control focus unless a field is typing or a press just left it; its draft resets to initial_text ("" when unset) and on_change is called with it. Any other control arms only while no control holds focus, and once: it never takes focus back after the user leaves it. Inert on a node that takes no focus
focus_ringboolean|Boundtruefalse keeps the engine’s focus outline off this node
focusedBoundNoneA focused(name) signal the engine sets while this node or its children hold control focus
focus_visibleBoundNoneA focus_visible(name) signal the engine sets while the focus outline would show on this node or its children, even with focus_ring = false
hoverBoundNoneA hover(name) signal the engine sets while the pointer is over this node or its children (hover)
pointerBoundNoneA pointer(name) signal the engine sets to the pointer’s node-local { x, y } while it is over this node or its children (pointer position)
geometryBoundNoneA geometry(name) signal the pass writes this node’s surface-local rect into (geometry)
cursorCursor|Bound"pointer" on a node with on_click, on_double_click, on_press, on_drag, on_wheel or submits_secure 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 moving the node under or off a still pointer does not call it, and the next motion does. Refused without hover on the same node
on_keyfun(key: KeyPress): boolean?NoneA key press or repeat, from the focused node up through its ancestors to the surface; true stops it (key handlers). Needs accessible_name to take focus
on_key_releasefun(key: KeyRelease): boolean?NoneA key release, routed like on_key; true stops it (key handlers). Needs accessible_name to take focus
on_clickfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneOn release over the same node that was pressed, with the same mouse button. rect is the node’s surface-local box, before transforms. pointer is node-local and unclamped like on_drag’s; Enter, Space and screen-reader activation report the node’s centre. modifiers is the keyboard’s Modifiers at that moment, also for Enter and Space; every handler below takes it after its own arguments (only on_drag’s velocity follows it), and it reads all false while none of this shell’s surfaces has keyboard focus. A left press on a textfield goes to the field instead; its other presses call its own on_click, never an ancestor’s, and a masked field calls none
on_double_clickfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneAfter on_click, on the release completing a second click on the same node with the same button, within 400 ms and 4 px of the first click’s release; the node may move or resize in between. The innermost node with on_double_click takes it, independently of on_click’s target, so single clicks on a node with only this handler still reach an ancestor’s on_click, which fires for both clicks. A third click starts a new pair. Arguments as on_click’s. A textfield doubles only on non-left clicks, its own handler only. Enter, Space and screen-reader activation never double-click, and a node with only this handler takes no keyboard focus
on_pressfun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers)NoneOn press of any mouse button, before on_click and before the release. rect and pointer are as in on_click. The only place toplevel(id) can move, resize or open the window menu, since compositors honour those for the press serial; an on_drag "start" works too. A textfield calls only its own, and only for non-left buttons; a left press edits the field and a masked field calls none
on_dragfun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end", modifiers: Modifiers, velocity: { x: number, y: number })NoneLeft-button drag. rect is the node’s box at each call, so it follows a node that moves under its own drag. pointer is node-local and unclamped. "start" on press, "end" on release (before on_click) or when the pointer leaves the surface. velocity is the pointer’s { x, y } in surface logical px per second over the last 100 ms of motion, by the compositor’s event clock: { x = 0, y = 0 } on "start" and after 100 ms without motion. A span under 8 ms keeps the last measured value, and an "end" that is not a release (the pointer leaving the surface) reports the last motion’s
on_wheelfun(rect: Rect, steps: number, modifiers: Modifiers)NoneVertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins; on one node, the scroll
submits_secureboolean|BoundfalseA click also submits the armed secure field, like Enter. Works without on_click and runs before it
hittableboolean|BoundInherited; true at the rootfalse lets the pointer through this node and its descendants to what is underneath; inherited, and a descendant’s true takes it back, and this node’s handlers still apply to it (pass-through)

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 and the four surface roles also take background, radius, corner_shape, corner_smoothing, border_color, border_width, clip, mask, compositor_blur and shadow_mode, and effect.backdrop. 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, title, icon_name, items)
    local rows = { text { content = title, font_size = 14, font_weight = 700, foreground = "#CDD6F4" } }
    for _, item in ipairs(items) do
        rows[#rows + 1] = row { spacing = 10, children = {
            icon { name = icon_name, size = 16, foreground = "#89B4FA", align_v = "center" },
            text { content = item, foreground = "#CDD6F4", align_v = "center" },
        } }
    end
    return column {
        id = name, -- a new id per view: the old view leaves and fades instead of being reused
        width = "fill",
        padding = 14,
        spacing = 10,
        radius = 12,
        background = "#1E1E2E",
        opacity = 1, -- `from` needs the property set
        animate = { opacity = { duration = 250, from = 0 }, exit = { duration = 250, opacity = 0 } },
        children = rows,
    }
end

local views = {
    wifi = function() return page("wifi", "Wi-Fi", "network-wireless-symbolic", { "Home", "Office 5G", "Cafe Guest" }) end,
    bluetooth = function()
        return page("bluetooth", "Bluetooth", "bluetooth-active-symbolic", { "Headphones", "Keyboard", "Phone" })
    end,
}

local body = rect {
    width = 260,
    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 clickableGive it on_click; to make a whole row clickable, put on_click on the row (pointer)
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, path, shader or textfield does not appearThey have no intrinsic width. Give width (and height, except for a textfield), 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 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
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[2]: expected a node tableA 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, 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. Use 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 = 40,
    height = 40,
    radius = 12,
    background = "#313244",
    children = {
        icon { name = "notification-symbolic", size = 22, 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",
            margin = { top = 4, right = 4 },
            padding = { left = 5, right = 5 },
            radius = 8,
            background = "#F38BA8",
            children = { text { content = unread:map(tostring), font_size = 11, font_weight = 700, 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: radius on the image
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 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, 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 percent = volume:map(function(v) return string.format("%d%%", math.floor((v or 0) * 100 + 0.5)) end)

local meter = row {
    width = "fill",
    height = 6,
    radius = 3,
    align_v = "center",
    background = "#313244",
    children = { rect { width = percent, height = "fill", radius = 3, background = "#89B4FA", animate = { width = 150 } } },
}

return row {
    width = 280,
    padding = 14,
    spacing = 12,
    radius = 12,
    background = "#1E1E2E",
    children = {
        icon { name = "audio-volume-medium-symbolic", size = 18, foreground = "#89B4FA", align_v = "center" },
        meter,
        text { content = percent, foreground = "#CDD6F4", align_v = "center" },
    },
}

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)
homogeneousboolean|Boundfalsetrue gives every visible child one equal main-axis slot, as GTK’s homogeneous; see equal slots
wrapboolean|Boundfalsetrue flows children onto new lines when the next does not fit the main axis, CSS flex-wrap; see wrapping. Refused with scroll
line_spacingnumber|Bound0Px between lines under wrap, 0 by default; negative values overlap them. Ignored without wrap

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 widthhomogeneous = true on the row, or equal slots
Scroll overflowing contentBound the axis (height or max_height on a column), then scroll = scroll("name") (scroll)
Overlap items, like stacked avatarsNegative spacing
Flow chips or tiles onto lineswrap = true, below

Equal slots

homogeneous = true gives every visible child a slot of the same size along the main axis.

ContainerSlot
Content-sized on the main axisThe largest child’s size plus its margins. A max_* cap below that overflows by whole slots
width/height, a percent or "fill"An equal share of the axis after spacing, whatever the content: a child can end up narrower than its text

A child fills its slot less its own margins, whatever its width (or height in a column), "fill" or content; a child with a pixel size on that axis keeps it at the slot’s start. The container’s main-axis align_* has no room left to pack, and the cross axis behaves as in any row.

A segmented control: labels of different widths in equal segments.

return row {
    homogeneous = true,
    spacing = 2,
    padding = 2,
    radius = 8,
    background = "#313244",
    children = {
        text { content = "Day", foreground = "#CDD6F4", text_align = "center", padding = 6 },
        text { content = "Week", foreground = "#CDD6F4", text_align = "center", padding = 6 },
        text { content = "Month", foreground = "#CDD6F4", text_align = "center", padding = 6 },
    },
}

Wrapping

wrap = true starts a new line when the next child does not fit the main axis: a row breaks into lines top to bottom, a column into columns left to right.

PropertyEffect
spacingBetween children within a line
line_spacingBetween lines; 0 by default
align_h / align_vThe container’s main-axis value packs each line; its cross-axis value packs the lines. "stretch" packs as "start"
Child cross alignmentPlaces the child within its line, not within the container
"fill" childCounts as zero when lines break, then takes what its line leaves
Content-sized cross axisGrows with the lines
homogeneousEvery cell is the largest child’s size across all lines, a grid
scrollRefused: a wrapped container has no single main-axis extent to scroll

A line breaks only against a bounded main axis: width, "fill", a max_width (max_height) or a stretching cross axis. A content-sized container with none holds one line. A homogeneous wrapping container needs the bound itself, and without one has a single cell per line.

A chip field:

local chips = {}
for _, name in ipairs({ "Rust", "Lua", "Wayland", "Taffy", "GPU", "Fonts", "Icons" }) do
    chips[#chips + 1] = row {
        padding = { left = 10, right = 10, top = 4, bottom = 4 },
        radius = 12,
        background = "#313244",
        children = { text { content = name, foreground = "#CDD6F4" } },
    }
end

return row { wrap = true, width = 180, spacing = 6, line_spacing = 6, children = chips }

With homogeneous = true the same chips become a grid of equal tiles, each the widest chip.

Push items apart

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

local header = row {
    width = 300,
    padding = 14,
    spacing = 10,
    radius = 12,
    background = "#1E1E2E",
    children = {
        icon { name = "network-wireless-symbolic", size = 18, foreground = "#89B4FA", align_v = "center" },
        text { content = "Wi-Fi", font_size = 14, font_weight = 700, foreground = "#CDD6F4", align_v = "center" },
        rect { width = "fill" }, -- takes the space left over, pushing what follows to the end
        text { content = "Connected", foreground = "#A6E3A1", 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.

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 sets weight and italic style for the whole node, 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.

These node-level styles change line spacing, character spacing, and the selected font face:

return column { width = 320, padding = 16, spacing = 10, radius = 12,
    background = "#313244", children = {
    text { content = "Default: One line\nSecond line", font_size = 16 },
    text { content = "Taller lines: One line\nSecond line", font_size = 16,
           line_height = 1.7, foreground = "#A6E3A1" },
    text { content = "Wide letters", font_size = 16, letter_spacing = 3,
           foreground = "#89B4FA" },
    text { content = "Bold italic", font_size = 16, font_weight = 700,
           italic = true, foreground = "#F9E2AF" },
} }

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]12Text size in logical pixels. A textfield sizes its placeholder with it
line_heightnumber|Bound, [0.1, 10]1.2Line height as a multiple of font_size
letter_spacingnumber|Bound, [-100, 100]0Extra space between characters in logical pixels. Negative values tighten text
font_weightnumber|Bound, [1, 1000]400Font weight from 1 to 1000. A run with bold = true uses weight 700
italicboolean|BoundfalseUse the family’s italic face when available. A run with italic = true stays italic
font_variationstable<string, number>|Bound{}OpenType variation axes by 4-character tag ({ FILL = 1, GRAD = -25, opsz = 24 }), as CSS font-variation-settings. Values clamp to each face’s range; axes a face lacks are ignored. An explicit wght overrides font_weight and bold runs. Changes snap; see variable fonts
foregroundColor|Bound"#FFFFFF"A colour; a run’s color overrides it, and a textfield’s placeholder takes it unless placeholder_color is set
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"|"start"|"middle"|Bound"none"Replaces what does not fit with …: "end" keeps the start, "start" the end, "middle" both. Under wrap it applies to the last kept line as "end"
elidedBoundNoneAn elided(name) signal; layout writes whether elide or max_lines removed content
on_linkfun(href: string)NoneClick on a run with an href; the engine never opens it. Takes the click from any on_click, the text’s own included; plain words pass it on

Runs

Each run in a content array is a table:

FieldValuesDefaultBehaviour
textStringRequiredA run without it is refused; an empty one is skipped
bold, italicBooleanfalsebold uses weight 700; italic enables italic. Other node-level styles carry through
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,
}

return body

Variable fonts

font_variations sets a variable font’s axes, as CSS font-variation-settings does. Keys are 4-character tags and case matters: registered axes are lower case (wght, wdth, opsz, slnt), a font’s own axes upper case (FILL, GRAD).

local starred = state("starred", false)
local star = text {
    font = "Material Symbols Rounded",
    font_size = 24,
    content = "\u{e838}",
    font_variations = starred:map(function(on) return { FILL = on and 1 or 0, GRAD = 0, opsz = 24 } end),
}
RuleBehaviour
Measure and paintBoth use the same axis values, so advances and outlines agree
RangeA value outside the face’s range clamps to it
Missing axisIgnored, so a fallback face that lacks it draws as before
wghtOverrides font_weight and bold runs; without it, font_weight sets wght
ChangeSnaps: animate does not tween it

Size

A text node measures its content: one line per paragraph line, line_height × font_size each, as wide as the widest line. The default ratio is 1.2. letter_spacing changes both glyph placement and the width used for wrapping. 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.

line_height sets layout only: glyph ink that runs past a tight line box, such as a descender, paints outside it. Only the node’s width and an ancestor’s clip cut it.

Bind an elided(name) signal as elided to detect content removed by elide or max_lines. Wrapping alone keeps it false. Elision cuts at grapheme boundaries, preserving combining marks and emoji sequences. elide = "start" keeps the end of the line and "middle" keeps both ends, which suits file names whose extension matters; the end side takes the odd grapheme. Both cut in logical order, so “start” is the start of the string even in right-to-left text, and both act as "end" under wrap. With wrap = "word", max_lines = N and elide = "end", the preview keeps the first N shaped lines and adds … to the last retained line. It shortens that line only as needed to fit the ellipsis; it never merges content from later lines into the preview.

How do I…

TaskAnswer
Truncate a long titlewidth (or "fill") plus elide = "end"
Keep a file extension visiblewidth plus elide = "middle"
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
Fill a Material Symbols iconfont_variations = { FILL = 1 } (variable fonts)
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 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 on_click 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. Use 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.active_client
    if client == nil then return "" end
    local index = apps.by_app_id[client.app_id] or apps.by_app_id[string.lower(client.app_id)]
    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.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 image path (absolute, or ~/ under $HOME), 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; a -symbolic icon with no currentColor is recoloured whole, hard-coded fills included. 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 the desktop portal’s org.gnome.desktop.interface icon-theme (what gsettings set org.gnome.desktop.interface icon-theme writes), else gtk-icon-theme-name from $XDG_CONFIG_HOME/gtk-4.0/settings.ini, else gtk-3.0/settings.ini, else hicolor. A change through the portal redraws every icon at once; a settings.ini edit shows after a shell restart. Files load as PNG, JPEG, WebP, GIF, ICO, BMP, 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 buttonGive the icon an on_click, or put it in a rect with padding and on_click for a bigger target
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 or ~/ under $HOME
foreground does not change a colour iconOnly SVGs that use currentColor (symbolic icons) take it
The wrong theme’s icons appearSet the theme with gsettings set org.gnome.desktop.interface icon-theme <theme> (needs xdg-desktop-portal with a GTK or GNOME backend). Without the portal, set gtk-icon-theme-name in settings.ini 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.

SVG and SVGZ <text> use the fonts loaded by Mantle. Declare required families in fonts { ... }; text without a family uses the chain’s primary font. Generic names use the families declared in the chain. An unavailable family falls back to the loaded serif family, or the primary if none is loaded. SVG text uses SVG layout and shaping, so its spacing can differ from a text node. Images, icons and file masks share this behavior, including background decoding.

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 = "in_out_cubic" },
        width = "fill",
        height = "fill",
    },
} }

Properties

image takes the common properties, plus:

PropertyTypeDefaultBehaviour
sourcestring|Bound""A file path (mantle.config_dir .. "/img/a.png", or ~/ under $HOME), never a theme name; "" draws nothing. PNG, JPEG, WebP, GIF, ICO, BMP, SVG or SVGZ; animated GIF, WebP and APNG 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 the first decode lands. A resize keeps drawing the previous size, scaled, until the new size decodes; 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. Animations ignore it. Under async, a change blanks the image until the re-decode lands; retain does not cover it
radiusnumber|Corners|Bound, [0, 8192]0Corner radius px of the drawn picture, as rect.radius: a number sets all four corners, a missing corner is 0, corners too big for a side shrink together. Rounds the visible picture, so "contain" rounds the fitted picture, not the box. Hit-testing ignores it
corner_smoothingnumber|Bound, [0, 1]0Continuous corners on the drawn picture, as rect.corner_smoothing. The shader that rounds a transition approximates a smoothed corner by a superellipse through its endpoints and midpoint
playingboolean|Boundtruefalse holds an animation on its current frame, the first if it never played, and stops its frame requests; true resumes from there. Per node: another image showing the same file plays on, and a node keeps its place while scrolled out or resized. Stills ignore it
statusBoundNoneAn image_status(name) signal. The engine writes the load of source; see load status

transition

FieldValuesDefaultBehaviour
durationms, [1, 60000]RequiredLength of the cross
easingAn easing"in_out_quad"Drives u_progress
shaderAbsolute, or ~/ under $HOME, .frag pathBuilt-in cross-dissolveReplaces the dissolve. Recompiled when the file changes
params{ name = number | { up to 4096 numbers } }{}Uniforms for that shader, as on a shader node. Refused without shader
images{ name = "/abs/path.png" }{}Up to 8 rasters to sample by name, with the rules of a shader node’s images; they take the units after u_from and u_to. 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.

Load status

status = image_status("name") reports whether source is loading, ready (with the file’s own size) or failed: see image_status.

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
Freeze an animation until askedplaying = live, a boolean state the config turns on
Show a file that ships with the configsource = mantle.config_dir .. "/img/logo.png"
Fade in once decoded, show a load error or a resolutionLoad status

Round an image’s corners

radius rounds the picture itself, as on a rect, so it needs no clipping wrapper. It rounds what shows: with fit = "contain" that is the fitted picture, not the box.

image {
    width = 96,
    height = 96,
    radius = 12,
    source = "/usr/share/backgrounds/a.jpg",
    fit = "cover",
    async = true,
}

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
transition.params or transition.images is refusedBoth need a shader
An animation restarts from its first framePlayback, playing included, is per node and file: a new source, or the node leaving the tree (its surface closes, a list drops it), starts it again
Two nodes on one animation show each other’s framePast the texture budget, or eight nodes on one file and size, nodes share one texture

See also: icon, shader, paint, animation.

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

capture

A preview of an output or a window. Outputs use ext-image-copy-capture-v1, with wlr-screencopy as a fallback. Window capture currently requires Hyprland’s toplevel address mapping and ext capture protocols. Without the required protocols it draws nothing and warns once per target.

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

local first_output = mantle.screens:map(function(screens)
    return 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.

Preview the focused window using its mantle.windows ID:

local focused_window = mantle.windows:map(function(state)
    for _, entry in ipairs(state.windows) do
        if entry.focused then return entry.id end
    end
    return ""
end)

return capture { window = focused_window, live = 15, fit = "contain", width = 320, height = 180 }

Window capture reads the toplevel itself, including when another window covers it. It does not crop an output. Pass the ID unchanged; names and titles are not identifiers.

Windows backendWindow capture
HyprlandSupported when the mapping and ext toplevel capture protocols are advertised
NiriUnsupported. Its foreign-toplevel identifiers match its window IDs, but its ext capture implementation currently handles outputs only
wlrUnsupported. Mantle assigns connection-local window IDs; the protocol provides no exact mapping to the renderer’s foreign-toplevel handles

Niri’s identifier implementation and capture handler confirm these are separate requirements. Non-Hyprland window capture remains on the roadmap.

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
windowstring|Bound""A mantle.windows entry’s id; "" draws nothing. Currently requires Hyprland’s exact toplevel mapping and ext capture protocols. Cannot combine with a nonempty output or region. A closed window clears its preview
fit"cover"|"contain"|"stretch"|Bound"cover"As on image
liveboolean|number|Boundfalsefalse: capture on show and on each target 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 its source changes. A resized source renegotiates its buffers and keeps going. A stopped live output session keeps its last frame and restarts when its frame-rate cap allows, even without a repaint. A completed one-shot keeps its frame. Any other failed output capture retries when the output list changes. A failed or closed window capture clears or stops its preview and remains stopped until the target changes or the node hides and shows again. An unrelated output change cannot revive a closed window’s ID. A one-shot captures the current frame; use live to follow opening animations.

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 first example above
Preview a windowwindow = entry.id from mantle.windows; currently Hyprland only
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
window and output are both setOnly one may be nonempty; region is output-only

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 only the images it names and takes no input. To run a shader between two pictures, use an image transition; to run one over a node’s painted subtree, use effect.shader, which shares this page’s .frag contract.

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, or ~/ under $HOME, .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, a list for vec2-vec4 or an array of either, flattened to at most 4096 numbers. Missing ones are 0. Not tweened
imagestable<string, string>|Bound{}Up to 8 PNG, JPEG or WebP paths, absolute or ~/ under $HOME, by sampler name: images. Missing ones sample transparent black. Not tweened

It has no intrinsic size: without width and height it draws nothing. opacity, transforms, shadows and effect.blur apply to it. Place animated boxes with compositor_blur = true beneath a shader when the desktop behind it should blur (blurs).

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 own, or arrays of themSet from params by name, 0 when params leaves one out. An array takes one flat list, element after element. A params name with no uniform is ignored; a wrong component count is padded or truncated and logged once

mantle_sdf and mantle_input exist only for effect.shader. 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

Images

images binds raster files for the shader to sample: a normal or displacement map, a noise texture, a gradient LUT.

local glass = shader {
    width = 200,
    height = 120,
    source = mantle.config_dir .. "/shaders/lens.frag",
    images = { lens = mantle.config_dir .. "/lens.png" },
}
void main() {
    vec2 offset = (texture(lens, v_uv).rg - 0.5) * 0.1;
    fragColor = vec4(offset + 0.5, 0.0, 1.0);
    // lens_size is the file's size in pixels, so a texel is 1.0 / lens_size.
}
RuleDetail
Declared for youuniform sampler2D <name>; and uniform vec2 <name>_size; (pixels). Do not declare them
NamesA GLSL identifier of up to 64 characters, not u_*, mantle_*, gl_*, v_uv, fragColor, main or a GLSL keyword or builtin such as texture, float, vec2 or mix, with no __ and not ending in _size. Others are refused
Files.png, .jpg, .jpeg or .webp (any case), absolute or ~/ under $HOME; at most 8 entries. GIF, SVG and other extensions are refused. An animated WebP or APNG draws its first frame. A side over 2048 px is downscaled to 2048; each image is decoded in the frame that first draws it and held for the node’s life, at most 16 MiB, so 128 MiB for eight
Samplingtexture(name, uv) with uv in 0..1, top-left origin like v_uv. Linear filter, no mipmaps. Clamped to the edge, so use fract(uv) to tile. Texels are premultiplied, as fragColor is, and in sRGB as stored: no linearising, so a normal map reads as written
Missing or undecodable fileSamples vec4(0.0) with name_size of vec2(1.0), logged once per path. The node still draws
Changed fileA new modification time or length reloads it, as an image does
Elsewhereeffect.shader takes the same key, on input = "content" and "backdrop"
SharingTwo nodes naming one path share one texture, held by the image cache and freed with it

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
Pass many values, like a visualizer’s barsuniform vec4 bars[64] takes 256 numbers, params = { bars = levels }; read bar i as bars[i / 4][i % 4]. Drivers may count each float array element as a whole vec4 against the uniform limit, so pack into vec4s
Work in pixelsv_uv * u_size is the fragment’s position in logical px
Click a shaderGive it on_click; without a handler it is transparent to the pointer
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 or ~/ 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.

path

Experimental vector leaf with no intrinsic size. width and height define its layout box; commands use logical pixels relative to the box’s top-left corner. Resizing does not scale coordinates. Use the existing scale transform to scale a drawing.

PropertyTypeDefaultBehaviour
commandsPathCommand[]|Bound{}Up to 4096 commands. Each has op M/L/Q/C/A/corner/Z and points containing 2/2/4/6/5/2/0 numbers; a corner also takes radius and corner_smoothing. Begin each subpath with M or A. Coordinates are in [-8192, 8192]; arc angles need only be finite
fillColor|Gradient|BoundNoneFill colour or gradient across the node box. Open subpaths close for filling
strokeColor|Gradient|BoundNoneStroke colour or gradient across the node box
stroke_widthnumber|Bound, [0, 8192]1Stroke width in logical pixels; centered on the path
stroke_cap"butt"|"round"|"square"|Bound"butt"How each open stroke end, trimmed ones included, finishes
stroke_join"miter"|"round"|"bevel"|Bound"miter"How stroked segments meet at a corner
trim_startnumber|Bound, [0, 1]0Where the stroke starts, as a fraction of the length of every subpath in order, closing segments included. The fill is untrimmed
trim_endnumber|Bound, [0, 1]1Where the stroke ends, as trim_start; at or before trim_start draws no stroke
trim_axis"length"|"x"|Bound"length"What trim_start and trim_end measure. "length": fractions of the path’s length. "x": fractions of the node’s width; the stroke keeps what lies inside that band of the box, cut at its edges with stroke_cap on every cut end, and the band stays put while shift moves the geometry through it
shiftAxes|Bound, [-8192, 8192]{ x = 0, y = 0 }Pixel offset of the geometry inside the node, applied before trimming and stroking. The box, the trim_axis = "x" band and the fill gradient do not move. Paint only

Each command has op and points. M moves, L draws a line, Q takes a control point and endpoint, C takes two control points and an endpoint, and Z closes the subpath. Z still needs points = {}. After closing, start another subpath with M or A. Empty commands draw nothing. Arrays must be dense.

A draws a circular arc: points = { cx, cy, r, start, sweep }, with the centre, a radius of at least 0, and angles in degrees from the +x axis, any finite value. A positive sweep turns clockwise on screen, a negative one anticlockwise, and a sweep of 360 or more draws the full circle. A can begin a subpath; inside one, a line joins the current point to the arc’s start. This is not SVG’s elliptical A.

corner rounds one point, { op = "corner", points = { x, y }, radius = 8, corner_smoothing = 0.6 }: the turn between the line in from the pen and the line out to the next command’s first point (or, before a Z, the subpath’s start) becomes a continuous corner, cut to fit its sides. A corner that ends an open subpath has no line out and is a plain line to its point. It cannot begin a subpath. A box’s shape takes the same commands.

Subpaths are solid whatever their winding, so overlapping ones merge. Set hole = true on the command that begins a subpath to cut it out of the fill instead. Each solid subpath counts +1 and each hole -1 at a point, and any count but 0 paints: a hole outside every solid subpath, or two holes overlapping inside one, fills.

stroke_cap shapes open stroke ends and stroke_join the corners. Open paths close for filling. The layout box does not grow to include strokes; leave padding inside it. Ancestor clipping, masks, opacity, transforms, shadows and content blur use the existing paint pipeline.

A painted path claims its whole box for input, not the drawn shape; an unpainted one is click-through unless it has a pointer handler (input region).

A chart component builds commands from values without writing an SVG file:

local function chart(values)
    local commands = {}
    for i, value in ipairs(values) do
        commands[i] = {
            op = i == 1 and "M" or "L",
            points = { 4 + (i - 1) * 16, 60 - value * 56 },
        }
    end
    return path {
        width = 120,
        height = 64,
        stroke = "#80c0ff",
        stroke_width = 3,
        commands = commands,
    }
end

return chart { 0.2, 0.5, 0.3, 0.9, 0.6, 0.8, 0.7 }

A progress ring is a full-circle track with a hole, and a stroked arc for the value:

local function ring(value)
    return rect {
        width = 48,
        height = 48,
        children = {
            path {
                width = 48,
                height = 48,
                fill = "#ffffff20",
                commands = {
                    { op = "A", points = { 24, 24, 22, 0, 360 } },
                    { op = "Z", points = {} },
                    { op = "A", points = { 24, 24, 16, 0, 360 }, hole = true },
                },
            },
            path {
                width = 48,
                height = 48,
                stroke = "#89b4fa",
                stroke_width = 6,
                commands = { { op = "A", points = { 24, 24, 19, -90, value * 360 } } },
            },
        },
    }
end

return row {
    padding = 14,
    spacing = 14,
    radius = 12,
    background = "#1e1e2e",
    children = {
        ring(0.7),
        column {
            align_v = "center",
            spacing = 2,
            children = {
                text { content = "Storage", font_weight = 700, foreground = "#cdd6f4" },
                text { content = "358 of 512 GB used", font_size = 12, foreground = "#a6adc8" },
            },
        },
    },
}

trim_start and trim_end stroke only part of the path, as fractions of its whole length: every subpath in order, a Z’s closing line included. The fill stays whole. Animate trim_end to grow an arc along a fixed path instead of morphing its commands; with round caps this is a progress indicator:

local value = state("download", 0.4)

return path {
    width = 48,
    height = 48,
    stroke = "#89b4fa",
    stroke_width = 4,
    stroke_cap = "round",
    trim_end = value,
    commands = { { op = "A", points = { 24, 24, 20, -90, 360 } } },
    animate = { trim_end = { duration = 300, easing = "out_cubic" } },
}

Bind commands to a signal to replace a path. animate tweens commands point by point when the old and new lists have the same ops and hole flags in the same order; any other change snaps. Arc angles tween too, so a ring can grow its sweep. To morph between shapes, give each one the same command layout. This loader samples three outlines at the same 60 angles and loops through them as keyframes, with no Lua running per frame:

local function outline(radius)
    local commands = {}
    for i = 0, 59 do
        local a = math.rad(i * 6 - 90)
        local r = radius(i, a)
        commands[i + 1] = { op = i == 0 and "M" or "L", points = { 24 + r * math.cos(a), 24 + r * math.sin(a) } }
    end
    commands[61] = { op = "Z", points = {} }
    return commands
end

local star = outline(function(i) return 22 - 12 * (1 - math.abs(i % 12 / 6 - 1)) end)
local square = outline(function(_, a) return 17 / math.max(math.abs(math.cos(a)), math.abs(math.sin(a))) end)
local circle = outline(function() return 20 end)

return path {
    width = 48,
    height = 48,
    fill = "#fab387",
    animate = {
        commands = {
            duration = 600,
            easing = "in_out_cubic",
            keyframes = { star, square, circle, star },
            loops = "infinite",
        },
    },
}

Each property tweens on its own, so a loop on one keeps running while another eases. trim_axis = "x" makes trim_start and trim_end fractions of the node’s width instead of the path’s length: the stroke keeps what lies inside that band of the box, cut at its edges with stroke_cap on every cut end. shift moves the geometry inside the node and leaves the box and the band where they are. This wave is trimmed to progress, loops its phase on shift and eases its amplitude on commands when amplitude changes. Setting it to 0 flattens the moving wave without restarting the loop:

local amplitude = state("amplitude", 6)
local progress = state("progress", 0.6)

local function wave(a)
    local commands = { { op = "M", points = { 0, 12 } } }
    for i = 0, 7 do
        local x, crest = i * 30, i % 2 == 0 and -2 * a or 2 * a
        commands[#commands + 1] = { op = "Q", points = { x + 15, 12 + crest, x + 30, 12 } }
    end
    return commands
end

return path {
    width = 120,
    height = 24,
    stroke = "#89b4fa",
    stroke_width = 3,
    stroke_cap = "round",
    trim_axis = "x",
    trim_end = progress,
    commands = amplitude:map(wave),
    animate = {
        commands = { duration = 300, easing = "out_cubic" },
        trim_end = { duration = 300, easing = "out_cubic" },
        shift = {
            duration = 1000,
            easing = "linear",
            keyframes = { { x = 0, y = 0 }, { x = -60, y = 0 } },
            loops = "infinite",
        },
    },
}

Source: vocabulary, vector path.

list

A row or column whose children come from data: one each(item) call per element of items, up to limit. Use 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"),
    items = rows,
    key = function(pair) return table.concat(pair, "\n") end,
    each = 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
itemsany[]|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
eachfun(item: any): NodeRequiredBuilds a node for every built item: all of them, or near the viewport in a keyed list with scroll
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 each 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’s each read builds that item alone; a change the list itself read builds every item, or every item near the viewport in a long scrolling list. A signal bound to an item’s property is not the build’s: a write to it runs that property’s getter in each item that binds it and calls no each.

ChangeRuns again
A write to items, or to a signal under a map or computed bound to itEvery item’s each
A write to a signal key read with :get()Every item’s each
A new items, each or key value, a new limit, or a reloadEvery item’s each
A write to a signal an item’s each call read with :get()That item’s each
A write to a signal bound to a property of an item at any depth, such as background = theme or its hoverThe getters that read it, no each
A write to anything else, even on the same surfaceNothing

So bind a shared signal such as a theme color as the property, background = theme.bg, rather than reading it in each with theme.bg:get(): flipping the theme then costs one getter per row, not one each per row. A selection highlight follows the same rule: derive it per row, background = computed({ results, selected }, ...), rather than folding the selection into items, which builds every row on each move.

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 each, key or item map that reads the clock, a mutable variable or an items 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.

Long scrolling lists

A list with both key and its own scroll builds only the items within a viewport of what it shows. The rest cost a key call when items changes, and scrolling, clamping and reveal behave as if every item were built. The first pass after the list appears builds everything once, to measure. An item scrolled further away loses its node: no animate.exit plays, and its tweens, keyboard focus and text field draft are dropped; when it comes back, each runs again but animate.from does not replay. Keep state that must survive in a named state.

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 items 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"),
    items = names,
    each = function(name)
        return text { content = name, width = "fill", padding = 6 }
    end,
}

Gotchas

TrapFix
A 2000-item list makes every update slowWithout key and scroll, every item is laid out on every pass, and built whenever anything it read changes, visible or not. Give it both so it builds only near its viewport, or cap it with limit
A list rebuilds though nothing it shows changedIts each 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 updatingeach read the clock with os.time(). Bind the text to a map of mantle.clock instead (what a node reads again)
A list of more than 10000 elements is refusedSet limit, or page the items
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 text input: a search box, a launcher query, a password, or with multiline a message box. The engine holds what the user types (the draft); Lua sees it through callbacks and sets it only with focus_target(name):set_text. 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_key = function(key)
                if key.name == "Down" then selected:set(math.min(#matches:get(), selected:get() + 1))
                elseif key.name == "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",
        items = matches,
        key = function(name) return name end,
        each = function(name)
            return rect {
                width = "fill", padding = { left = 10, right = 10, top = 6, bottom = 6 }, radius = 8,
                background = computed({ matches, selected }, function(found, index)
                    return found[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 = "on_demand", child = launcher } }

The image shows the empty search field and unfiltered list. Typing updates the list through on_change. The panel needs keyboard_interactivity for the field to get keys (panel).

Properties

textfield takes the common properties, plus:

PropertyTypeDefaultBehaviour
fontstring|BoundThe fonts chainFamily placed before the fonts chain. "" raises; an unknown family falls back to the chain
font_sizenumber|Bound, [1, 8192]12Text size in logical pixels. A textfield sizes its placeholder with it
line_heightnumber|Bound, [0.1, 10]1.2Line height as a multiple of font_size
letter_spacingnumber|Bound, [-100, 100]0Extra space between characters in logical pixels. Negative values tighten text
font_weightnumber|Bound, [1, 1000]400Font weight from 1 to 1000. A run with bold = true uses weight 700
italicboolean|BoundfalseUse the family’s italic face when available. A run with italic = true stays italic
font_variationstable<string, number>|Bound{}OpenType variation axes by 4-character tag ({ FILL = 1, GRAD = -25, opsz = 24 }), as CSS font-variation-settings. Values clamp to each face’s range; axes a face lacks are ignored. An explicit wght overrides font_weight and bold runs. Changes snap; see variable fonts
foregroundColor|Bound"#FFFFFF"A colour; a run’s color overrides it, and a textfield’s placeholder takes it unless placeholder_color is set
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
placeholderstring|Bound""Shown while the field is empty, focused or not. Never submitted
placeholder_colorColor|BoundforegroundColour of the placeholder
caretCaret|BoundNoneThe caret bar: { color, width, height, radius }. color defaults to foreground; width is px, default a sixteenth of font_size rounded, at least 1. height is px, or a fraction of the line height when 1 or less; default the whole line, centred on it. radius is px, default 0. Each key takes a signal. Paint only: animate snaps it
selectionSelection|BoundNoneThe selection’s ink: { background, foreground }. background is the highlight, used as given (add alpha to tint); default the field’s foreground at 30% alpha. foreground colours the selected glyphs; default the text colour. Each key takes a signal. Paint only: animate snaps it
disabledboolean|BoundfalseRenders like a field but takes no keyboard focus (Tab skips it, a press does not focus it, focus_target requests and autofocus pass over it) and draws no caret; set_text still reaches it. A focused field that becomes disabled loses focus and keeps its draft. Dim it yourself by binding colours to the same signal
max_lengthnumber|Bound0Most grapheme clusters the field holds; 0 is unlimited and a negative value is refused. Typing, paste, IME commits and focus_target(name):set_text(text) cut what they insert at the limit, secure fields included. Lowering it below the current text keeps that text; edits can then only shorten it. The cut is silent, so a limit below a password’s length truncates it
multilineboolean|BoundfalsePlain fields only: wrap the draft at the field’s width and take newlines. Return inserts one and submit_key submits; Up and Down move by visual row, Home and End to its ends, Ctrl+Home and Ctrl+End to the text’s. Paste and IME commits keep newlines, \r\n and \r as \n. The field grows from min_lines to max_lines rows; past that, or at a fixed height, its rows scroll to keep the caret in view, and the wheel scrolls them. Refused with secure_submit
min_linesnumber|Bound1Ignored without multiline: the fewest rows the field is tall; 0 is 1, a negative value is refused
max_linesnumber|Bound0Ignored without multiline: the most rows the field grows to before its rows scroll; 0 is unlimited, a negative value or one below min_lines is refused
submit_key"ctrl+return"|"return"|Bound"ctrl+return"Ignored without multiline: the chord that submits. "ctrl+return" leaves Return to insert a newline; "return" submits on Return and Shift+Return inserts the newline
initial_textstring|Bound""Plain fields only: seeds the draft once, when the field enters the tree (a new node: a changed id or key counts as new), with the value at that moment, read without subscribing: writing the signal alone does not re-resolve the field. Later changes are ignored and an emptied field stays empty; set_text pushes new text. Like set_text: cut at max_length, caret at the end, no undo history, no on_change; hidden and disabled fields are seeded too. Refused with secure_submit, control characters (but \n in a multiline field) and over 64 KiB
on_changefun(text: string)NoneFull text after every edit
on_submitfun(text: string)NoneEnter (in a multiline field, submit_key) with the full text; the field stays focused and clears. Never fires on a secure_submit field
escape"clear"|"blur"|"pass"|Bound"clear"What Escape does in a plain field. "clear" empties the draft (on_change("") if it had text), then gives up focus if on_cancel is set. "blur" keeps the draft and gives up focus. "pass" keeps both and does not take the key: it goes up through on_key, then to the surface’s on_escape. A secure_submit field ignores it and always scrubs and stays armed. Read when the field takes focus
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
secure_submit{ capability: string, action: string, name?: string }|BoundNoneMakes the field masked; bytes never reach Lua. Targets: lock/authenticate, polkit/authenticate, network/connect, network/vpn_secret with a request id and key in name, secrets/store with a public name, or bluetooth/pair with a request id and MAC in name (secure fields)
mask_characterstring|Bound"•"Drawn per typed character in a secure_submit field. Only the first character counts; "" hides the length
maskedboolean|Boundtruefalse draws a secure_submit field’s typed text instead of the mask, for a prompt PAM shows (prompt.echo). Still no IME, caret or selection, and the text never reaches Lua. Turning it off scrubs what was typed under the mask. Ignored without secure_submit

The field has no intrinsic width, so give it width; without height it is one line tall (font_size times line_height), or a multiline field’s rows. It draws one line of text and a caret, vertically centred (a multiline field: rows from the top), with the same typography properties as text, the draft, placeholder and mask alike, in the fonts chain unless font names a family. Plain fields use zwp_text_input_v3 for composition when the compositor offers it and text-input enters the field’s own surface. Raw keys stay active between compositions and are suppressed during pending or active composition. Preedit text is underlined; commits and surrounding deletions call on_change. Secure fields read wl_keyboard and never send their draft to an input method.

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

Multiline

multiline = true wraps the draft at the field’s width. Return inserts a newline and Ctrl+Return submits; submit_key = "return" swaps them, with Shift+Return inserting the newline. The field grows from min_lines to max_lines rows; past that, or at a fixed height, its rows scroll to keep the caret in view, and the wheel scrolls them. Up and Down move by visual row, Home and End to its ends, Ctrl+Home and Ctrl+End to the text’s. Up on the first row and Down on the last reach on_key. Paste, input-method commits and set_text keep newlines; a secure_submit field cannot be multiline.

local reply = column { width = 280, padding = 10, background = "#1E1E2E", radius = 12, children = {
    textfield {
        width = "fill",
        font_size = 14,
        foreground = "#CDD6F4",
        placeholder = "Write a reply. Return sends it, Shift+Return starts a new line.",
        multiline = true,
        min_lines = 3,
        max_lines = 8,
        submit_key = "return",
        on_submit = function(text) print("send", text) end,
    },
} }

return { panel { id = "reply", layer = "top", anchor = { bottom = true },
    keyboard_interactivity = "on_demand", child = reply } }

How do I…

TaskAnswer
Filter a list as the user typesThe example above: on_change sets a state, the list’s items maps it
Move a selection with the arrow keyson_key on the field, 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
Return keys to the field after a clickOne local h = focus_target("name"): focus_target = h on the field, h:request() in the on_click (input)
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 buttonsubmits_secure = true on the clickable node
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 width. Give it width
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 alone never firesIt does not make the field focusable. Add on_change or on_submit
You cannot read the draft from LuaIt arrives only through on_change and on_submit. focus_target(name):set_text writes it; 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 {
    width = 240,
    padding = 16,
    spacing = 10,
    background = "#1E1E2EF2",
    radius = 12,
    border_width = 1,
    border_color = "#FFFFFF1A",
    shadows = { { color = "#00000099", blur = 18, offset = { x = 0, y = 8 } } },
    children = {
        row {
            width = "fill",
            children = {
                text { content = "Battery", width = "fill", font_size = 14, font_weight = 700, foreground = "#CDD6F4" },
                text { content = "82%", font_size = 14, foreground = "#A6E3A1" },
            },
        },
        rect {
            width = "fill",
            height = 6,
            radius = 3,
            background = "#313244",
            children = { rect { width = "82%", height = 6, radius = 3, background = "#A6E3A1" } },
        },
        text { content = "3 h 10 min left", font_size = 12, 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 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 effect.shader, effect.blur, a colour filter and some shadows use. Unlike other offscreen passes, Mantle keeps it and reuses it while the subtree does not change
GlassA box with effect.backdrop.blur or a backdrop colour filter
SigmaA Gaussian blur’s standard deviation in logical px. The blur reaches about 3 sigma

Output scale

Node sizes, positions, input and compositor regions use logical pixels. Mantle paints each surface into a buffer sized for that surface’s compositor scale, including fractional scales when the compositor offers fractional-scale and viewporter together. Moving a surface between outputs repaints its buffer at the new scale. Text and images gain resolution without changing the layout. The node scale property is a separate paint transform; it does not set output scale.

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
shadows, effect, opacityEvery node, including text, icon, image, list, textfield
background, radius, corner_shape, shape, border_color, border_width, ring, clip, mask, shadow_mode, compositor_blurBox kinds only
effect.backdropBox kinds only; the key is refused elsewhere, naming it
source_blurimage only
radiusBox kinds and image
foreground (text, icon, textfield), z, scale, rotate, translate, origin, visibleAlso affect paint; documented on Nodes

Every property can be a signal, and so can a value inside a property table, such as a gradient stop’s colour or one border edge. A malformed value draws the property’s default and goes to mantle log and mantle.rescue; a reload and mantle check refuse it (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|BackgroundLayer[]|BoundNoneA colour, a gradient or a list of layers. Absent draws nothing; "#00000000" is an explicit transparent fill. Colour layers tween under animate; a gradient snaps
maskMask|BoundNoneMultiplies the alpha of this node and its subtree; see Mask
radiusnumber|Corners|Bound, [0, 8192]0Corner radius px; a number sets all four corners, a missing corner is 0. Corners too big for a side shrink together, so radius = 999 makes a pill or circle. Shadows round by the mean corner
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 compositor_blur region follow
corner_smoothingnumber|Bound, [0, 1]0Continuous corners, as Figma’s corner smoothing: 0 is the circular arc, 0.6 is close to iOS. A smoothed corner spreads up to (1 + corner_smoothing) * radius along each side, less where the side is short. Refused with corner_shape = "scoop". Fill, border, clip, mask, effect.backdrop and the compositor_blur region follow; a "box" shadow stays the circular mean-radius approximation
shapeShape|BoundNoneThe box’s shape as one closed contour or a melt, in place of radius; see Shape
border_colorColor|BorderColors|Gradient|BoundNoneA string sets all four edges; a missing edge has none. An edge draws only with both a colour and a width. A gradient runs along the whole outline and refuses a per-edge one; it snaps under animate
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
ringRing|BoundNoneA stroke of width px and color around the box, offset px outside its edge; see Ring
compositor_blurboolean|BoundfalseAsk the compositor to blur the desktop behind this box; see Blurs. Never inferred from a translucent background
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" on a surface or a scroll viewport, else "none""box" cuts children to the rectangle, "rounded" also to radius, "none" leaves them on the parent’s clip; a mask cuts to the box regardless. See Clip

Continuous corners

corner_smoothing is Figma’s corner smoothing, the model behind Apple’s continuous corners: 0 is the circular arc, 0.6 is close to iOS, 1 the softest. A smoothed corner spreads up to (1 + corner_smoothing) * radius along each side as a curve that eases into a shorter circular arc, so the edge leaves the straight with no visible kink. Where a side is too short, the radius stays and the smoothing gives way; two corners share a side in proportion to their radii. Each corner of a radius table is smoothed by the one value, and a "scoop" refuses it. image takes it too.

return rect {
    width = 160, height = 96, radius = 24, corner_smoothing = 0.6,
    background = "#1E1E2E", border_width = 2, border_color = "#89B4FA",
    animate = { corner_smoothing = 200 },
}

Fill, border, clip = "rounded", mask, effect.backdrop and the compositor_blur region all follow the one shape. Two things are approximate: a "box" shadow still takes the circular mean radius, and an image transition shader rounds a smoothed corner by a superellipse through its end points and middle, exact only at 0. A border’s inner edge is the outer curve scaled to the inner radii. corner_smoothing only reshapes the shape, so an animate tween on it runs without a layout.

A border follows the corners, round or scooped, as CSS draws it. Where two edges meet, the corner splits between their colours in proportion to their widths, so a lone edge curves round both corners and tapers away:

local function tile(label, color, props)
    props.width, props.height, props.radius = 104, 72, 16
    props.background = "#1E1E2E"
    props.border_color = props.border_color or color
    props.children = { text { content = label, foreground = "#CDD6F4", align_h = "center", align_v = "center" } }
    return rect(props)
end

return row {
    spacing = 16,
    children = {
        tile("round", "#89B4FA", { border_width = 2 }),
        tile("scoop", "#CBA6F7", { corner_shape = "scoop", border_width = 2 }),
        tile("Per-edge", "#A6E3A1", { border_width = { bottom = 3 } }),
        tile("Four colours", nil, {
            border_width = 3,
            border_color = { top = "#89B4FA", right = "#CBA6F7", bottom = "#F38BA8", left = "#A6E3A1" },
        }),
    },
}

Shape

shape = { commands = { .. } } replaces the rounded rectangle with any shape: one closed contour of path commands: an M, at least two more commands, then Z, with no other M or Z, no hole and at most 256 commands. A popover and its arrow, a speech bubble’s tail, a tab joined to its panel or a notched card is one shape, so the fill, the border, shadows (inset too), clip = "rounded", mask, effect.backdrop, effect.shader’s mantle_sdf, hit testing and the compositor_blur region run round it without a seam. It refuses radius, corner_shape and corner_smoothing, and its border takes one width and one colour or gradient.

Each coordinate follows the box’s size, so a shape fits content and "fill" widths:

FormMeans
12px from the box’s left or top edge
"50%"that share of the box’s width or height, up to "1600%"
{ from = "right", px = -12 }px from "left", "right", "top", "bottom", "center" or a "NN%"

"left" and "right" are x and "top" and "bottom" are y, so each is refused in the other’s slot. A placed coordinate is held to [-8192, 8192] px.

{ op = "corner", points = { x, y }, radius = r, corner_smoothing = s } (radius is required) rounds the turn at a point between the line in and the line out, with the continuous corners above. Two corners sharing a side split it, and a radius too big for its sides shrinks. A corner where the contour turns the other way is a concave fillet, so a tail can flare into its body. Points may reach past the box; the tail paints, takes clicks and casts a shadow there, and the box keeps its layout size. Children stay cut to the box rectangle under clip = "box", so a child hanging into the tail is neither drawn nor hit; under clip = "rounded" they follow the contour. A contour that crosses itself or winds twice is unsupported: paint fills by the non-zero rule, hit testing agrees, and mantle_sdf measures even-odd.

return column {
    padding = { top = 10 },
    children = {
        column {
            padding = 14,
            spacing = 4,
            background = "#1E1E2E",
            border_width = 2,
            border_color = "#89B4FA",
            shadows = { { blur = 12, offset = { y = 4 }, color = "#00000080" } },
            shape = { commands = {
                { op = "M", points = { 0, 20 } },
                { op = "corner", points = { 0, 0 }, radius = 12, corner_smoothing = 0.6 },
                { op = "corner", points = { { from = "center", px = -12 }, 0 }, radius = 4 },
                { op = "corner", points = { "50%", -10 }, radius = 3 },
                { op = "corner", points = { { from = "center", px = 12 }, 0 }, radius = 4 },
                { op = "corner", points = { "100%", 0 }, radius = 12, corner_smoothing = 0.6 },
                { op = "corner", points = { "100%", "100%" }, radius = 12, corner_smoothing = 0.6 },
                { op = "corner", points = { 0, "100%" }, radius = 12, corner_smoothing = 0.6 },
                { op = "Z", points = {} },
            } },
            children = {
                text { content = "Wi-Fi", font_weight = 700, foreground = "#CDD6F4" },
                text { content = "Connected to home", font_size = 12, foreground = "#A6ADC8" },
            },
        },
    },
}

animate = { shape = .. } tweens point by point, springs included, between two lists of the same commands; any other change snaps. A point written in px on one side and "NN%" on the other crosses as their resolved positions would. A corner’s radius holds at 0 and its smoothing in [0, 1] through an overshoot. The tween runs without a layout or Lua.

mantle_sdf measures a shape as a polygon of at most 256 points, each curve cut every 3° of turn; past 256 points the cut widens to 6°, 12° and so on, so the gradient bends by at most 3° at a point up to about 8 corners, more beyond (and straight segments past 256 are thinned). Hit testing reads the exact curves, so it and mantle_sdf can differ by a fraction of a pixel. A shadow on a shape is the silhouette’s, blurred offscreen like shadow_mode = "content", and an inset shadow takes one offscreen blur per paint where a rounded box takes one gradient quad.

Melt

shape = { melt = k, parts = { .. } } joins up to 8 rounded boxes into one outline: where two parts come within k px of each other, a fillet of about k px joins them, tangent to both, and elsewhere each part keeps its own edge. A circle tab grows out of a panel’s corner, and two bubbles drift together and fuse. Each part is { left = .., top = .., right = .., bottom = .., radius = r }. Its edges take the point forms above, so "100%" follows the box. radius is held to half the shorter side, so radius = 999 makes a circle or pill. melt is [0, 256], default 0, a plain union.

The engine computes the fillets, so a tween stays smooth at every frame. A hand-built contour cannot: animate.shape moves each fillet’s centre in a straight line, which pulls the fillet off the circle it should touch and leaves a spur on the edge until the tween ends. A melt tweens each part’s edges and radius plus melt, between two melts with the same number of parts. A melt against a contour or a different count snaps. A still melt is traced once. A tween traces it every frame, which averages 0.1 ms over a 400 by 300 panel collapsing into its tab.

Everything that follows shape follows a melt. Two separate blobs paint, take clicks and claim their regions as two. In the frame where two blobs first touch, the neck between them pinches to a point, as in any metaball. Parts that ring a hole leave it empty: the fill, clicks and an inset shadow skip it, and a border bands its edge from the parts’ side. A part shows from a quarter pixel across and grows from there, so a 2 px divider draws. The outline is cut 1024 px outside the box. mantle_sdf measures only the biggest outline, so a hole reads as inside and the other blobs as outside.

local function tab(right, bottom, melt)
    return { melt = melt, parts = {
        { left = 0, top = 0, right = 48, bottom = 48, radius = 999 },
        { left = 24, top = 24, right = right, bottom = bottom, radius = 14 },
    } }
end

local function bubbles(at)
    return { melt = 16, parts = {
        { left = 0, top = 0, right = 40, bottom = 40, radius = 999 },
        { left = at, top = 0, right = at + 40, bottom = 40, radius = 999 },
    } }
end

local function loop(a, b)
    return { duration = 1500, easing = "in_out_cubic", keyframes = { a, b, a }, loops = "infinite" }
end

return row {
    spacing = 24,
    children = {
        rect {
            width = 200, height = 120,
            background = "#1E1E2E", border_width = 2, border_color = "#89B4FA",
            shape = tab(200, 120, 12),
            animate = { shape = loop(tab(200, 120, 12), tab(40, 40, 6)) },
        },
        rect {
            width = 120, height = 40, align_v = "center",
            background = "#CBA6F7",
            shape = bubbles(80),
            animate = { shape = loop(bubbles(80), bubbles(30)) },
        },
    },
}

Gradients

background, mask and border_color 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

On border_color the gradient spans the node’s box and shows only where border_width draws, so width, radius, corner_shape and corner_smoothing shape the ring as for a flat colour.

return rect {
    width = 120, height = 40, radius = 12,
    border_width = 2,
    border_color = { gradient = "conic", stops = { { 0, "#CBA6F7" }, { 0.5, "#89B4FA" }, { 1, "#CBA6F7" } } },
}

ShapeGeometry
"linear"Along angle through the centre, long enough that the corners take the end stops (CSS)
"radial"An ellipse from the centre out to the box’s edges, not its corners
"conic"A turn around the centre, starting at angle
local stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } }

local function swatch(label, fill)
    return column {
        spacing = 8,
        children = {
            rect { width = 112, height = 80, radius = 12, background = fill },
            text { content = label, font_size = 13, foreground = "#BAC2DE", align_h = "center" },
        },
    }
end

return row {
    spacing = 16,
    children = {
        swatch("linear, 90", { gradient = "linear", angle = 90, stops = stops }),
        swatch("radial", { gradient = "radial", stops = stops }),
        swatch("conic", { gradient = "conic", stops = stops }),
    },
}

Background layers

background also takes a list of layers, as CSS multiple backgrounds: the first is on top, like shadows. A layer is a colour, a gradient, or { fill = <colour or gradient>, blend = <mode> }, where blend is one of the blend modes. Every layer shares the box’s radius, corner_shape and corner_smoothing. At most 16; {} draws nothing. A single colour or gradient stays valid.

rect {
    width = 120, height = 40, radius = 12,
    background = {
        { gradient = "linear", stops = { { 0, "#FFFFFF33" }, { 1, "#FFFFFF00" } } },
        "#313244",
    },
}

Under animate, colour layers tween pairwise and a layer only one side has fades in or out. A gradient layer and a layer’s blend snap.

Clip

clip decides what a box cuts its children to. As CSS overflow: visible, a box without one cuts nothing: a child laid out, shadowed or transformed past it paints there and is hit there. A row, column or list with a scroll signal and every surface default to "box", since a viewport has to hide what it scrolled out. Set clip = "box" on a button whose ripple or hover scale-up must stay inside it. A list takes no clip: it cuts only when it scrolls.

A child’s translate, scale or rotate moves its own paint, never the parent’s clip: under a clipping parent, a child that transforms past the parent’s box is cut there (a rotated one by the box’s bounds in its own space).

ValueChildren are cut toCost
"box"The box’s rectangleFree (a scissor)
"rounded"The box’s radius, corner_shape and corner_smoothing, or its 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 box. The default but on a scroll viewport or a surfaceFree

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
{ node = "shape" }The named direct child’s painted subtree; RGB is ignored
Any form, plus invert = trueThe complement: kept and cut swap

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

local networks = { "Home", "Office 5G", "Cafe Guest", "Library", "Studio", "Garden", "Lab", "Backup", "Hotspot", "Lobby",
    "Attic", "Garage" }
local rows = {}
for i, name in ipairs(networks) do
    rows[i] = row {
        width = "fill",
        padding = 10,
        spacing = 10,
        radius = 8,
        background = i == 1 and "#89B4FA26" or "#313244",
        children = {
            icon { name = "network-wireless-symbolic", size = 16, foreground = i == 1 and "#89B4FA" or "#A6ADC8", align_v = "center" },
            text { content = name, foreground = "#CDD6F4", align_v = "center" },
        },
    }
end

return column {
    width = 220,
    height = 240,
    padding = 8,
    spacing = 6,
    radius = 12,
    background = "#1E1E2E",
    scroll = scroll("feed"),
    mask = {
        gradient = "linear",
        stops = { { 0, "#00000000" }, { 0.1, "#000000" }, { 0.9, "#000000" }, { 1, "#00000000" } },
    },
    children = rows,
}

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

A mask can name an owned direct child by id with mask = { node = "shape" }. The child keeps its ordinary layout and reactive properties, but paints only into the mask and receives no input. Its RGB is ignored; transparent or hidden mask content cuts everything. invert = true reverses that alpha. Missing child ids fail the pass. The child’s opacity multiplies the mask; the parent’s opacity applies once to content. Mask subtrees can contain transforms, effects, and further masks.

rect {
    width = 160, height = 80, background = "#3366ffff",
    mask = { node = "shape" },
    children = {
        rect { id = "shape", width = 80, height = "fill", radius = 24,
            background = "#ffffffff" },
    },
}

The mask child contributes to Content sizing and flow spacing like other children. Use a stacking rect with explicit dimensions when the mask must not size the content. Mask alpha does not change the content’s input or desktop blur region.

Ring

ring = { width, color, offset } strokes the box width px thick in color, offset px (default 0, inside when negative) from its edge with a clear gap between: Material’s focus indicator (3 px, 2 px offset) and CSS outline with outline-offset. width = 0, a clear color or an inset past the box’s middle draws nothing. The gap stays offset all round, so a rounded corner’s ring has radius radius + offset + width outside and follows corner_smoothing and shape too. It is paint only: no layout space or hit area, drawn over the children. The node’s own clip does not cut it, a clipping ancestor does as it does a shadow, and animate.ring tweens all three numbers.

local ring = focus_visible("save")

return panel {
    id = "dialog",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = rect {
        width = 80,
        height = 32,
        radius = 8,
        accessible_name = "Save",
        focus_ring = false,
        focus_visible = ring,
        ring = ring:map(function(on) return { width = on and 3 or 0, color = "#89b4fa", offset = 2 } end),
        on_click = function() end,
    },
}

Shadows

shadows is a list of layers, CSS’s box-shadow: a, b: the first layer is on top and a node takes at most 16, inset and outer together. A layer is a table, and draws when its color has alpha above 0 and at least one of blur, offset or spread is set. A single shadow is shadows = { { blur = 8 } }.

Layer keyValuesDefault
colorColour"#000000"
blurCSS blur radius in px [0, 8192]; the Gaussian’s sigma is half of it0
offset{ x, y } px, each [-8192, 8192], missing axis 0{ x = 0, y = 0 }
spreadpx [-8192, 8192] the shape grows (negative shrinks) per side. On a non-box shadow it scales the shadow about the box centre instead. On an inset layer it shrinks the unshaded area instead0
insettrue: CSS box-shadow: inset, see Inset shadowsfalse
PropertyValuesDefault
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(label, mode)
    return row {
        padding = 14,
        spacing = 10,
        radius = 12,
        border_width = 2,
        border_color = "#89B4FA",
        shadow_mode = mode,
        shadows = { { color = "#000000", blur = 4, offset = { x = 5, y = 6 } } },
        children = {
            icon { name = "starred-symbolic", size = 22, foreground = "#F9E2AF", align_v = "center" },
            text { content = label, font_size = 20, foreground = "#CDD6F4", align_v = "center" },
        },
    }
end

return row {
    padding = 28,
    spacing = 28,
    radius = 16,
    background = "#6C7086",
    children = { card("box", "box"), card("content", "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 effect.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 each layer’s color
Box mode on a translucent scoopA layer of the scoop’s silhouette, cut out under the box

Layered shadows

A tight key shadow under a wide ambient one:

rect {
    width = 160,
    height = 64,
    radius = 12,
    background = "#1E1E2E",
    shadows = {
        { color = "#0000004D", blur = 2, offset = { y = 1 } },
        { color = "#00000026", blur = 6, offset = { y = 2 }, spread = 2 },
    },
}

Inset shadows

inset = true shades the inside of the box: the padding box is filled with color everywhere outside a hole, the box moved by offset and shrunk by spread, feathered by blur. The box’s shape clips it, and it draws above every background layer and under the children and the border. It needs shadow_mode = "box" on a box kind; text, icon, image and the other non-box kinds refuse it. The hole takes the mean of the box’s corner radii.

rect {
    width = 160,
    height = 64,
    radius = 12,
    background = "#1E1E2E",
    shadows = { { color = "#00000080", blur = 8, offset = { y = 2 }, inset = true } },
}

In content mode each layer is a blur pass over the same offscreen, so it costs one blur per layer. animate.shadows tweens layer by layer, and a layer only one side has fades in or out at its own geometry. A spring on shadows restarts from rest when its target changes mid-flight.

Blurs

Four blurs read 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
compositor_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
effect = { 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
effect = { 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

compositor_blur = true. Mantle sends the compositor a region, through ext-background-effect-v1, made of every compositor_blur = true box on the surface: rounded to radius, scooped or shaped as its shape, cut by ancestor clips, followed through transforms (a rotated box gets a rotated region; ancestor clips stay upright), 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. Animations 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_zone = true,
    background = "#1E1E2E99",
    compositor_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 = 360,
    height = 200,
    radius = 16,
    clip = "rounded",
    children = {
        image { source = "/usr/share/backgrounds/default.png", width = "fill", height = "fill", async = true },
        row {
            align_h = "center",
            align_v = "center",
            padding = { left = 16, right = 16, top = 8, bottom = 8 },
            spacing = 10,
            radius = 999,
            background = "#FFFFFF1F",
            border_width = 1,
            border_color = "#FFFFFF33",
            effect = { backdrop = { blur = 12 } },
            children = {
                text { content = "12:45", font_size = 20, font_weight = 700, foreground = "#FFFFFF", align_v = "center" },
                text { content = "Thu 24 Sep", font_size = 13, foreground = "#FFFFFFCC", align_v = "center" },
            },
        },
    },
}

A frosted pill: the image is painted first, so the pill’s effect.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.

Colour filters

effect also takes CSS saturate(), brightness() and contrast(), on the node’s subtree and, in backdrop, on the pixels under a box. Each is a factor in [0, 8], default 1, which is off and costs nothing. At one level they apply in a fixed order: blur, then saturate, brightness, contrast, whatever order the table lists them in. They work on the straight sRGB colour, as CSS’s do, so a translucent fill keeps its alpha. A colour filter folds into the blur’s last pass; a backdrop with only a colour filter still copies what is under the box once, and a node with a colour filter is drawn into a layer like one with effect.blur.

rect {
    width = 360,
    height = 120,
    radius = 16,
    background = "#FFFFFF1F",
    effect = { backdrop = { blur = 45, saturate = 2 } },
}

The usual glass: CSS backdrop-filter: blur(45px) saturate(2).

Progressive blur

effect.backdrop.mask takes the values of a node’s mask (a gradient or a source image; not a node) and scales the glass’s coverage. Where the mask is clear the ground shows through untouched, with no blur, colour filter or backdrop shader output; where it is opaque the glass is whole; between, it is a crossfade of the two. It is the soft edge under a toolbar, or a panel whose frost feathers out. The mask never touches the node’s fill, border or children.

rect {
    width = 360,
    height = 200,
    radius = 16,
    clip = "rounded",
    children = {
        image { source = "/usr/share/backgrounds/default.png", width = "fill", height = "fill", async = true },
        rect {
            width = "fill",
            height = 130,
            align_v = "end",
            effect = {
                backdrop = {
                    blur = 10,
                    mask = { gradient = "linear", angle = 0, stops = { { 0, "#FFFFFFFF" }, { 1, "#FFFFFF00" } } },
                },
            },
        },
    },
}

A crossfade, not a blur radius that falls off: at a large sigma the faded part reads as a blend of the sharp and the blurred picture. invert = true flips the mask as on a node.

Shader effects

effect.shader runs a fragment shader of yours over the node’s painted subtree: the fill, children and border drawn offscreen, which the program reads as a texture and replaces with its output. It is how a duotone, a chromatic aberration, a ripple or a glow that follows the node’s own shape is a config’s to write. A shader node draws one quad and reads nothing; this reads the pixels.

rect {
    width = 240,
    height = 80,
    radius = 20,
    corner_smoothing = 0.6,
    background = "#335577",
    effect = {
        shader = {
            source = mantle.config_dir .. "/shaders/shape.frag",
            params = { width = 3, tint = { 1, 0.8, 0.2, 1 } },
            padding = 4,
        },
    },
}
KeyTypeDefaultBehaviour
sourcestringrequiredAbsolute, or ~/ under $HOME, .frag path; relative is refused
input"content", "backdrop" or "desktop""content"What u_input holds: the node’s painted subtree, what the surface painted under the box (see Backdrop input), or that over the desktop behind the surface (see Desktop input)
paramstable<string, number|number[]>{}Uniforms by name, as on a shader node; missing ones are 0
imagestable<string, string>{}Raster files to sample, by sampler name, with the rules, <name>_size and sampling of a shader node’s images. Clamped to the edge (fract(uv) tiles), premultiplied, sRGB as stored; .png, .jpg, .jpeg or .webp only, a side over 2048 px downscaled. They take the units after u_input and u_input_blurred, so a refraction can read a displacement map
progressnumber, [-8192, 8192]0Becomes u_progress, as on a shader node. animate effect to move it
paddingnumber, [0, 512]0Logical px around the box the program can read and draw. The layer, its damage and its clip grow by it

The .frag contract is the shader node’s with these changes. Write void main() and set fragColor to premultiplied RGBA.

NameTypeWhat
v_uvin vec2Box coordinate, top-left origin, y down. 0..1 over the box, and outside it over the padding
u_sizevec2The node’s box in logical px, without the padding
mantle_input(uv)vec4The input at a box coordinate, premultiplied; transparent outside the padded area
mantle_input_blurred(uv)vec4The same, through effect.backdrop’s blur and colour filters; the plain input when it has none or input = "content"
u_input, u_input_blurred, u_input_rectsampler2D, sampler2D, vec4The textures and where they sit as (x, y, w, h) in box fractions. A texture may be larger than what it holds, so read it through the functions above
mantle_sdf(p)floatSigned distance in logical px from p (a box position in logical px, v_uv * u_size) to the node’s shape, negative inside. It follows radius, per-corner radii, corner_smoothing and shape, so a shader can draw a rim, a glow or a clip that matches the shape. Smoothed corners are approximate, as in Continuous corners
u_progressfloatprogress
mantle_opacityNot set

Unlike a shader node, no step follows your main: the output is not rounded to radius and not multiplied by opacity, since the subtree already carries its opacity. Cut to the shape yourself with mantle_sdf when you want to.

uniform float width;
uniform vec4 tint;

void main() {
    vec4 body = mantle_input(v_uv);
    float d = mantle_sdf(v_uv * u_size);
    // A rim `width` px deep just inside the shape, over the subtree.
    float rim = smoothstep(-width - 0.5, -width + 0.5, d) * (1.0 - smoothstep(-0.5, 0.5, d));
    fragColor = body * (1.0 - rim * tint.a) + vec4(tint.rgb * tint.a, tint.a) * rim;
}
CaseResult
The shader fails to compile or linkLogged once per revision of the file; the node draws as if it had no shader
The .frag is savedThe config reloads, which recompiles it and repaints the node
params, images or a file changeThe layer is redrawn; an unchanged layer is reused
animate on effectTweens blur, the colour filters and shader.progress; the rest of the shader table (source, params, images, padding) takes the target’s value at once. A progress-only step repaints the layer without resolving Lua
Loop an animationanimate = { effect = { keyframes = { at(0), at(1) }, duration = 2000, loops = "infinite" } }, with at(p) returning { shader = { source = ..., progress = p } } (keyframes)
Hit testing and input regionsIgnore it: a shader draws pixels, not shape

Order: the shader reads the node after its fill, children and border, and its output goes through shadows (content mode), then effect.blur and the colour filters. padding is cut at the same clips as a shadow, so a node at a surface’s edge has no room to pad into.

Backdrop input

With input = "backdrop" the program reads what this surface already painted under the box and padding around it, never the desktop behind the surface. It runs before the node paints, beside effect.backdrop: one copy of those pixels feeds both, and mantle_input_blurred reads the frost the backdrop filters made from it. The output replaces the whole read area, padding included, mixed with the original pixels by the node’s opacity; the node’s fill, children and border paint over it. Return mantle_input(uv) to leave a pixel as it was, and do so outside the shape, or the pixel comes out as whatever the program returns. Offsetting the coordinate by the shape’s distance is a refraction. Box kinds only.

rect {
    width = 200,
    height = 64,
    radius = 32,
    background = "#FFFFFF14",
    effect = {
        backdrop = { blur = 6 },
        shader = { source = mantle.config_dir .. "/shaders/lens.frag", input = "backdrop", padding = 8 },
    },
}
void main() {
    vec2 p = v_uv * u_size;
    float d = mantle_sdf(p);
    // Up to 8 px of pull toward the centre in the outer 12 px of the shape.
    vec2 toward = normalize(u_size * 0.5 - p + 1e-4);
    vec2 bent = v_uv + toward * smoothstep(-12.0, 0.0, d) * 8.0 / u_size;
    float inside = 1.0 - smoothstep(-0.5, 0.5, d);
    fragColor = mix(mantle_input(v_uv), mantle_input_blurred(bent), inside);
}

Desktop input

input = "desktop" is "backdrop" with the desktop behind the surface under it: windows, the wallpaper and other clients’ layers, so a lens bends what is on screen, not only what this shell drew. The engine captures the read area through wlr-screencopy, and the capture holds this surface too, so it takes out what the surface last presented there. Until the first capture lands the program does not run and the area shows through.

The shader contract is Backdrop input’s: return mantle_input(uv) to leave a pixel. What the surface presents of the output is the output itself, at most 88% opaque so the next capture can still unmix it, except where it shows the desktop: there nothing is presented and the live desktop stays. Frost with effect.backdrop and bend with the shader: both run in this renderer, so compositor_blur is not needed.

This reads other clients’ pixels through screen capture, which the compositor may refuse or ask about. Nothing is captured while the node does not draw: hidden, at opacity = 0, or on an unmapped surface.

panel {
    id = "lens",
    layer = "overlay",
    anchor = { top = true },
    margin = { top = 40 },
    exclusive_zone = "ignore",
    width = 320,
    height = 96,
    child = rect {
        width = "fill",
        height = "fill",
        radius = 48,
        effect = { shader = { source = mantle.config_dir .. "/shaders/lens.frag", input = "desktop" } },
    },
}
CaseResult
A pixel this surface covers opaquelyWhere paint drawn after the glass (its children, later siblings) covers a pixel at 92% or more, nothing behind it can be seen, so the desktop there is unknown. Unknown pixels read as the nearest known one in their row in mantle_input and mantle_input_blurred, so a sharp read under text shows a plausible desktop, not black. The output there is presented like any other pixel. Paint over the glass (a border, a rim light) adds to its 88%: keep the total under 92%, or the rim reads as its row neighbour
A pixel the output leaves as the desktopPresented transparent (within 1.5/255 per channel), so it stays live. Any other pixel is presented as the program wrote it, held to 88% opaque: past that the unmix would multiply rounding by 8 or more, and the glass would go black. An opaque frost shows 88% frost and a 12% ghost of the live desktop; a translucent tint keeps its own alpha. An antialiased edge between the two is a 1 px ring
Cost of a settled glassA capture whose sampled bytes (one in 13) match the last one’s within 3 levels is not unmixed and repaints nothing, so a change smaller than a sample step can go unseen. A pixel is unmixed once its presented value has outlasted one capture request, so a shader that changes a pixel before every request freezes the desktop under it. The unmixed desktop holds unless it moves more than 3 levels over clear paint, about 17 under glass at the 88% cap, so a smaller change behind nearly opaque glass waits for a larger one
CostAt most 30 captures a second while what is behind changes, 4 once a capture shows no change, none while the node does not draw. A change behind a settled glass shows up to 250 ms late
MemoryAbout 20 bytes per buffer pixel of the read area: the capture, the unmixed desktop, the surface’s last two paints there and a stamp each, plus its texture. A 1920x48 bar at scale 1 is about 1.8 MB
output = "active"Placed on the output the surface entered, from its first repaint after it maps
A panel other zones push inwardPlaced from its anchor and margins only, so the capture is offset by the zone
A window or popup, or a compositor without wlr-screencopyNo capture: the program reads this surface’s pixels alone, as "backdrop"
Inside a rotateThe capture is of the rotated box’s bounding rectangle, so the lens samples slightly off; scale and translate place it exactly

Blend modes

blend composites a node, a background layer ({ fill = .., blend = .. }) or a shadows layer onto what this surface painted under it, by CSS mix-blend-mode’s formulas: "normal" (the default), "multiply", "screen", "overlay", "darken", "lighten", "color_dodge", "color_burn", "hard_light", "soft_light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", and Apple’s "plus_lighter" and "plus_darker".

rect {
    width = 120, height = 40, radius = 12,
    blend = "plus_lighter",
    background = { { fill = "#FFFFFF33", blend = "overlay" }, "#1E1E2E" },
    shadows = { { color = "#00000066", blur = 12, blend = "multiply" } },
}
WhereBlends with
blend on a nodeIts whole painted output, its shadows included and after every effect filter, against what is under it. The backdrop filters are not part of it
A background layerThe layers below it and what is under the box, as a Figma fill does; CSS background-blend-mode would isolate the box
A shadows layerWhat is under the shadow

Inside a mask, effect layer or content-mode shadow the blend sees only what that ancestor drew so far, as a glass does; clip = "rounded" is no barrier. "normal" costs nothing. Any other mode draws the blended part offscreen, copies the pixels under it and runs one pass: about 0.1 ms to 0.2 ms per blended part for a 400x300 box on integrated graphics. A box with one opaque "normal" colour layer is opaque whatever blends above it; a blended node is never opaque. blend snaps under animate.

Combining effects

One node paints in this order, each step over the last. The order is fixed: the keys of effect apply in it, whatever order the table lists them in.

  1. Backdrop (effect.backdrop): replaces the pixels under the box with their blur, then saturate, brightness and contrast; a backdrop shader instead replaces them with its output, from the same copy. It reads what precedes the node, so it comes first.
  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. A blended background or shadows layer blends as it draws.
  4. Layer: for a content effect.shader, effect.blur, a colour filter, a layered shadow or a node blend, the shadow and body are drawn offscreen, run through the shader, the content shadow cast from that, then blurred and recoloured.
  5. Blend: the finished layer composites onto what is under it by the node’s blend. It is last because it is how the result meets the backdrop, as CSS applies mix-blend-mode after filter.
  6. Transform (scale, rotate, translate) wraps all of the above.
CombinationWhat happensDo this
mask and effect.backdrop.blur on one nodeThe mask fades the fill, border and subtree, not the node’s own glass or box shadoweffect.backdrop.mask for the glass; a child of the masked node for the rest
effect.blur and effect.backdrop.blur on one nodeThe glass stays sharp; only the fill, border and subtree blurExpected
effect.backdrop.blur inside a parent with mask, effect.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
effect.backdrop.blur inside clip = "rounded" without a maskThe glass sees what is under the parent, as without the clipNothing to do
effect.backdrop.blur on a surface rootIt blurs transparency: it never reads the desktopcompositor_blur = true
Shadow and effect.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 effect.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 effect.blur near a clipping parent’s edge (clip, a scroll viewport, the surface)Cut at that clip, like any child paintGive that parent padding
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 effect.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 who takes what

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",
        compositor_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",
    shadows = { {
        color = "#00000099",
        blur = lifted:map(function(on) return on and 36 or 12 end),
        offset = lifted:map(function(on) return { x = 0, y = on and 20 or 6 } end),
    } },
    animate = { shadows = 200 },
    children = { text { content = "Hover me" } },
}

hover drives the shadow and animate eases it. Leave room around the card when its parent clips: a scroll viewport or the surface cuts the shadow; mantle check warns when the surface does.

Pill button

local hovered = hover("save_hover")
rect {
    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 = {
        row {
            padding = { left = 18, right = 18, top = 12, bottom = 12 },
            spacing = 8,
            radius = 12,
            background = "#1E1E2E",
            children = {
                icon { name = "starred-symbolic", size = 16, foreground = "#CBA6F7", align_v = "center" },
                text { content = "Upgrade to Pro", font_weight = 700, foreground = "#CDD6F4", align_v = "center" },
            },
        },
    },
}

This paints the gradient as an outer fill and covers all but a 2px ring with an opaque inner box; keep the inner radius the outer radius minus the ring width. For a ring on one box, give border_color the gradient instead (Gradients).

Circular avatar

row {
    padding = 12,
    spacing = 12,
    radius = 14,
    background = "#1E1E2E",
    children = {
        rect {
            width = 56,
            height = 56,
            radius = 28,
            clip = "rounded",
            border_width = 2,
            border_color = "#89B4FA",
            children = { image { source = "/var/lib/AccountsService/icons/user", width = "fill", height = "fill" } },
        },
        column {
            spacing = 2,
            align_v = "center",
            children = {
                text { content = "user", font_size = 15, font_weight = 700, foreground = "#CDD6F4" },
                text { content = "Signed in", font_size = 12, foreground = "#A6ADC8" },
            },
        },
    },
}

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_zone = "ignore",
    keyboard_interactivity = "on_demand",
    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",
                compositor_blur = true,
                shadows = { { color = "#00000080", 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 compositor_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
clip = "rounded" changes nothingIt needs a non-zero radius or a shape, and only clips children
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, except for a shape, which is hit by its contour. Shrink the clickable node or accept it

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

Source: allowlist, parsers, effect parsers, paint style, paint order, canvas, clips and masks, 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.

Six knobs slide 200 px over 600 ms. Each uses a different easing; "out_back" passes the end and comes back:

local go = state("go", false)

local function lane(label, easing, color)
    return row {
        spacing = 12,
        children = {
            text { content = label, width = 88, font_size = 13, foreground = "#bac2de" },
            rect {
                width = 216,
                height = 16,
                radius = 8,
                background = "#313244",
                children = {
                    rect {
                        width = 16,
                        height = 16,
                        radius = 8,
                        background = color,
                        translate = go:map(function(on) return { x = on and 200 or 0 } end),
                        animate = { translate = { duration = 600, easing = easing } },
                    },
                },
            },
        },
    }
end

return column {
    padding = 16,
    spacing = 10,
    radius = 12,
    background = "#1e1e2e",
    children = {
        text { content = "Six easings, 600 ms", font_size = 14, font_weight = 700, foreground = "#cdd6f4" },
        lane("linear", "linear", "#89b4fa"),
        lane("in_out_quad", "in_out_quad", "#cba6f7"),
        lane("out_cubic", "out_cubic", "#a6e3a1"),
        lane("out_back", "out_back", "#fab387"),
        lane("out_bounce", "out_bounce", "#f38ba8"),
        lane("steps = 4", { steps = 4 }, "#f9e2af"),
    },
}

Run mantle set go true to start the race in your shell, then mantle set go false to reset it.

Mental model

A motion has two inputs: the target, the value a pass resolves, and the entry, how to get there (duration, easing, delay, from, keyframes or spring). Both are ordinary Lua values, so either may be a signal.

  • A changed target starts a tween from the value on screen. The same target resolved again does not.
  • A changed entry retimes the run under way; it starts nothing by itself. Keyframes are the exception: new frames, timing, loops or run start a new run.
  • An entry field read from a signal is how one property gets different timing per direction (recipe).

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(...)), and so may an entry or a field inside one. An entry’s timing read from a signal applies to the run already under way: a new duration mid-tween moves where the run is.

SituationResult
Number, "NN%" size, "#rrggbb[aa]" colour, number edge table { top, right, bottom, left }, corner table { top_left, top_right, bottom_right, bottom_left }, { x, y } tableTweens against a new value of the same shape. A missing edge, corner or axis reads as 0 (1 for scale, 0.5 for origin)
A path’s commandsTweens point by point against a list with the same ops and hole flags in the same order; any other list snaps. A spring retargeted mid-flight restarts from rest
"fill", booleans, strings that are not colours, per-edge colour tables, gradients, or a change of shape (2 to { x = 2 } or { top_left = 2 }, "50%" to "fill")Snaps
A width or height you did not set (sized to its content) with an eased entryAfter each pass the engine measures the content and eases the box from its size on screen to the measured one, laying siblings and children out at every frame. Children are cut to the moving box only under clip = "box" or "rounded", or in a scroll viewport or surface. A first layout, a spring and keyframe entries snap. A root axis the compositor sizes (window, lock, a panel anchored at both opposite edges) never eases
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-flightAn eased tween returning to its prior endpoint shortens the run according to the progress already covered. Other eased targets and keyframe entries 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. Showing the subtree cancels its moves
z, animate, or a name the node kind does not acceptRefused: the pass fails with an error naming the entry. move, exit and scroll are special entries

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; on boxes radius, border_width; spacing on row, column, list; font_size on text and textfield; size on icon; progress on shader; stroke_width on path
"NN%"width, height
Colourbackground, border_color (single colour), foreground; fill and stroke on path
{ top, right, bottom, left }padding, margin, border_width as tables
{ top_left, top_right, bottom_right, bottom_left }radius as a table
{ x, y }translate, scale, origin
{ blur, saturate, brightness, contrast, backdrop = { .. } }effect; a key only one side sets tweens from or to its off value, 0 for a blur and 1 for a colour filter
Shadow listshadows, layer by layer (layered shadows)
Path commandscommands on path (morphing)

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 and a shadows layer’s offset and spread may go negative. padding, spacing and icon size have no range as plain values but tween within [0, 8192]. Path coordinates stay within [-8192, 8192] and arc radii at 0 or more.

Layout cost

Tween onEach frame
opacity, colours, radius, translate, scale, rotate, origin, progress, commands, fill, stroke, stroke_width, shadows, ring, effectRepaints; 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.

Lua cost

Let animate drive motion. A timer that sets a signal every frame runs Lua and resolves the surface on every tick. animate computes each frame in the engine, about 20 times cheaper, at the display’s refresh rate.

A progress ring that fills forever, with no Lua per frame:

local function ring(sweep)
    return { { op = "A", points = { 16, 16, 13, -90, sweep } } }
end

return row {
    padding = 16,
    spacing = 12,
    radius = 12,
    background = "#1e1e2e",
    children = {
        rect {
            width = 32,
            height = 32,
            align_v = "center",
            children = {
                path { width = 32, height = 32, stroke = "#313244", stroke_width = 4, commands = ring(360) },
                path {
                    width = 32,
                    height = 32,
                    stroke = "#89b4fa",
                    stroke_width = 4,
                    commands = ring(0),
                    animate = {
                        commands = { duration = 1200, easing = "linear", keyframes = { ring(0), ring(360) }, loops = "infinite" },
                    },
                },
            },
        },
        text { content = "Syncing photos", font_size = 14, foreground = "#cdd6f4", align_v = "center" },
    },
}

Not a timer that redraws it 30 times a second:

local tick = state("tick", 0)
interval(33, function() tick:set(tick:get() + 1) end)

return path {
    width = 32,
    height = 32,
    stroke = "#89b4fa",
    stroke_width = 4,
    commands = tick:map(function(n) return { { op = "A", points = { 16, 16, 13, -90, n % 36 * 10 } } } end),
}

Live data, like a volume level, still comes from a signal: give it an animate entry and the engine eases between updates.

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). Beside keyframes, a spring is each segment’s curve instead.

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 "in_out_quad". 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, whose first frame is the start ("current" there takes the value on screen)
spring{ stiffness, damping }stiffness in (0, 100000], damping in (0, 10000], both required. Refuses easing, and duration and loops unless keyframes is set (spring segments)
keyframesAt least 2 framesSee keyframes
loopsWhole count [1, 10000] or "infinite"Default 1. Only with keyframes
runA numberA different value starts a new run, even with the same frames and mid-run. 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
Linear"linear"
Quad, Cubic, Quart, Quint"in_quad", "out_quad", "in_out_quad", "in_cubic", "out_cubic", "in_out_cubic", "in_quart", "out_quart", "in_out_quart", "in_quint", "out_quint", "in_out_quint"
Sine, Expo, Circ"in_sine", "out_sine", "in_out_sine", "in_expo", "out_expo", "in_out_expo", "in_circ", "out_circ", "in_out_circ"
Back, Elastic, Bounce"in_back", "out_back", "in_out_back", "in_elastic", "out_elastic", "in_out_elastic", "in_bounce", "out_bounce", "in_out_bounce"

in_ starts slow, out_ ends slow, in_out_ does both. The back and elastic families 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

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. The carried velocity is capped at 100 times the new distance per second, so a target set almost where the value already is cannot fling it past. 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 lane(label, damping, color)
    return row {
        spacing = 12,
        children = {
            text { content = label, width = 150, font_size = 13, foreground = "#bac2de" },
            rect {
                width = 216,
                height = 16,
                radius = 8,
                background = "#313244",
                children = {
                    rect {
                        width = 16,
                        height = 16,
                        radius = 8,
                        background = color,
                        translate = go:map(function(on) return { x = on and 160 or 0 } end),
                        animate = { translate = { spring = { stiffness = 400, damping = damping } } },
                    },
                },
            },
        },
    }
end

return column {
    padding = 16,
    spacing = 10,
    radius = 12,
    background = "#1e1e2e",
    children = {
        text { content = "Springs, stiffness 400", font_size = 14, font_weight = 700, foreground = "#cdd6f4" },
        lane("damping = 12, rings", 12, "#cba6f7"),
        lane("damping = 40, critical", 40, "#a6e3a1"),
        lane("damping = 120, crawls", 120, "#fab387"),
    },
}

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. spring = { stiffness, damping } in place of easing springs that segment
First frameWhere the run starts; its own duration and easing are never read. The bare string "current" starts on the value on screen instead: mid-flight, the tween’s last sample, else the value the node rests on. It is read once as the run starts, so each loop returns to that value, and it is only the first frame, never a table form. Works for every shape that tweens. A property the node does not set has no value on screen, so the run does not start
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, loops or run starts a new run from the first frame

To replay a run, change run: a counter that goes up on each trigger starts the same frames again, whether the last run is over or not, and the property never snaps in between. pulse is the other way: it adds the entry for a window after its source changes and takes it away again, so the property snaps back when the window closes.

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 = "out_quad", keyframes = { 1, 1.25, 0.9, 1 } } }

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    padding = 8, -- room for the overshoot: a scaled node paints past its box
    child = rect {
        width = 48,
        height = 48,
        radius = 12,
        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 = 24, foreground = "#f9e2af", 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 },
    child = row {
        padding = 10,
        spacing = 8,
        radius = 18,
        background = "#1e1e2e",
        visible = busy,
        children = {
            icon { name = "view-refresh-symbolic", size = 16, foreground = "#89b4fa", align_v = "center", animate = SPIN },
            text { content = "Checking for updates", font_size = 13, foreground = "#cdd6f4", align_v = "center" },
        },
    },
}

Spring segments

A spring on a keyframe entry, or on one frame, replaces easing for those segments. Each segment starts the spring from rest and runs it in real time, so stiffness and damping look the same as on a plain spring and overshoot meets the same range clamp. The segment’s duration still decides when the next frame starts: the frame lands exactly at that instant, and a spring that has not settled by then jumps the rest of the way. Give a ringing step a duration of at least its settle time, roughly 14 / damping seconds, to avoid the jump.

-- A quarter turn every 800 ms, overshooting and settling on each; 14 / 18 is under 0.8 s.
local TICK = {
    rotate = {
        duration = 800,
        spring = { stiffness = 300, damping = 18 },
        keyframes = { 0, 90, 180, 270, 360 },
        loops = "infinite",
    },
}

return icon { name = "view-refresh-symbolic", size = 24, foreground = "#cdd6f4", animate = TICK }

Move

animate.move eases a matched node when a scene pass changes its laid-out position. It takes a duration in milliseconds, or { duration = 180, easing = "out_cubic", delay = 0 }. The easing and delay have the same meanings as a property tween. from, keyframes, loops and spring are refused. A new node has no previous position, so it appears at its new rect.

local card = rect {
    id = "notice-42",
    width = 280,
    height = 60,
    animate = { move = { duration = 180, easing = "out_cubic" } },
}
return card

Use a stable id on a child that can change position in its parent’s children, or a list key for each item. Without one, id-less siblings match by position; removing the first item can pair the next item with its old rect. A removed child’s animate.exit paints from its current position while live siblings move into the gap. It still takes no flow space or input.

A move tracks the node’s position relative to its parent. If an ancestor shifts, give that ancestor animate.move too.

The solver, geometry and pointer callbacks report the destination rect throughout the move. Paint, hit-testing, text links, carets, input regions and background blur follow the moving pixels. A second layout change starts from the last painted position. Scrolling and paint-only property tweens do not start a move; an already running move keeps advancing on compositor frames. A parent with clip, a scroll viewport or the surface can cut moving pixels; leave room there.

Scroll

animate.scroll on a row, column or list bound to a scroll signal eases each mouse-wheel notch. It takes a duration in milliseconds, an eased entry or a spring; keyframes are refused. Notches add to a target that stops at either end of the content, and a notch mid-run eases on from the offset on screen, carrying a spring’s velocity. The signal holds the offset on screen, so a map of it reflows with every frame. :reveal eases the same way, to the least move from the target that shows the child, as do :scroll_to(offset) and :scroll_by(delta), which adds to the target. A touchpad or a high-resolution wheel, which report fractions of a notch, and reset_on_close move the offset at once and stop the run. The offset never passes the ends, even under an overshooting easing or content that shrinks mid-run; content that grows mid-run does not move the target. A run hidden with its container finishes while hidden: shown again, it lands on its target on the next frame.

local rows = {}
for i = 1, 40 do rows[i] = text { content = "Row " .. i } end
return column {
    width = 300,
    height = 200,
    scroll = scroll("feed"),
    animate = { scroll = { duration = 160, easing = "out_cubic" } },
    children = rows,
}

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 = "in_quad", 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, shadows, ring, 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 = row {
    width = 260,
    padding = 14,
    spacing = 12,
    radius = 14,
    background = "#1e1e2ee6",
    opacity = 1,
    translate = { y = 0 },
    children = {
        icon { name = "audio-volume-medium-symbolic", size = 20, foreground = "#89b4fa", align_v = "center" },
        rect {
            width = "fill",
            height = 6,
            radius = 3,
            background = "#313244",
            align_v = "center",
            children = { rect { width = "42%", height = 6, radius = 3, background = "#89b4fa" } },
        },
        text { content = "42%", font_size = 13, foreground = "#cdd6f4", align_v = "center" },
    },
    animate = {
        opacity = { duration = 200, from = 0 },
        translate = { duration = 200, easing = "out_cubic", from = { y = 16 } },
        exit = { duration = 150, easing = "in_quad", opacity = 0, translate = { y = 16 } },
    },
}

return panel {
    id = "osd",
    layer = "overlay",
    anchor = { bottom = true },
    width = 260,
    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),
    },
}

settled: know when a node stops moving

settled = settled("name") on a node gives a read-only signal the engine writes: false while any animate tween on that node is running, true otherwise. Swap content, unmount a node or start a second motion when the first ends, without a timer that guesses its duration.

RuleDetail
Initialtrue. The pass that starts a tween writes false; the frame that ends the last writes true
ScopeThe node’s own animate entries, move, and a wheel or fling scroll run on it. A descendant’s tween does not count; declare settled on the node that moves
WritesOn the edge only, never per frame, and a retarget mid-run writes nothing. A reader re-resolves in one follow-up pass before the frame is drawn, like geometry
Keyframesfalse for the whole run, a hold between equal values and a delay lead-in included. A counted run is true on its last frame; loops = "infinite" never is
Hidden or leavingtrue. A hidden subtree is frozen and a leaving node is not counted, so its exit tween does not clear the signal. A hidden subtree that shows again is false if a tween resumes

One name is one signal across reloads. Nodes that share it read true only when none of them moves. Nothing is walked until the shell has called settled(...) or elided(...) in its current evaluation. Do not drive a node’s own animated target from its own settled: the target changes when the motion ends, which starts the next motion, and the node bounces once per tween end.

The sheet below collapses over 300 ms; the note appears when the collapse ends, not when it starts. In the pass that sets open, sheet_still still reads true, but the follow-up pass runs before the frame is drawn, so the note never paints early:

local open = state("sheet_open", true)
local sheet_still = settled("sheet")

return column {
    spacing = 8,
    children = {
        rect {
            width = 200,
            height = open:map(function(on) return on and 120 or 24 end),
            background = "#313244",
            settled = sheet_still,
            on_click = function() open:set(not open:get()) end,
            animate = { height = { duration = 300, easing = "out_cubic" } },
        },
        text {
            content = "Collapsed",
            foreground = "#cdd6f4",
            visible = computed({ open, sheet_still }, function(on, still) return not on and still 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 content in after a panel grows, but out at once on closeDifferent timing per direction
Run phases in order: content out, container resizes, content swapsSequence phases
Grow a card out of a small shapeGrow a container

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 NOTES = {
    { icon = "battery-caution-symbolic", color = "#fab387", title = "Battery low", body = "12% remaining" },
    { icon = "view-refresh-symbolic", color = "#89b4fa", title = "Update ready", body = "Restart to install" },
    { icon = "notification-symbolic", color = "#a6e3a1", title = "Download complete", body = "photos.zip, 48 MB" },
}

local function card(index, note)
    local wait = (index - 1) * 250
    return row {
        width = 260,
        padding = 12,
        spacing = 12,
        radius = 12,
        background = "#1e1e2e",
        opacity = 1,
        translate = { x = 0 },
        animate = {
            opacity = { duration = 300, delay = wait, from = 0 },
            translate = { duration = 300, delay = wait, easing = "out_cubic", from = { x = -24 } },
        },
        children = {
            icon { name = note.icon, size = 20, foreground = note.color, align_v = "center" },
            column {
                spacing = 2,
                children = {
                    text { content = note.title, font_size = 13, font_weight = 700, foreground = "#cdd6f4" },
                    text { content = note.body, font_size = 12, foreground = "#a6adc8" },
                },
            },
        },
    }
end

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

Slide a notification out

Dropping a child makes it leave. Here each card sits in a slot of fixed height, so a dismissed card slides out in place and the others stay put. Remove the item from a keyed list instead to have the rest close up at once.

local dismissed = state("dismissed", {})
local NOTES = {
    { icon = "battery-caution-symbolic", color = "#fab387", title = "Battery low", body = "12% remaining" },
    { icon = "view-refresh-symbolic", color = "#89b4fa", title = "Update ready", body = "Restart to install" },
    { icon = "notification-symbolic", color = "#a6e3a1", title = "Download complete", body = "photos.zip, 48 MB" },
}

local function dismiss(title)
    local gone = { [title] = true }
    for other in pairs(dismissed:get()) do gone[other] = true end
    dismissed:set(gone)
end

local function card(note)
    return row {
        width = 280,
        height = 60,
        padding = 12,
        spacing = 12,
        radius = 12,
        background = "#1e1e2e",
        border_width = 1,
        border_color = "#45475a",
        on_click = function() dismiss(note.title) end,
        animate = { exit = { duration = 200, easing = "in_cubic", opacity = 0, translate = { x = 16 } } },
        children = {
            icon { name = note.icon, size = 20, foreground = note.color, align_v = "center" },
            column {
                spacing = 2,
                align_v = "center",
                children = {
                    text { content = note.title, font_size = 13, font_weight = 700, foreground = "#cdd6f4" },
                    text { content = note.body, font_size = 12, foreground = "#a6adc8" },
                },
            },
        },
    }
end

-- The slot keeps its height after its card leaves.
local function slot(note)
    return rect {
        width = 280,
        height = 60,
        children = dismissed:map(function(gone) return gone[note.title] and {} or { card(note) } end),
    }
end

return panel {
    id = "notifications",
    layer = "overlay",
    anchor = { top = true, right = true },
    width = 300,
    height = 400,
    child = column { spacing = 8, children = { slot(NOTES[1]), slot(NOTES[2]), slot(NOTES[3]) } },
}

Use different timing for opening and closing

delay, duration and the rest of an entry accept a signal, read on the same pass that moves the target. Derive the timing from the state that moves it. Here the label waits 200 ms for the pill to grow, then fades in; on close the same entry resolves delay = 0, so it fades out while the pill shrinks.

local open = state("open", false)
-- 200 ms to wait for the grow on the way in, none on the way out.
local wait = open:map(function(on) return on and 200 or 0 end)

return rect {
    width = open:map(function(on) return on and 200 or 56 end),
    height = 56,
    radius = 28,
    background = "#1e1e2e",
    clip = "rounded",
    animate = { width = { duration = 200, easing = "out_cubic" } },
    children = {
        row {
            width = 200,
            height = 56,
            padding = 16,
            spacing = 10,
            children = {
                icon { name = "notification-symbolic", size = 24, foreground = "#89b4fa", align_v = "center" },
                text {
                    content = "New message",
                    font_size = 14,
                    foreground = "#cdd6f4",
                    align_v = "center",
                    opacity = open:map(function(on) return on and 1 or 0 end),
                    animate = { opacity = { duration = 150, delay = wait } },
                },
            },
        },
    },
}

The row keeps its full width, so the label never rewraps as the pill moves; clip = "rounded" cuts what the pill has not grown over yet.

Sequence phases

An exit cannot do this: a leaving node takes no flow space, so its parent closes up at once and there is nothing left to resize around. Keep the container’s size in state instead and stage each phase with delay(signal, ms), which lags a state by a fixed time. Each phase is a signal that changes ms later than the one before; the phase durations must add up to those offsets. A fade that only needs to wait can take delay in its own entry instead, as in the recipe above.

TimeSignalMoves
0 mspageContent fades out (150 ms)
150 msdelay(page, 150)Container height eases to the new page’s (250 ms)
400 msdelay(page, 400)Content swaps while invisible, then fades in (150 ms)
local page = state("page", "short")
local PAGES = {
    short = { height = 64, lines = { "Sync finished" } },
    tall = { height = 112, lines = { "Sync finished", "48 files uploaded", "2 conflicts need review" } },
}

local resized = delay(page, 150)
local swapped = delay(page, 400)

return rect {
    width = 240,
    height = resized:map(function(p) return PAGES[p].height end),
    radius = 14,
    background = "#1e1e2e",
    clip = "rounded",
    animate = { height = { duration = 250, easing = "out_cubic" } },
    children = {
        column {
            padding = 16,
            spacing = 4,
            -- Visible only while the page and the swapped content agree.
            opacity = computed({ page, swapped }, function(now, shown) return now == shown and 1 or 0 end),
            animate = { opacity = 150 },
            children = swapped:map(function(p)
                local out = {}
                for i, line in ipairs(PAGES[p].lines) do
                    out[i] = text { content = line, font_size = i == 1 and 14 or 12, foreground = "#cdd6f4" }
                end
                return out
            end),
        },
    },
}

Grow a container out of a small shape

Animate width and height with from set to the small size. from applies to a node’s first layout, so the card is built when the state turns on. Eased runs end exactly on the size; a spring with damping below critical overshoots it and rings. Give the children a fixed size so they do not rewrap while the box moves, and set clip = "rounded" so they are cut to the moving box and its radius.

local go = state("go", false)
local EASED = { duration = 350, easing = "out_cubic", from = 40 }
local SPRING = { spring = { stiffness = 300, damping = 16 }, from = 40 }

local function card(label, timing)
    return rect {
        width = 176,
        height = 96,
        radius = 20,
        background = "#1e1e2e",
        clip = "rounded",
        align_h = "center",
        align_v = "center",
        animate = { width = timing, height = timing },
        children = {
            column {
                width = 176,
                height = 96,
                padding = 16,
                spacing = 4,
                children = {
                    text { content = label, font_size = 14, font_weight = 700, foreground = "#cdd6f4" },
                    text { content = "Grows from 40 px", font_size = 12, foreground = "#a6adc8" },
                },
            },
        },
    }
end

local function slot(label, timing)
    return rect {
        width = 216,
        height = 136,
        children = go:map(function(on) return on and { card(label, timing) } or {} end),
    }
end

return row { spacing = 8, children = { slot("Eased", EASED), slot("Spring", SPRING) } }

Replay a keyframe run from where it is

A pupil glances aside and back on each click. run counts the clicks, so identical frames play again on every one, and "current" starts each glance from wherever the pupil is, mid-glance or not. No timer takes the entry away and puts it back:

local looks = state("looks", 0)

return rect {
    width = 12,
    height = 12,
    radius = 6,
    background = "#cdd6f4",
    translate = { x = 0, y = 0 },
    on_click = function() looks:set(looks:get() + 1) end,
    animate = looks:map(function(n)
        local glance = { "current", { x = 6, y = -4 }, { x = 0, y = 0 } }
        return { translate = { duration = 150, easing = "out_cubic", run = n, keyframes = glance } }
    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
animate.exit never plays and the log says shell.lua:120 replaced shell.lua:95 in placechildren swapped one id-less node for another of its kind, so the old node was reused. Give the siblings distinct ids
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. Give the entry a run and count it up (recipe), or gate the entry 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. Use run
Sliding with margin stutters on a large surfaceTween translate: it skips layout
Removing a keyed item makes later items snap into the gapPut animate.move on each item
A looping motion driven by a timer costs CPU on every tickUse keyframes with loops = "infinite" (Lua cost)
width will not overshoot below 0 with "out_back"The 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, interpolation, easing, spring, keyframes, leaving nodes, range clamps.

Input

Pointer and keyboard input: clicks, drags and the wheel on any node, hover, scrolling containers, keyboard activation, and typing into a textfield, including password fields whose keys never reach Lua. 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",
    keyboard_interactivity = "on_demand",
    anchor = { top = true },
    child = rect {
        padding = 8,
        background = "#313244",
        accessible_name = "Increase clicks",
        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 that clips (clip = "box" or "rounded", a mask, a scroll viewport, a surface) reaches none of its children; a child overflowing any other node is hit where it paints
Skippedvisible = false subtrees and nodes playing an exit. opacity = 0 is still hit
Pass-throughhittable = false skips a node and its descendants, so a decorative overlay does not swallow the click meant for what is under it. A descendant with hittable = true is hit again, and the skipped ancestor’s handlers, cursor and hover apply when it is. Hover, cursor, wheel and the surface input region skip it too
EdgesHalf-open: two nodes 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

Any node takes clicks, drags and the wheel, hit-tested by its own box. For each event the innermost node with a handler for that event wins; a node without one is transparent, so a handle inside a draggable track leaves the track draggable, and a text with no handler passes the click to the row around it. A node with a handler or submits_secure = true shows the "pointer" cursor unless it sets its own cursor.

HandlerArgumentsContract
on_click(rect, button, pointer, modifiers)button is "left", "right", "middle", "back" or "forward"; pointer is { x, y } in the node’s own untransformed box (mapped back through its transforms and its ancestors’), unclampedFires on release over the same node that was pressed, with the same mouse button. Other mouse buttons are ignored. On a textfield only non-left buttons fire, and only the field’s own on_click, never an ancestor’s; a left press edits the field and a secure_submit field fires none. The press focuses the field and keeps a selection it lands in, so :cut() and :copy() act on it
on_double_click(rect, button, pointer, modifiers)As on_clickFires after on_click on the release completing a second click on the same node with the same button, within 400 ms and 4 px of the first click’s release. See below
on_press(rect, button, pointer, modifiers)As on_clickFires at press, for any of those buttons, before the release and so before on_click. A textfield calls only its own, for non-left buttons. See below
on_drag(rect, pointer, phase, modifiers, velocity)pointer is { x, y } in the node’s own untransformed box (mapped back through its transforms and its ancestors’), unclamped; phase is "start", "move" or "end"; velocity is { x, y } in surface px per secondLeft button only. See below
on_wheel(rect, steps, modifiers)steps is a number of wheel notchesVertical wheel only. See below
submits_secure = 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 node and coming back before release still clicks; the pointer leaving the surface cancels. The click also cancels if the node’s laid-out box moved between press and release, so give press feedback with scale or translate rather than width or margin. A textfield clicks only as the on_click row says. A link in a text (on_link) takes the click before any on_click, the text’s own included.

Double click. on_click fires for both clicks, then on_double_click for the second; a third click starts a new pair, so a triple click is one double and one single. The innermost node with on_double_click takes it, apart from on_click’s target: an icon with only on_double_click inside a row with on_click still lets each single click reach the row. The pair is the same node even if the first click moved or resized it. A left click on a textfield goes to the field and never doubles; its non-left clicks double on its own handler. Enter, Space and screen-reader activation never double-click, and a node with only on_double_click takes no keyboard focus. Wayland has no portable double-click setting, so the 400 ms and 4 px are fixed.

Modifiers. Each pointer handler takes modifiers after its own arguments (only on_drag’s velocity follows it), the { ctrl, shift, alt, super } booleans on_key reports, so a list can toggle on Ctrl-click or extend on Shift-click. Enter and Space activation report the keyboard’s modifiers too. Wayland tells a client the modifiers only while one of its surfaces has keyboard focus; otherwise all four read false.

local picked = state("picked", false)
return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = rect {
        padding = 8,
        background = picked:map(function(on) return on and "#89b4fa" or "#313244" end),
        on_click = function(_, _, _, modifiers)
            picked:set(modifiers.ctrl and not picked:get() or not modifiers.ctrl)
        end,
        children = { text { content = "Ctrl-click toggles" } },
    },
}

Press. on_press is the one place a callback can start a window move, resize or menu with toplevel(id): the compositor checks those requests against the press, and on_click fires on release, too late. An on_drag "start" works too. After the compositor takes the pointer it may send no release, so the engine ends the drag and the armed click; on_drag gets no "end" and on_click does not fire.

Drag. A left press on an on_drag node 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, but pointer is measured against the node’s current box, so a node that moves mid-drag shifts it. On release, "end" fires first and the click (if the node also has on_click) after it; a leave ends the drag and cancels the click.

velocity is how fast the pointer moved over the last 100 ms, in surface logical px per second, timed by the compositor’s event clock rather than when the shell got round to the event. It is { x = 0, y = 0 } on "start", and on "end" when the pointer was still for 100 ms before the release. Motion spanning under 8 ms keeps the last measured value, so a 1 px twitch after a rest does not read as a flick. A drag that ends without a release (the pointer leaving the surface, the surface closing, a compositor move) reports the last motion’s velocity, so a flick off the edge keeps its speed. The velocity on "end" is what a flick scroll keeps going with. mantle input drag reports zero.

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 node or scroll container under the pointer takes the whole event, with no chaining to a parent; on a node that is both, the scroll wins.

local level = state("brightness", 0.5)
local function clamp(value) return math.max(0, math.min(1, value)) end
local percent = level:map(function(value) return string.format("%d%%", math.floor(value * 100 + 0.5)) end)

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = row {
        padding = 14,
        spacing = 12,
        radius = 12,
        background = "#1e1e2e",
        children = {
            icon { name = "display-brightness-symbolic", size = 18, foreground = "#f9e2af", align_v = "center" },
            rect {
                width = 200,
                height = 10,
                radius = 5,
                align_v = "center",
                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 = "#f9e2af", width = percent } },
            },
            text { content = percent, width = 36, foreground = "#cdd6f4", align_v = "center" },
        },
    },
}

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 a node under or off a still pointer updates hover but does not call it; the next pointer motion does. 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 = rect {
        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 } },
    },
}

Pointer position

pointer(name) returns a read-only signal of the pointer’s { x, y } in logical pixels from the top-left corner of one node, or nil while the pointer is outside it. Bind it to a node’s pointer; the name is the identity, as for hover.

APIContract
pointer = pointer(name)Any node kind. A table while the pointer is over the node or any of its children (the same rule as hover), nil otherwise, including after it leaves the surface. Coordinates are in the node’s own untransformed space and unclamped, like on_click’s pointer. Updated once per motion batch and when layout moves the node under a still pointer
CostMotion writes it only while something reads it. A signal nothing reads costs the hit walk the pointer already does; a read one re-resolves only the instances that read it. One output’s instance holds the position while the pointer is on it, as the name is one signal across outputs

A pointer read only inside a callback (on_click, on_press, on_key) is always nil: nothing read it, so motion never wrote it. Bind it to a node’s pointer, or read it in a computed that a node reads.

local at = pointer("canvas")

return panel {
    id = "bar",
    layer = "top",
    anchor = { top = true },
    child = rect {
        width = 200,
        height = 60,
        pointer = at,
        children = {
            rect {
                width = 8,
                height = 8,
                radius = 4,
                background = "#f5c2e7",
                visible = at:map(function(p) return p ~= nil end),
                translate = at:map(function(p) return { x = p and p.x - 4 or 0, y = p and p.y - 4 or 0 } end),
            },
        },
    },
}

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
BoundThe offset stays within [0, content − viewport]: the wheel stops at the ends the last layout measured, and layout clamps again and writes the clamped value back. A map of it that read an offset layout then moved (content that shrank, or a :reveal or :scroll_to) lays out again before the frame is drawn. 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, or per frame while an eased notch runs
InertiaFingers lifting from a touchpad coast to a stop in 0.75 s; no animate needed. Wheel notches don’t
Smoothanimate = { scroll = 160 } on the container eases each wheel notch: notches add to a target that stops at the ends, and the offset follows it frame by frame. The signal holds the offset on screen, so a map of it lays out each frame from what that frame draws. :reveal, :scroll_to and :scroll_by ease too; a touchpad, a high-resolution wheel and reset_on_close move it at once. See animation
: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
:scroll_to(offset)On the next pass, moves to offset px, clamped to the bound. NaN and infinity raise. Only a scroll signal has it
:scroll_by(delta)Like :scroll_to, but adds delta to where the offset is headed, as a wheel notch does: during an eased run the signal reads the offset on screen, so :scroll_to(s:get() + step) would fall short, while clicks of :scroll_by(step) add up. A request waits for its container to be shown, and sums with an earlier one in the same turn before clamping (:scroll_to(900) then :scroll_by(-50) asks for 850). reset_on_close drops it, and so does the next pass when no container in any surface holds the signal (a hidden one still does), so an area built later starts at the top; a request made in the same turn as the area it scrolls lands. A container that is leaving (playing its exit) does not hold it

Arrow buttons for a carousel; strip:scroll_to(0) would rewind it:

local strip = scroll("carousel")
local cards = {}
for i = 1, 12 do cards[i] = rect { width = 120, height = 80, radius = 8, background = "#313244" } end
local function arrow(label, step)
    return rect {
        padding = 6,
        accessible_name = label,
        on_click = function() strip:scroll_by(step) end,
        children = { text { content = label } },
    }
end

return panel {
    id = "carousel",
    layer = "top",
    child = row { spacing = 8, children = {
        arrow("<", -128),
        row { width = 376, spacing = 8, scroll = strip, animate = { scroll = 200 }, children = cards },
        arrow(">", 128),
    } },
}

Keyboard controls and accessibility

A node with on_click, submits_secure = true, on_key or on_key_release becomes a keyboard control when it has a nonempty accessible_name. The name is announced through AT-SPI. Give every textfield an accessible_name too; fields take focus through their existing callbacks or secure_submit. Keys reach controls only on the surface with keyboard focus. Panels use keyboard_interactivity = "on_demand" or "exclusive"; shown popups under that surface share its focus scope.

Tab moves to the next control in document order across the focused surface and its shown popups. Shift+Tab moves backward, and both wrap. Hidden, leaving and zero-size controls are skipped. Tab on the only control, while it holds focus, moves nothing, shows the outline and reaches on_key. Enter or Space activates a named control once per press, calling on_click(rect, "left", pointer) with pointer at the node’s centre, and running submits_secure when set. Pointer presses also focus named controls.

A panel, window or popup takes on_escape() for dismissing without a field. It fires once per Escape press (not on repeat) on the innermost shown popup under the focused surface that declares it (no order is promised among sibling popups), else the surface itself, and never on a surface without keyboard focus. A focused field keeps its own Escape while it has text to clear (or a composition) or declares on_cancel; otherwise Escape reaches on_escape, so a launcher can clear on the first Escape and close on the second.

local open = state("menu_open", true)

return panel {
    id = "menu",
    layer = "overlay",
    anchor = { top = true },
    keyboard_interactivity = "on_demand",
    visible = open,
    on_escape = function() open:set(false) end,
    child = text { content = "Menu" },
}

Key handlers

on_key(key) on any node or surface hears the keys of a custom control: a grid, a menu, a slider. key is { name, text, modifiers = { ctrl, shift, alt, super }, repeat }.

FieldMeaning
nameThe xkb keysym name for the key under the active layout: "a" ("A" with Shift), "Return", "Escape", "space", "Down", "Page_Down", "F5", "KP_Enter". on_key names are case-sensitive. mantle input key takes the same names in any case
textWhat the key types, nil when it types nothing: Return, arrows, F-keys and every Ctrl chord
modifiersWhich of Ctrl, Shift, Alt and Super are held. mantle input key sets Ctrl and Shift only
repeattrue for an auto-repeat while the key is held, false for the press

A key goes to the focused node first, then each ancestor up to the surface, which hears it last whether or not it holds a control. A handler that returns true stops it; returning nothing or false passes it up. A node without on_key is skipped. Without focus on a control, only the surface’s own on_key hears keys, while the surface has the keyboard. A control’s on_key runs before its Enter/Space activation, and a handled Escape never reaches on_escape.

A node with on_key takes Tab focus, a press and the outline like any named control, but only with an accessible_name, so a screen reader can announce it. Without one it is not focusable, and hears just what its descendants pass up. That is how a container sees the keys of the field inside it.

A focused plain textfield takes the keys it edits with (characters, Backspace, Delete, Enter, caret motion, Ctrl+A/Z/Y, and Escape, unless escape = "pass" or there is nothing to clear and no on_cancel) and passes the rest up: Up, Down, paging, Tab when the field is the only control, an arrow at the caret’s edge, F-keys and other Ctrl chords (Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste while a field takes them, and reach on_key otherwise). Tab moves control focus when it can, so on_key hears Tab only where nothing moves. Repeats arrive with repeat = true.

A key typed into a secure_submit field never reaches on_key, and while one is armed no on_key on that surface is called.

on_key_release(key) hears a key going up, routed the same way: focused node first, then each ancestor, then the surface; true stops it. key is the press’s name and text with the modifiers held at release, and no repeat. A release is heard only when its press was offered to on_key, whether or not a handler took it, and goes to the node that press was routed from even if the press moved focus: never for a key a field typed or pasted with, a Tab that moved focus, a key typed into an armed secure_submit field, a bare modifier, a key already held when the keyboard entered, or after the keyboard left. Like on_key, it makes a node focusable only with an accessible_name. mantle input key sends the release right after the press.

local picked = state("picked", 1)

return panel {
    id = "grid",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row {
        accessible_name = "Picker",
        on_key = function(key)
            if key.name == "Right" and not key.modifiers.ctrl then
                picked:set(picked:get() + 1)
                return true
            elseif key.name == "Left" then
                picked:set(math.max(1, picked:get() - 1))
                return true
            end
        end,
        children = { text { content = picked:map(tostring) } },
    },
}

The engine draws a black and white outline around the focused control only when Tab, Shift+Tab or an assistive-technology action moved focus there. Focus from a press, autofocus or focus_target(name):request() from a click draws none, and a press hides an outline Tab drew. focus_ring = false keeps the outline off a node. The outline follows the node’s radius, corner_smoothing and shape.

focused(name) returns a read-only boolean signal, false until the bound node first holds focus. Bind it to a node’s focused and the engine sets it true while that node or any node inside it holds control focus, however focus got there. Like hover(name), the name is the identity and survives reloads. It is not focus_target(name), the handle :request() uses to move focus.

focus_visible(name) is the same signal under the rule the engine’s outline follows (CSS :focus-visible): true only while the bound node or one inside it holds control focus that Tab, Shift+Tab, an assistive-technology action or a :request() from a key callback or an Enter or Space activation moved there, and false after a press, autofocus or a :request() from a click, while focused(name) stays true. It ignores focus_ring. Bind it to a node’s focus_visible, and draw a ring that shows for keyboard users only:

local search = focused("search")

return panel {
    id = "launcher",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = rect {
        padding = 6,
        radius = 8,
        border_width = 2,
        focused = search,
        border_color = search:map(function(on) return on and "#89b4fa" or "#45475a" end),
        children = {
            textfield {
                width = 240,
                height = 28,
                autofocus = true,
                focus_ring = false,
                accessible_name = "Search",
                on_change = function(text) end,
            },
        },
    },
}
local ring = focus_visible("save")

return panel {
    id = "dialog",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = rect {
        padding = 4,
        radius = 8,
        border_width = 2,
        focus_visible = ring,
        border_color = ring:map(function(on) return on and "#89b4fa" or "#00000000" end),
        children = {
            rect {
                padding = 6,
                background = "#45475a",
                accessible_name = "Save",
                focus_ring = false,
                on_click = function() end,
                children = { text { content = "Save" } },
            },
        },
    },
}

Mantle publishes the resolved node tree through AT-SPI. Text nodes expose their displayed text; plain fields expose their draft. Secure fields expose a password role but no value or length. Assistive-technology focus and click actions use the same control path as keyboard input. A screen reader can also click a button on a surface without keyboard focus; the keyboard stays where it is.

Text fields

A textfield is a text input, one line unless multiline. The engine holds what the user types (the draft); Lua sees it through callbacks, and sets it only with :set_text. The field with focus is the one keys go to. It has no intrinsic width, so give it width; height defaults to one line (nodes).

A field takes the keyboard only when both hold:

ConditionDetail
The surface has keyboard focusA panel needs keyboard_interactivity = "on_demand" 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) 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_key(key)The keys the field does not edit with, from its own on_key up (key handlers): Up, Down, paging, Left and Right when the caret cannot move that way and Shift is up, and Tab when the field is the only control. The draft is untouched
escapeWhat Escape does: "clear" (the default) as under on_cancel; "blur" keeps the draft and drops focus, then calls on_cancel(false); "pass" keeps the draft and focus and does not take the key, which goes up through on_key and then to the surface’s on_escape. A secure_submit field ignores it
autofocustrue: take the keys, with the draft reset to initial_text ("" when unset) and a call to on_change with it, when the surface gains keyboard focus or the field appears under it. The first visible such field or focusable control in document order wins. A field never takes over from one already typing, nor re-takes one the user just clicked away from. A control arms only while no control holds focus, once per appearance or keyboard enter
focus_targetA focus_target(name) handle. :request() from an on_click, on_key, or an edit typed or committed into the field (on_change, on_submit, on_cancel) focuses the first visible plain field with that name on the same keyboard-focused surface or a popup under it, after the callback’s state changes appear. It keeps that field’s draft and caret and does not call on_change. A key callback’s request shows the focus outline
focus_target(name):set_text(text)Replaces the draft of every plain field with that focus_target and an on_change or on_submit, hidden ones too, from any callback, once it returns: caret at the end, undo history cleared, on_change not called, composition discarded. A field without focus keeps the text for when it takes the keys. Never reaches a secure_submit field. Control characters other than \n, or over 64 KiB, raise; a single-line field refuses a \n with a warning
focus_target(name):cut(), :copy(), :paste(), :select_all()The chords’ actions, on the plain field with that focus_target that holds the keyboard now; any other field, and any secure_submit, is left alone. They count only inside an on_click, on_key or edit callback caused by a real key press or pointer click, since the compositor wants that event’s serial for the clipboard; key repeat and mantle input key cannot cut or copy. They apply when the callback returns. A click moves keyboard focus off the field, so call :request() first in the same callback
initial_textSeeds the draft once, when the field enters the tree (a new node: a changed id or key counts as new, and a field that leaves and returns is seeded again from the value then). Plain fields only; secure_submit refuses it. Like set_text: cut at max_length, caret at the end, no undo history, no on_change; hidden and disabled fields are seeded too. Later changes to the value are ignored and an emptied field stays empty: use set_text to push new text. Read without subscribing: writing the signal alone does not re-resolve the field
disabledtrue: the field draws as usual but takes no focus. Tab skips it, a press and autofocus pass over it, :request() finds nothing, and no caret shows. A focused field that becomes disabled loses focus and keeps its draft. A disabled secure_submit field is not a destination. set_text still reaches it. Dim it by binding colours to the same signal
max_lengthMost grapheme clusters the field holds; 0 is unlimited. Typing, paste, IME commits and set_text cut the insert at the limit, secure fields included. Lowering it below the current text keeps that text: only the insert is limited, so edits can then only shorten it. The cut is silent: a limit below a password’s length truncates it, on a lock field too
secure_submit, mask_characterSee secure fields
placeholder, placeholder_color, 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) pass up to on_keyNothing
Ctrl+ASelects allNothing
Ctrl+Z, Ctrl+Shift+Z, Ctrl+YUndo or redo the last plain edits; on_changeNothing
Ctrl+CCopies selected textNothing
Ctrl+XCopies selected text and deletes it as one undo step; on_change. Nothing without a selectionNothing
Ctrl+VReplaces selection with clipboard text; on_changeAppends clipboard text to the native buffer
Up, Down, Page Up, Page DownPass up to on_keyNothing
Multiline: Return, Up, Down, Home, EndReturn inserts a newline and submit_key submits; Up and Down move by visual row (from the first or last row they pass up to on_key); Home and End go to the row’s ends, Ctrl+Home and Ctrl+End to the text’sNever multiline
Tab, Shift+TabMoves between controls; when the field is the only control, passes up to on_key ("ISO_Left_Tab" for Shift+Tab)Moves between controls
Any other Ctrl chordLeft to the compositorSame

Selection and clipboard. Dragging or Shift+clicking with the pointer selects too. A double-click selects the word under the pointer (a space run or a single punctuation mark when it lands on one), and a triple-click the line: newline to newline in a multiline field, all the text in a single-line one. Presses within 400 ms and 4 logical pixels of the last on the same field form a series, and a fourth starts over as a single click. Dragging or Shift+clicking after a double or triple click grows the selection by whole words or lines. Paste accepts up to 64 KiB of valid UTF-8 without control characters (a multiline field also takes newlines; \r\n and \r become \n). A paste is dropped if the selection, field, or keyboard focus changes before the read ends. Copy and cut work only with a plain-field selection. has_selection(name) is a read-only boolean signal, false until the plain field whose focus_target is name holds the keyboard and has text selected. It survives reloads. Editing keys repeat while held; Escape, undo and redo do not. The undo stack retains at most 100 snapshots and 1 MiB of saved text. Submit, leaving the field, and fresh autofocus clear that history. When text-input-v3 enters the field’s own surface, composition appears underlined. Preedit alone does not call on_change; committed text and surrounding deletions do. Raw typing resumes when composition ends. A pointer press or Escape discards uncommitted composition. Secure fields do not use an IME.

Draft lifetime. Each field keeps its own draft. Clicking elsewhere, moving to another field, or the surface losing the keyboard stops typing but keeps the draft; focusing the field again resumes it, though a click that returns to a field puts the caret where it was left. Undo history does not survive leaving a field. 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. Restoring a draft is silent: on_change does not fire, so reset a field with set_text.

To return typing to a field after a click changes the view, give the field the handle and call :request() from the on_click. focus_target("") and a focus_target property that is not a handle raise. on_key, and edits typed or committed into a field (on_change, on_submit, on_cancel), can request too; the on_change an autofocus arm or set_text fires cannot. Requests from anywhere else, to a hidden or masked field, or to a surface other than the focused one and its popups do nothing.

local search_focus = focus_target("search")

return panel {
    id = "search",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        textfield {
            focus_target = search_focus,
            autofocus = true,
            width = 180,
            height = 32,
            on_change = function() end,
        },
        rect {
            on_click = function() search_focus:request() end,
            children = { text { content = "Return to search" } },
        },
    } },
}

A Cut button that shows whether it can act:

local note = focus_target("note")
local selected = has_selection("note")

return panel {
    id = "note",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        textfield { focus_target = note, width = 180, height = 32, on_change = function() end },
        rect {
            on_click = function()
                note:request()
                note:cut()
            end,
            children = { text { content = selected:map(function(on) return on and "Cut" or "Nothing selected" end) } },
        },
    } },
}

A right-click menu on a field. The press focuses the field without clearing a selection it lands in, so :cut() acts on it:

local note = focus_target("note")
local menu = state("note_menu", false)
return panel {
    id = "note",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = column { children = {
        textfield {
            focus_target = note,
            width = 180,
            height = 32,
            on_change = function() end,
            on_click = function(_, button)
                if button == "right" then menu:set(true) end
            end,
        },
        row {
            visible = menu,
            children = {
                rect {
                    on_click = function() note:request(); note:cut(); menu:set(false) end,
                    children = { text { content = "Cut" } },
                },
            },
        },
    } },
}

focus_target and autofocus also work on any focusable control. A request from a key callback shows the outline, so Lua can rove focus with the arrow keys. On a node that takes no focus they do nothing, and set_text does nothing on a control.

local next_button = focus_target("next")

return panel {
    id = "toolbar",
    layer = "top",
    keyboard_interactivity = "on_demand",
    child = row { children = {
        rect {
            accessible_name = "First",
            autofocus = true,
            width = 40,
            height = 32,
            on_key = function(key)
                if key.name == "Right" then next_button:request() return true end
            end,
        },
        rect { accessible_name = "Next", focus_target = next_button, width = 40, height = 32, on_key = function() end },
    } },
}

A search field that picks from a list moves the selection with on_key and closes on a second Escape. The app launcher recipe is the full version.

local query = state("query", "")
local selected = state("selected", 1)
local open = state("launcher_open", true)

return textfield {
    width = 240,
    height = 32,
    placeholder = "Search apps",
    autofocus = true,
    on_change = function(text)
        query:set(text)
        selected:set(1)
    end,
    on_key = function(key)
        local step = ({ Up = -1, Down = 1 })[key.name]
        if step then selected:set(math.max(1, selected:get() + step)) 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,
}

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)
{ capability = "network", action = "vpn_secret", name = request.id .. "/" .. field }One secret of a VPN activation’s pending request (network)
{ capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }Answers a Bluetooth PIN or passkey entry request (bluetooth)
{ capability = "secrets", action = "store", name = "mail" }Stores a named value in the session Secret Service (secrets)
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 or selection, and no on_key; on_change and on_submit never fire
SendingEnter, or a click on a submits_secure = true node, 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 submits_secure = true node works. Tab to a named button keeps the buffer too: typing stops, Enter on a submits_secure = true button sends it, and Escape still clears it and calls on_cancel. 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
on_escapeA surface’s on_escape fires when the buffer is empty and the field has no on_cancel, so it reveals that one bit (ADR-0320)
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
maskedfalse draws the typed text instead of the mask, for a PAM prompt meant to be seen (prompt.echo). Keys, sending and the no-IME rule stay the same, and the text still never reaches Lua. Turning it off wipes what was typed under the mask, so a password typed early is never shown
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",
                accessible_name = "Password",
                mask_character = "•",
                secure_submit = { capability = "lock", action = "authenticate" },
            },
            rect {
                padding = 8,
                radius = 8,
                background = "#89b4fa",
                submits_secure = true,
                accessible_name = "Unlock",
                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 keyson_key on the field (key handlers); the app launcher adds scroll(name):reveal
Close a search box on a second EscapeThe same example: on_cancel(cleared) closes only when cleared is false
Let Escape reach the surface from a fieldescape = "pass" on the textfield
Handle arrows or shortcuts on a custom controlon_key (key handlers)
Close a dialog or menu on Escapeon_escape on the surface (keyboard controls)
Ask for a passwordThe lock example under secure fields
Reach a button by keyboard or screen readerGive the node with on_click or submits_secure an accessible_name (keyboard controls)
Hide or restyle the focus outlinefocus_ring = false on the control, and style a wrapper from focus_visible(name), or focused(name) to track any focus (keyboard controls)
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 node’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 node, 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 rect {
        width = "fill",
        padding = 8,
        radius = 6,
        on_click = function()
            menu_open:set(false)
            run()
        end,
        children = { text { content = label, foreground = "#cdd6f4" } },
    }
end

local files = row {
    padding = 8,
    spacing = 8,
    radius = 8,
    background = "#1e1e2e",
    on_click = function(rect, which)
        if which == "right" then
            menu_at:set(rect)
            menu_open:set(true)
        else
            process.detach("nautilus", {})
        end
    end,
    children = {
        icon { name = "folder", size = 18, align_v = "center" },
        text { content = "Files", foreground = "#cdd6f4", align_v = "center" },
    },
}

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_right",
        offset = { y = 4 },
        visible = menu_open,
        on_dismiss = function() menu_open:set(false) end,
        child = column {
            width = 160,
            padding = 4,
            radius = 10,
            background = "#1e1e2e",
            border_width = 1,
            border_color = "#45475a",
            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 node in a keyed list. pointer is measured against the row’s current slot, so pointer.y divided by the row pitch counts the rows to move from where it is now; offsetting from the slot at the press would undo each move on the next motion. 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 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 rect {
        width = 200,
        height = 28,
        padding = 6,
        background = "#313244",
        cursor = "grab",
        on_drag = function(_, pointer, phase)
            if phase ~= "move" then return end
            -- `pointer` is relative to the row's current slot, so the offset counts rows from there.
            local order = { table.unpack(items:get()) }
            local now = index_of(name)
            local target = math.max(1, math.min(#order, now + math.floor(pointer.y / STEP)))
            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, items = items, each = row_for, key = function(name) return name end },
}

Gotchas

TrapFix
A textfield is invisible or cannot be clickedIt has no intrinsic width. Give it width
A field in a panel shows no caret and takes no keysSet the panel’s keyboard_interactivity to "on_demand" (or "exclusive" for a modal)
A field with only on_key/on_cancel ignores clicksAdd on_change or on_submit
Tab skips a button, or Tab does nothingGive the button an accessible_name, and the panel a keyboard_interactivity other than "none"
A clicked or autofocus field shows no focus outlineThe outline follows keyboard navigation only. Bind focus_visible(name) to draw your own ring by the same rule, or focused(name) for a style that tracks any focus
Tab stopped reaching on_keyWith two or more controls in the focus scope, Tab moves focus instead. It reaches on_key only when the focused node is the sole control
The mouse wheel does nothing over a scrolling rowRows scroll on the horizontal axis. Use a column, or an on_wheel on a node around it 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 node grows on pressThe release must land on the same laid-out box. Animate scale instead
A tooltip or menu anchored to a scaled node is offRects are laid-out boxes before transforms. Anchor on an untransformed parent
Lua needs to prefill or clear a fieldCall focus_target(name):set_text(text); "" clears. autofocus also re-arms empty, and 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 (textfield, list), surfaces (keyboard_interactivity, popups, lock), signals (state the handlers write), animation (press and hover motion), capabilities (lock, polkit, network).

Source: pointer, press and click, callbacks, 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 = rect {
        on_click = refresh,
        children = { text { content = temperature, font_size = 14 } },
    },
}

Which one do I use

process.run / process.capturesession_processprocess.detach
OutputLine by line to on_line; capture: one string at the endInherited stdio: mantle logNone: /dev/null
Exiton_exit(code); capture: on_done(stdout, 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, on_exit(nil)Keeps runningUnaffected
Renderer crashKilled, no on_exitKeeps 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
  • Whole stdout as one string and the exit code once it ends: process.capture.
  • Lines as they arrive, or a stream that never ends (tail -f): process.run. A long-running one restarts with each save.
  • 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, on_line, on_exit) → 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. nil or {} for none
on_line(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
on_exit(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. on_exit 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. ~/ is not expanded in args
LifetimeUntil the next reload, failed ones included, which kills its group as kill() does and calls on_exit(nil) at once, before the new evaluation runs; no on_line follows. A Renderer replacement or shell exit reaps its group without calling on_exit
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 on_exit(nil) with no output; the reason is logged as a warning.

process.capture

Runs a helper to its end and hands over all of its stdout at once. process.run without the line accumulator, and with the newlines left in.

process.capture("date", { "+%F" }, function(stdout, code)
    if code == 0 then today = stdout:gsub("\n$", "") end
end)
PartContract
Signatureprocess.capture(cmd, args, on_done) → handle
cmd, args, handle, stdin, environmentAs process.run
on_done(stdout, code)Once, after the process exits. stdout is everything it wrote, newlines kept ("a\nb\n"; no newline is added), "" for none. Invalid UTF-8 is replaced. code is the exit status
FailureA signal, a failed spawn or a reload gives on_done(nil, nil); the reload calls it at once, as for process.run
stderrDropped
LimitsPast 1 MiB of stdout the rest is dropped, with no warning to Lua. Callbacks run outside the CPU budget; a raise 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 = rect {
        padding = 8,
        on_click = function()
            process.detach("xdg-open", { os.getenv("HOME") or "/" })
        end,
        children = { text { content = "Home folder" } },
    },
}
PartContract
Signatureprocess.detach(cmd, args?, opts?) → nothing
cmd, argsAs process.run
opts.env{ NAME = "value", OTHER = false }: a string sets the variable, false unsets it, on top of the shell’s environment. An empty name, one holding =, a value holding NUL, or any other type raises. A single-instance app that is already running hands the launch to its existing process, which keeps its old environment
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 = rect {
        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 filefile.read, or persistent_table for JSON settings

Poll a command every N seconds

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

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)
end
poll()
interval(30000, 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 on_line. 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 on_exit(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 on_lineOutput arrives one line at a time. Collect lines and decode once in on_exit
Treating kill() as cancelon_exit still fires, usually with nil. Tag requests with a counter and ignore stale ones
on_exit 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 on_exit(nil). Report only a non-zero code
Invalid stop_signal, or signal(name) with oneRaises mantle.processes:declare: unknown variant ... (or :signal:) and sends 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, or retrytimer
Repeat every N msinterval
Run code from a keybind or scriptaction + mantle call
Parse or write JSONjson.decode, json.encode
Write to the shell’s loglog.*
Rank search resultsfuzzy
Pull colours out of an imagepalette.quantize
Build Material 3 colours from a seed, or from an image (quantize, then score, then scheme; write them to app configs)palette.scheme, palette.score, palette.hct, palette.from_hct, palette.harmonize
Read or write a text filefile
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 settings = persistent_table {
    path = "~/.local/state/myshell/settings.json",
    defaults = { clock_24h = true, recorder = { fps = 60, audio = "desktop" } },
}

-- Reads its declared default before the first push.
local clock_24h = settings.clock_24h

return panel {
    id = "clock", layer = "top", anchor = { top = true },
    child = rect {
        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, defaults? } → store. Another key raises
pathAbsolute, or ~/ under $HOME; relative, ending in /, or with an empty, . or .. component raises. Its directory is created if missing.
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: its defaults entry (else nil) until mantle.storage pushes this file, then the stored value; a key the pushed file lacks reads its defaults entry, else nil
store:set(key, value)Writes one key; nil deletes it, and the signal then reads its defaults entry (else nil). The signal updates on the next push, not inside set. The next evaluation writes the default back to the file
IdentityOne table per file: another call with the same path 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. To repeat at a fixed rate, use interval.

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()

-- Re-arming from the callback repeats on a varying delay, here aligned to the minute.
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 Lua 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.

When to use a timer

Each time a timer sets a signal, every :map reading it runs again. Once a minute is free; every 33 ms is 30 times a second, forever. Arm a timer as often as the data changes, never per frame.

ToUse
Retry with backoff, or act once after a delaytimer
Update a clocktimer, re-armed for the next minute, as above
Poll something with no capability and no output to followinterval at the slowest rate that stays useful (poll a command)
React to a program’s outputFollow it with process.run instead of polling (follow)
Hide something a few seconds after the last changetimer, cancelling the previous handle on each change; or delay
Show a value late, or blink itdelay or pulse
Move, spin, pulse or morph somethinganimate (Lua cost)

interval

interval(ms, fn) → handle. Runs fn every ms until handle:cancel(), with timer’s range, budget, order and lifetime. Turns stay on the original schedule; one that comes late runs once and the next is ms after it, so missed runs never queue. A raise is logged and the next run still happens. Cancelling from inside fn stops it.

local load = state("load", "")
interval(5000, function()
    process.run("cat", { "/proc/loadavg" }, function(line) load:set(line:match("^%S+")) end, function() end)
end)

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 10
action("volume.up", function(step)
    local audio = mantle.audio:get()
    for _, sink in ipairs(audio and audio.sinks or {}) do
        if sink.active and sink.volume then
            local volume = math.max(0, math.min(150, sink.volume + (tonumber(step) or 5)))
            mantle.audio:set_volume(sink.id, volume)
            return string.format("%d%%", math.floor(volume + 0.5))
        end
    end
    return "no output device"
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
LimitsLua 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

json.encode

json.encode(value) → string: compact JSON, object keys sorted. It raises on a value JSON cannot hold, since that is a config mistake rather than bad input.

LuaJSON
Table whose keys are all positive integers, {} includedArray; a hole inside it is null. More than half holes raises
Table with string keysObject
Table mixing both, or with other keysRaises
nilnull
Integer, float3, 3.0: json.decode reads each back as it was
Function, userdata, a table that contains itself, NaN, infinity, nesting past 127, over 2^20 values, a decoded array given a named keyRaises
local body = json.encode({ title = "Backup", done = true }) -- {"done":true,"title":"Backup"}
process.detach("curl", { "-s", "-H", "Content-Type: application/json", "-d", body, "http://localhost:8080/events" })

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 = "on_demand",
    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 { items = results, each = 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

Dominant colours of an image by Material’s quantizers, computed on a background thread. Put a raster image at wallpaper.png in your config directory, or change the path below to an existing image. The book checks this file-dependent snippet’s syntax; the callback runs only in a live Renderer.

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

palette.quantize(mantle.config_dir .. "/wallpaper.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?, on_done) → 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
opts.method"celebi" (default; Wu, then k-means in Lab, as matugen does) or "wu" (Wu alone, faster, rougher). Another raises
optsOnly depth, method 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
on_done(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

palette.scheme

Material 3 colour roles from one seed colour (to write them into other programs’ configs: Colour your apps), built by Material Color Utilities at spec 2021 on the phone platform. Synchronous: one scheme takes about 0.5 ms.

local roles = palette.scheme("#6750A4", { dark = true, variant = "tonal_spot" })

return rect {
    background = roles.surface_container, padding = 12,
    children = { text { content = "Material", foreground = roles.on_surface } },
}
PartContract
Signaturepalette.scheme(seed, opts?) → roles
seed"#RRGGBB"; anything else raises
opts.darkBoolean, default false
opts.variant"tonal_spot" (default), "vibrant", "expressive", "fidelity", "content", "neutral", "monochrome", "rainbow" or "fruit_salad". Another raises
opts.contrast-1 to 1, default 0; 1 is high contrast. Out of range raises
optsOnly those three keys; another raises
RolesA table of 49 "#RRGGBB" strings keyed by role in snake_case: primary, on_primary_container, surface_container_highest, … (PaletteScheme in the stubs)

palette.score

Ranks palette.quantize swatches as Material 3 picks a wallpaper’s seed: chromatic colours that cover more of the image, spread across hues. Quantize at depth 6 or 7, so Score has enough colours to choose from.

local roles = state("roles", palette.scheme("#6750A4"))

palette.quantize(mantle.config_dir .. "/wallpaper.png", { depth = 7 }, function(found)
    if found then roles:set(palette.scheme(palette.score(found)[1], { dark = true })) end
end)
PartContract
Signaturepalette.score(swatches) → { "#RRGGBB", ... }
swatches{ color = "#RRGGBB", share } entries; only the ratios of the shares count. A bad colour or a share not above 0 raises
ResultUp to 4 colours, best first, at least 15° of hue apart. Each is one input swatch’s colour as uppercase #RRGGBB, so a quantize swatch looks it up by color. Colours with chroma under 5 or hues under 1% of the image drop out; with none left it is { "#4285F4" }

palette.hct

A colour’s hue, chroma and tone in HCT, Material’s colour space, which Score and every scheme work in. A config can rank or filter swatches by its own rule: tonal spot’s primary palette uses chroma 36, so a seed far below that comes out muted.

local seed = "#6750A4"
local hct = palette.hct(seed) -- hue ≈ 299, chroma ≈ 48, tone ≈ 40

return text { content = hct.chroma >= 36 and "vivid" or "muted" }
PartContract
Signaturepalette.hct(color) → { hue, chroma, tone } (PaletteHct in the stubs)
color"#RRGGBB"; anything else, alpha included, raises
hueDegrees, 0 to under 360; a grey still gets a number
chromaColourfulness from 0. Greys stay under 5, the cutoff below which Score ignores a colour
toneLightness, 0 (black) to 100 (white)

palette.from_hct

palette.hct’s inverse: palette.from_hct{ hue, chroma, tone } → "#RRGGBB". hue wraps, chroma is 0 or more, tone 0 to 100, all numbers; anything else raises. A chroma sRGB cannot show at that hue and tone is reduced to what fits.

local hct = palette.hct("#6750A4")
return text { content = palette.from_hct { hue = hct.hue, chroma = hct.chroma, tone = 90 } }

palette.harmonize

palette.harmonize(color, source) → "#RRGGBB". Moves color’s hue toward source’s by half the gap, at most 15°, keeping chroma and tone: Material’s way to seat a fixed colour, such as an error red, beside a seed.

return text { content = palette.harmonize("#FF0000", "#6750A4"), foreground = "#FFFFFF" }

file

Whole-file read and write on a background thread. The config VM has no blocking IO: results arrive in a callback, like palette.quantize. Worked example: Colour your apps.

PartContract
file.read(path, on_done)on_done(text, err): the text, or nil and a message for a missing, unreadable, non-UTF-8 or over-8 MiB file
file.write(path, text, opts?, on_done?)Creates parent directories. When the file already holds exactly text, writes nothing and calls on_done(false); otherwise replaces it atomically (temporary in the same directory, then rename) and calls on_done(true). On failure on_done(nil, err). opts = { in_place = true } truncates and rewrites the same inode, for files another program watches by path. An existing file keeps its mode, a new one gets the default. A crash mid-write leaves a truncated file, and a watcher can read it empty or partial. A callback in the opts slot raises: pass nil first
pathAbsolute, or ~/ under $HOME. Anything else raises at the call
OrderWrites run one at a time in call order, so two writes to a path end with the later text. A read does not wait for them: read in the write’s on_done. A symlink keeps its link and its target is replaced; the file’s mode is kept
Handlehandle:cancel() drops the callback. A started write still lands
ReloadDrops pending callbacks, so keep what they touch in named state. A write begun before the reload still lands
mantle checkCalls return a handle and do nothing

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); font_variations sets a variable font’s axes (variable fonts)
Uncovered glyphfontconfig is asked for any installed face that covers it
SVG <text>Images, icons and file masks draw it with the loaded chain; declare a family it names (image)
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
A timer re-armed every few milliseconds to move somethingEach tick runs Lua and resolves again. Use animate (Lua cost)
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 startupThe key has no defaults entry and mantle.storage has not pushed, or the file lacks it. Give it a default
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, file, scheme, fonts.

Colour your apps

Turn the wallpaper’s colours into config files other programs read: a kitty theme, GTK and Qt colour definitions. The pipeline is palette.quantize, then palette.score, then palette.scheme once for dark and once for light, then a template renderer, then file.write. A reload hook runs only when a file’s bytes changed.

Render matugen templates

This renderer fills the placeholder subset {{colors.<role>.<default|light|dark>.<format>}}. Roles are palette.scheme’s snake_case names, the ones matugen uses. Formats are hex (#RRGGBB, uppercase like palette.scheme), hex_stripped, rgb, rgba (alpha 1.0) and hsl. default is the mode you pass. A placeholder it cannot resolve stays as written. Not supported: filters ({{ ... | lighten: 10 }}) and <* ... *> blocks, so templates using them need those lines edited.

local function hsl(r, g, b)
    r, g, b = r / 255, g / 255, b / 255
    local hi, lo = math.max(r, g, b), math.min(r, g, b)
    local l, h, s = (hi + lo) / 2, 0, 0
    if hi ~= lo then
        local d = hi - lo
        s = l > 0.5 and d / (2 - hi - lo) or d / (hi + lo)
        if hi == r then h = (g - b) / d + (g < b and 6 or 0)
        elseif hi == g then h = (b - r) / d + 2
        else h = (r - g) / d + 4 end
    end
    local function round(x) return math.floor(x + 0.5) end -- `%d` raises on a float
    return string.format("hsl(%d, %d%%, %d%%)", round(h * 60), round(s * 100), round(l * 100))
end

local function format(hex, how)
    local r, g, b = tonumber(hex:sub(2, 3), 16), tonumber(hex:sub(4, 5), 16), tonumber(hex:sub(6, 7), 16)
    if how == "hex" then return hex end
    if how == "hex_stripped" then return hex:sub(2) end
    if how == "rgb" then return string.format("rgb(%d, %d, %d)", r, g, b) end
    if how == "rgba" then return string.format("rgba(%d, %d, %d, 1.0)", r, g, b) end
    if how == "hsl" then return hsl(r, g, b) end
end

--- `schemes` is { dark = palette.scheme(...), light = palette.scheme(...) }; `mode` is "dark" or "light".
local function render(template, schemes, mode)
    return (template:gsub("{{%s*colors%.([%w_]+)%.(%w+)%.([%w_]+)%s*}}", function(role, which, how)
        local scheme = schemes[which == "default" and mode or which]
        local hex = scheme and scheme[role]
        return hex and format(hex, how) or nil
    end))
end

local seed = "#6750A4"
local schemes = {
    dark = palette.scheme(seed, { dark = true }),
    light = palette.scheme(seed, { dark = false }),
}
return text {
    content = render("background {{colors.surface.default.hex}} / {{colors.primary.light.rgb}} / {{colors.primary.dark.hsl}}", schemes, "dark"),
}

Save the format and render functions (with hsl) as app_colours.lua in your config directory, ending in return render.

Reference templates

kitty, kitty.conf.tpl, written to ~/.config/kitty/mantle.conf. Add include mantle.conf to kitty.conf:

foreground {{colors.on_surface.default.hex}}
background {{colors.surface.default.hex}}
selection_foreground {{colors.on_primary.default.hex}}
selection_background {{colors.primary.default.hex}}
cursor {{colors.primary.default.hex}}
url_color {{colors.tertiary.default.hex}}
active_border_color {{colors.primary.default.hex}}
inactive_border_color {{colors.outline_variant.default.hex}}

GTK, gtk.css.tpl, for ~/.config/gtk-3.0/colors.css and gtk-4.0/colors.css. Add @import 'colors.css'; at the top of the gtk.css beside it:

@define-color accent_color {{colors.primary.default.hex}};
@define-color accent_bg_color {{colors.primary_container.default.hex}};
@define-color accent_fg_color {{colors.on_primary_container.default.hex}};
@define-color window_bg_color {{colors.surface.default.hex}};
@define-color window_fg_color {{colors.on_surface.default.hex}};
@define-color headerbar_bg_color {{colors.surface_container.default.hex}};
@define-color view_bg_color {{colors.surface_container_low.default.hex}};

qt6ct, mantle.conf.tpl, for ~/.config/qt6ct/colors/mantle.conf. The 22 entries per palette are Qt’s QPalette::ColorRole indices in order, which qt6ct reads by position: WindowText, Button, Light, Midlight, Dark, Mid, Text, BrightText, ButtonText, Base, Window, Shadow, Highlight, HighlightedText, Link, LinkVisited, AlternateBase, NoRole, ToolTipBase, ToolTipText, PlaceholderText, Accent:

[ColorScheme]
active_colors={{colors.on_surface.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.primary.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.primary.default.hex}}
disabled_colors={{colors.on_surface_variant.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.outline.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.outline.default.hex}}
inactive_colors={{colors.on_surface.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface_container_high.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface_dim.default.hex}}, {{colors.outline_variant.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.surface_container_low.default.hex}}, {{colors.surface.default.hex}}, {{colors.shadow.default.hex}}, {{colors.primary.default.hex}}, {{colors.on_primary.default.hex}}, {{colors.tertiary.default.hex}}, {{colors.secondary.default.hex}}, {{colors.surface_container.default.hex}}, {{colors.surface.default.hex}}, {{colors.surface_container_highest.default.hex}}, {{colors.on_surface.default.hex}}, {{colors.on_surface_variant.default.hex}}, {{colors.primary.default.hex}}

Qt reads #RRGGBB or #AARRGGBB; select the file in qt6ct’s Appearance tab once.

The pipeline

The wallpaper path is yours. file.read fetches each template, file.write writes the output, and process.run signals the app only when changed is true, so an unchanged wallpaper restarts nothing.

local render = require("app_colours")

local targets = {
    { template = "kitty.conf.tpl", out = "~/.config/kitty/mantle.conf", reload = { "pkill", { "-USR1", "kitty" } } },
    { template = "gtk.css.tpl", out = "~/.config/gtk-4.0/colors.css" },
    { template = "mantle.conf.tpl", out = "~/.config/qt6ct/colors/mantle.conf" },
}

local function apply(seed, mode)
    local schemes = {
        dark = palette.scheme(seed, { dark = true }),
        light = palette.scheme(seed, { dark = false }),
    }
    for _, target in ipairs(targets) do
        file.read(mantle.config_dir .. "/templates/" .. target.template, function(template, err)
            if not template then return log.warn("app colours:", err) end
            file.write(target.out, render(template, schemes, mode), nil, function(changed, werr)
                if werr then return log.warn("app colours:", werr) end
                if changed and target.reload then
                    process.run(target.reload[1], target.reload[2], function() end, function() end)
                end
            end)
        end)
    end
end

palette.quantize(mantle.config_dir .. "/wallpaper.png", { depth = 7 }, function(found)
    if found then apply(palette.score(found)[1], "dark") end
end)

A reload drops callbacks still pending. If one lands first, the file is written but its hook skipped, and the next run reports changed = false. Touch the output by hand to force the hook.

file.write replaces a file by renaming a temporary over it, which ends an inotify watch an app holds on that one path. For an app that live-reloads a single file, pass { in_place = true } in the opts slot: it rewrites the same inode and keeps the watch. A crash mid-write leaves a truncated file, so use it only where the app needs it.

file.write(target.out, text, { in_place = true }, on_done)

Recolour the shell itself

Keep the theme in one state, or a persistent_table key to survive restarts, and derive every colour token as a :map over it. Each node reading a token repaints when the theme changes; nothing else needs rebuilding.

local seed = "#6750A4"
local schemes = {
    dark = palette.scheme(seed, { dark = true }),
    light = palette.scheme(seed, { dark = false }),
}
local theme = state("theme", "dark")

local function token(role)
    return theme:map(function(name) return schemes[name][role] end)
end
local surface, primary = token("surface"), token("primary")

return rect {
    padding = 12, radius = 8,
    background = surface,
    -- an alpha suffix goes inside the map: a signal does not concatenate
    border_color = primary:map(function(hex) return hex .. "80" end),
    border_width = 2,
    on_click = function() theme:set(theme:get() == "dark" and "light" or "dark") end,
    children = {
        rect { width = 80, height = 6, background = {
            gradient = "linear", angle = 90, stops = { { 0, primary }, { 1, surface } },
        } },
    },
}

What takes a signal:

Where the colour goesPass
A property, such as backgroundThe token
A gradient stop’s colour, a shader params entryThe token, or a :map of it, in the table. The walk reaches 8 tables deep and skips a table with a metatable
A string built from a token, such as token .. "80"The whole string as one :map. .. on a signal raises
A whole gradient or params tableOne :map that returns the full table

A shader’s params hold numbers, never hex: map the token to { r, g, b } in 0..1.

React to a theme change

on_change runs fn(current, previous) after any write: :set or mantle set on a state; store:set or another process editing the file for a key. Rewrite the templates and relaunch from there instead of watching mantle.storage and diffing keys by hand.

local settings = persistent_table { path = "~/.local/state/myshell/theme.json", defaults = { mode = "dark" } }

settings.mode:on_change(function(mode)
    apply(seed, mode) -- the pipeline above
    mantle.applications:launch("org.example.Editor", { env = { GTK_THEME = false } })
end)

The env table sets variables with a string and unsets them with false; here the relaunched app reads the files just written, not a GTK_THEME inherited from the shell. A running single-instance app keeps its old environment: see process.detach and the applications page.

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.

local function default_sink(audio)
    for _, sink in ipairs(audio and audio.sinks or {}) do
        if sink.active then
            return sink
        end
    end
end

text {
    on_click = function()
        local sink = default_sink(mantle.audio:get())
        if sink then
            mantle.audio:toggle_mute(sink.id)
        end
    end,
    on_wheel = function(_, steps)
        local sink = default_sink(mantle.audio:get())
        if sink and sink.volume then
            mantle.audio:set_volume(sink.id, sink.volume + steps * 5) -- percent, clamped to [0, 150]
        end
    end,
    content = mantle.audio:map(function(audio)
        local sink = default_sink(audio)
        if sink == nil or sink.volume == nil then
            return "--" -- no volume without a default sink
        end
        return sink.muted and "muted" or string.format("%d%%", math.floor(sink.volume + 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, before?)Derived signal. fn never sees the pre-push nil: until the first push the signal holds before (default nil, the property’s default). computed skips fn the same way; see Capabilities before their first push
:on_change(fn)fn(current, previous) once per push, after it lands; previous is nil on the first. Runs under the Lua CPU 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(sink.id, 50). 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, audio_levels and privacy share one PipeWire thread; workspaces and windows share one compositor reader. Whichever is read first starts it
BusesOne system-bus connection for all. tray, notifications, mpris, idle and appearance each open their own session bus. Every D-Bus method call times out after 25 s
PushesOn backend events. clock, 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 folders 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 name, type and count of each argument before sending; the Supervisor checks what needs its live state, such as whether an ID still exists.

MistakeResult
A state field read off the capability (mantle.audio.sinks)Raises at the read: did you mean `mantle.audio:get().sinks`?
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 appearance, audio_levels, battery, privacy or clockRaises: they have no actions
A function or userdata argumentRaises at the call, naming its slot
Wrong type, range or argument countRaises at the call: mantle.audio:set_volume: bad argument #2: expected a number, got string "loud", or mantle.audio:set_volume: expects 2 arguments, got 3
A value the type rules outRaises at the call: an empty name, a relative folders:watch path, a negative hold_expiry
A float where an integer goesRaises: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer
Arguments to an action that takes noneRaises: mantle.network:scan(1) is refused
An ID, profile or MAC the Supervisor does not knowDoes not raise: logged (mantle log) or ignored, as the capability’s page says

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
LevelsPercent, 100 for full: brightness and battery read 0 to 100; setters cap output volume at 150 and input and app volume at 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
android_connectAndroid device synchronization through KDE ConnectNo devices until daemon runs
appearanceDark mode, accent colour, contrast and reduced motion from the desktop portalDefaults without a portal
applicationsDesktop entries, window app_id index, launching
audioOutput and input volume and mute, devices, per-app streams, Bluetooth codecs
audio_levelsPeak levels of chosen sinks and sources, for VU metersnil before the first audio:set_level_meters this session
batteryCharge, state, time estimatesCheck present first
bluetoothAdapter power, devices, discovery, pairing prompts
brightnessScreen backlight percentagenil without a backlight
foldersListings 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
radiorfkill soft and hard blocks per radio kind; airplane modenil without /dev/rfkill
secretsStatus of named Secret Service writesValues go through secure_submit, never Lua
storageEach persistent_table fileUse persistent_table, not its actions
sysinfoCPU, memory, swap, temperatures, disks, GPU, network throughputnil until configure
clockWall 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, Hyprland, sway or mango 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 panel’s output 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.clock.time: clock
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.app_id through applications.by_app_id: applications
Play or pause whatever is playingcontrol on the id of the player whose play_state is "playing": 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 (a sink’s volume before its first report)
local ok = mantle.audio:set_volume(...) is always nilBind the state the action changes; read mantle log for refused commands
An action raises bad argument #N: expected ... or expects N arguments, got MWrong argument type or count, often a float where an integer goes (keyboard:switch_layout(1.0))
An action silently does nothingIts target is stale or unknown to the Supervisor, such as a closed window’s ID. 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, action types, payload types under supervisor/src/capabilities/.

android_connect

Android device synchronization through KDE Connect: battery, cellular signal, find my phone, clipboard sync, file sharing, and SFTP mount.

list {
    items = mantle.android_connect:map(function(android)
        return android.devices
    end),
    key = function(device) return device.id end,
    each = function(device)
        local battery = device.battery and string.format(" %d%%", device.battery) or ""
        return rect {
            on_click = function() mantle.android_connect:ring(device.id) end,
            children = { text { content = device.name .. battery } },
        }
    end,
}

State

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

FieldTypeDescription
availablebooleanWhether the KDE Connect daemon (io.github.hepp3n.kdeconnect) is running on the session bus.
commandsAndroidCommand[]Commands paired devices can run on this PC with sh -c, from ~/.config/kdeconnect/runcommand.json.
devicesAndroidDevice[]Known devices. Unordered; sort before drawing.
transfer_progress?integerPercent sent of the file transfer in progress, or nil when none runs. The daemon does not say which device it goes to.

AndroidCommand

A command a paired device can run on this PC.

FieldTypeDescription
commandstringShell command line this PC runs with sh -c when a device asks.
idstringUnique identifier, the key remove_command takes.
namestringLabel the device shows.

AndroidDevice

A remote Android or paired device known to the daemon.

FieldTypeDescription
battery?integerBattery level percentage (0 to 100), or nil if unknown or unannounced. With two devices connected, the daemon can report one’s value as the other’s.
browse_error?stringWhy the last browse failed, such as missing sshfs. Clears on the next browse or a mount.
charging?booleanWhether the battery is charging, or nil if unknown.
clipboard_auto_sharebooleanWhether every desktop clipboard change goes to this device. On unless switched off.
device_typeDeviceTypeDevice form-factor / category.
disabled_pluginsstring[]Plugins switched off for this device, by id: battery, clipboard, connectivity_report, contacts, findmyphone, mpris, notification, ping, runcommand, sftp, share, sms, systemvolume, telephony, or the daemon’s default mousepad and presenter.
idstringUnique KDE Connect device identifier.
mountedbooleanWhether the device’s SFTP storage is currently mounted.
namestringDevice’s advertised or configured friendly name.
pairedbooleanWhether the device has established mutual pairing.
pairing_requestedbooleanWhether an incoming pairing request from this device is awaiting confirmation. Clears on accept, reject or disconnect, or after 30 s: the daemon reports no cancel from the device.
pairing_sentbooleanWhether a pairing request this PC sent is awaiting the device’s answer. Clears when the device accepts, on unpair, on disconnect, or after 30 s: the daemon reports no rejection.
reachablebooleanWhether the device is currently reachable on the network.
reconnectingbooleanWhether the device is reconnecting to apply a plugin switch. Clears when it connects again, or after 30 s.
signal_strength?integerCellular network signal strength (0 to 4 bars), or nil if not reported. With two devices connected, the daemon can report one’s value as the other’s.

DeviceType

The device category reported by KDE Connect.

One of "phone", "tablet", "desktop", "laptop", "tv", "unknown".

Actions

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

ActionArgumentsDescription
broadcastBroadcasts identity over UDP to discover nearby devices and prompt them to re-announce.
ringid: stringRings the device to locate it.
pingid: string, message?: stringSends a ping notification with an optional custom message.
send_clipboardid: string, text: stringSends text to the remote device’s clipboard.
share_clipboardid: stringSends the current desktop clipboard to the device.
send_filesid: string, files: string[]Sends existing local files to the device.
browseid: stringMounts the device’s storage via SFTP and opens it in the file manager.
unmountid: stringUnmounts the device’s SFTP storage.
pairid: stringInitiates pairing with an unpaired device.
unpairid: stringUnpairs a paired device.
accept_pairingid: stringAccepts an incoming pairing request from a device.
reject_pairingid: stringRejects an incoming pairing request from a device.
set_plugin_enabledid: string, plugin: string, enabled: booleanSwitches one of the device’s plugins on or off; disabled_plugins lists the ids. The daemon drops the connection so the device learns the change, and the device reconnects.
add_commandname: string, command: stringOffers paired devices a name button that runs command on this PC with sh -c. Saved to runcommand.json, then the list is resent to every connected paired device.
remove_commandid: stringStops offering the command with this commands[].id, then resends the list to every connected paired device. An unknown id does nothing.
set_clipboard_auto_shareid: string, enabled: booleanSwitches clipboard_auto_share for the device. Takes effect at the next clipboard change.

Backend

kdeconnect-service (io.github.hepp3n.kdeconnect) on the session bus.

ContractBehavior
StateTracked via io.github.hepp3n.kdeconnect.Daemon. Without a running daemon, available is false, devices is empty, and every action is a no-op. When the daemon appears, the capability asks it to broadcast its identity so nearby devices answer
Securitysend_files takes absolute paths of existing regular files; one relative path, missing file or directory rejects the whole batch
Daemon filesclipboard_auto_share is read from, and set_clipboard_auto_share rewrites only the autoShare line of, ~/.config/kdeconnect/<id>/kdeconnect_clipboard/config. An id holding anything but letters, digits, _ and - is refused. commands is read from, and add_command and remove_command rewrite, ~/.config/kdeconnect/runcommand.json; a file that does not parse is left untouched

Installing the daemon

Only kdeconnect-service is needed, not the COSMIC applet. Install it with Rust and the libpulse and dbus development headers:

cargo install --git https://github.com/cosmic-utils/kdeconnect kdeconnect-service

Run the same command to update: it rebuilds only when upstream has new commits. Leave out --locked: upstream’s Cargo.lock lags its own crate versions, so cargo refuses it.

Start it from the compositor, so it has the Wayland socket that clipboard sync needs. In niri’s config.kdl:

spawn-sh-at-startup "~/.cargo/bin/kdeconnect-service"

In Hyprland’s hyprland.conf:

exec-once = ~/.cargo/bin/kdeconnect-service

Open TCP and UDP ports 1714–1764 in the firewall, and stop KDE’s kdeconnectd if it runs: both bind port 1716.

How do I…

Find my phone

Call ring with the target device’s id:

mantle.android_connect:ring(device.id)

Send clipboard text

mantle.android_connect:send_clipboard(device.id, "Hello from desktop")

Show why browsing failed

browse reports no error itself. When the mount fails, browse_error names the cause until the next browse or a successful mount:

text { content = device.browse_error or "" }

Stop syncing the clipboard to a device

mantle.android_connect:set_clipboard_auto_share(device.id, not device.clipboard_auto_share)

Offer a command to my phone

Any paired device can then run it on this PC, as sh -c, from its Run command screen:

mantle.android_connect:add_command("Lock screen", "loginctl lock-session")

Source: supervisor/src/capabilities/android_connect/

appearance

Portal appearance settings: colour scheme, accent colour, contrast and reduced motion.

Follow the system’s dark mode and accent colour instead of hardcoding them:

local scheme = mantle.appearance:map(function(appearance)
    return appearance.color_scheme or "default"
end, "default")

rect {
    padding = 8,
    background = scheme:map(function(name)
        return name == "light" and "#EFF1F5" or "#1E1E2E"
    end),
    border = {
        width = 2,
        color = mantle.appearance:map(function(appearance)
            return appearance.accent or "#89B4FA"
        end, "#89B4FA"),
    },
}

State

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

mantle.appearance’s payload: the org.freedesktop.appearance settings of xdg-desktop-portal. Without a portal, or for a key it does not provide, a field holds its default; the capability is never nil after it starts.

FieldTypeDescription
accent?stringSystem accent colour as "#rrggbb" (sRGB), or nil when none is set.
color_schemeColorSchemePreferred colour scheme; "default" means no preference.
contrastContrastPreferred contrast level.
reduced_motionbooleantrue when the user asks for reduced motion; false for no preference or an older portal.

ColorScheme

The user’s preferred colour scheme, from the portal’s color-scheme.

ValueDescription
"default"No preference, or a value the portal does not define.
"dark"The user prefers a dark appearance.
"light"The user prefers a light appearance.

Contrast

The user’s preferred contrast level, from the portal’s contrast.

ValueDescription
"normal"No preference, or a value the portal does not define.
"high"The user prefers higher contrast.

Actions

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

Backend

FieldPortal key (org.freedesktop.appearance)Default
color_schemecolor-scheme: 1 is "dark", 2 is "light""default"
accentaccent-color: an sRGB triple in [0, 1], as "#rrggbb"nil, also for a triple out of range
contrastcontrast: 1 is "high""normal"
reduced_motionreduced-motion: 1 is truefalse

The source is org.freedesktop.portal.Settings on the session bus. Mantle reads the namespace once, then again on each SettingChanged in it and when org.freedesktop.portal.Desktop gains or loses an owner. Any other value of a key, or a key the portal does not provide, is the default.

Gotchas

TrapFix
Every field stays at its defaultNo portal backend owns org.freedesktop.portal.Desktop on the session bus. Install xdg-desktop-portal with a backend that serves Settings (GNOME, KDE and xdg-desktop-portal-gtk do)
reduced_motion is always falseThe portal predates the reduced-motion key

Source: supervisor/src/capabilities/appearance.rs

applications

Installed desktop entries, indexed by window app_id.

text {
    content = computed({ mantle.workspaces, mantle.applications }, function(workspaces, applications)
        local client = workspaces.active_client
        if client == nil then
            return ""
        end
        local index = applications.by_app_id[client.app_id]
        return index and applications.entries[index].name or client.app_id
    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), including NoDisplay entries for window lookup. Launchers omit entries with no_display = true. Hidden entries are excluded. A change under an applications directory rescans 250 ms after the last event.

AppSummary

One 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.
no_displaybooleanNoDisplay=true: omit from launchers, but keep its name and icon for window lookup.

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: string, opts?: LaunchOptsLaunches entries[].id, detached; Terminal=true entries run in $TERMINAL. opts.env sets a variable to a string or unsets it with false.
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.

LaunchOpts

Options of a detached launch: process.detach’s third argument and launch’s second.

FieldTypeDescription
env?table<string, string|false>Changes to the Supervisor’s environment: NAME = "value" sets, NAME = false unsets.

Backend

Reads applications/ under $XDG_DATA_HOME (default ~/.local/share), then each absolute $XDG_DATA_DIRS entry (default /usr/local/share:/usr/share), subdirectories included, four levels deep. 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 a non-empty Exec are listed, including NoDisplay=true entries so windows can resolve their names and icons. Launchers filter out entries with no_display = true; Hidden=true entries are excluded entirely. inotify watches every directory, including ones created later. launch spawns the Exec command detached, dropping field codes such as %u; a Terminal=true entry runs as $TERMINAL -e command args. launch(id, { env = { NAME = "value", OTHER = false } }) sets or unsets variables for that launch (a string sets, false unsets; for a Terminal=true entry they apply to the $TERMINAL process); a single-instance app that is already running passes the launch to its existing process, which keeps its old environment. open_url takes no options: use process.detach("xdg-open", { url }, { env = ... }).

How do I…

TaskAnswer
Hide an app from a launcherCopy its .desktop file to ~/.local/share/applications and add NoDisplay=true; filter entry.no_display in the launcher

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 {
    items = mantle.audio:map(function(audio)
        return audio.sinks
    end),
    key = function(sink) return tostring(sink.id) end,
    each = function(sink)
        return rect {
            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.
cardsAudioCard[]ALSA and BlueZ cards with their profiles, ordered by device. A card makes sinks and sources only for its active profile: analog or HDMI on one Intel HDA card, a codec on a headset.
sinksAudioDevice[]Every output device; the active one is the default.
sourcesAudioDevice[]Every input device; the active one is the default.

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, set_app_muted and set_app_target.
media_name?stringmedia.name, which tells one app’s streams apart, e.g. a browser tab’s "YouTube Music". Follows the stream as the client renames it.
mutedbooleanStream mute; false until volume is known.
name?stringapplication.name, if the client set one.
pidintegerOwning process id, from application.process.id.
pinned_to?integerThe sinks[].id or sources[].id the stream is pinned to: the device its target.object in the default metadata names, by node.name or object.serial, as set_app_target writes. nil when unset, or when it names no current device of the stream’s direction, such as an unplugged headset; WirePlumber then plays it on the default.
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.
target?integerThe sinks[].id a playback stream plays into, or the sources[].id a recording reads; nil while it is linked to neither.
volume?numberStream volume in percent; nil until PipeWire reports the stream’s Props.

AudioCard

One ALSA or BlueZ card and every profile it offers.

FieldTypeDescription
active?integerindex of the active profile; nil before PipeWire reports it.
deviceintegerPipeWire device id, the first argument of set_card_profile.
mac?stringA BlueZ card’s MAC address, from its bluez_card.* name with _ turned to :; joins mantle.bluetooth. nil on an ALSA card.
namestringdevice.description, e.g. "Built-in Audio", else device.nick, else device.name.
portsCardPort[]Every port, unplugged ones included, ordered by index. A device’s port is its active one.
profilesCardProfile[]Every profile, unavailable ones included, ordered by index.

AudioChannel

One sink or source channel. position is nil if PipeWire omits or does not name its channel map.

FieldTypeDescription
indexintegerZero-based index for set_channel_volume.
position?stringSpeaker position, e.g. "front_left" or "front_right".
volumenumberReported level in percent. Sink writes stop at 150; only the default output is corrected above it.

AudioDevice

One sinks or sources entry.

FieldTypeDescription
activebooleanThis is the default output or input; with no default known, or one not in this list, the lowest id is.
balance?number-1.0 (left) to 1.0 (right); nil for mono, an unknown channel map, or before its first report.
bus?stringdevice.bus, e.g. "pci", "usb", "bluetooth".
card?integerThe cards[].device this comes from; nil for a virtual device.
channels?AudioChannel[]Channel levels in percent, or nil before valid channel volumes arrive. Indices name the current channel order; read a fresh snapshot after a device changes.
form_factor?stringdevice.form-factor, e.g. "headset".
icon?stringdevice.icon-name theme name, e.g. "audio-card-analog".
idintegerPipeWire node id, the first argument of every device action; not reboot-stable.
mutedbooleanfalse before its first report.
namestringnode.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name.
node_namestringnode.name, e.g. "alsa_output.pci-0000_00_1f.3.analog-stereo": stable across reboots, so a config keys its own labels on it.
port?stringThe active card route’s port.type, e.g. "headphones", "hdmi", "mic".
volume?numberVolume in percent, the loudest channel; nil before its first report. A sink’s default is pulled back to 150 when another client sets it louder.

CardPort

One entry of AudioCard.ports: a place a card routes sound, such as speakers or headphones.

FieldTypeDescription
activebooleanThe card routes sound through it now: an active route of the active profile names it, and its type is that sink’s or source’s port.
availablebooleanfalse when PipeWire reports the port unavailable, such as an unplugged jack.
descriptionstringPipeWire’s description, e.g. "Headphones".
directionPortDirection"output" for a sink’s port, "input" for a source’s.
indexintegerPort index, the second argument of set_port.
namestringPipeWire’s untranslated id, e.g. "analog-output-headphones".
profilesinteger[]profiles[].index values the port works under.
type?stringport.type, e.g. "speaker", "headphones", "hdmi", "mic".

CardProfile

One entry of AudioCard.profiles.

FieldTypeDescription
availablebooleanfalse when PipeWire reports the profile unavailable, such as HDMI with no display plugged in.
codec?stringThe Bluetooth codec the profile names, e.g. "AAC", "LDAC", "mSBC"; nil when it names none, as off and every ALSA profile.
descriptionstringPipeWire’s description, e.g. "Digital Stereo (HDMI) Output".
indexintegerProfile index, the second argument of set_card_profile.
inputsintegerSources the profile makes; 0 for an output-only profile or off.
namestringPipeWire’s untranslated id, e.g. "output:hdmi-stereo" or "off".
outputsintegerSinks the profile makes; 0 for an input-only profile or off.

PortDirection

Whether a port plays or records.

ValueDescription
"output"A sink’s port, such as speakers.
"input"A source’s port, such as a microphone.

Actions

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

ActionArgumentsDescription
set_volumeid: integer, volume: numberSets a sinks[].id or sources[].id volume in percent, clamped to [0, 150] for a sink and [0, 100] for a source. It need not be the default.
set_mutedid: integer, muted: booleanSets a sinks[].id or sources[].id mute.
toggle_muteid: integerToggles a sinks[].id or sources[].id mute.
set_balanceid: integer, balance: numberSets a sinks[].id or sources[].id 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. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it.
set_default_sourceid: integerMakes this sources[].id the default input. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it.
set_channel_volumeid: integer, index: integer, volume: numberSets one channels[] level of a sinks[].id or sources[].id in percent, clamped as set_volume. Other channels stay unchanged.
set_app_volumeid: integer, volume: numberSets an apps[].id stream’s volume in percent, clamped to [0, 100].
set_app_mutedid: integer, muted: booleanSets an apps[].id stream’s mute.
set_app_targetid: integer, device?: integerMoves an apps[].id stream to a sinks[].id, or a recording to a sources[].id; nil returns it to the default. WirePlumber keeps the choice for that app.
set_card_profiledevice: integer, index: integerSwitches a cards[].device to one of its profiles[].index. WirePlumber keeps the choice.
set_portcard: integer, index: integerRoutes a cards[].device to one of its ports[].index, e.g. headphones instead of speakers. The port must work under the active profile. WirePlumber keeps the choice.
set_virtual_devicesdevices?: VirtualDevice[]Replaces the declared virtual devices: each appears in sinks or sources while listed, and an empty list removes them. A reload keeps them until the next call; a replaced Renderer’s go. A change to any kind but a null sink restarts the other kinds’ devices, which drop out briefly.
set_level_metersids?: integer[]Meters these sinks[].id and sources[].id: mantle.audio_levels carries their peaks. Replaces the last list; an empty one stops every meter, and none runs before the first call. A reload keeps them until the next call; a replaced Renderer’s go.

VirtualDevice

One set_virtual_devices entry.

FieldTypeDescription
args?table<string, any>More arguments for the PipeWire module, or node properties for a null sink, as PipeWire’s docs spell them, e.g. { ["filter.graph"] = {...} }. Each top-level key replaces Mantle’s default for it, capture.props included.
description?stringnode.description, the name a mixer shows.
kindVirtualKind
namestringnode.name, unique among the declared devices.

VirtualKind

What makes a virtual device.

ValueDescription
"null_sink"A sink that plays nowhere, support.null-audio-sink; its monitor records what it receives.
"combine"One sink playing to several, libpipewire-module-combine-stream; args takes its stream.rules.
"loopback"A sink that plays into the default output, libpipewire-module-loopback. For a source instead, pass args with capture.props = {} and playback.props holding ["media.class"] = "Audio/Source" and ["node.name"].
"echo_cancel"A microphone with the speakers’ echo removed, libpipewire-module-echo-cancel: the source is name, the sink it listens to name .. ".sink".
"filter_chain"A sink through a filter graph such as an equalizer, libpipewire-module-filter-chain; args takes its filter.graph. For a source, set capture.props and playback.props as for a loopback.

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, their active, channels, volume, muted and balance; card is the node’s device.id
default.configured.audio.sink/source in the default metadataWritten with default.audio.sink/source by set_default_sink/set_default_source, as wpctl set-default does. WirePlumber saves this key and picks the default from it on every rescan, so the choice survives a volume change or a re-plug; default.audio.* alone is overwritten by the next rescan
Stream/Output/Audio, Stream/Input/Audio nodesapps, minus the streams its field lists
Link globalsan app’s target: the sink or source its links reach
target.object in the default metadata, keyed by the stream’s idAn app’s pinned_to: the sink or source whose node.name or object.serial it names, nil when unset or when it names no current device of the stream’s direction. set_app_target writes the device’s node.name there, how WirePlumber moves a stream; nil deletes it. WirePlumber’s stream restore keeps the choice per app, so a stream that reconnects goes back to it, unless node.stream.restore-target is off. Whether that restore also writes target.object back, and so shows as pinned_to, is unverified; a choice made through another mixer that writes the key does show
ALSA and bluez_card.* devices and their profilescards; a BlueZ card’s mac comes from its name, a profile’s codec from its name, else its English description
Each ALSA and BlueZ device’s EnumRoutecards[].ports; set_port writes a Route on the profile device a sink or source of the active profile uses, so a port outside the active profile is dropped
support.null-audio-sink adapters and one pipewire -c childset_virtual_devices: a null sink is an adapter the mixer creates on its own connection, so it goes with the Supervisor. Every other kind is a module in one child pipewire, whose conf the Supervisor writes under $XDG_RUNTIME_DIR and removes on exit. It restarts the child with backoff when it exits, and again when the mixer reconnects to a restarted PipeWire, which the child outlives with no modules; the child dies with the Supervisor. A module that fails to load is skipped, with PipeWire’s warning on the Supervisor’s stderr, and the others still load
A capture stream per id set_level_meters namesaudio_levels, pushed apart from this state
Each ALSA and BlueZ device’s active Routeport, and a hardware sink’s or source’s volume, mute and balance, Bluetooth microphones included. The node’s Props only mirror the Route: they are read until it arrives, and a write before then is dropped. A virtual device has no Route and uses its node’s Props

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 until it comes up. A lost connection pushes empty lists, the same as no hardware. Either way the Supervisor retries after 1 s, doubling to every 30 s, and the first push after it reconnects is a full one.

Writes are not optimistic: state changes when PipeWire reports the new value. An action with no target (a stale id, or a volume write or mute toggle before that device’s first report) is dropped with a debug log. Volume, mute and balance writes go to the device named, which need not be the default.

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). The default output is the active entry of sinks.

local function default_sink(audio)
    for _, sink in ipairs(audio and audio.sinks or {}) do
        if sink.active then
            return sink
        end
    end
end

rect {
    on_wheel = function(_, steps)
        local sink = default_sink(mantle.audio:get())
        if sink == nil or sink.volume == nil then
            return
        end
        mantle.audio:set_volume(sink.id, math.max(0, math.min(100, sink.volume + steps * 5)))
    end,
    on_click = function(_, which)
        local sink = default_sink(mantle.audio:get())
        if which == "middle" and sink then
            mantle.audio:toggle_mute(sink.id)
        end
    end,
    children = {
        text {
            content = mantle.audio:map(function(audio)
                local sink = default_sink(audio)
                if sink == nil or sink.volume == nil then
                    return "--"
                end
                return sink.muted and "muted" or string.format("%d%%", math.floor(sink.volume + 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

local function default_sink(audio)
    for _, sink in ipairs(audio.sinks) do
        if sink.active then
            return sink
        end
    end
end

mantle.audio:on_change(function(audio, previous)
    local sink, before = default_sink(audio), previous and default_sink(previous)
    if before == nil or sink == nil or sink.id ~= before.id or before.volume == nil or sink.volume == nil then
        return -- the first push, or a new default, is learned state, not a change
    end
    if sink.volume == before.volume and sink.muted == before.muted then
        return
    end
    osd_text:set(sink.muted and "Muted" or string.format("Volume %d%%", math.floor(sink.volume + 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 },
}
return osd

See also: Volume OSD recipe.

Add a virtual sink or an echo-cancelled microphone

Declare the whole list once; each device shows up in sinks or sources like a hardware one, and args passes PipeWire’s own module arguments through:

mantle.audio:set_virtual_devices({
    { kind = "null_sink", name = "stream_mix", description = "Stream mix" },
    { kind = "echo_cancel", name = "mic_aec", description = "Microphone (echo cancelled)" },
})

return text { content = "Virtual devices declared" }

Source: supervisor/src/capabilities/audio/

audio_levels

Peak levels of the sinks and sources mantle.audio:set_level_meters names; nil before the first call this session.

mantle.audio:on_change(function(audio)
    local ids = {}
    for _, sink in ipairs(audio.sinks) do
        if sink.active then
            ids[#ids + 1] = sink.id
        end
    end
    mantle.audio:set_level_meters(ids)
end)

return rect {
    height = 6,
    background = "#A6E3A1",
    width = mantle.audio_levels:map(function(levels)
        local level = levels.levels[1]
        return level and level.peak * 2 or 0
    end, 0),
}

State

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

mantle.audio_levels’ payload: the peaks of the devices mantle.audio:set_level_meters names, pushed at most 30 times a second and only when one moves by 1 % or more.

FieldTypeDescription
levelsAudioLevel[]One entry per metered device that has sent audio since the call, ordered by id.

AudioLevel

One metered sink or source.

FieldTypeDescription
idintegerThe sinks[].id or sources[].id.
peaknumberThe loudest sample since the last push, in percent of full scale, 0 to 100; linear, not the cubic scale of volume. A sink’s is read from its monitor, which PipeWire takes before the sink’s own volume unless the sink sets monitor.channel-volumes.

Actions

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

Backend

SourceFeeds
One PipeWire capture stream per id mantle.audio:set_level_meters names: a sink’s monitor or a source, stereo (FL and FR) at 60 samples a second with resample.peaks, so each sample is the peak of the graph samples it replaceslevels: each buffer’s loudest sample across both channels, held until the next push

Meters run only between a set_level_meters call naming ids and the next call, an empty one stops them all, and none runs before the first call: reading audio_levels alone opens no stream. A reload keeps them until the next call; a replaced Renderer’s stop. The streams share audio’s thread, and a push goes out at most every 33 ms, only when a level moved by 1 point or more, so a silent or steady device costs no pushes.

Gotchas

  • An id that is not a current sink or source is skipped, not waited for, and a meter ends with its device. A device that comes back, or PipeWire restarting, gives new ids: call set_level_meters again from mantle.audio:on_change, which runs no meter twice.
  • A metered device keeps running: a sink no longer suspends when nothing plays, since its monitor is being read.
  • Metering a microphone reads it, so mantle.privacy lists the Supervisor among microphone_users. A sink’s monitor is not listed.
  • peak is linear in full scale, while volume is cubic. A sink’s monitor carries its streams before the sink’s own volume, unless the sink sets monitor.channel-volumes as Mantle’s null sinks do, so turning a hardware sink down need not lower its peak.

Source: supervisor/src/capabilities/audio/mixer/meters.rs

battery

UPower: system battery charge, state and time estimates, plus peripheral batteries.

text {
    content = mantle.battery:map(function(battery)
        local seconds = 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. Without a system battery, present = false; peripherals may remain.

FieldTypeDescription
health?integerBattery health: full charge as a percent of design capacity, 0 to 100, combined over the system batteries. nil when UPower reports no design capacity.
percentintegerUPower’s Percentage, rounded to 0 to 100; a spurious 0 while not draining keeps the last value.
peripheralsPeripheralBattery[]UPower batteries outside the system supply, ordered by object path. This may include devices also shown by mantle.bluetooth.
presentbooleanUPower’s display device is a present battery. Check it before drawing system charge, state or time estimates.
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 == "pending_charge".

ValueDescription
"unknown"No answer: UPower unreachable, an unknown state number, or a device without a battery state.
"charging"Taking current from an adapter.
"discharging"Draining.
"empty"Flat.
"fully_charged"Charged and holding.
"pending_charge"On mains, neither draining nor taking current: a charge limit, weak charger or thermal pause.
"pending_discharge"Waiting to discharge.

PeripheralBattery

One UPower battery outside the system supply.

FieldTypeDescription
icon?stringUPower icon name, or nil when absent.
idstringUPower object path; stable only while this UPower owner holds the device.
kindstringUPower device type, e.g. "mouse", "keyboard", "headset".
level?stringCoarse charge level, including "unknown", or nil when UPower says to use percent.
namestringUPower model, vendor, or object name.
percent?integerCharge percentage, or nil when UPower reports a coarse level or has no usable reading.
stateBatteryStatusCharge state; "unknown" when UPower has no recognized value.

Actions

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

Backend

ContractBehavior
SourceUPower’s DisplayDevice for the system battery; EnumerateDevices for peripherals and health, which sums each system battery’s EnergyFull over its EnergyFullDesign because DisplayDevice reports no design capacity
UpdatesRe-reads the display device on PropertiesChanged; follows peripheral add, remove and property changes; retries failed enumeration after one second
No system batterypresent = false, percent = 0, state = "unknown", no time estimates; peripherals may remain
No UPowerThe same display defaults, with peripherals = {}

How do I…

Show a battery label

before stands in for the first push, so fn needs no nil guard.

text {
    foreground = mantle.battery:map(function(battery)
        return (battery.present and battery.percent <= 15) and "#F38BA8" or "#CDD6F4"
    end, "#CDD6F4"),
    content = mantle.battery:map(function(battery)
        if 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 {
    items = mantle.bluetooth:map(function(bluetooth)
        return bluetooth.connected_devices
    end),
    key = function(device) return device.mac end,
    each = function(device)
        local battery = device.battery and string.format(" %d%%", device.battery) or ""
        return rect {
            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. Entry requests use secure_submit; confirmation, authorization and service requests use answer_pairing.

ConnectedDevice

FieldTypeDescription
battery?integerBattery percentage, or nil when the device reports none.
busy?DeviceActionSame as DiscoveredDevice.busy.
categoryDeviceCategory
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".

DeviceCategory

A device’s kind, read from its BlueZ class of device.

ValueDescription
"keyboard"
"mouse"
"headphones"
"headset"
"phone"
"computer"
"generic"Any class not listed.

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.

PairedDevice

FieldTypeDescription
blockedbooleanBlueZ refuses every connection to or from the device until it is unblocked.
busy?DeviceActionSame as DiscoveredDevice.busy.
categoryDeviceCategory
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", "pin_entry", "passkey_entry".

PairingRequest

What the pairing agent is asking the user.

FieldTypeDescription
code?stringSix-digit passkey for "confirm", passkey or PIN for "display", else nil.
idstringUnique for this Supervisor session. Include it with the device MAC in a secure entry target.
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. "pin_entry" and "passkey_entry": type a secret in a Bluetooth secure_submit field.
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 a confirmation, authorization or service pairing_request for mac; a yes within 750 ms of it appearing is ignored. Entry requests use secure_submit.

Backend

BlueZ on the system bus.

ContractBehavior
Stateorg.bluez’s ObjectManager plus property changes. An adapter added later is picked up; if the tracked one disappears, another present adapter takes over. A bluetoothd that starts late or restarts is read afresh and gets the pairing agent again; what the old one reported, and its open pairing_request, are dropped. Without BlueZ, available is false, the lists stay empty and every action does nothing
AgentMantle registers the default KeyboardDisplay agent at /org/mantle/Bluez/Agent1. A confirmation, authorization, displayed code, PIN entry or passkey entry 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
Discoverystart_discovery clears discovered_devices, but the next device change refills it with every unpaired device BlueZ still holds, earlier scans’ included; stop_discovery keeps it
Battery, categoryBattery1 gives battery; the Class major and minor bits give category
CodecsPipeWire owns them: audio.cards lists each device’s profiles, joined here by mac, each with its codec when it names one, and set_card_profile switches one

How do I…

Pair a new device

Scan, pair a discovered device, then answer the pairing_request it raises. A "display" request only shows a code to type on the device. Confirm, authorize and service requests take a yes or no; a yes within 750 ms of the prompt appearing is ignored. For "pin_entry" or "passkey_entry", submit a masked field with name = request.id .. "/" .. request.mac. The PIN must have 1–16 ASCII letters or digits; a passkey must have 1–6 digits (0–999999). A stale id or device is refused. Use No to decline an entry request. The typed value never enters Lua:

local function answer(accept)
    return function()
        local bluetooth = mantle.bluetooth:get()
        local request = bluetooth and bluetooth.pairing_request
        if request then
            mantle.bluetooth:answer_pairing(request.mac, accept)
        end
    end
end

local function button(label, on_click, visible)
    return rect { padding = 4, on_click = on_click, visible = visible, children = { text { content = label } } }
end

return column {
    spacing = 6,
    children = {
        button("Scan", function() mantle.bluetooth:start_discovery() end),
        list {
            items = mantle.bluetooth:map(function(bluetooth)
                return bluetooth.discovered_devices
            end),
            key = function(device) return device.mac end,
            each = function(device)
                return button(device.name ~= "" and device.name or device.mac, function()
                    mantle.bluetooth:pair(device.mac)
                end)
            end,
        },
        text {
            content = mantle.bluetooth:map(function(bluetooth)
                local request = bluetooth.pairing_request
                if request == nil then
                    return ""
                end
                return string.format("%s (%s) %s", request.name, request.kind, request.code or "")
            end, ""),
        },
        row {
            visible = mantle.bluetooth:map(function(bluetooth)
                local request = bluetooth.pairing_request
                return request ~= nil and
                    (request.kind == "confirm" or request.kind == "authorize" or request.kind == "service")
            end, false),
            spacing = 6,
            children = { button("Yes", answer(true)), button("No", answer(false)) },
        },
        textfield {
            visible = mantle.bluetooth:map(function(bluetooth)
                local request = bluetooth.pairing_request
                return request ~= nil and (request.kind == "pin_entry" or request.kind == "passkey_entry")
            end, false),
            width = "fill",
            height = 28,
            placeholder = "PIN or passkey",
            accessible_name = "Bluetooth PIN or passkey",
            secure_submit = mantle.bluetooth:map(function(bluetooth)
                local request = bluetooth.pairing_request
                if request and (request.kind == "pin_entry" or request.kind == "passkey_entry") then
                    return { capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }
                end
            end),
        },
        button("No", answer(false), mantle.bluetooth:map(function(bluetooth)
            local request = bluetooth.pairing_request
            return request ~= nil and (request.kind == "pin_entry" or request.kind == "passkey_entry")
        end, false)),
    },
}

Gotchas

TrapFix
A device pairing from its own side gets rejectedMantle only prompts for invited devices. Set set_discoverable to true while pairing

Source: supervisor/src/capabilities/bluetooth/

brightness

The screen backlight percentage; nil without a backlight.

Scroll to change the brightness by 5% a notch:

rect {
    on_wheel = function(_, steps)
        local brightness = mantle.brightness:get()
        if brightness then
            local percent = brightness.percent + steps * 5 -- fractions round to the nearest raw step
            mantle.brightness:set(math.max(1, math.min(100, percent)))
        end
    end,
    children = {
        text {
            content = mantle.brightness:map(function(brightness)
                return string.format("☀ %d%%", brightness.percent)
            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: numberSets the screen backlight, 0 to 100, fractions allowed; values outside clamp to it.

Backend

ContractBehavior
DeviceOne /sys/class/backlight device with max_brightness > 0: firmware, then platform, then raw, then by name. Chosen at startup and again when udev adds or removes one. External monitors are not covered
No deviceStays nil until one appears; set is logged and ignored. A removed device leaves its last reading, since no value means “gone”
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/

folders

Live file listings of watched folders.

-- `~/` also works; `watched` is keyed by the expanded path
local folder = (os.getenv("HOME") or "") .. "/Pictures/Wallpapers"
mantle.folders:watch(folder, { "jpg", "png" })

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

State

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

mantle.folders payload.

FieldTypeDescription
watchedtable<string, Folder>One entry per "watch", keyed by its absolute path (~/ expanded) minus trailing slashes; nil until watched.

FileEntry

FieldTypeDescription
modifiedintegerModification time in Unix seconds; 0 when unavailable.
namestringFile name, e.g. "sunrise.jpg".
pathstringAbsolute path.
sizeintegerSize in bytes; a symlink reports its target’s size.

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 keeps this error until it appears, then lists on its own.
readybooleanfalse until the first listing lands, then true even when empty or failed.

Actions

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

ActionArgumentsDescription
watchpath: string, extensions?: string[]Keeps watched[path] listing a folder, absolute or ~/ under $HOME. extensions match case-insensitively, dot optional; omitted means every file.
unwatchpath: stringStops watching path (absolute or ~/) and removes it from watched.

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.

A missing folder is watched through its nearest existing ancestor: ready is true with error set until the folder appears, then it lists with error cleared. A watched folder that is deleted or moved away does the same, and lists again when it comes back.

Gotchas

TrapFix
watched["~/Pictures"] is nil after watch("~/Pictures")watched is keyed by the expanded absolute path: watched[os.getenv("HOME") .. "/Pictures"]
watched[path] is nil after a watchThe key drops trailing slashes: watch("/walls/") lands at watched["/walls"]
error is set but the folder existsIt is set while the folder is missing and clears on the next listing, up to 200 ms after the folder appears

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

Source: supervisor/src/capabilities/folders/

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
compositor_hold_stalebooleanThe compositor last withheld idle notifications, but input resumed before it could answer again. Its hold may have ended; a fresh idle period clears this uncertainty.
inhibitedbooleanTrue when logind blocks idle, a ScreenSaver client holds it, or the compositor was last observed withholding idle notifications. The compositor answer can remain true after a release while the seat is active; the next idle threshold refreshes it.
inhibitorsIdleInhibitor[]Holders other than this shell, ScreenSaver clients included. The compositor’s hold has an empty who; draw why then. An unconfirmed compositor hold is omitted.

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(cause) when that idle ends or input follows a non-input resume. 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

on_resume receives "input" for user input, "activity" when the compositor’s regular idle notification resumes before input is confirmed, or "inhibitor" when logind blocks idle. An "activity" or "inhibitor" callback can be followed by "input" for the same idle period. On a protocol v1 compositor there is no input-only listener; its regular resume is reported as "input" and may also represent compositor-specific activity such as a presence sensor.

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("inhibitor") and none fire; input during the hold sends on_resume("input"). 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, or when the normal one resumes alone because a hold began on an idle seat, so it needs a registered threshold and an idle seat. It sets inhibited and adds one holder with an empty who. After a detected hold, input resuming sets compositor_hold_stale, removes the unconfirmed holder from inhibitors, and keeps inhibited at its last value until the next idle threshold

How do I…

Keep the screen awake (caffeine)

The hold survives reloads, so named state records it:

local caffeine = state("caffeine", false)

rect {
    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.inhibited then
                    return "awake (another app)"
                end
                return "idle allowed"
            end, "idle allowed"),
        },
    },
}

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.

rect {
    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)
                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_percent?integerKeyboard backlight, 0 to 100, or nil 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: numberSets the keyboard backlight, 0 to 100, fractions allowed; values outside clamp to it.
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 polled every 250 ms; with none of those, false
BacklightReads sysfs *::kbd_backlight, writes through logind’s SetBrightnessbacklight_percent = nil; set_backlight is logged and ignored
LayoutThe event stream workspaces reads: niri’s layout events, Hyprland’s devices for the keyboard marked main (the one typed on last), sway’s GET_INPUTS for the first keyboard that lists layouts, or mango’s keyboardlayout nameactive_layout = "", layout_count = 0; switch_layout is logged and ignored. On mango only active_layout is set, so layout_count stays 0 and switch_layout is ignored

The first push comes when keyboard starts, with whatever layout the compositor has reported.

Gotchas

TrapFix
caps_lock changes up to 250 ms after the keyThe Supervisor cannot read /dev/input, so it polls sysfs. Add the user to the input group
backlight_percent 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 relays PAM’s conversation. PAM starts when the session locks: prompt is its own question ("Password: ", a verification code) and messages its info and error lines. The one secure field answers every prompt; bind its masked to not prompt.echo so a visible answer shows.

With fprintd and an enrolled finger, a fingerprint unlocks too. A second conversation on the mantle-fingerprint PAM service runs beside the password, and the first to succeed unlocks. fingerprint is nil while it is off, else { messages } with pam_fprintd’s lines such as “Place your finger”. It is briefly non-nil on every lock even without a reader, since the worker exits within milliseconds, so draw a fingerprint icon only once fingerprint.messages is non-empty.

rect {
    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.
authenticatingbooleanAn answer is with PAM, or typed before its prompt and held for it. 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.
fingerprint?FingerprintStateThe fingerprint conversation beside the password one, or nil while none runs: no mantle-fingerprint stack, or it ended without a match. A password submit and the next lock start it again. prompt, messages, attempts and error stay the password’s.
messagesPamNotice[]PAM’s info and error lines, oldest first, at most 8. Cleared by the next answer and when the lock ends.
prompt?PamPromptPAM’s pending question, or nil while it owes nothing.
unlockingbooleanPAM said yes and the lock is still up: the window for an out-animation.

FingerprintState

mantle.lock.fingerprint while its conversation runs.

FieldTypeDescription
messagesPamNotice[]pam_fprintd’s lines, such as “Place your finger” or “Failed to match”, oldest first, at most 8. Translated: show them, never match on them.

PamNotice

One line a PAM module showed without asking anything, e.g. a fingerprint hint or a lockout.

FieldTypeDescription
kindPamNoticeKind"info" or "error".
textstringWhat the module said, as it said it.

PamNoticeKind

What a PamNotice reports.

One of "info", "error".

PamPrompt

A question a PAM module asked.

FieldTypeDescription
echobooleanThe answer is meant to be seen: set the secure field’s masked to not echo.
idintegerUnique for this session, so two identical prompts in a row still differ.
textstringPAM’s own text, e.g. "Password: " or "Verification code:".

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. PAM must reply within 30 s of an answer; a prompt waits for you without limit. An expired password still unlocks
ConversationStarts on lock and again after a wrong password. A failure before any prompt (a faillock lockout) waits for the next submit. An answer typed before its prompt goes to the next masked prompt; a visible prompt drops it
FingerprintStarts on lock when mantle-fingerprint is in /etc/pam.d or /usr/lib/pam.d, with no fallback service. pam_fprintd retries misses for the whole lock. Any other end, or a prompt from that stack, turns it off until the next password submit or lock. Misses never count as attempts or set error. Packages install the stack
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
Ask for a second factorShow prompt.text above the field and set masked = lock:map(function(l) return not (l.prompt and l.prompt.echo) end, true)
Show a lockout noticemessages, each { kind = "info" or "error", text }, cleared by the next answer
Show a fingerprint hintfingerprint.messages, the same shape, when fingerprint is not nil: Lock screen recipe
Turn fingerprint offReplace the content of /etc/pam.d/mantle-fingerprint with auth requisite pam_deny.so. An empty file falls back to PAM’s other stack, and a deleted one returns with the next upgrade
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
With pam_fprintd inside the password stack (system-auth, as Fedora’s authselect sets), each submit restarts its scanUse the mantle-fingerprint stack instead, and point /etc/pam.d/mantle at a stack without pam_fprintd
fingerprint turns nil with “device claimed” after a quick relock, or after suspendThe reader was still busy. Submit a password, even a wrong one, to retry, or lock again

See also: Lock screen recipe; idle to lock after inactivity.

Source: supervisor/src/capabilities/lock/

mpris

MPRIS: media players with metadata, controls, TrackList and Playlists.

local player = mantle.mpris:map(function(mpris)
    return mpris.players[1]
end)

rect {
    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.

LoopStatus

MPRIS LoopStatus, also the set_loop_status argument.

ValueDescription
"none"Plays through once.
"track"Repeats the current track.
"playlist"Repeats the playlist.

PlayState

MPRIS PlaybackStatus.

One of "playing", "paused", "stopped".

PlayerState

FieldTypeDescription
albumstringAlbum title; empty when unset.
album_art_pathstringCover art as an existing local path, or empty when unavailable.
album_artiststringAlbum artists joined with ", "; empty when unset.
art_urlstringmpris:artUrl as sent, e.g. an https:// image to fetch and cache; empty when unset.
artiststringArtists joined with ", "; empty when unset.
can_go_nextbooleanMPRIS CanGoNext.
can_go_previousbooleanMPRIS CanGoPrevious.
can_pausebooleanMPRIS CanPause.
can_playbooleanMPRIS CanPlay.
can_quitbooleanMPRIS CanQuit on the root interface.
can_raisebooleanMPRIS CanRaise on the root interface.
can_seekbooleanMPRIS CanSeek.
desktop_entrystringThe player’s .desktop basename, e.g. "firefox", for app matching; empty when unset.
genrestringGenres joined with ", "; 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.
length?integerTrack length in microseconds, or nil when unknown, as for a live stream.
loop_status?LoopStatusMPRIS loop mode; nil when the player does not report one.
maximum_ratenumberMPRIS maximum playback rate, or 0 when unavailable.
minimum_ratenumberMPRIS minimum playback rate, or 0 when unavailable.
pid?integerProcess id of the bus name’s owner, which mantle.audio.apps[].pid matches exactly; nil when the bus refuses. A sandboxed app’s streams may report a pid from inside its sandbox.
play_statePlayStateMPRIS PlaybackStatus; keeps the last value when a read fails, "stopped" if none.
playlistsPlaylistsStateOne bounded page from the optional MPRIS Playlists interface.
position?integerPlayback offset in microseconds as of position_updated_at, not polled while playing: add elapsed time. nil when unknown.
position_updated_atintegerCLOCK_MONOTONIC microseconds when position was read: the clock of mantle.clock:now() in seconds, so mantle.clock:now() - position_updated_at / 1e6 is the age.
ratenumberMPRIS playback rate.
shufflebooleanMPRIS shuffle setting.
titlestringTrack title; empty when unset, normal between tracks.
track_listTrackListStateNearby tracks from the optional MPRIS TrackList interface.
urlstringxesam:url as sent, e.g. a file:// path or an https:// page; empty when unset.
volumenumberMPRIS volume in percent, 100 when unreported. The protocol permits amplification above 100.

PlaylistSummary

FieldTypeDescription
iconstringIcon URI, or empty if absent.
idstringStable playlist object path.
namestringUser-facing playlist name.

PlaylistsState

FieldTypeDescription
active?PlaylistSummaryActive playlist, if the player reports one.
countintegerTotal playlists reported by the player.
indexintegerIndex used for the current page.
orderstringOrdering used for the current page.
orderingsstring[]Ordering names accepted by playlists_get.
page_sizeintegerNumber of entries requested for the current page, at most 100.
playlistsPlaylistSummary[]At most 100 playlist entries.
reversebooleanWhether the page is reversed.

TrackListState

FieldTypeDescription
can_editbooleanWhether the player permits add and remove calls.
current_trackstringCurrent TrackList object path, or empty if unknown.
tracksTrackSummary[]At most 100 tracks around the current track; the full playlist remains player-owned.

TrackSummary

FieldTypeDescription
artiststringTrack artists joined with ", ".
idstringTrackList object path, used by track_list_go_to and track_list_remove_track.
length?integerTrack length in microseconds, or nil when unknown.
titlestringTrack title, empty when the player has none.

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 nil).
seek_relativeid: string, offset_us: integerSeeks by a signed offset in microseconds, unclamped; past the end may skip to the next track.
raiseid: stringCalls Raise; check players[].can_raise before calling.
quitid: stringCalls Quit; check players[].can_quit before calling.
open_uriid: string, uri: stringOpens an absolute URI in the player.
set_volumeid: string, value: numberSets MPRIS Volume in percent; finite values at or above zero are accepted.
set_loop_statusid: string, value: LoopStatusSets MPRIS LoopStatus.
set_shuffleid: string, value: booleanSets MPRIS Shuffle.
set_rateid: string, value: numberSets a positive finite MPRIS playback rate.
track_list_add_trackid: string, uri: string, after_track: string, set_as_current: booleanInserts a URI after a track id, or after /org/mpris/MediaPlayer2/TrackList/NoTrack to prepend.
track_list_remove_trackid: string, track_id: stringRemoves a track by its TrackList object path.
track_list_go_toid: string, track_id: stringStarts the track identified by its TrackList object path.
playlists_getid: string, index: integer, count: integer, order: string, reverse: booleanReads a bounded playlist page into players[].playlists.
playlists_activateid: string, playlist_id: stringActivates a playlist by its object path.

PlayerCommand

One of "play", "pause", "play_pause", "next", "previous", "stop".

Backend

ContractBehavior
DiscoverySession bus ListNames once, then NameOwnerChanged for org.mpris.MediaPlayer2.*. Skips playerctld and any player reporting CanControl = false
PushesOn playback, metadata, control-property, TrackList or Playlists changes 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
ControlsRead can_* before calling matching methods. Setters accept a finite nonnegative volume percent, positive finite rate within advertised limits, and "none", "track", or "playlist" loop status
ArtworkUses existing local file:// paths. Remote artwork URLs are unsupported

How do I…

TaskAnswer
Show a live progress barCompare position_updated_at / 1e6 with mantle.clock:now(), or stamp each new report in on_change and 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.clock.monotonic is off by a millionSame clock, different unit: microseconds against seconds. Divide by 1e6; use mantle.clock:now() for a live reading

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 not network.connected then
            return "offline"
        elseif network.ssid == "Ethernet" then
            return "wired"
        end
        local wifi = network.wifi_devices[1] -- the primary interface
        return string.format("%s %d%%", network.ssid or "", wifi and wifi.strength or 0)
    end, ""),
}

State

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

mantle.network’s payload.

FieldTypeDescription
connect_error?JoinErrorThe last failed join on any Wi-Fi device, 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 current join’s SSID on any Wi-Fi device, 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_speed?integerThat wired device’s link speed in Mb/s; nil when unknown or none is activated.
networking_enabledbooleanNetworkManager networking is on (NetworkingEnabled).
password_ssid?stringThe join SSID awaiting a password from a network/connect secure field on any device, or nil. Also set after a rejected key; cleared when a join starts or by cancel_connect.
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.
vpn_error?VpnErrorThe last failed VPN activation, or nil before any or after the next connect_vpn.
vpn_secret?VpnSecretRequestThe one pending VPN secret request, or nil. Answer it with network/vpn_secret secure fields or drop it with cancel_vpn_secret.
vpnsVpnInfo[]Saved VPN and WireGuard profiles, ordered by name.
wifi_devicesWifiDeviceInfo[]Every Wi-Fi interface, primary first. IDs are interface names, never NetworkManager object paths.
wifi_enabledbooleanWi-Fi radio power (WirelessEnabled); can be true with no Wi-Fi hardware, see wifi_present.
wifi_presentbooleanAt least one 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 compatible with this device names this SSID.
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.

VpnError

A failed VPN activation, as vpn_error.

FieldTypeDescription
messagestringDisplay text, such as "login failed" or "connection timed out".
uuidstringThe profile the activation was for.

VpnInfo

One saved NetworkManager VPN profile in vpns.

FieldTypeDescription
activatingbooleanThe profile is being activated, including while it waits for a secret.
activebooleanThe profile is activated.
idstringProfile name.
kindVpnKind
uuidstringProfile UUID; pass it to connect_vpn and disconnect_vpn.

VpnKind

vpns[].kind.

ValueDescription
"vpn"A plugin VPN.
"wireguard"A native WireGuard profile.

VpnSecretRequest

NetworkManager asking for VPN secrets, as vpn_secret.

FieldTypeDescription
fieldsstring[]Secret keys still to enter, such as "password"; each entered key leaves the list.
idstringRequest ID. A secure field for fields[i] uses name = request.id .. "/" .. fields[i].
retrybooleanNetworkManager rejected the previous secrets and asks again.
uuidstringUUID of the profile being activated.
vpn_idstringName of that profile.

WifiDeviceInfo

One Wi-Fi interface, with its own scan, join, address and access points. connected means activated here, while the flat connected means a default route; ssid never uses "Ethernet".

FieldTypeDescription
available_networksAccessPointInfo[]Visible networks on this interface: one per SSID, at most 20, ordered associated, then saved, then strongest.
connect_error?JoinErrorFailed join on this interface, or nil; cleared by the next join or cancel_connect.
connectedbooleanThis interface has an activated connection.
connecting_ssid?stringSSID of this interface’s current join, or nil.
idstringInterface name, such as "wlan0"; pass it to actions ending in _device.
password_ssid?stringSSID awaiting a password on this interface, or nil.
scanningbooleanA scan is in flight on this interface.
ssid?stringAssociated SSID, or nil.
strengthintegerAssociated access point strength, 0 to 100.
wifi_ip?stringIPv4 address without prefix, or nil.

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.
scan_deviceid: stringRequests a scan on the named Wi-Fi interface.
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.
connect_devicessid: string, hidden: boolean, id: stringJoins through the named Wi-Fi interface; a removed ID is never retargeted.
cancel_connectDrops the one current password request, on any Wi-Fi device; a join already running continues.
abort_connectStops the one current join on any Wi-Fi device, 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.
disconnect_wifi_deviceid: stringDisconnects the named Wi-Fi interface.
connect_vpnuuid: stringActivates the saved VPN or WireGuard profile with this UUID. A missing secret raises vpn_secret.
disconnect_vpnuuid: stringDeactivates the VPN or WireGuard profile with this UUID.
cancel_vpn_secretDeclines the pending vpn_secret request, failing that activation.

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
Deviceswifi_devices lists each Wi-Fi interface by name, primary first, with its association, scan, address and access points; {} without Wi-Fi hardware. Flat join fields describe the one join on any device. Actions without _device use the primary. Wired fields describe the first activated wired device
TogglesNetworking through Enable, Wi-Fi through WirelessEnabled
ScanRequestScan. The device’s 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
VPN profilesvpns lists saved profiles of type vpn (plugin VPNs) and wireguard, by name. active and activating follow NetworkManager’s active connections, so a VPN started elsewhere shows too. The list needs a profile change or restart to notice an edited profile
VPN activationconnect_vpn(uuid) calls ActivateConnection and watches the verdict for 45 s, not counting time on that profile’s secret prompt; at 45 s it deactivates the attempt. A failure lands in vpn_error; disconnect_vpn or cancel_vpn_secret during the attempt sets none. A second connect_vpn for a profile already waiting does nothing. Only profiles NetworkManager manages: no VPN client the shell runs itself
VPN secretsThe engine registers a NetworkManager secret agent, answering only NetworkManager itself. When an activation needs secrets, vpn_secret lists the fields to enter and waits for secure_submit fields named request.id .. "/" .. field; the last one answers NetworkManager. One request at a time: a second is refused. cancel_vpn_secret or a 120 s wait fails the activation. Nothing is stored; NetworkManager asks again when a secret is agent-owned
VPN secret scopeOnly vpn and wireguard requests are answered; other connection types get “no secrets”, as with no agent. Fields come from NetworkManager’s hints, else from the profile’s <key>-flags entries marked agent-owned or not saved. WireGuard asks for private-key only; a peer’s preshared key and a plugin’s x-vpn-message text are not handled. Registering needs polkit’s org.freedesktop.NetworkManager.network-control; without it a warning is logged and VPN activation still works for secrets NetworkManager already stores. NetworkManager takes one agent per identifier per user, so only the first running shell gets prompts; a second logs the same warning
MissingStays nil. The next generation’s first read retries

Use scan_device(id), connect_device(ssid, hidden, id) and disconnect_wifi_device(id) to target an entry in wifi_devices. An unknown or removed ID is never switched to another device: all three log a warning and return. Without Wi-Fi hardware, scan and disconnect_wifi do nothing; connect reports an error in the flat connect_error field. A password prompt keeps its selected device through submission and activation. Only one password prompt or join attempt is tracked across all devices. Starting another settles the previous join first.

How do I…

Ask for a Wi-Fi password

A click on an available_networks entry of a wifi_devices interface calls mantle.network:connect(entry.ssid, false); a secured one without a saved profile then sets password_ssid. Show the field while it 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.password_ssid ~= nil
end, false)

return column {
    visible = asking,
    spacing = 6,
    children = {
        text {
            content = mantle.network:map(function(network)
                return 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,
        },
    },
}

List and toggle a VPN

Which profile counts as the preferred one is the shell’s call; vpns carries what it needs:

local vpns = mantle.network:map(function(network) return network.vpns or {} end, {})

return column {
    spacing = 4,
    children = vpns:map(function(list)
        local rows = {}
        for _, vpn in ipairs(list) do
            rows[#rows + 1] = row {
                on_click = function()
                    if vpn.active or vpn.activating then
                        mantle.network:disconnect_vpn(vpn.uuid)
                    else
                        mantle.network:connect_vpn(vpn.uuid)
                    end
                end,
                children = { text { content = vpn.id .. (vpn.active and " (on)" or "") } },
            }
        end
        return rows
    end),
}

Ask for a VPN secret

NetworkManager’s request sets vpn_secret; show one secure field per entry of fields. cancel_vpn_secret drops the whole request, not one field, and fails the activation. Here a first Escape clears the field and a second cancels. retry is true after rejected secrets:

local request = mantle.network:map(function(network) return network.vpn_secret end)

return column {
    visible = request:map(function(secret) return secret ~= nil end),
    spacing = 6,
    children = request:map(function(secret)
        local rows = {}
        for _, field in ipairs(secret and secret.fields or {}) do
            rows[#rows + 1] = textfield {
                width = 240,
                height = 24,
                placeholder = field,
                secure_submit = { capability = "network", action = "vpn_secret", name = secret.id .. "/" .. field },
                on_cancel = function(cleared)
                    if not cleared then mantle.network:cancel_vpn_secret() end
                end,
            }
        end
        return rows
    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.connect_error
        return failure and (failure.ssid .. ": " .. failure.message) or ""
    end, ""),
}

Gotchas

TrapFix
A wifi_devices entry’s 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.

rect {
    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.dnd and "DND on" or "DND off"
            end, "DND off"),
        },
    },
}

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.clock.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; path may start with ~/. 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,
    items = mantle.notifications:map(function(notifications)
        return notifications.feed
    end),
    key = function(item) return tostring(item.id) end,
    each = function(item)
        return column {
            width = 320,
            padding = 8,
            radius = 8,
            background = "#1E1E2E",
            on_click = function() mantle.notifications:dismiss(item.id) end,
            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. The first submit starts polkit’s helper; after that prompt and messages relay its PAM conversation as on lock, and the same field answers each prompt.

column {
    visible = mantle.polkit:map(function(polkit)
        return polkit.active
    end, false),
    spacing = 8,
    children = {
        text {
            content = mantle.polkit:map(function(polkit)
                return polkit.message or ""
            end, ""),
        },
        text {
            content = mantle.polkit:map(function(polkit)
                return polkit.prompt and polkit.prompt.text or "Password"
            end, ""),
        },
        textfield {
            width = 280,
            height = 24,
            masked = mantle.polkit:map(function(polkit)
                return not (polkit.prompt and polkit.prompt.echo)
            end, true),
            secure_submit = { capability = "polkit", action = "authenticate" },
        },
        text {
            foreground = "#F38BA8",
            content = mantle.polkit:map(function(polkit)
                local last = polkit.messages[#polkit.messages]
                return last and last.text or polkit.error or ""
            end, ""),
        },
        list {
            direction = "horizontal",
            spacing = 8,
            items = mantle.polkit:map(function(polkit) return polkit.identities end, {}),
            key = function(identity) return tostring(identity.uid) end,
            each = function(identity)
                return rect {
                    on_click = function() mantle.polkit:select_identity(identity.uid) end,
                    children = { text { content = identity.name } },
                }
            end,
        },
        rect {
            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.
authenticatingbooleanAn answer is with PAM, or typed before its prompt and held for it. 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.
identitiesPolkitIdentity[]The accounts polkit accepts, as it offered them; groups arrive already expanded.
identityintegerThe uid the next helper authenticates: this session’s user if offered, else the first.
messagestringThe action’s prompt, e.g. "Authentication is required to ...", in en_US: the locale the agent registers with.
messagesPamNotice[]The helper’s info and error lines, as on mantle.lock.
prompt?PamPromptThe helper’s pending question, as on mantle.lock. nil until the first submit starts it.

PamNotice

One line a PAM module showed without asking anything, e.g. a fingerprint hint or a lockout.

FieldTypeDescription
kindPamNoticeKind"info" or "error".
textstringWhat the module said, as it said it.

PamNoticeKind

What a PamNotice reports.

One of "info", "error".

PamPrompt

A question a PAM module asked.

FieldTypeDescription
echobooleanThe answer is meant to be seen: set the secure field’s masked to not echo.
idintegerUnique for this session, so two identical prompts in a row still differ.
textstringPAM’s own text, e.g. "Password: " or "Verification code:".

PolkitIdentity

One account a polkit challenge accepts.

FieldTypeDescription
namestringLogin name, or empty when the uid has no passwd entry.
uidinteger

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.
select_identityuid: integerAuthenticates as uid, one of identities. Clears the prompt and error; the next submit starts PAM for it. After a submit it ends that conversation, which PAM may count as a failure.

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, and again each time polkitd restarts, cancelling the challenge the old polkitd held. If another agent already answers, it stays off until polkitd restarts. 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 starts a new helper
Another admin should authenticateList identities and call select_identity(uid) from a click. The default is your own account when polkit offers it. Switching after a submit ends that attempt, which PAM may count
prompt is nil before anything is typedpolkit’s helper starts on the first submit, because closing a helper that waits on a prompt counts as a failed attempt

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",
    items = mantle.power:map(function(power)
        return power.profiles
    end),
    each = function(name)
        return rect {
            padding = 6,
            background = mantle.power:map(function(power)
                return power.active_profile == name and "#89B4FA" or "#313244"
            end, "#313244"),
            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; a name not in it, or one the daemon rejects, 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, and so does either daemon stopping or restarting: a stopped one’s fields are absent, a restarted one’s are read fresh. 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.microphone_users > 0 or #privacy.camera_users > 0
    end, false),
}

State

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

FieldTypeDescription
camera_usersPrivacyUser[]Apps using a camera, one per name: running PipeWire video streams linked to a camera, and processes holding a /dev/videoN open outside PipeWire. Empty when none is.
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 and portal Video/Source PipeWire nodesscreencast_users
Running Stream/Input/Video PipeWire nodes linked to a camera Video/Sourcecamera_users, named by the app. A viewer of a screen-share source is not counted
/proc/*/fd links to a /dev/videoN, rescanned on each inotify open or close of the devicecamera_users for apps using V4L2 directly. The PipeWire daemon is skipped, since it holds the device for the apps above

The PipeWire half shares audio’s thread. privacy pushes its first /proc scan as soon as it starts. While PipeWire is down, or before it first answers, the microphone and screencast lists are empty and cameras used through PipeWire are missing; all refill when it reconnects.

Gotchas

TrapFix
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.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/

radio

rfkill: soft and hard block state per radio kind; nil without /dev/rfkill.

Toggle airplane mode with one call, and show which radios are blocked:

rect {
    on_click = function()
        local radio = mantle.radio:get()
        if radio then
            local blocked = false
            for _, r in ipairs(radio.radios) do
                blocked = blocked or r.soft_blocked
            end
            mantle.radio:set_all_blocked(not blocked)
        end
    end,
    children = {
        text {
            content = mantle.radio:map(function(radio)
                for _, r in ipairs(radio.radios) do
                    if r.hard_blocked then return "airplane (switch)" end
                end
                return "radios"
            end, ""),
        },
    },
}

rfkill is the airplane layer: set_blocked and set_all_blocked flip the kernel’s soft block, which switches the radios themselves off. network.set_wifi_enabled and bluetooth.set_enabled are separate and unchanged. There is no airplane field: compute it from radios.

State

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

mantle.radio’s payload; the capability stays nil without /dev/rfkill or until a radio exists.

FieldTypeDescription
radiosRadio[]One entry per kind with at least one device, ordered by kernel type id.

Radio

One radio class and the rfkill block state of its devices.

FieldTypeDescription
hard_blockedbooleanHardware switch block, which software cannot clear: any device of this kind is blocked.
kindRadioKind
soft_blockedbooleanSoftware block, which set_blocked changes: any device of this kind is blocked.

RadioKind

A radio class, named as the kernel’s rfkill type; discriminants are the kernel type ids.

One of "wlan", "bluetooth", "uwb", "wimax", "wwan", "gps", "fm", "nfc".

Actions

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

ActionArgumentsDescription
set_blockedkind: RadioKind, blocked: booleanSoft-blocks or unblocks every device of kind. A hardware block stays.
set_all_blockedblocked: booleanSoft-blocks or unblocks every radio (airplane mode).

Backend

ContractBehavior
Source/dev/rfkill only: one event per existing device at start, then live changes. No polling
KindsOne entry per kind with a device, ordered by kernel type id. A kind counts as blocked when any of its devices is. Unknown kernel types are skipped
No device nodeStays nil for good; actions are logged and ignored
No radiosStays nil until one appears: the kernel sends nothing to read
WritesOne CHANGE_ALL request to the same fd; the kernel also applies it to radios plugged in later. State updates when the kernel reports the change

Gotchas

TrapFix
set_blocked(kind, false) leaves hard_blocked = trueA hardware switch overrides software. Only the switch clears it; show it instead
A session without a seat cannot write /dev/rfkillState is still reported; actions are logged and ignored

Source: supervisor/src/capabilities/radio/

secrets

Named Secret Service writes and their pending, stored or error status. Names are public metadata.

A secure_submit field can store a named UTF-8 value in the session Secret Service. The Renderer sends the native buffer straight to the Supervisor; callbacks, Lua state, logs, and command arguments see no value bytes. The service stores it under schema io.github.anasgets111.mantle.Secret with attribute name. Another application must query that schema and attribute to find it.

column {
    children = {
        textfield {
            width = 240, height = 36,
            secure_submit = { capability = "secrets", action = "store", name = "mail" },
        },
        text {
            content = mantle.secrets:map(function(secrets)
                return secrets.entries.mail or ""
            end, ""),
        },
    },
}

Names are public Secret Service attributes. Use a short label, not account credentials or any other secret. Names must be nonblank UTF-8 without control characters, at most 128 bytes. Repeating a write to a name while one is pending is ignored. With no Secret Service installed (gnome-keyring, KeePassXC or oo7-daemon), the state reports unavailable; Mantle does not write a fallback file. A locked keyring shows the keyring’s own unlock prompt, and a missing default keyring is created the same way. The store waits as long as the user takes to answer the prompt; a keyring that exits meanwhile reports unavailable. A service call that takes over 25 seconds reports timed_out, which is final, and the name accepts a new write. The state describes the latest write in this Supervisor session and does not list or read stored values.

State

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

FieldTypeDescription
entriestable<string, SecretStatus>Public lookup names and the result of their latest write.

SecretStatus

Status of one named write. unavailable is a missing or failing service, a dismissed prompt, or a service that exits mid-prompt; timed_out is a service call over 25 seconds, is final, and the name accepts a new write.

One of "pending", "stored", "unavailable", "timed_out".

Actions

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

Source: supervisor/src/capabilities/secrets.rs

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
contentstable<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 a JSON file, absolute or ~/ under $HOME, into contents[expanded 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 (path absolute or ~/), nil deleting it; saved 1 s after the last write.

Backend

Plain JSON files at absolute paths (~/ is expanded before it reaches the Supervisor), 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, swap, disks, GPU, network and temperatures. nil until configure sets intervals.

mantle.sysinfo:configure({ cpu_interval = 2, ram_interval = 5 })

text {
    content = mantle.sysinfo:map(function(sysinfo)
        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.
disksDiskDevice[]Physical block devices and their mounted partitions.
gpu?GpuTelemetryGPU telemetry (load, VRAM, temperature) if a supported backend was detected; nil otherwise.
net_rx_bytes_secintegerDownload rate across active non-loopback interfaces in bytes per second; 0 until two samples form a delta.
net_tx_bytes_secintegerUpload rate across active non-loopback interfaces in bytes per second; 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_gpu?integeramdgpu, nouveau, nvidia, i915 or xe hwmon temperature in whole Celsius, or nil without a readable one.

DiskDevice

One physical block device and its mounted partitions.

FieldTypeDescription
namestringKernel block device name, e.g. “nvme0n1” or “sda”.
partitionsDiskPartition[]Mounted partitions under this block device.
percentintegerAggregate percentage in use, 0 to 100.
total_bytesintegerAggregate total bytes across mounted partitions.
used_bytesintegerAggregate bytes in use across mounted partitions.

DiskPartition

One mounted partition under a physical block device.

FieldTypeDescription
mount_pointstringMountpoint path, e.g. “/” or “/home”.
percentintegerPercentage in use, 0 to 100.
total_bytesintegerTotal bytes on this filesystem.
used_bytesintegerBytes in use on this filesystem.

GpuTelemetry

Telemetry metrics for the first detected GPU device.

FieldTypeDescription
mem_total?integerTotal VRAM in bytes, or nil if shared or unavailable.
mem_used?integerDedicated/used VRAM in bytes, or nil if shared or unavailable.
namestringDevice model name reported by driver/tool, e.g. “Raptor Lake-P (Iris Xe Graphics)” or “NVIDIA GeForce RTX 4070”.
temp?integerGPU temperature in whole Celsius, or nil if no sensor reported.
util_percent?integerGPU core utilization percentage, 0 to 100.

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 raises at the call.

FieldTypeDescription
cpu_interval?integerSeconds between CPU reads; 0 (the default) stops them.
disk_interval?integerSeconds between disk space reads; 0 (the default) stops them.
gpu_interval?integerSeconds between GPU telemetry reads; 0 (the default) stops them.
net_interval?integerSeconds between network throughput 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, nvidia, i915 or xetemp_interval
diskslsblk --json --bytes block devices and mounted partitions (FSUSED and FSSIZE)disk_interval
gpunvtop -s, nvidia-smi or DRM sysfsgpu_interval
net_rx_bytes_sec, net_tx_bytes_sec/proc/net/dev non-loopback interface counter deltasnet_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.clock.time’s tick and the two can share one layout pass.

How do I…

Show GPU, disk, and network transfer rates

mantle.sysinfo:configure({ disk_interval = 30, gpu_interval = 2, net_interval = 1 })

text {
    content = mantle.sysinfo:map(function(sysinfo)
        local gpu_str = sysinfo.gpu and sysinfo.gpu.util_percent and
            string.format("GPU %d%%", sysinfo.gpu.util_percent) or "GPU --"
        local disk = sysinfo.disks[1]
        local disk_str = disk and string.format("Disk %d%%", disk.percent) or "Disk --"
        local down_kib = sysinfo.net_rx_bytes_sec // 1024
        local up_kib = sysinfo.net_tx_bytes_sec // 1024
        return string.format("%s  %s  ↓%d KiB/s ↑%d KiB/s", gpu_str, disk_str, down_kib, up_kib)
    end, ""),
}

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
gpu stays nilNo supported GPU tool (nvtop, nvidia-smi) or DRM sysfs was found, or gpu_interval is unset
disks stays emptylsblk is missing or returned no mounted partitions, or disk_interval is unset
net_rx_bytes_sec stays 0Rate calculation needs two consecutive samples. Set net_interval to 1 or higher
Network rates look high with bridges or tunnelsThe counters include every non-loopback interface, including virtual ones; the same traffic may cross more than one

See also: clock for clock time; battery for charge and power; network for Wi-Fi and connectivity.

Source: supervisor/src/capabilities/sysinfo/

clock

Wall and monotonic clocks, pushed once a second until configure sets the interval.

mantle.clock:configure({ interval = 60 }) -- the text below shows no seconds

text {
    content = mantle.clock:map(function(clock)
        return os.date("%a %H:%M", clock.time)
    end, ""),
}

State

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

mantle.clock’s payload, pushed on each tick of interval.

FieldTypeDescription
monotonicnumberSystem-wide CLOCK_MONOTONIC seconds since boot, as of the last push; excludes suspend. Take durations from it, since NTP moves time. mantle.clock:now() reads it live.
timeintegerUnix epoch seconds, as os.date takes them.

Actions

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

ActionArgumentsDescription
configuresettings: ClockConfigureSets the push interval, 1 second until this. Each push lands on a wall-clock multiple of it, and one lands at once.

ClockConfigure

clock: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.

Reading monotonic time now

monotonic is CLOCK_MONOTONIC: seconds since boot as a number with a fraction, the same clock in every process, and it excludes suspend. The field holds the reading from the last push, up to interval stale. mantle.clock:now() reads the clock at that call, works before the first push and sends nothing:

local armed_at = mantle.clock:now()
local function elapsed() return mantle.clock:now() - armed_at end

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; the time and monotonic fields keep their last values, but now() stays live. 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/clock/

tray

StatusNotifierItem: registered tray items with artwork, status and menus.

list {
    direction = "horizontal",
    spacing = 4,
    items = mantle.tray:map(function(tray)
        return tray.items
    end),
    key = function(item) return item.id end,
    each = function(item)
        return rect {
            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, a spooled PNG path from raw icon-data, or nil.
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_typeMenuType
toggle_state?integer0 off, 1 on, -1 indeterminate or unreported; nil exactly when toggle_type is.
toggle_type?ToggleTypenil for an entry that is not a toggle.

tray.items[].menu[].menu_type.

ValueDescription
"standard"An ordinary entry; also any type outside the spec.
"separator"A divider line; it has no label and does nothing.

ToggleType

tray.items[].menu[].toggle_type.

ValueDescription
"checkmark"A check box.
"radio"One of a group of mutually exclusive entries.

TrayItem

FieldTypeDescription
attention_icon_name?stringArtwork to draw while status == "needs_attention", 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.
statusTrayStatusSNI Status.
tooltip?stringTooltip title and text joined by a newline, or nil when both are empty.

TrayStatus

SNI Status.

ValueDescription
"active"Normal; also any value outside the spec, or none.
"passive"The item asks to be hidden.
"needs_attention"The item wants attention; draw its attention artwork if set.

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.
context_menuid: string, x: integer, y: integerContext-menu activation at screen coordinates x, y.
secondary_activateid: string, x: integer, y: integerMiddle-click activation at screen coordinates x, y.
scrollid: string, delta: integer, orientation: ScrollOrientationScrolls the icon by delta along orientation.
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.

ScrollOrientation

The tray:scroll axis, sent to the item as spelled.

One of "vertical", "horizontal".

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. Drops items with visible = false. Spools raw PNG icon-data (up to 512 KB) to shared memory as icon_name. 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 dynamic menu or submenu is emptySome apps fill menus only after AboutToShow. Send menu_will_show(item.id, 0) when opening the root menu, and with submenu_id when expanding a submenu
Right-clicking an item with no menu does nothingThe app provides no DBusMenu. Call mantle.tray:context_menu(item.id, x, y) on right click to trigger its native popup

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 })

rect {
    on_click = function() mantle.updates:check() end,
    children = {
        text {
            content = mantle.updates:map(function(updates)
                if updates.checking then
                    return "…"
                end
                return #updates.packages > 0 and (#updates.packages .. " 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.
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 raises at the 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)
CheckRepo databases download to $XDG_RUNTIME_DIR/mantle/pacmanMetadata goes to your cache (~/.cache/libdnf5)Package lists download to $XDG_RUNTIME_DIR/mantle/apt
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 file = (os.getenv("HOME") or "") .. "/.local/state/myshell/updates.json"
local cache = persistent_table { path = file }

mantle.storage:on_change(function(storage, previous)
    local saved = storage.contents[file]
    if saved and not (previous and previous.contents[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 tostring(#updates.packages)
    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 {
    items = mantle.windows:map(function(windows)
        return windows.windows
    end),
    key = function(window) return window.id end,
    each = function(window)
        return rect {
            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, sway, mango or wlr-foreign-toplevel backend.

FieldTypeDescription
sourcestring"niri", "hyprland", "sway", "mango", or "wlr_foreign_toplevel".
windowsWindowEntry[]Sorted by numeric workspace_id, then Hyprland named ones. Windows with a non-numeric id (sway names such as 1:web, mango’s DP-1:3) or none come last, in backend order.

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.
urgentbooleanWhether the window is asking for attention; always false on wlr, which has no such state.
workspace_id?stringWorkspaceEntry.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, sway and mango.
move_to_workspaceid: string, workspace_id: stringMoves a window to a workspace.

Backend

niri, Hyprland, sway and mango share the workspaces reader; any other compositor needs zwlr_foreign_toplevel_manager_v1 (backend table).

BackendReportsWrites
nirifloatingfocus, close, move_to_workspace
Hyprlandfloating, fullscreen, maximizedfocus, close, set_fullscreen, set_maximized, move_to_workspace
swayfloating, fullscreenfocus, close, set_fullscreen, move_to_workspace
mangofloating, fullscreen, maximized, minimizedfocus, close, set_fullscreen
wlr foreign-toplevelfullscreen, maximized, minimizedfocus, close, set_fullscreen, set_minimized, set_maximized

A mango window’s workspace_id is its lowest tag on its output (nil on the special tag).

A flag a backend does not report is nil; an action it lacks is logged at debug level and dropped. On Hyprland, a window’s id also feeds capture { window = id }.

Gotchas

TrapFix
window.app_id is a class on swayXwayland windows have no app_id; sway reports their X11 class instead
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.outputs) do
                    if entry.name == output then
                        for _, workspace in ipairs(entry.workspaces) do
                            if workspace.id == entry.active_workspace then
                                return "workspace " .. (workspace.number or workspace.name)
                            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, Hyprland, sway or mango.

FieldTypeDescription
active_client?ActiveClientThe focused window, or nil when none has focus. One per session, not per output.
compositorstring"niri", "hyprland", "sway" or "mango".
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
app_idstringWayland app_id, e.g. "firefox"; the key of applications.by_app_id. Empty when unset.
floatingbooleanWhether the window floats rather than tiles.
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_workspacestringWorkspaceEntry.id shown on this output.
focused_workspace?stringWorkspaceEntry.id with focus, present only on the focused output.
namestringConnector name, e.g. "eDP-1", as in mantle.screens and a panel’s output.
workspacesWorkspaceEntry[]Workspaces on this output: niri by position, Hyprland numbered ones by number, then named ones by name; mango’s tags by number.

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.
window_id?stringwindow_id of its representative window, chosen as WorkspaceEntry.app_id is.

WorkspaceEntry

One workspace. Draw number or name, 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.
idstringOpaque string, only passed back to actions such as "focus". Hyprland’s workspace id in decimal, so a numbered workspace’s id is its number and focusing an unlisted number creates it; named workspaces have negative ids. niri’s id in decimal; sway’s workspace name; mango’s tag on its output, "<output>:<tag>" ("DP-1:3").
name?stringWorkspace name; nil when unnamed, always on mango, or on Hyprland when the name is just the number.
number?integerThe number a keybind targets: niri’s 1-based position on the output, renumbered on reorder; Hyprland’s workspace number; sway’s leading number; mango’s tag number. nil for a Hyprland named or non-numeric sway workspace.
populatedbooleanWhether a window sits here.
urgentbooleanWhether a window here is asking for attention. Clears when the compositor clears it, on Hyprland when that window gains focus. Hyprland special workspaces carry none; their windows report it in windows.
window_id?stringwindow_id of a window here, chosen as WorkspaceEntry.app_id is. nil when empty.

Actions

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

ActionArgumentsDescription
focusid: stringFocuses a WorkspaceEntry.id. Hyprland creates a missing number and sway a missing name; niri ignores it. mango shows only that tag on its output and focuses the output.
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, then $SWAYSOCK, then $MANGO_INSTANCE_SIGNATURE (compositor/). One reader feeds both workspaces and windows.

CapabilityniriHyprlandswaymangoNone
workspacesIPC event stream.socket2.sock events, then one re-read per burst over .socket.sock; a title change alone patches in placei3 IPC subscription (workspace, window, input events), then GET_WORKSPACES and GET_TREE re-read per eventwatch all-monitors snapshots plus one get all-clients eachnil for the run
windowsSame event streamSame re-readSame re-readSame snapshotszwlr_foreign_toplevel_manager_v1 on its own Wayland connection; nil if the protocol is missing or setup takes over 5 s

Hyprland’s, sway’s and mango’s refusal of a write logs at debug level only (MANTLE_LOG=debug); niri’s is not logged.

mango tags

mango has tags, not workspaces: each output has a fixed set (1 to 9 by default) and may show several at once. Every tag is one workspace with number = the tag number, no name, and id = "<output>:<tag>" (for example "DP-1:3"), listed whether empty or not. With several tags shown, only the lowest is active_workspace; focus shows just the chosen tag on its output and moves focus there. special and overview_open are nil.

How do I…

Draw workspace buttons

Draw number or name, send id (list builds one button per entry):

list {
    direction = "horizontal",
    spacing = 4,
    items = mantle.workspaces:map(function(workspaces)
        local output = workspaces.outputs[1]
        return output and output.workspaces or {}
    end),
    key = function(workspace) return tostring(workspace.id) end,
    each = function(workspace)
        local active = mantle.workspaces:map(function(workspaces)
            local output = workspaces.outputs[1]
            return output ~= nil and output.active_workspace == workspace.id
        end, false)
        return rect {
            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.number or workspace.name) } },
        }
    end,
}

Gotchas

TrapFix
Labels show large or odd numbersDraw number or name, send id. id is an opaque string: on Hyprland the workspace id in decimal ("3", or negative like "-1337" for a named workspace), on niri its own id, on mango "<output>:<tag>", on sway the workspace name (sway focuses and moves by name). Don’t do arithmetic on it
The strip differs between compositorsHyprland lists no empty workspace but the active one, and focus on an unlisted number creates it (a numbered workspace’s id is its number as a string, so focus("7") works); niri keeps its own empty workspace and ignores an unknown id. sway also creates a workspace on focus of an unlisted name. Branch on compositor
focus does nothing on sway for some workspace namesSway reads next, prev, current, number, output, gaps, back_and_forth (any case) and names starting -- as commands, and cannot take a name holding ", \ or $. The call is logged at debug level and dropped
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/

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 startmantle list, then mantle stop --pid <pid> (shell selection)
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 quietThe save raised a Lua error: a failed evaluation drops every action, timer, handler and idle threshold. A save that evaluates but fails to apply keeps its actions and handlers, not its timersEvaluation, reload and generations
An on_change, timer, process.run, palette or idle callback does nothingIts error is a warning, and so is a blown CPU budget in on_change or timer. Read mantle logOutput and logging
mantle.<cap>:<action>(...) returns nil and nothing changesActions are fire and forget. A wrong argument raises at the call, so the Supervisor refused what needs its live state, such as a stale ID, with a log lineRead mantle log; actions
A keybind running mantle set or mantle toggle does nothingIt may have been refused (an undeclared name, a bare toggle on a non-boolean). The compositor discards the errorRun the command in a terminal or read mantle log (values and arguments)
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 :get() 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 missing (lock and polkit push at startup, so they read nil only while a relock is pending); :map and computed skip the nilThe 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 50ms CPU limit for one evaluationA map, computed, handler or timer ran away or did far 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 inside a table a signal returned, or under a metatable, is not readsignals 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 on_exit 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; 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 an SNI app so it registers again. An XEmbed-only app cannot show
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 nothingNo supported compositor, only one layout configured (layout_count 1), or the compositor refused the switchConfigure several layouts in the compositor; on Hyprland, MANTLE_LOG=debug shows the reply to switchxkblayout main <i>
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 pairingMake the adapter visible or pair from the shell; PIN and passkey entry use a secure field

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, then once per sample set: first values, alternate values and empty lists. 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, then one apply. See runtime.
Surface fingerprintA declaration’s creation-time fields (panel: id, layer, anchor, output, 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 reload keeps the previous scene; a failed live update keeps the previous tree of each surface it broke. What stays registered: runtime.
Rescuemantle.rescue, { is_rescue, error_log }, set while the shell runs on a scene it could not replace. See runtime.

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 output = "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, output, 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, 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 or a window.
ControlA node that takes keyboard focus: a textfield, or a node with on_click or submits_secure and a nonempty accessible_name. Tab walks them; Enter and Space activate. See keyboard controls.
Keyboard focusThe surface the compositor sends keys to, plus the popups shown under it. Only its controls take keys. See panel.
Path nodepath: vector commands filled or stroked in the node’s box, without an SVG file.

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, pointer, 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, or on a state after a write changes it.
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(sink.id, 50), 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, config_dir and pid.
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. Each version is tagged vX.Y.Z and published on GitHub releases. While the version is 0.x, a minor release can break the Lua API. A Breaking: entry needs a config edit.

Unreleased

0.13.3 - 2026-11-10

  • Fixed: mantle.lock and mantle.polkit publish their idle state at startup, so computed and :map over them run without waiting for a lock or authentication request. After a restart while locked, mantle.lock stays nil until the lock is taken again.

0.13.2 - 2026-11-10

  • Fixed: icon nodes use the icon theme set with gsettings set org.gnome.desktop.interface icon-theme, read through the desktop portal, and redraw when it changes. They used to fall back to hicolor without a GTK settings.ini, and a theme change needed a restart.
  • Fixed: mantle.updates reports the current package and install step counts when pacman runs without a terminal.
  • Fixed: mantle.audio:set_default_sink and set_default_source choices stick. A volume change no longer moves the default back to the highest-priority device.

0.13.1 - 2026-11-09

  • Fixed: process.run’s on_exit and capture’s on_done fire as soon as the command exits. A command that closed its output and kept running used to report its exit up to 100 ms late.
  • Added: image and icon nodes load .ico and .bmp files, so an app whose only icon is a .ico (/usr/share/pixmaps/<name>.ico) shows it.
  • Fixed: an image node’s SVG draws its embedded <image> PNG, JPEG, GIF and WebP pictures. They used to draw blank.
  • Changed: mantle.keyboard’s caps_lock, num_lock and scroll_lock stay live without the input group, up to 250 ms late. They used to freeze at their startup values.

0.13.0 - 2026-11-06

  • Added: mantle.audio.cards lists each ALSA and Bluetooth sound card with every profile it offers (index, name, description, available) and the active one, so a config can switch one card between analog and HDMI. Unplugged profiles stay listed with available = false.
  • Breaking: mantle.audio:set_bluetooth_profile(device, index) is now mantle.audio:set_card_profile(device, index), and takes any cards[].device. Rename the call.
  • Breaking: mantle.audio.bluetooth is gone; Bluetooth cards are in mantle.audio.cards, with a mac to join mantle.bluetooth. Each profile carries the codec it names, if any, and unavailable ones stay listed: a codec picker reads profiles where codec and available, instead of codecs.
  • Added: Each mantle.audio sink and source carries its own volume, muted and balance, the card (a cards[].device; nil for a virtual device) it comes from, and its reboot-stable node_name, so a mixer can show and set every output, not only the default, and group outputs by card.
  • Breaking: mantle.audio’s volume, muted, balance, source_volume and source_muted are gone: read the active entry of sinks or sources.
  • Breaking: mantle.audio:set_volume, set_muted, toggle_mute and set_balance take a sinks[].id or sources[].id first: set_volume(sink.id, 50). set_source_volume, set_source_muted and toggle_source_mute are gone; pass a source’s id to the same actions. set_sink_channel_volume and set_source_channel_volume are now one set_channel_volume(id, index, volume).
  • Added: mantle.audio.cards[].ports lists each card’s ports (index, name, description, type, direction, available, profiles), and mantle.audio:set_port(card, index) switches one, such as speakers to headphones. Unplugged ports stay listed with available = false.
  • Added: mantle.audio.apps[].target is the sink an app plays into, or the source it records from, and mantle.audio:set_app_target(id, device) moves it; nil returns it to the default.
  • Added: mantle.audio:set_virtual_devices(devices) declares null sinks, combined sinks, loopbacks, echo-cancelled microphones and filter chains such as an equalizer. Each appears in sinks or sources, PipeWire’s module arguments pass through args, and a reload keeps them until the next call.
  • Added: mantle.audio.cards[].ports[].active marks the port a card routes through now, such as headphones rather than speakers.
  • Added: mantle.audio.apps[].pinned_to is the device an app is pinned to with set_app_target, or by another mixer; nil when it follows the default or the device it names is gone. target stays the device it plays through.
  • Added: Each mantle.audio.cards[].profiles[] entry carries outputs and inputs, how many sinks and sources it makes, so a picker can list output or input profiles without reading their names.
  • Added: mantle.audio:set_level_meters(ids) meters those sinks and sources, and mantle.audio_levels carries their peaks (levels, each { id, peak } in percent) up to 30 times a second, for VU meters. No call, no meter; an empty list stops them.
  • Changed: mantle check fails when a config reads a capability field that does not exist, such as a field a release removed, and names the nearest one; a running shell still reads nil. It covers reads made while it evaluates and lays out the config; on_click, action and timer handlers do not run.
  • Changed: mantle check fails when a capability on_change handler raises on sample data; before, it only logged a warning.
  • Fixed: A content-sized node with animate.height or animate.width grows with a child’s running animation. A pass between two frames, such as a disclosure’s first open, used to freeze it until the child settled, so it stuttered.

0.12.1 - 2026-11-05

  • Added: Packages point to what fingerprint unlock needs: mantle-bin lists fprintd as optional, the .deb suggests fprintd and libpam-fprintd, and the .rpm suggests fprintd and fprintd-pam.

0.12.0 - 2026-11-05

  • Added: The lock screen unlocks with a fingerprint when fprintd has a finger enrolled. Packages install the mantle-fingerprint PAM stack. mantle.lock.fingerprint is nil while fingerprint is off, else { messages } with pam_fprintd’s lines. A missed scan never counts as an attempt.
  • Added: mantle.polkit.identities lists the accounts a polkit request accepts, identity is the one it authenticates as (your own when offered), and mantle.polkit:select_identity(uid) switches to another.
  • Added: mantle.lock and mantle.polkit gain prompt ({ id, text, echo }, PAM’s own question, nil while PAM owes nothing) and messages (PAM’s info and error lines). Two-factor codes, a re-asked password and fingerprint hints now reach the lock screen. Bind the secure field’s masked to not prompt.echo to show a visible answer.
  • Changed: The lock screen starts PAM when the session locks, so prompt is ready before you type. A password typed before PAM asks is held for its first masked prompt, and authenticating now means an answer is with PAM.
  • Changed: A polkit prompt starts PAM on the first submit, so its prompt is nil until then.
  • Changed: The release tarball no longer writes to /etc, so an upgrade keeps your edits. It puts the PAM stacks, with -debian variants for Debian and Ubuntu, and the polkit rule in /usr/local/share/mantle/; copy them to /etc once.
  • Fixed: After a faillock lockout runs out, the first correct password on the lock screen unlocks. It used to fail once, because the lock kept the PAM attempt that had shown the lockout.
  • Fixed: A lock screen no longer refuses a correct password once the account’s password has expired. The session is already logged in, as GDM decides.
  • Fixed: Polkit prompts in other languages show their real text instead of escape codes, and a visible polkit prompt is no longer answered with an empty line.
  • Added: masked = false on a secure_submit textfield draws what is typed instead of the mask. Turning it off wipes what was typed under the mask.

0.11.2 - 2026-10-29

  • Added: Animated WebP and APNG images (.apng files too) now play and loop in image nodes, like GIFs, and playing pauses them. Masks and a shader’s images still draw the first frame. source_blur is ignored on them, as on GIFs. Still WebP and PNG files draw as before.
  • Changed: A long GIF on a large canvas may now loop over fewer frames, and a GIF whose frames never change draws as a still.
  • Fixed: On Hyprland 0.57, mantle.workspaces, mantle.windows and the special workspace list stayed empty whenever a special workspace existed. Hyprland 0.57 sends no id for specials.

0.11.1 - 2026-10-27

  • Fixed: Images in a fast-scrolled list decode the ones now on screen first, and drop decodes for images no longer shown.
  • Fixed: The Renderer settles about 3 MB lower after image-heavy use.
  • Fixed: A missing lsblk now logs a warning instead of leaving mantle.sysinfo.disks silently empty.
  • Fixed: Unreadable /dev/input devices log a warning naming the input group, instead of caps_lock and num_lock quietly stopping updates.
  • Fixed: When screen capture can’t use dma-buf, the warning names the cause, such as a render node the user cannot open.

0.11.0 - 2026-10-23

  • Breaking: mantle.network drops its flat scanning, strength, wifi_ip and available_networks, which repeated the primary Wi-Fi interface. Read them from wifi_devices[1] (nil without Wi-Fi hardware).
  • Added: mantle.android_connect lists your phone and other KDE Connect devices with battery, signal and pairing state, and can ring, ping, pair, send files and clipboard, switch plugins and clipboard sync per device, offer commands your phone can run on the PC, and show transfer progress and why browsing failed. It needs kdeconnect-service running.

0.10.0 - 2026-10-22

  • Breaking: file.write(path, text, on_done?) is now file.write(path, text, opts?, on_done?). Add nil, before an existing callback; a function in the opts slot raises.
  • Added: file.write(..., { in_place = true }) rewrites the file in place, so a program watching that one path keeps its watch.
  • Added: process.detach(cmd, args, { env = { NAME = "value", OTHER = false } }) and mantle.applications:launch(id, { env = ... }) set or unset environment variables for the program they start; a string sets, false unsets.
  • Added: :map and computed signals, and so persistent_table keys, have on_change(fn(current, previous)), run like a state’s when a source’s write changes the value. delay and pulse still have none.
  • Changed: file.read no longer logs its errors; handle err in on_done.

0.9.1 - 2026-10-21

  • Fixed: a mantle.mpris Firefox or zen player’s position could fall up to a second behind after a play, pause or seek, and step back mid-track. Those browsers report position in whole seconds; Mantle now keeps the precise offset they send with each seek.
  • Fixed: a computed reading another computed that had just changed ran its function a second time on the next unrelated layout pass.

0.9.0 - 2026-10-20

  • Added: mantle.mpris players carry pid, the process that owns the player, so a config can match a player to its mantle.audio.apps[] stream by pid instead of by name. It is nil when the bus does not report one.
  • Added: mantle.audio.apps[].media_name, PipeWire’s media.name for the stream, such as a browser tab’s "YouTube Music", so a config can tell one app’s streams apart. It updates as the tab navigates.
  • Breaking: state(name, initial) raises when one evaluation seeds a name with a table and a scalar, such as {} then -1; rename one. Both calls used to return one signal, so the table’s reader failed only once its node was built, often in a closed panel, and mantle check passed.

0.8.1 - 2026-10-19

  • Changed: the LuaLS stubs type mantle.tray menu_type and toggle_type, mantle.network vpns[].kind and mantle.bluetooth device category as their literal values, so the editor flags a misspelled comparison. A tray menu entry with an unknown menu_type now reads as "standard", and one with an unknown toggle_type has nil for both toggle_type and toggle_state (the state was -1).
  • Added: on_click, on_double_click and on_press report a mouse’s back and forward thumb buttons as "back" and "forward". They used to be ignored. A handler that treats every button but "left" alike now also runs for them.

0.8.0 - 2026-10-17

  • Breaking: effect.shader’s u_input and u_input_blurred may be larger than their content, so texture() on them reads past its edge. Read them with mantle_input and mantle_input_blurred.
  • Changed: a list with both key and scroll builds only the rows near its viewport, so a 1000-row list costs about as much as 50. A row scrolled more than a viewport away plays no exit and loses its tweens, keyboard focus and text draft. Keep row state that must survive in a named state.
  • Changed: the --profile idle report’s ms columns start with handlers=: due timers, signal wakes, off-thread results and scroll steps. They used to count toward resolve=, which could show 130 ms with no resolve pass.
  • Changed: ToplevelHandle:resize types its edge as a union of the eight edge names, so LuaLS flags a misspelled edge.
  • Fixed: a shape with corner commands could cost about 1,000 times a rounded box to draw on every repaint. Mantle skips zero-length curves.
  • Fixed: an idle shell spent about 1 ms per clock tick in the Lua garbage collector, run after every getter that allocates.
  • Fixed: an idle shell showing an image from your home directory (a ~/Pictures wallpaper) redrew the whole surface twice a second. Mantle watches image files with inotify, so an edited, replaced or deleted file repaints at once, shader images included. A symlink is still checked twice a second.
  • Fixed: a "fill" scroll container under a window, lock or spanning panel with no size of its own grew to its content instead of the surface, so it never scrolled (since 0.4.0).
  • Fixed: the .deb’s lock screen and polkit agent refused every password. Its PAM stack named system-auth, which Debian and Ubuntu lack. It includes common-auth and common-account instead.

0.7.5 - 2026-10-16

  • Changed: a getter, on_change handler, action or timer no longer fails at 2.5 ms of CPU, which broke configs on slower machines. It fails at 50 ms, as a runaway: exceeded the 50ms CPU limit for one evaluation. Past 2.5 ms the log warns once per signal and names the slowest one.
  • Added: mantle-bin on the AUR, published from each release’s binaries.
  • Fixed: on NVIDIA with egl-wayland2’s explicit-sync fix (NVIDIA/egl-wayland2#56), the renderer leaked about 3 MiB an hour. egl-wayland2 1.0.2 holds the same buffer releases where Mantle cannot reach them, so on 1.0.2 the leak remains until #56 ships.

0.7.4 - 2026-10-16

  • Fixed: while the session was locked, the renderer could hold one CPU core at 100% until unlock. It showed after a restarted shell took the lock over, and dates from 0.1.1.
  • Fixed: with --profile on the NVIDIA proprietary driver, the supervisor leaked one file descriptor per sample (since 0.7.1). After about 2.5 h at --profile=10 it could not spawn processes, so the lock screen could not unlock.

0.7.3 - 2026-10-16

  • Changed: a looping keyframes animation or a delay costs no frames while it holds one value. A blink that rests 3.6 s no longer costs a tick per refresh.
  • Changed: mantle check prints the source line under each error that names a file.lua:N, indented after | .
  • Changed: a touchpad scroll over a scroll container coasts to a stop after the fingers lift. It used to stop dead. Wheel notches are unchanged.
  • Added: a keyframes entry takes "current" as its first frame, to start the run on the value on screen. See Keyframes.
  • Added: run on a keyframes entry. A different value starts a new run with the same frames, even mid-run, so a counter replays an animation without removing the entry and adding it back.
  • Added: settled = settled("name") on any node gives a read-only signal. It is false while an animate tween on that node runs and true once the last one ends. Swap content when a collapse finishes instead of after a guessed timer.
  • Added: effect.shader.input = "desktop" gives a shader on a panel box the desktop behind it, for lens and refraction effects. It captures through wlr-screencopy. Return mantle_input to leave a pixel, as with "backdrop". See Desktop input for how it composites and what it costs.
  • Added: shape = { melt = k, parts = { .. } } joins up to 8 rounded boxes with fillets of about k px. The fillets stay tangent through animate.shape. Paint, borders, clip, shadows, hit testing and regions follow the joined shape. See Melt.
  • Added: mantle check warns when its surface’s edge cuts off an outer shadow by 4 px or more, naming the side and the room to add. A side where a panel meets the output edge is exempt. The exit status is unchanged.
  • Added: a warning when another node replaces an id-less child with animate.exit in place, which skips its exit.
  • Fixed: a compositor_blur region that changed while the rest of the surface did not applied only at the next repaint.
  • Fixed: a rotated or skewed box takes input across its rotated shape, not its upright bounding box. Clicks in the empty corners pass through.
  • Fixed: compositor_blur follows rotate and skew instead of blurring the upright bounding box.
  • Fixed: text in a rotated, skewed or non-uniformly scaled node drew about 1 px heavier per stroke.
  • Fixed: mantle.privacy names apps using the camera through PipeWire (browsers, Snapshot, OBS) instead of listing pipewire.

0.7.2 - 2026-10-13

  • Changed: an icon name containing / no longer resolves through the icon theme.
  • Changed: mantle.secrets talks to the Secret Service directly. A locked keyring shows its unlock prompt, and the store waits for it. A missing default keyring is created. timed_out means a service call took over 25 s, and it is final.
  • Fixed: icon lookup finds .svgz theme icons and icons in ~/.icons.
  • Fixed: a getter, handler or callback could fail with “exceeded the 2.5ms CPU budget” for garbage collection it didn’t cause.

0.7.1 - 2026-10-12

  • Changed: a move or translate paints at whole buffer pixels, so a slide of under one pixel steps instead of gliding. Fractional positions drew glyphs differently on each driver.
  • Changed: on_hover fires on the first pointer motion after layout moves its node under or off a still pointer. The pointer used to have to leave the node and come back.
  • Changed: Tab on a lone plain textfield that does not hold focus focuses it. It used to reach on_key.
  • Added: size in bytes on each mantle.folders entry. A symlink reports its target’s size.
  • Added: text elide accepts "start" (… then the end) and "middle" (start and end around a …, the end taking the odd grapheme). Both cut at grapheme boundaries, and elided(name) is true for either. Under wrap = "word" both act as "end".
  • Added: on_key_release(key) on any node hears a key going up whose press was offered to on_key, routed the same way. key is { name, text, modifiers }. mantle input key sends the release after the press. See key handlers.
  • Added: on_double_click(rect, button, pointer, modifiers) fires after on_click on a second click on the same node with the same button, within 400 ms and 4 px. The innermost node with on_double_click takes it, so single clicks still reach an ancestor’s on_click. See pointer.
  • Added: on_drag takes a fifth argument, velocity = { x, y }, in surface px per second over the last 100 ms of motion. On "end" it is the release velocity, or the last motion’s when the pointer left the surface. Use it for flick scrolling. See pointer.
  • Added: image_status(name), bound as an image’s status, reports the load of its source: "empty", "loading", "ready" with the file’s own width and height, or "failed". See image_status.
  • Added: image.playing, default true. false holds an animated GIF on its current frame (the first, if it never played) and stops redrawing it. true resumes from that frame. Each image node pauses on its own.
  • Fixed: elide = "end" on one line keeps a right-to-left paragraph reading right to left when the kept head loses its first strong character ("123 שלום" cut to "123…").
  • Fixed: a panel with exclusive_zone = "ignore" or a fixed zone could map at a transient size first.
  • Fixed: Tab on the only control, while it holds focus, shows the focus outline and reaches on_key. It used to be swallowed.

0.7.0 - 2026-10-11

Renamed

Rename each in your config. The old name raises as unknown.

OldNewWhy
mantle.files, field foldersmantle.folders, field watchedIt lists watched folders, and folders.folders read badly
mantle.systemmantle.clockIt holds the wall and monotonic clocks, nothing else
mantle.battery capacityhealthIt is full charge over design capacity; capacity read as the charge level
mantle.storage filescontentsThe values are each file’s contents, keyed by path
list sourceitemsIt names the data; source is a file path on image and shader
list itemfneachA plain word
window on_closeon_close_requestThe window stays open until the config hides it
behind_blurcompositor_blurThe compositor does the blurring
box outline (type Outline)shape (type Shape)outline read as CSS outline, which ring covers
shader uniforms mantle_outline, mantle_outline_len, mantle_outline_pointmantle_shape, mantle_shape_len, mantle_shape_pointThey carry the shape property’s points
node submitsubmits_secureIt sends the armed secure field; submit read like on_submit

Changed, needs an edit

  • computed(dependencies, fn, before?) and a capability’s :map(fn, before?) never call fn with a capability’s pre-push nil. Until every capability dependency has pushed, the derived signal holds before (default nil). Move what fn returned for that nil ({}, 0, "") into before and drop the guard. Without before the signal reads nil, which breaks #t or arithmetic downstream. :get() and on_change are unchanged.
  • persistent_table { path, name } becomes persistent_table { path }, where path is the file path, absolute or starting with ~/. name raises as an unknown key. A relative path, one ending in /, or one with an empty, . or .. component raises. The engine creates the parent directory.
  • mantle.clock’s monotonic is CLOCK_MONOTONIC seconds since boot, as a fractional number. It used to be whole seconds since the capability started. mantle.mpris’s position_updated_at uses the same clock in microseconds, so divide it by 1e6 to compare.
  • The internal --detached flag is gone and raises as an unknown argument.

Changed, no edit needed

  • A leading ~/ means $HOME in every path a config passes: persistent_table { path }, mantle.folders:watch/unwatch, mantle.storage:open/set, mantle.notifications:set_sound, palette.quantize, and the image source, icon name, shader source and images, transition.shader and images, effect.shader and mask.source properties.
  • persistent_table keys read their declared defaults entry until mantle.storage pushes that file, instead of nil. set(key, nil) and a key the file lacks also read the declared default. Saves keep a symlink’s link and the file’s mode.
  • mantle.folders:watch on a missing folder, or on one deleted later, waits for it to appear and lists it then. error holds until it does. It used to stay unwatched until a second watch.

Added

  • file.read(path, on_done) and file.write(path, text, on_done?) read and write whole files off the Lua thread. A write that would change nothing is skipped and reports changed = false. Writes land in call order, through a symlink to its target, keeping the file’s mode. See file and Colour your apps.
  • process.capture(cmd, args, on_done) runs a program to its end and calls on_done(stdout, code) with all of stdout as one string, newlines kept. It keeps the first 1 MiB and drops stderr. A signal, failed spawn or reload gives nil, nil.
  • palette.from_hct{ hue, chroma, tone } and palette.harmonize(color, source) return "#RRGGBB".
  • mantle.clock:now() reads the monotonic clock at the call, before the first push too.
  • computed accepts plain strings, numbers, booleans and tables among its dependencies and passes them to fn unchanged. A function or userdata still raises.

0.6.2 - 2026-10-11

  • Changed: a HUP, USR1, USR2, ALRM or other terminating signal stops the shell the way SIGTERM does. The error names the signal and the sender’s pid and command line. SIGINT and SIGTERM log their sender too.
  • Changed: the renderer’s connection ended line says whether the supervisor process is still running.
  • Fixed: mantle set and toggle read a joined --pid=N or --profile=N after the state name as an option. It was stored as the value.
  • Fixed: a notification image-path or app_icon given as a file:// URL with escapes (%20) or a localhost host resolves. It was refused.
  • Fixed: a tray item that leaves the bus while the tray starts is dropped. It could stay listed.

0.6.1 - 2026-10-11

  • Fixed: a failed EGL surface, swap or dmabuf image call crashed the renderer. It logs EGL error 0x… and carries on.
  • Fixed: the privacy capability could crash the supervisor on an empty camera or microphone device-event batch.
  • Fixed: hot reload of a config file that could not be re-watched after a reload stopped silently. It logs a warning.

0.6.0 - 2026-10-10

  • Breaking: config error text changed throughout. A shell that matches on mantle.rescue.error_log text must update.
  • Breaking: package.searchers holds only the Lua file searcher. The C-module searchers could never load in safe mode.
  • Changed: a bad value, duplicate id or unknown property on a top-level panel, window, popup or lock names the line that built it, as errors on child nodes do: shell.lua:2: panel: invalid value for `height`: ....
  • Changed: an unknown property on any node reports the node, not a top-level return error or a bad children value: shell.lua:2: text: no property `contnet`; did you mean `content`?.
  • Changed: a layout error from mantle check or a reload leads with the line that built the failing node: shell.lua:9: text: invalid value for `content`: .... Then come the path to its parent and the surface in parentheses. A children entry reads text[2], counting from 1 like Lua, and a lone child no longer shows a made-up [0].
  • Changed: mantle check puts the pass on its own last line and no longer repeats layout: after the pass name.
  • Changed: argument and value errors read like Lua’s. An error from a global function, capability action or mantle check leads with the file:line of the config call that caused it: mantle.audio:set_volume: expects 1 argument, got 2. A bad element inside a table argument is named. A method on an engine object names itself, as method reveal. A caught pcall keeps the caller’s file:line, and a rejected property value of the wrong type names its type, as string "tall" or nil.
  • Changed: every out-of-range value reads like timer: ms must be within [1, 86400000], got integer 0, unit in the subject. A float reads number 2.5 and a whole float number 1.0. This covers timer, interval, delay, pulse, loops, gradient stops and idle:register_threshold.
  • Changed: a config handler that raises logs where it was defined (widgets/bar.lua:12) and folds identical repeats into raised again (N times).
  • Changed: error({...}) names the value type. A missing require lists config-relative paths without the C-modules line.
  • Changed: a misspelt table key or choice value (anchor = { lefft = true }) ends with “did you mean left?”.

0.5.5 - 2026-10-10

  • Added: mantle.workspaces, mantle.windows and keyboard layouts on Sway, over $SWAYSOCK. A workspace’s id is its name, and an Xwayland window reports its X11 class as app_id. Sway ignores set_maximized and toggle_special.
  • Added: MangoWC support for mantle.workspaces, mantle.windows and the mantle.keyboard layout name. Each tag is a workspace with id "<output>:<tag>".
  • Fixed: mantle.idle reports a compositor hold taken while the seat is already idle, such as a video resumed from a phone. inhibited stayed false until input came and went.

0.5.4 - 2026-10-09

  • Added: mantle.mpris players carry art_url, the cover URL as the player sent it, so a config can fetch remote artwork such as Spotify’s.
  • Fixed: padding on a text or textfield insets its glyphs, caret, links and IME cursor. They drew from the box’s corner, so padded labels sat off-centre.

0.5.3 - 2026-10-09

  • Changed: an image file rewritten in place may take up to 0.5 s to show, because Mantle checks each path for edits at most twice a second.
  • Fixed: the lock screen ignores a second password submitted during the unlock animation. It used to re-run PAM and flash an error.

0.5.2 - 2026-10-09

  • Fixed: mantle call, set and input fail at once when no shell is connected. They used to wait 5 s.
  • Fixed: a process.run child printing faster than the shell reads no longer gets the shell restarted. The child waits on its pipe.
  • Fixed: mantle.bluetooth device names are BlueZ’s Alias. A rename shows, and a nameless device shows its address-style alias instead of an empty name.
  • Fixed: a Bluetooth device’s Class change refreshes its category.
  • Fixed: mantle.privacy sees a camera plugged in after start or replugged.
  • Fixed: mantle.appearance keeps its last value when the portal call fails.
  • Fixed: workspaces and windows recover after niri restarts. They stayed frozen at the last value.
  • Fixed: a polkit prompt that asks a visible question (OTP, username) fails instead of receiving the password.

0.5.1 - 2026-10-07

  • Fixed: an effect under animate keeps its backdrop.mask while it tweens. It was dropped from the first frame on.

0.5.0 - 2026-10-06

  • Breaking: workspace ids are strings, and WorkspaceEntry.idx is replaced by number. Treat id, active_workspace, focused_workspace, windows[].workspace_id and the focus and move_to_workspace arguments as opaque strings to pass back, never to compute with. On Hyprland a numbered workspace’s id is its number ("3"), so focus(tostring(n)) still creates one. number is what a keybind targets (niri’s position on the output, Hyprland’s number), and nil for a Hyprland named workspace.
  • Changed: the engine’s focus outline follows the node’s radius, corner_smoothing and outline.
  • Changed: a rounded border has the box’s radius on its outer edge instead of half its width more.
  • Changed: a card press released over a field inside it no longer clicks the card.
  • Added: ring = { width, color, offset } on box nodes draws a stroke offset px outside the box (inside when negative) with a clear gap, for Material-style focus rings. It follows radius, corner_smoothing and outline, takes no layout space and tweens under animate.
  • Added: double-click in a plain textfield selects a word and triple-click a line. Dragging or Shift+clicking afterwards extends by words or lines.
  • Added: a right or middle press on a plain textfield calls the field’s own on_press and, on release, its own on_click, never an ancestor’s. Use it for right-click menus. The press focuses the field and keeps a selection it lands in. Left presses and secure_submit fields are unchanged.
  • Added: Ctrl+X cuts a field’s selection.
  • Added: focus_target(name) gains :cut(), :copy(), :paste() and :select_all(). has_selection(name) is a read-only signal for a field with text selected.
  • Added: textfield takes multiline = true. The draft wraps and takes newlines, and the field grows from min_lines to max_lines rows, then scrolls. submit_key ("ctrl+return" or "return") picks the chord that submits. multiline with secure_submit is refused. See Multiline.
  • Added: focus_target and autofocus work on any focusable control, not only a plain textfield. :set_text stays textfield-only.
  • Added: focus_target(name):request() works from on_key and from a field’s on_change and on_submit. A request from a key callback shows the focus outline, and one from on_click does not.
  • Added: toplevel(id) takes :set_maximized(on), :set_minimized() and :set_fullscreen(on), which need no press. The result arrives through state().
  • Added: textfield takes selection = { background, foreground }, the highlight colour and the selected glyphs’ colour. The highlight is drawn as given, so tint it with alpha. Unset, the highlight stays foreground at 30% alpha and the glyphs keep the text colour.
  • Added: mantle.appearance, a read-only capability for the desktop portal’s appearance settings, pushed live: color_scheme, accent, contrast and reduced_motion. Without a portal every field holds its default.
  • Added: effect.backdrop.mask takes a node mask’s gradient or source (and invert) and scales the glass’s coverage, for a scroll edge or a feathered panel. Where the mask is clear the ground shows through, and between it crossfades from the ground to the blur. A node mask is refused.
  • Added: on_click, on_press, on_drag and on_wheel take a trailing modifiers argument, the { ctrl, shift, alt, super } table on_key reports, for Ctrl-click, Shift-click and Shift-drag. It reads all false while none of the shell’s surfaces has keyboard focus.
  • Fixed: on_drag’s rect is the node’s current box on every call, not its box at the press. rect.x + pointer.x stays the surface position when the node moves under its own drag (a splitter).
  • Fixed: Hyprland named workspaces (workspace = "name:chat") are listed in mantle.workspaces and mantle.windows after the numbered ones instead of dropped, and focus reaches them (issue #1).

0.4.0 - 2026-10-05

  • Breaking: content_blur and backdrop_blur are replaced by one effect table, { blur, backdrop = { blur } }, like CSS filter and backdrop-filter. content_blur = 4, backdrop_blur = 8 becomes effect = { blur = 4, backdrop = { blur = 8 } }, and animate = { content_blur = 200 } becomes animate = { effect = 200 }. Each blur is a sigma in [0, 8192] and bindable. Only box kinds take backdrop. Unknown keys are refused with the accepted ones listed. animate.effect tweens both blurs, and a key only one side sets tweens from or to 0.
  • Breaking: textfield.on_navigate is removed. The field’s own on_key hears the same keys, and so do its ancestors’. on_navigate = function(k) if k == "down" then ... end end becomes on_key = function(key) if key.name == "Down" then ... end end. Key names become keysym names: "up", "down", "left", "right", "page_up", "page_down", "tab" and "backtab" become "Up", "Down", "Left", "Right", "Page_Up", "Page_Down", "Tab" and "ISO_Left_Tab". Left and Right still arrive only when the caret cannot move and Shift is up. A key repeats only if an on_key handler hears it.
  • Breaking: textfield.caret_color is replaced by caret = { color, width, height, radius }. caret_color = c becomes caret = { color = c }. See textfield for each key’s default.
  • Changed: shadows takes up to 16 layers, up from 8.
  • Added: box kinds take outline = { commands = { .. } } in place of radius, any shape as one closed contour of path commands: a popover with its arrow, a speech bubble, a tab joined to its panel. A coordinate is px, "NN%" of the box or { from = "right", px = -12 }, so the shape follows the box’s size and may reach past it. Fill, border (one width and colour), shadows, inset shadows, clip = "rounded", mask, effect.backdrop, mantle_sdf, hit testing and behind_blur follow it. animate.outline tweens point by point between lists of the same commands, springs included, and snaps otherwise. 0.7.0 renames it shape.
  • Added: path commands take { op = "corner", points = { x, y }, radius = r, corner_smoothing = s }, which rounds the turn at one point as a continuous corner.
  • Added: effect.shader takes progress ([-8192, 8192], default 0), which becomes u_progress as on a shader node. animate = { effect = .. } tweens it, keyframes and loops = "infinite" included, so a content or backdrop shader animates without Lua running per frame. The rest of the shader table snaps to the target.
  • Added: focus_visible(name), a read-only signal like focused(name), bound as a node’s focus_visible. It is true while the node or one inside it holds focus that Tab, Shift+Tab or an assistive-technology action moved there (CSS :focus-visible). A press, autofocus or focus_target(name):request() makes it false. focus_ring = false does not affect it, so a custom ring bound to it stays hidden after a mouse click.
  • Added: window.geometry_inset adds a band around a client-decorated window’s frame for its shadow and rounded corners. The compositor sizes, tiles and snaps by the frame alone. Bind it to toplevel(id):state() to drop the band when maximized, fullscreen or tiled. See Client-side decoration.
  • Added: shader, effect.shader and transition.shader take images = { name = "/abs/path.png" }, up to 8 .png, .jpg, .jpeg or .webp files for the .frag to sample: a normal map, a noise texture or a LUT. Each entry declares uniform sampler2D name and uniform vec2 name_size. Changing a file reloads it. See Images for naming rules and sampling.
  • Added: toplevel(id):state(), a read-only signal of a window’s configure state: activated, maximized, fullscreen, resizing, tiled, bounds, capabilities and decoration. It returns to its defaults when the window closes. See Window state.
  • Added: window.decorations picks who draws the frame, "server" (the default, as before) or "client". A reload that changes it asks the compositor again.
  • Added: blend sets a CSS mix-blend-mode ("multiply", "screen", "plus_lighter" and more; default "normal") on a node, a background layer ({ fill = .., blend = .. }) or a shadows layer. It composites onto what the surface painted under it, never the desktop. A node’s blend applies after every effect filter and takes its shadows with it. blend snaps under animate. See Blend modes.
  • Added: effect.shader takes input = "backdrop" on box kinds. The program reads what the surface painted under the box through mantle_input, and the effect.backdrop copy through mantle_input_blurred. Its output replaces that area before the node paints, mixed with the original by the node’s opacity. Return mantle_input(uv) to leave a pixel unchanged. See Backdrop input.
  • Added: a shadows layer with inset = true works like CSS box-shadow: inset. It shades the inside of the padding box with the same color, blur, offset and spread, above the background and under the children and border. It needs shadow_mode = "box" and a box kind. See Inset shadows.
  • Added: textfield.escape picks what Escape does: "clear" (the default, as before), "blur" or "pass". "blur" keeps the draft, leaves the field and calls on_cancel(false). "pass" keeps the draft and focus, and Escape goes up through on_key, then on_escape. A secure_submit field ignores it.
  • Added: on_key(key) on any node or surface hears key presses and repeats, for custom controls. key is { name, text, modifiers = { ctrl, shift, alt, super }, repeat }, with name an xkb keysym name ("Down", "Return", "a"). A key goes to the focused node, each ancestor, then the surface, and a handler returning true stops it. A focused textfield keeps the keys it edits with and passes the rest up. A named node with on_key takes keyboard focus. See key handlers.
  • Added: pointer(name), a read-only signal of the pointer’s { x, y } in logical pixels from a node’s top-left corner while the pointer is over the node or its children, nil otherwise. Bind it as a node’s pointer. It costs nothing while unread.
  • Added: mantle [-c DIR] input <surface> move | press | release | leave | click | drag | wheel | key | type injects pointer and keyboard input into one surface of a running config, for testing. Lock surfaces and secure_submit fields refuse it. See Injecting input.
  • Added: background takes a list of up to 16 layers, first on top, like CSS multiple backgrounds. Each is a colour, a gradient or { fill = .. }. animate.background tweens colour layers pairwise and fades in one a side lacks. Gradient layers snap.
  • Added: textfield takes font, font_weight, italic, letter_spacing, line_height and font_variations, with text’s ranges and defaults. The caret, the selection and IME placement follow the metrics. An unset height is font_size * line_height.
  • Added: wrap = true on a row or column flows children onto new lines, like CSS flex-wrap. line_spacing sets the gap between lines. wrap with scroll is refused. See Wrapping.
  • Added: homogeneous = true on a row or column gives every visible child one equal slot along the main axis, like GTK’s homogeneous. See Equal slots.
  • Added: toplevel(id) returns a handle for a window’s own frame: :move(), :resize(edge) and :show_menu(). They work only from an on_press or an on_drag "start". Anywhere else they warn and send nothing. See Custom title bar.
  • Added: on_press(rect, button, pointer) fires on any node at press time for the left, right and middle buttons, before on_click. A press on a textfield does not call it.
  • Added: border_color takes a gradient table, { gradient = "linear" | "radial" | "conic", angle, stops } as background does, painted along the whole outline. A gradient per edge is refused, and a gradient border snaps under animate.
  • Added: effect.shader = { source, input, params, padding } runs a fragment shader over a node’s painted subtree, which a shader node cannot read. The .frag reads the subtree through mantle_input(uv) and the distance to the node’s outline through mantle_sdf(p). padding ([0, 512] px) lets it read and draw past the box. Saving the file recompiles it, and one that fails to build leaves the node as painted. See Shader effects.
  • Added: corner_smoothing, 0 to 1, gives box kinds and image continuous corners like Figma’s corner smoothing. radius = 24, corner_smoothing = 0.6 is close to iOS, and 0 is the circular arc as before. animate tweens it. See Continuous corners.
  • Added: effect takes the CSS colour filters saturate, brightness and contrast, each in [0, 8] with 1 off, on the node and in backdrop (effect = { backdrop = { blur = 45, saturate = 2 } }). They apply after the blur, are bindable, and animate.effect tweens them.
  • Fixed: text and textfield glyphs that run past a tight line_height (descenders below the line box) are no longer cut flat at it. line_height sets layout only, and an ancestor’s clip still cuts the ink.

0.3.0 - 2026-10-05

  • Breaking: clip defaults to "none", like CSS overflow: visible. Children, their shadows and their transforms paint and take the pointer past a box that does not set it. A scrolling row, column or list, a surface and a box with a mask still cut to the box. Add clip = "box" where a box must cut, such as a button holding a ripple or a fixed-width rect holding a long text.
  • Breaking: shadow_color, shadow_blur, shadow_offset and shadow_spread are replaced by shadows, a list of { color, blur, offset, spread } layers like CSS box-shadow: a, b. Wrap the old four in one layer: shadow_blur = 8, shadow_offset = { y = 2 } becomes shadows = { { blur = 8, offset = { y = 2 } } }, and animate = { shadow_blur = 200 } becomes animate = { shadows = 200 }. The first layer is on top, up to 8. Each field is bindable, and animate.shadows tweens layer by layer.
  • Changed: palette.quantize uses Material’s Celebi quantizer instead of median cut, so the same image gives different swatches (colours and shares). opts.method = "wu" picks Wu alone.
  • Changed: a parent’s clip stays put when a child moves with translate, scale or rotate. A child that transforms past a clipping parent’s box is cut at the box.
  • Changed: an invalid property value (out of range, wrong type, bad colour, malformed table) no longer freezes its surface at startup or in a live update. The node uses that property’s default and the rest applies. The error is logged and held in mantle.rescue until the value is fixed. A reload and mantle check still refuse it.
  • Changed: a node that fails a live update or the startup apply (a raising getter, a malformed children) freezes only its own surface, which keeps its last applied tree. The other surfaces keep updating, an error banner drawn from mantle.rescue included. The failure is logged as an error, once until its message changes. A reload that fails anywhere is still refused whole.
  • Changed: on_click and on_drag measure pointer in the node’s own untransformed box, so a translated, scaled or rotated node reports coordinates within its size.
  • Changed: each textfield keeps its own draft. Moving focus to another field, with Tab or a click, no longer discards the text of the one you left. A draft goes when its node leaves the tree.
  • Changed: textfield.caret_color colours the caret, not the selection highlight. It defaults to foreground, as before.
  • Changed: a textfield without height is one line of font_size tall instead of 0 px, so its placeholder shows. width is still required.
  • Changed: a stacking parent whose size is fixed or capped below its largest child gives every child its content box, so a smaller child no longer centres in the larger one’s width.
  • Changed: a text or icon capped by max_width or max_height sizes its content-sized parent at the cap instead of its full content, so an elided title in a content-sized row stays centred.
  • Changed: battery.capacity reports battery health: full charge as a percent of the design capacity, nil when UPower does not know it.
  • Changed: the wheel stops at the end of the content the last layout measured, so a map of a scroll signal runs once per wheel event at an end instead of twice.
  • Changed: an icon with foreground recolours every shape of a -symbolic icon that never uses currentColor, as GTK does, so themes that hard-code symbolic colours (Tela) follow it. Icons that use currentColor keep their accents, and full-colour icons are unchanged.
  • Changed: images scale down with a bilinear filter instead of box sampling. The result is closer to the source, and a 4K wallpaper scales to 2133x1200 in 8.5 ms instead of 75 ms.
  • Added: path.trim_axis = "x" makes trim_start and trim_end fractions of the node’s width. The stroke keeps what lies inside that band, cut at its edges with stroke_cap on every cut end.
  • Added: path.shift moves the geometry inside the node before trimming. Animate it to scroll a shape through a fixed window.
  • Added: a keyframes entry, or one frame, takes spring = { stiffness, damping } in place of easing. The segment springs from rest in real time and lands on its frame when its duration ends.
  • Added: textfield.initial_text (string or signal) seeds a plain field’s draft once, when the field enters the tree, with the caret at the end. Later changes are ignored, and a changed id or key is a new field. autofocus resets the draft to initial_text ("" when unset) and calls on_change with it. It is refused with secure_submit. See textfield.
  • Added: scroll(name):scroll_to(offset) and :scroll_by(delta) move a scroll offset from Lua, clamped to the content and eased under animate.scroll. scroll_by adds to where a run is headed, so repeated arrow clicks add up. A request waits while its container is hidden, and the next pass drops one when no container holds the signal.
  • Added: animate.scroll on a scrolling row, column or list eases each mouse-wheel notch toward a target that stops at the ends. The scroll signal holds the offset on screen each frame, so its readers reflow continuously. :reveal eases the same way. Touchpads, high-resolution wheels and reset_on_close still move it at once.
  • Added: a signal may sit inside a property table, such as margin = { top = sig }, a gradient stop’s colour, an animate entry or a text run’s flag, and is read again once it is written. children, a list’s source, structural fields such as a panel’s anchor, tables with a metatable and tables a signal returns still refuse one.
  • Added: textfield.disabled keeps a field drawn but out of keyboard focus (Tab, press, autofocus, focus_target).
  • Added: textfield.max_length caps the draft in grapheme clusters for typing, paste, IME and set_text, secure_submit fields included.
  • Added: image.radius rounds the picture’s corners with no clipping wrapper, as a number or a per-corner table like rect.radius. It rounds what shows, so fit = "contain" rounds the fitted picture.
  • Added: path takes stroke_cap ("butt", "round", "square"), stroke_join ("miter", "round", "bevel"), and trim_start/trim_end, which stroke only that fraction of the path’s length and animate like any number.
  • Added: focus_target(name):set_text(text) prefills or clears the draft of every plain textfield with that focus_target, hidden and disabled ones too. It calls no on_change and never touches a secure_submit field.
  • Added: textfield.placeholder_color colours the placeholder. It defaults to foreground, as before.
  • Added: text takes font_variations, a table of OpenType axis tags to values such as { FILL = 1, GRAD = 0, opsz = 24 }, for variable fonts like Material Symbols. An explicit wght overrides font_weight.
  • Added: on_escape() on panel, window and popup fires on Escape when the surface has the keyboard and no focused field took it, so a dialog or menu without a field can close. A field with text or on_cancel keeps Escape.
  • Added: palette.hct(color) returns a colour’s { hue, chroma, tone } in HCT, so a config can apply its own seed rule. Each palette.score seed is one input swatch’s colour.
  • Added: palette.scheme(seed, opts?) returns the 49 Material 3 colour roles for a seed, with dark, variant and contrast options.
  • Added: palette.score(swatches) ranks palette.quantize swatches into seed colours as Material 3 does.
  • Added: animate.width and animate.height ease a content-sized axis. When a label changes, the box grows from the size on screen to the new content size, and siblings follow. Only eased entries animate it. Springs and keyframes snap.
  • Added: radius takes per-corner values, { top_left, top_right, bottom_right, bottom_left }, which animate tweens. Corners too big for a side shrink together, as in CSS. A shadow rounds by the mean corner.
  • Added: hittable = false lets the pointer through a node and its descendants to what is underneath. A descendant’s hittable = true takes it back.
  • Added: on_click takes the pointer as a third argument, { x, y } in node-local coordinates like on_drag’s. A keyboard activation passes the node’s centre.
  • Fixed: a map of a scroll signal kept a stale offset when layout clamped it (a wheel past either end) or a :reveal moved it, so offset-sized children jumped at the ends. It lays out again in the same frame.
  • Fixed: a text whose content runs table or font_variations table was edited in place and set again kept its old size and glyphs. It re-measures.
  • Fixed: audio recovers when PipeWire restarts or starts after Mantle. While PipeWire is down, audio reads as a machine with no audio hardware and privacy clears its microphone and screencast users.
  • Fixed: network rebinds its devices when NetworkManager restarts, so wifi_devices and wired state drop the old daemon’s values.
  • Fixed: the Bluetooth pairing agent rejects calls from anyone but bluetoothd.
  • Fixed: brightness picks up a backlight that appears after startup, such as on a dock or a hybrid GPU.

0.2.0 - 2026-10-04

  • Breaking: an omitted panel width or height fills the configured root axis when both opposite edges are anchored. To keep a deliberately narrow root, wrap its content in a sized child and move its background there.
  • Breaking: every engine-defined string enum is lowercase snake_case, and old spellings are refused: align_h = "center", width = "fill", layer = "top", keyboard_interactivity = "on_demand", easing = "in_out_quad", loops = "infinite", constraint_adjustment = { "flip_y", "slide_x" }, exclusive_zone = "ignore", gradients "linear"/"radial"/"conic". Capability values follow the same rule: battery.state ("fully_charged", "pending_charge", …), mpris play_state ("playing"), loop_status and set_loop_status ("none", "track", "playlist"). POSIX signal names and SVG path ops keep their case.
  • Breaking: panel output keywords are "all" and "active". "All" names a connector and logs that none is connected.
  • Breaking: tray.items[].status is "active", "passive" or "needs_attention". An item that sends another value, or none, reads "active".
  • Breaking: panel’s monitor is renamed output and exclusive is renamed exclusive_zone, with the same values.
  • Breaking: margin on a window, popup or lock root, and align_h/align_v on any surface root, are refused as unknown properties instead of ignored. Set them on the child. animate on a lock refuses width, height and visible, which a lock already refuses.
  • Breaking: node property blur becomes behind_blur. focus(name) becomes focus_target(name), and the textfield property focus becomes focus_target.
  • Breaking: every volume is a percent. audio.volume (0 to 150), audio.source_volume, apps[].volume and mpris players[].volume read 100 for 100%, and set_volume, set_source_volume, set_app_volume and mantle.mpris:set_volume take the same scale. Multiply old fractions by 100.
  • Breaking: mantle.mpris:set_loop_status takes "none"|"track"|"playlist" and raises on anything else. players[].loop_status is nil instead of "" when unreported, and play_state reads "stopped" instead of "" before its first reading.
  • Breaking: mantle.tray:scroll raises on an orientation other than "vertical" or "horizontal".
  • Breaking: unknown values are nil instead of -1: sysinfo.temp_gpu, bluetooth connected-device battery, and mpris position, length and track-list length. network.ethernet_speed is nil instead of 0 when unknown or no wired device is active.
  • Breaking: keyboard.backlight_pct is renamed backlight_percent, nil without a readable backlight. workspaces.active_client fields class, is_floating and is_fullscreen are renamed app_id, floating and fullscreen, matching windows entries.
  • Breaking: mantle.updates.count and the bluetooth DiscoveredDevice.paired field are removed. Use #updates.packages. A discovered device is never paired.
  • Breaking: list properties such as popup constraint_adjustment and text content runs reject a list with a nil hole or named keys. Entries after a hole used to be dropped.
  • Breaking: dofile and loadfile are unavailable in configs, because their synchronous file reads can stall the renderer. Use require for Lua modules or process.run for other files.
  • Breaking: with two or more focusable controls on a surface, Tab moves focus and no longer reaches a textfield’s on_navigate("tab").
  • Breaking: a path subpath is solid regardless of winding, so an inner subpath drawn in the opposite direction no longer cuts a hole. Mark it with hole = true on its first command.
  • Changed: mantle.power:set_profile warns and sends nothing for a name not in profiles.
  • Changed: surfaces paint at their output’s compositor scale, fractional included, so text and images are sharp on HiDPI displays. Sizes stay in logical pixels. A shader’s size uniform stays logical while gl_FragCoord counts buffer pixels.
  • Changed: a resized image with async keeps drawing its previous size, scaled, until the new size decodes, instead of blanking. A changed source_blur, or a changed source without retain, still blanks.
  • Changed: stubs give panel, window, popup and lock a Surface return type and node constructors a Node one, so a surface used as a child is a type error.
  • Added: network.vpns lists saved NetworkManager VPN and WireGuard profiles with active and activating. connect_vpn(uuid) and disconnect_vpn(uuid) toggle one, and vpn_error reports a failed activation.
  • Added: a NetworkManager secret agent raises vpn_secret when an activation needs secrets. Answer it with secure_submit = { capability = "network", action = "vpn_secret", name = request.id .. "/" .. field } fields, or cancel_vpn_secret().
  • Added: workspaces and windows entries have urgent. niri reports it as set. Hyprland sets it on an urgent event and clears it when the window gains focus or closes. It is always false on wlr-foreign-toplevel.
  • Added: a radio capability. mantle.radio.radios lists each rfkill kind with soft_blocked and hard_blocked, and set_blocked(kind, blocked) and set_all_blocked(blocked) write /dev/rfkill.
  • Added: Bluetooth pairing_request has an id and supports "pin_entry" and "passkey_entry". Answer these with secure_submit = { capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }.
  • Added: network.wifi_devices lists Wi-Fi interfaces by name. scan_device, connect_device and disconnect_wifi_device target one. Flat join fields describe that join, while other flat Wi-Fi fields and the old actions use the primary interface.
  • Added: plain textfield supports Ctrl+Z undo and Ctrl+Shift+Z and Ctrl+Y redo, with on_change for restored drafts.
  • Added: plain textfield composes through text-input-v3 where available. Preedit stays local until commit. Secure fields do not use an input method.
  • Added: animate.move eases a matched node from its previous painted position to its new layout position, without moving siblings or running Lua each frame. Give items that can change order a stable id or list key.
  • Added: audio.sinks[] and audio.sources[] report per-channel index, position and percent volume. set_sink_channel_volume and set_source_channel_volume set one channel without changing the others.
  • Added: battery.peripherals lists UPower devices outside the system supply, with charge percentage or coarse level when reported.
  • Added: json.encode(value) returns compact JSON with sorted keys. It raises on functions, userdata, cycles, NaN, infinity, nesting past 127, over 2^20 values, arrays more than half holes and a decoded array given a named key.
  • Added: interval(ms, callback), a repeating timer with timer’s range, cancel and per-evaluation lifetime.
  • Added: process.run and process.detach accept nil for args.
  • Added: accessible_name makes clickable nodes keyboard focusable and names them for screen readers. Tab and Shift+Tab traverse controls, and Enter and Space activate them. Mantle exposes the resolved scene through AT-SPI, with secure field values withheld.
  • Added: the engine outlines a control only when Tab or an assistive-technology action focused it. focus_ring = false turns the outline off. focused(name), bound as a node’s focused, reports focus within a node for custom styles.
  • Added: path commands tween point by point under animate between lists with the same ops and hole flags, keyframes included, so shapes morph without Lua per frame. Any other change still snaps.
  • Added: path A draws a circular arc from centre, radius, start and sweep in degrees. hole = true on a subpath’s first command cuts it out of the fill.
  • Added: a list of up to 4096 numbers in shader and transition params fills a float or vec2-vec4 uniform array, enough for a 256-bar visualizer.
  • Added: idle on_resume receives "input", "activity" or "inhibitor", so a config can wake displays only for input while stopping idle stages for other resumes.
  • Fixed: input regions, text links and pointer cursors follow ancestor transforms. Canvas Frame.transform composes nested transforms.
  • Fixed: a per-edge border_width or border_color on a rounded box, and any border on a corner_shape = "scoop" box, drew as four straight rectangles with square corners. Borders follow the corners, and where two edges meet the colour change sits on the corner in proportion to their widths, as in CSS.
  • Fixed: Bluetooth switches to a present adapter when the tracked adapter is removed.

0.1.0 - 2026-10-01

First release. Mantle is an engine for Wayland desktop shells declared in Lua. It ships no shell of its own beyond the one-clock starter bar.

  • Added: surfaces: panels, popups, windows and lock screens.
  • Added: nodes: rects, rows, columns, text, text fields, images, icons, lists, paths, shaders and capture.
  • Added: capabilities: 24 desktop services, among them audio, network, Bluetooth, notifications, tray, MPRIS, workspaces, idle, updates and secrets.
  • Added: signals update widgets when values change. Saving the config reloads the shell.
  • Added: mantle check catches Lua and layout errors before a run. mantle init sets up LuaLS completion and type checking.
  • Added: packages for Ubuntu 26.04+ (.deb) and Fedora 44+ (.rpm), and an x86_64 tarball (installation).

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.

None open.

Later

Wanted, but each needs a consumer or a decision first.

ItemWhy / what’s leftADR
Greeter (#8)Mantle as a greetd login screen, inside whatever compositor greetd starts for it. Needs a session-launch command—
Output actions (#9)windows has six actions; screens have none. Pick the actions, then settle niri/Hyprland differences and revert0119, 0247
Text field editing (#10, #15)The caret has no stop inside a ligature, and IME needs the field’s surface focused, so a parent-focused popup gets none. The secure field keeps end-only editing and no IME, so an input method never sees the draft0236, 0312
Wayland and input extras (#12, #17, #18)No shortcut inhibition, touch gestures, or cross-app drag and drop—
Window capture backends (#13, #16)capture.window accepts Hyprland windows IDs. Niri needs a toplevel capture source; wlr needs an exact bridge from its connection-local IDs0247, 0248

Won’t do

ItemInsteadADR
Native FFT or audio peak meteringStream Cava output into state and draw it0316
Weather, currency, geolocation, HTTP, sockets or watched file contentsprocess.run streaming curl, socat or tail -f, then json.decode; file.read reads a file once; folders watches folders—
MPRIS remote artworkcurl the player’s art_url into a cache file through process.run and draw that path0036
Generic IPC state read, subscription or per-panel commandsAn action that returns the value, read with mantle call; mantle set and toggle write state0197
Child stdin, cwd or envprocess.run("env", { "-C", dir, "K=V", cmd }); sh -c for one-shot stdin0175, 0188
Per-surface idle inhibitionidle:inhibit while the surface shows, release_inhibit when it hides—
Clipboard capabilityprocess.detach("wl-copy", { text }): a selection needs a process that stays alive to serve it0188
Video encoding or global input captureA recorder or input backend under session_process, streamed in0175
Wallpaper capabilityA "background"-layer panel, an image with async/retain/transition, folders 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—
Deferred surface loaderWayland objects are created when shown; the Lua CPU budget guards one signal resolve, not a whole evaluation0157
Shaders over the desktop behind a surfaceThe compositor owns those pixels. effect.shader reads what this surface painted, a node’s subtree; the input-less shader node and image.transition keep their contracts0184, 0253, 0336
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 every_lua_block_in_the_docsRuns 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 four layout passes as mantle check: no Wayland, no subprocesses, every capability nil, then one push each of the first, alternate and empty sample sets, 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:

rect {
    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 "slide_x" 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. delay, pulse, timer and interval advance with the frame time and fire in order, so a staged sequence plays out. frames=0,50,120 lists them. Each frame shows until the next time and the last for a second; 0@900 holds one for 900 ms, so a loop can rest on the state worth seeing; 0..400/20@20 holds a range’s last frame, so a loop runs on without a pause. Viewers without APNG show the frame held longest
<!-- shot-alt: ... --> immediately above a shot or its frame directiveDescribes the visible result for readers who cannot see the image. Name the state or motion the example demonstrates, not the code syntax
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 table it returns, shaped like fakes, is the next push, so a capability can change mid-shot. 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 payload types in shared/src/state/ and the action types in shared/src/action/. 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/layout/node/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/layout/node/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

Start with the action or example a reader came for. Put prerequisites and implementation detail after it. A reference page can then give property tables; a tutorial can explain the example in small steps. Add How do I… or Gotchas only when they answer questions the page owns. The introduction, sidebar and FAQ already route readers to topics and symptoms, so do not repeat their indexes on every page.

Keep each behavioral fact on one page and link to it elsewhere. End technical pages with a Source: line of code links. 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.

The demo

The README video is recorded by demo/director, a Mantle shell that drives a second, scratch shell on camera. It types each edit into a code pane, saves it, and the scratch shell reloads.

Layout

FileHolds
shell.luaThe storyboard: captions, edits and beats in order, and the runner
takes.luaThe edit list, the modules and assets copied beside the scratch shell, prune
stages/take.tplThe scratch shell’s shell.lua, once, with markers naming the edit that adds each block
stages/*.luaModules the scratch shell requires, some with their own marked blocks
edits.luaTurns one edit into typing: lines matching the edit’s type substrings are typed, the rest pasted
feeds.luaMock data the director feeds through mantle set, shaped like each capability
layout.luaPane, caption and popup geometry from the screen size, shared by both shells
tools/demo-check and its pieces

Markers

A block between --@ <edit> and --@ end appears once <edit> has played. --@ else gives the text shown before it. Several --@ <edit> lines in one block form a cascade: the first played edit wins, so list the latest first. Markers are flat; nesting raises.

--@ 07-wallpaper
local theme = require("theme")
--@ else
local theme = { accent = "#cba6f7" }
--@ end

take.tpl is not valid Lua on its own, so just lua skips it; just demo-check parses and format-checks every snapshot pruned from it.

Changing a beat

  1. Edit take.tpl or a module, add or change the entry in takes.lua, and the beat in shell.lua.
  2. just demo-check: every checkpoint through mantle check at 1920x1080, 1920x1200 and 3440x1440, with and without sample mocks, plus layout fit, at most 120 typed characters and 5 s per edit, and lines of at most 108 columns. It stops no shell.
  3. just preview-beat <from> <to> plays that range on screen and saves a screenshot per beat. It stops your shells for the run and restarts them.
  4. just demo [out] records the take.

The take refuses to record unless an empty workspace is focused, and ends the moment the recorded output shows another workspace or a foreign window. Keep the screen alone while it runs.