antle
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",
},
},
},
}
| Part | What it does |
|---|---|
panel | Places a 32 px bar at the top of each output and reserves that space from windows |
row and the "fill" rect | Put 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 build | Start with |
|---|---|
| A clock that changes on click | Clock bar, then signals |
| An app launcher opened by a key | App launcher, then CLI keybinds |
| A bar with workspace buttons | Workspaces |
| A popup, window or lock screen | Surfaces |
| Your own widget layout | Nodes and paint |
| Help with a shell that shows nothing | FAQ |
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.
| Route | What to do |
|---|---|
| Arch | Install mantle-bin from the AUR for the latest release, or mantle-git to build main. Both install the PAM stack |
| Ubuntu, Fedora | Install 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 tarball | sudo 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 source | cargo 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 development | just 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
| Feature | Needs |
|---|---|
| Surfaces | A Wayland compositor with wlr-layer-shell-v1; ext-session-lock-v1 for lock |
window, popup | xdg-shell; these surfaces are skipped when absent |
capture | ext-image-copy-capture-v1, else wlr-screencopy-v1 |
compositor_blur = true | ext-background-effect-v1; ignored when absent |
| Fonts | fontconfig (fc-match) |
| Editor completion | lua-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.
| Capability | Backend |
|---|---|
notifications, tray | Session bus; no other notification daemon or tray host holding the name |
mpris | Session bus |
appearance | xdg-desktop-portal on the session bus; defaults without it |
network | NetworkManager |
bluetooth | bluetoothd, running before Mantle starts |
audio, privacy | PipeWire, running when the capability starts; it does not reconnect |
battery | UPower |
power | UPower, power-profiles-daemon |
brightness | A /sys/class/backlight device, logind |
keyboard | Read access to the /dev/input keyboard; niri, Hyprland, sway or mango for layouts |
workspaces | niri, Hyprland, sway or mango |
windows | niri, Hyprland, sway or mango, else wlr-foreign-toplevel-management-v1 |
idle | ext-idle-notify-v1, logind |
lock | ext-session-lock-v1, logind, the Mantle PAM stack (system files) |
polkit | polkitd with its helper socket /run/polkit/agent-helper.socket, $XDG_SESSION_ID, no other polkit agent running |
sysinfo | hwmon 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 |
updates | pacman 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:
| Distro | Packages |
|---|---|
| Arch | base-devel clang pipewire pam systemd-libs wayland libxkbcommon libglvnd mesa |
| Fedora | gcc pkgconf-pkg-config clang pipewire-devel pam-devel systemd-devel wayland-devel libxkbcommon-devel mesa-libEGL-devel mesa-libgbm-devel |
| Debian, Ubuntu | build-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.
| File | Install to | Without it |
|---|---|---|
pam.d/mantle | /etc/pam.d/mantle | Unlock 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-fingerprint | The 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
| Trap | Fix |
|---|---|
cargo run -p supervisor runs a stale Renderer and reports the mismatch as a config error | Use just run, which builds both binaries |
| Another notification daemon, tray host or polkit agent is running | Stop 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
| Command | Does |
|---|---|
mantle | Starts the shell in the foreground. Stops on Ctrl-C or SIGTERM |
mantle -d | Starts 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 check | Evaluates 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 list | Prints running shells, oldest first: PID, UPTIME, DIR (the instance directory) and CONFIG |
mantle stop | Sends 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 call | Prints 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 toggle | Prints 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, --version | Prints mantle <version> |
mantle -h, --help | Prints the built-in help |
Flags and the command may come in any order. -V and -h win over anything after them.
Flags
| Flag | With | Does |
|---|---|---|
-c <dir>, --config <dir>, --config=<dir> | Everything except list | The config directory. A path to a file (shell.lua) means its directory, with a notice. A relative path is made absolute |
-d, --detach | Run only | Detached start, as above |
-v, --verbose | Run only | Raises the log level. Repeat or group: -v, -vv, -vvv |
--profile[=SECS] | Run only | Logs 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 |
--force | init only | Overwrites .luarc.json and shell.lua |
-f, --follow | log only | Follows the log until its shell exits |
--pid <pid>, --pid=<pid> | set, toggle, call, input, log, stop | Addresses 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:
| Flags | Prints |
|---|---|
| none | Errors, warnings, and start, reload, respawn and stop notices |
-v | Also info |
-vv | Also debug |
-vvv | Also 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
| Variable | Read by | Effect |
|---|---|---|
MANTLE_CONFIG_DIR | Every command | The config directory, below -c in precedence |
XDG_CONFIG_HOME, HOME | Every command | The default config directory, $XDG_CONFIG_HOME/mantle or ~/.config/mantle |
XDG_RUNTIME_DIR | Run, list, log, set, toggle, call, stop | Required. Instance directories live under $XDG_RUNTIME_DIR/mantle/ |
XDG_DATA_HOME | init | Where stubs go when no package provides them. Default ~/.local/share |
MANTLE_LOG | Run | Log filter, as above. Overrides the -v level |
MANTLE_DUMP_LAYOUT=<instance> | Run, with -vvv | Logs every visible node’s kind, rect and text on that surface instance (bar@eDP-1) after each layout pass |
RUST_BACKTRACE=1 | Run | Adds a backtrace to a logged panic |
__EGL_VENDOR_LIBRARY_DIRS, __EGL_VENDOR_LIBRARY_FILENAMES | Run | Your 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
| Binary | Role |
|---|---|
mantle | The Supervisor (the long-lived process that owns backends and restarts the Renderer) and every command on this page |
mantle-renderer | The 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.
| Order | Source |
|---|---|
| 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 | -c | Neither |
|---|---|---|---|
set, toggle, call, input | That running shell | The newest running shell on that config, else an error | The newest running shell on the default config, else the newest running shell of any config |
log | That shell, running or stopped | The newest running shell on that config, else its last stopped run | The 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.
| Typed | Arrives in Lua as |
|---|---|
true, false | boolean |
3, -5, 0.1 | number. A value starting with - is a value, not a flag |
notifications | string: not JSON, so taken as is |
'"true"', '"3"' | string, because the JSON quotes survive the shell’s |
'[1,2]', '{"a":1}' | table |
null | nil |
call passes any number of arguments to the handler in order. Its output:
| Handler returns | mantle call prints | Exit |
|---|---|---|
nil or nothing | Nothing | 0 |
| A string | The string, unquoted | 0 |
| Any other value | JSON | 0 |
| Raises, blows the Lua CPU budget, is not declared, or returns over 1 MiB | `name` failed: <reason> on stderr | 1 |
| No answer within 5 s | A timeout message. The call may still have run | 1 |
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:
| Pass | Capabilities read | Catches |
|---|---|---|
before capability data | nil, as at start before the first push | A :get() that forgets the nil case |
with sample capability data | One 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 variant | Typos and bad properties in a list each or a branch that only shows with data |
with alternate sample data | The same push with every boolean false and every enum its last variant | A branch taken only on false or a later variant, such as unmuted audio or a launcher that drops no_display entries |
with empty sample lists | The first push with every list and map empty | An 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>
| Caught | Not caught |
|---|---|
| Lua syntax errors, in any required module | Handler 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 modules | Branches 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 value | process.run output: commands are queued and never run |
| Surface and node properties: unknown names, wrong value types, bad colours, out-of-range sizes | Fonts, images, shaders and the compositor’s response |
Errors in :map, computed, list eachs and function child builders, with nil and with sample capabilities | Sizes 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.
| Verb | Sends |
|---|---|
move X Y | Pointer 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 |
leave | The 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 DY | DY 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
locksurface; - a press or release over a
secure_submitfield or asubmits_secure = truenode, and anykeyortypewhile asecure_submitfield holds focus, including the real keyboard’s; ctrl+candctrl+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
ctrlandshift.
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
| Code | When |
|---|---|
| 0 | Success. For set and toggle: the shell applied the write |
| 1 | The 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 |
| 2 | Bad 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
| Trap | Fix |
|---|---|
mantle set label true stores a boolean, mantle set count 3 a number | Quote JSON strings: mantle set label '"true"' |
mantle toggle modal is refused on a string state | A bare toggle needs a boolean. Pass the value: mantle toggle modal settings |
mantle call x says no action exists after a broken save | A Lua error in the config clears actions until an evaluation succeeds. Fix the config and save (runtime) |
mantle -c dir list is refused | list shows every config’s shells; drop -c |
mantle log -f exits at once | That shell has stopped. The command printed its last run |
XDG_RUNTIME_DIR is not set | The 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
| Step | Command | Touches the session? |
|---|---|---|
| 1. Discover | mantle call, mantle set, mantle list | Reads only |
| 2. Extend | Edit shell.lua | No |
| 3. Validate | mantle check | No: no Wayland, no GPU |
| 4. Apply | Save; mantle log -f | Yes: live reload |
| 5. Drive | mantle call, toggle, set, input | Yes |
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
| Goal | Command |
|---|---|
| Run an action | mantle call clicks.bump 3 |
| Flip or set a state | mantle toggle launcher_open, mantle set modal settings |
| Click | mantle input counter click 40 16 |
| Drag, scroll | mantle input bar drag 10 10 90 10, mantle input bar wheel 40 16 -3 |
| Keyboard | mantle 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.
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.
| Recipe | Builds | New since the one above |
|---|---|---|
| Clock bar | A top bar with a centred clock that toggles to the date on click, with a tooltip | computed, named state, a hover tooltip |
| Battery indicator | A bar pill with charge icon, percentage, time-left tooltip and a low-battery warning | on_change, process.detach |
| Volume OSD | A card that slides in with an icon and level bar whenever the volume changes | pulse, animate with from, output = "active" |
| Workspaces | Per-monitor workspace pills on every supported compositor, with wheel switching and Hyprland special workspaces | Per-output child, keyed list, capability actions |
| Lock screen | A per-monitor lock screen with clock, password field, error hint, fade and idle lock | lock, a secure field, an idle threshold |
| Power menu | A full-screen menu to lock, suspend, log out, restart or power off, with confirmation | A full-screen overlay closed by an outside click |
| Notification popups | A corner stack of notification cards with formatted bodies, action buttons and dismiss | Text runs, animate.exit, nested buttons |
| App launcher | A search overlay that fuzzy-ranks installed apps, with keyboard selection | textfield, fuzzy, scroll:reveal |
| System tray with menu | Tray icons with click, wheel and a right-click dropdown menu with submenus | A grabbing popup, a flattened menu tree |
| Media player | A now-playing pill and a card with cover art, a seekable progress bar and controls | Extrapolated 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.clockpushes the time once a second, andos.dateformats it (clock).computedcombines 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). hoverandhover_rectdrive a non-grabbing popup as a tooltip (hover).
Variations
| Change | Edit |
|---|---|
| Seconds | "%H:%M:%S" |
| 12-hour clock | "%I:%M %p" |
| Clock on the right | Drop the second spacer: children = { rect { width = "fill" }, clock } |
| One monitor only | output = "DP-1" on the panel |
| Bottom bar | anchor = { 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.batteryreadspresent = falseon a desktop, and a map givesbeforefor the time before its first push (battery).statenames UPower’s charge state;time_to_emptyandtime_to_fullare optional and need their own guard.- The icon is picked by name from the icon theme and tinted with
foreground(icon). on_changecompares with the previous push to fire only on the downward crossing (on_change);process.detachrunsnotify-send(process.detach).- The tooltip is a non-grabbing popup anchored to
hover_rect(hover).
Variations
| Change | Edit |
|---|---|
| Charge as a bar instead of an icon | A 24 × 10 rect track with a child rect { width = battery.percent .. "%", height = "fill" } (sizes) |
| Cycle the power profile on click | Make 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 wattage | Add text { content = mantle.power:map(function(power) return power.energy_rate and string.format("%.1f W", power.energy_rate) or "" end, "") } |
| Different threshold | LOW = 20 |
| Hide the pill on mains at full charge | visible 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_changereacts to each audio push and follows theactivesink. 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;pulsereadstruefor a while after each change (pulse). - A longer second
pulsekeeps 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);translateandopacityanimate without re-laying out (animation). - The glyph comes from the icon theme by name (icon).
Variations
| Change | Edit |
|---|---|
| Brightness too | A 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 screen | anchor = { top = true }, margin = { top = 80 } and from = { y = -12 } |
| Every monitor | Remove output = "active" |
| Longer on screen | pulse(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[].namematches that connector;active_workspaceis theidshown there (workspaces).- The
listrebuilds its buttons from the mapped array, andkeykeeps each button’s tweens when workspaces come and go (list). - Labels draw
number(orname) and clicks sendid: ids are opaque strings (workspaces gotchas). on_wheelon the outerrectreads the live state with:get()inside the handler (pointer).- The
widthtween makes the active pill grow in place (animation).
Variations
| Change | Edit |
|---|---|
| Always show workspaces 1 to 5 on Hyprland | Pad items with { id = tostring(n), label = tostring(n), active = false, populated = false } for missing numbers; focus creates them |
| App icons instead of numbers | Carry 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 workspaces | label = workspace.name or tostring(workspace.number), with min_width and side padding instead of a fixed width |
| Dots only | Drop the text and set width = item.active and 20 or 8, height = 8 |
| Show the focused window’s title | Add text { content = mantle.workspaces:map(function(workspaces) return workspaces.active_client and workspaces.active_client.title or "" end, ""), elide = "end", max_width = 400 } |
| Vertical bar | Anchor 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
lockdoes not lock;mantle.lock:lock()does, and only a correct password in the one secure field unlocks (lock surface, lock capability). secure_submitsends keystrokes straight to PAM; Lua never sees the password, so the field has noon_changeoron_submit(secure fields).child = function(output)gives every monitor its own copy (per-output child).set_unlock_animationkeeps the lock up forFADE_MSafter success, whileunlockingfades the card out (animation).- PAM starts when the session locks. The hint shows its latest
messagesline (a faillock countdown, say) or else itsprompttext, anderror,attemptsandauthenticatingdrive the rest of the hint and the red border. maskedfollowsprompt.echo, so a two-factor code shows as typed while a password stays masked. Either way it never reaches Lua (lock capability).actionexposesmantle call lockto a keybind, and an idle threshold locks after 300 s without input (action, idle).
Variations
| Change | Edit |
|---|---|
| Wallpaper behind it | Wrap 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 wallpaper | Add source_blur = 24 to that image (blurs) |
| Unlock button | A clickable node with submits_secure = true beside the field sends it like Enter (pointer) |
| Fingerprint hint | In 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 only | visible = output == "DP-1" on the clock texts |
| Lock before suspend | An 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.detachrunssystemctland 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’compositorfield (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 ormantle toggleclosed 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
rectunder the row closes the menu; the row is declared after it, so it is on top (close an overlay). hoverdrives the tint and ascaletween, which does not re-lay out the row (hover, animation).
Variations
| Change | Edit |
|---|---|
| No confirmation | Remove confirm = true from the entries |
| Hibernate | Add { key = "hibernate", label = "Hibernate", glyph = "drive-harddisk-symbolic", confirm = true, run = function() process.detach("systemctl", { "hibernate" }) end } |
| Hyprland with a Lua config | process.detach("hyprctl", { "dispatch", "hl.dsp.exit()" }) |
| Vertical list | column instead of row, and width = 240, height = 56 on each button with a row inside |
| Open from a keybind | Bind 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
feedis the newest 20, expired ones included; the map keeps the live ones and caps them (notifications).dndonly mutes sounds, so hiding popups during it is the config’s filter; critical ones still show.keybyidkeeps each card’s node when a newer one arrives above it, so only the new one animates in whileanimate.moveslides the older ones down; a dismissed card fades out throughanimate.exit, except the last one, which leaves with its hidden panel (identity, move, exit).- A body’s text spans pass to
textas runs unchanged;on_linkhands a clickedhreftoopen_url(text runs, applications). - The × is a clickable
rectinside the clickable card: the innermost one with a handler takes the click (pointer). on_hoveron the stack callshold_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
| Change | Edit |
|---|---|
| Play sounds | Once at top level: mantle.notifications:set_sound("normal", "/usr/share/sounds/freedesktop/stereo/message.oga") |
| Bottom-right corner | anchor = { bottom = true, right = true }, margin = { bottom = 8, right = 8 } |
| Hide everything under do-not-disturb | local quiet = notifications.dnd |
| Relative time | Add text { content = mantle.clock:map(function(clock) return math.floor((clock.time - item.timestamp) / 60) .. " min ago" end, "") } |
| Mute one app | mantle.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.entriesincludes desktop entries used for window icon lookup; the launcher omits entries withno_display = true. It follows installs and removals;launchtakes an entry’sidand runs it detached (applications).fuzzyscores one candidate; ranking, the tiebreak and the cap stay in Lua (fuzzy).computedjoins the capability with the query (derived signals).- Each row derives its own highlight from
matchesandselected. An arrow key reruns those 50 small functions and builds no row; folding the selection intoitemswould calleachfor all 50 on every keypress (when items rebuild). - The
textfieldowns the typed text and reports it throughon_change;on_keygets the arrow and Tab keys the field does not use (textfield, key handlers). scroll(name):reveal(index)keeps the selected row in view inside themax_heightlist (scroll, list).reset_on_closereturns the query, the selection and the scroll to their start whenever the launcher closes, including bymantle toggle(reset on close)."exclusive"hands the panel the keyboard when it maps, andautofocusgives it to the field (keyboard focus).- A full-size transparent
rectunder the card closes it on an outside click (close an overlay).
Variations
| Change | Edit |
|---|---|
| Pointer focus on Hyprland | "on_demand" instead of "exclusive", so other surfaces keep taking clicks (panel gotchas) |
| Fewer rows | MAX_RESULTS = 8 and drop max_height |
| Debounce typing | Rank against delay(query, 80) instead of query (debounce a search) |
| No dimmed backdrop | Remove 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.itemsarrive in registration order with their whole DBusMenu tree inmenu(tray).on_clickgets the button’s rect and the mouse button; the rect goes straight into the popup’sanchor_rect(pointer, popup).- The popup grabs the pointer, so an outside click dismisses it and
on_dismissclears the state (dismissal). menu_will_shownotifies apps when a menu opens (id0) or a submenu expands (entry.id), letting apps that build items lazily populate them.- Right-clicking an item with no DBusMenu calls
context_menuat 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
| Change | Edit |
|---|---|
Show hidden ("passive") items | Return tray.items from the source map |
| Tooltips | Give each button hover = hover("tray_" .. item.id) and add a grab = false popup showing item.tooltip (tooltip) |
| Bigger icons | size = 20 and width = 20, height = 20 |
| Open the menu rightwards, for a tray on the left | gravity = "bottom_right" |
| Attention badge | Stack 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
playersis in registration order; the map prefers one that is playing and keeps it selected while paused (mpris).positionis a snapshot, not polled.on_changestamps each new report withmantle.clock:now(), and acomputedadds the seconds since, from the tickingmonotonicfield, clamped at 0 (clock, derived signals).- The fill is a
"NN%"width in a rounded, clipped track;on_dragon the track seeks on release (pointer, clip). album_art_pathis an existing local path or"". A remote cover arrives only asart_url; fetch it into a cache file withcurlthroughprocess.runand draw that path. Animagewithsource = ""draws nothing over the placeholderrect(image).- The card is a grabbing popup anchored to the pill’s click rect; it also closes when the last player quits.
Variations
| Change | Edit |
|---|---|
| Always the first player | pick returns mpris and mpris.players[1] |
| Seek 10 s back and forward | Two more buttons whose on_click calls seek_relative with the player’s id and -10000000 or 10000000 |
| Show the app icon | icon { name = current.desktop_entry } from the player’s desktop_entry |
| Hide browsers | Skip 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 bar | Put 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.
| Library | Look | Module | Needs |
|---|---|---|---|
| mantle-glass | Liquid Glass | glass | Mantle built from main after 0.4.0 |
| mantle-material | Material 3 Expressive | m3 | Mantle 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).
| Library | Available | Missing |
|---|---|---|
| Base | assert, 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 it | dofile, loadfile |
coroutine | close, create, isyieldable, resume, running, status, wrap, yield | None |
string | byte, char, dump, find, format, gmatch, gsub, len, lower, match, pack, packsize, rep, reverse, sub, unpack, upper. Strings have the usual ("x"):upper() metatable | None |
table | concat, insert, move, pack, remove, sort, unpack | None |
math | abs, 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, tanh | None |
utf8 | char, charpattern, codepoint, codes, len, offset | None |
package | config, cpath (unused), loaded, path (the config directory only), preload, searchers (the Lua file searcher only), searchpath | loadlib exists but raises. C modules never load |
os | clock, date, getenv, time. require("os") returns the same four | difftime, execute, exit, remove, rename, setlocale, tmpname |
io | Nothing | The whole library. require("io") fails too |
debug | Nothing | The whole library. debug.traceback included |
| FFI, native modules | Nothing | All |
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.
| Global | Kind | Owning page |
|---|---|---|
panel, window, popup, lock | Surface constructors | surfaces |
rect, row, column, text, icon, image, capture, shader, list, textfield | Node constructors | nodes |
state, computed, delay, pulse, geometry | Signal constructors | signals |
hover, hover_rect, scroll, focus_target | Input signals and the textfield focus handle | input |
mantle | Capabilities and renderer members | capabilities |
process, session_process | Processes | processes |
persistent_table, timer, action, json, log, fuzzy, palette, fonts | Scripting | scripting |
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.
| Rule | Detail |
|---|---|
| Search path | <config>/?.lua;<config>/?/init.lua, nothing else. No system Lua paths, no ./ |
| Names | Each . in a module name is a /: require("widgets.clock") loads widgets/clock.lua. require("widgets") also finds widgets/init.lua |
| Cache | package.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 value | Lua 5.4’s require returns the module and its file path. In the last position of a table constructor both land in the table |
| Symlinks | A 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.
| Term | Meaning |
|---|---|
| Evaluation | One run of shell.lua and whatever it requires. Its top level has no CPU budget |
| Reload | An evaluation in the same VM, triggered by a saved file or an output change, then one apply to the live scene |
| Apply | The engine reconciles the new surface list with the scene on screen. A surface whose fingerprint changed is rebuilt; everything else updates in place |
| Generation | One 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:
| Event | Reloads? |
|---|---|
A .lua or .frag file anywhere under the config directory is written, created, renamed in or deleted | Yes, 200 ms after the last event of a burst |
| A save with the same bytes as the last one the watcher saw for that file | No |
Any other extension (.json, images, editor swap files) | No |
| A new subdirectory | Watched 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:
| Failure | Result |
|---|---|
| Startup evaluation raises | No 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 invalid | The 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 invalid | The 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 it | mantle.rescue becomes { is_rescue = true, error_log = "<the reason>" }. The error is logged (lock) |
| A reload would recreate the lock surface while locked | Refused with a warning and mantle.rescue; save again after unlocking |
| The Renderer crashes | The Supervisor starts a new generation. After three crashes within 60 s, it waits 30 s before the next respawn |
| The compositor goes away | The 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):
| Failure | Registered afterwards |
|---|---|
| Evaluation (a Lua error) | Nothing |
| Apply | The 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
| Thing | In-place reload | New generation (crash) | Shell stops |
|---|---|---|---|
state, hover, hover_rect, scroll, geometry values (by name) | Kept. A changed scalar state seed re-seeds (named state) | Lost | Lost |
| Plain Lua globals the config assigns | Kept: same VM | Lost | Lost |
Config modules in package.loaded | Dropped, re-required | Lost | Lost |
Derived signals (:map, computed, delay, pulse) | Rebuilt. A pending delay or open pulse resets | Rebuilt | Lost |
persistent_table (by file) | Same table | Same file, new table | On disk |
session_process (by name) | Keeps running, same table | Keeps running (the Supervisor holds it) | Stopped |
process.run child | Killed with its process group, failed reloads included. Its on_exit(nil) runs before the new evaluation; no on_line follows | Killed with its process group | Killed |
process.detach program | Unaffected | Unaffected | Unaffected |
timer, interval | Cleared. The new evaluation’s timers start when its result is applied | Cleared | Gone |
action, mantle.<cap>:on_change, state:on_change, a derived :on_change | Cleared, re-registered by the new evaluation | Cleared | Gone |
mantle.idle thresholds | Cleared, re-registered | Cleared | Gone |
fonts { ... } chain | Not re-read | Re-read | Gone |
Capability state (mantle.<cap>) | Unchanged | Replayed from the Supervisor’s last snapshot | Gone |
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.
| Limit | Value | Applies to | When exceeded |
|---|---|---|---|
| CPU limit | 50 ms of thread CPU time | Each :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer or interval callback. Nested reads share the outermost deadline | The call raises exceeded the 50ms CPU limit for one evaluation; a runaway loop?. pcall inside the callback does not hide it |
| Frame target | 2.5 ms of thread CPU time | The same evaluations | Nothing 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 nesting | 32 levels | Signal 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 pass | 2 s | One whole pass over the scene, including list eachs and function child builders | The pass fails and every surface keeps its last applied tree |
| Tree depth | 64 levels | Nested nodes in one surface | The pass fails |
| Scalar values | Numbers finite, integers within ±(2^53 − 1), strings at most 64 KiB | state seeds, :set(), mantle set, and number or string node properties. Tables are not checked | state 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 length | 10,000 | children of one node, items of one list (items, and limit is clamped to it), runs in one text content | The pass fails; a startup or live pass gives a content its default instead |
delay, pulse duration | [1, 60000] ms | delay(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 answer | 1 MiB of JSON | What an action handler returns | The mantle call fails |
mantle call wait | 5 s | The CLI waiting for an answer | The CLI gives up. The handler may still have run |
| Process output line | 64 KiB | One line a process.run child writes | The line arrives cut; its tail is dropped |
| Reload debounce | 200 ms after the last file event | The watcher | A burst of saves reloads once |
| Respawn brake | 3 Renderer deaths within 60 s | The Supervisor | The 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
| Call | Goes 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 idle | A warning |
A process.run or process.detach that cannot spawn, a failing mantle call | A warning |
| An icon name no theme has, an image that does not decode | A 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…
| Task | Answer |
|---|---|
| Find out why a reload did nothing | Debug a reload |
| Split a config into files | Put modules beside shell.lua and bind each require to a local, as in the example at the top |
| Share values between modules | Share values |
| Run something once, not on every reload | Run once |
| Keep a program running across reloads | session_process. A reload kills every process.run child |
| Do heavy work without missing the 2.5 ms frame target | Build 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.lua | mantle.config_dir .. "/shaders/wave.frag". os.getenv("HOME") and the rest of the shell’s environment work too |
| Guard code that needs a newer engine | Compare mantle.version.major, .minor and .patch (renderer members) |
See what print wrote | mantle log, or mantle check, which prints it above its report when the config evaluates |
Find out why a reload did nothing
| Step | Command | Tells you |
|---|---|---|
| 1 | mantle check | Syntax 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) |
| 2 | mantle log | shell.lua re-evaluation failed (evaluation error) or the re-evaluated config failed to apply (layout error, previous scene kept), and errors raised in callbacks |
| 3 | Draw mantle.rescue | The 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
| Trap | Fix |
|---|---|
return { require("a"), require("b") } fails with surface 3 is a string | Bind each module to a local first |
require("lib.json") from a luarocks install is not found | Only the config directory is searched. Copy the pure-Lua module into it |
A map raises exceeded the 50ms CPU limit | A 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 target | The named signal is slow but still works. Index and format inside it, and move the rest out |
dofile or loadfile is nil | Use require for Lua modules; read other files through process.run or persistent_table for JSON |
| A global counter keeps growing across reloads | Globals 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 exists | A 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 nothing | The font chain is read when the Renderer starts. Restart the shell |
Saving a .json or an image beside shell.lua does not reload | Only .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 value | Behaviour |
|---|---|
sig | Read 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.
| Rule | Detail |
|---|---|
| Identity | One name, one signal. hover, pointer, scroll, geometry, elided, settled and image_status names are separate namespaces |
| Reload | Keeps its value across in-place reloads. Lost when the Renderer process is replaced (a crash respawn or a shell restart) |
| Changed seed | A 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 seed | Never re-seeds: tables compare by identity, so a fresh table cannot count as a change |
| Types | Not checked at runtime; initial is the type LuaLS infers |
| CLI | mantle 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 closes | Keeps 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) } },
}
| Rule | Detail |
|---|---|
| When | After the write’s callback returns, before the next layout pass, so the pass paints what the handler wrote |
| Change only | A 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 |
| Chains | A 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 |
| Failure | Each handler runs under the CPU budget. A raise is logged; the value stays written and the other handlers still run |
| Reload | Handlers 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 |
| Kinds | State, 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:
state | When |
|---|---|
"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.
| Event | Re-resolves |
|---|---|
:set, a capability push, a hover or scroll change | Instances that read that signal in their last resolve |
The same, under a map or computed | Instances that read it, once its result changed |
| A write to a signal no instance reads | Nothing |
A delay coming due or a pulse window closing | Every instance |
A geometry rect moving | One follow-up pass over the instances that read it |
A text’s elided result changing, or a node’s settled flipping | One follow-up pass over the instances that read it |
An image’s image_status changing | One follow-up pass over the instances that read it |
A wheel over a container whose scroll signal nothing else reads | Nothing: its children move where they are |
| Any write while the session is locked | Every instance |
| A reload, or a re-resolve that failed | Every 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).
| Change | The 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 function | Kept until a signal it read changes | Instead |
|---|---|---|
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 pulse | Nothing: 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
| Expression | Returns | Contract |
|---|---|---|
sig:get() | value | The current value, read once. nil before a capability’s first push (why) |
sig:map(fn) | signal | fn(value), run again when read, at most once per layout pass. Works on capabilities |
sig:set(value) | nothing | State signals only; see who writes each kind |
sig:reveal(index) | nothing | scroll signals only; scrolls the index-th child into view (input) |
sig:scroll_to(px), sig:scroll_by(px) | nothing | scroll signals only; moves to an offset, or by a distance from where it is headed (input) |
sig:on_change(fn) | nothing | State, :map, computed, persistent_table keys and capabilities; fn(current, previous) after each change (state, capabilities) |
cap:<action>(...) | nothing | Capabilities only (capabilities) |
computed({ a, b, ... }, fn, before?) | signal | fn(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 signal | Writable named state, written with :set(value) |
delay(sig, ms) | signal | sig’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 signal | true for ms after sig changes, false otherwise. A change inside the window restarts it. Starts false |
pointer(name) | { x, y } or nil signal | Bind 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 signal | Bind 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 signal | Bind it as a text node’s elided. Reports content removed by elide or max_lines; false until measured |
settled(name) | boolean signal | Bind 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? } signal | Bind 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.
| Kind | Made by | :set | :reveal | Written by |
|---|---|---|---|---|
| State | state(name, initial) | ✓ | The config, mantle set, mantle toggle | |
| Capability | mantle.<name> | The capability’s snapshot pushes | ||
| Derived | :map, computed, delay, pulse | Nobody: recomputed on read | ||
| Stored | A persistent_table key (scripting) | The table’s own :set(key, value) | ||
| Geometry | geometry(name) | Layout | ||
| Elision | elided(name) | Text fitting | ||
| Settled | settled(name) | Layout, tween ticks and scroll runs | ||
| Image status | image_status(name) | Layout and paint | ||
| Hover | hover(name), hover_rect(name) (input) | The pointer | ||
| Pointer | pointer(name) (input) | The pointer, while read | ||
| Scroll | scroll(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 with | Cause |
|---|---|
computed() dependency 2 is nil | That computed list entry is nil, often a misspelled variable |
computed() dependencies: key | The 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 pulse | The first argument is not a signal or capability |
delay: ms must be within [1, 60000] / pulse: ms must be within | ms 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 boundary | NaN, infinity, an integer past ±(2^53−1) or a string over 64 KiB |
state("name", ...) refused its initial value | The 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 levels | A derived chain deeper than 32, or one that reads itself |
exceeded the 50ms CPU limit for one evaluation | A map or computed body ran away (runtime) |
a Signal resolved to another Signal | A map returned a signal; return a plain value |
`x` is a Signal handle, not a plain value | A signal in a structural property, or inside a table a signal returned (see gotchas) |
How do I…
| Task | Answer |
|---|---|
| Open a dropdown under a button | Dismissal |
| Switch tabs | Switching views with ids |
| Keep a toggle across shell restarts | Named state is lost with the Renderer; use persistent_table (scripting) |
| Run a side effect when a capability or a state changes | on_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 }
Debounce a search
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
| Trap | Fix |
|---|---|
content = sig:get() never updates | Pass sig or sig:map(...); :get() is a snapshot |
:get() errors with attempt to index a nil value at startup | The 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 hydration | The signal holds nil until the first push, and visible defaults to true; pass before: cap:map(fn, false) |
`margin.left` is a Signal handle | The 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 Signal | Resolution 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 refused | These are structural and take plain values only (surfaces) |
| A named state resets on every reload | Its scalar seed changed between evaluations. Keep it stable |
state("x", ...) is declared twice in this evaluation | Two 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 push | Payloads are tables, which never compare ==. Delay or pulse a scalar derived with :map |
Hiding a view with visible = false keeps its whole subtree | Switch views through children = sig:map(...) |
A :set inside a map or computed | Maps must be side-effect free; write state from on_click, on_change or a timer |
A clock from os.date() alone stops updating | Nothing 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 building | Role | Protocol | Instances |
|---|---|---|---|
| Bar, dock, wallpaper, OSD, launcher overlay, notification stack | panel | zwlr_layer_surface_v1 | One per matched output, id id@output; one with the bare id id for output = "active" |
| Settings window, dialog the user can move, tile or close | window | xdg_toplevel | One, id id |
| Dropdown, context menu, tooltip hanging off a panel or window | popup | xdg_popup | One, id id |
| Lock screen | lock | ext_session_lock_surface_v1 | One 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:
| Property | On a surface root |
|---|---|
width, height | Role-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_height | Bound the root’s size: they cap a content-sized or spanned panel, a popup, and a window or lock root |
margin | A panel’s offset from its anchored edges. Refused on the other roles |
align_h, align_v | Refused: the root sits at the surface’s origin |
| Everything else | As 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.
| Rule | Behaviour |
|---|---|
| Return value | One surface, a list of them, {} or nothing. Any other top-level node is refused |
| Matching | A surface whose fingerprint is unchanged keeps its Wayland objects; a missing one is destroyed; a new one is created |
| Fingerprint | panel: 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 fields | id, 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 fields | Everything else takes a signal and updates the existing object in place |
| Invalid live value | A signal that resolves to a bad value logs a warning and keeps the last applied spec |
| Hotplug | An output change re-evaluates the config and adds or removes panel and lock instances; instances on other outputs keep their objects |
| Count | Any 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 },
}
| Rule | Detail |
|---|---|
| Takes | state(...) and scroll(...) handles. A map, a capability, another signal kind or a plain value fails the evaluation, naming the surface and the entry |
| Closes | visible turns false, a reload removes the surface, its last output goes away, or (a popup) its parent closes |
| Does not close | A surface never shown, a failed reload, a reload that rebuilds it under the same id, being covered, losing the keyboard |
| Per-output panel | Resets when its last instance closes |
| Resets to | The initial the evaluation on screen declares, the value mantle toggle returns to; a scroll to offset 0 |
| Timing | The turn the surface is unmapped, after it is gone, so the closed surface never paints the reset value |
| Write | An 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 root | Claims input |
|---|---|
A box (rect, row, column) with a background or a non-zero border_width | Its whole box, painted bounds under its own transform; a rotated or skewed box claims its rotated shape. #00000000 counts |
text, icon, image, capture, textfield | Its box, rotated with it |
Any node with on_click, on_double_click, on_drag, on_wheel or submits_secure = true | Its box, even with nothing painted |
A shader | Nothing; 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 command | Its whole box, not the drawn shape. Otherwise nothing; give it a handler for a hit area |
A box’s mask child | Nothing, nor its subtree |
| A transparent container | Nothing; its children are asked instead |
| The surface root itself | Nothing, even with a background; with a handler, the whole surface |
Anything on a layer = "background" panel | Only 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…
| Task | Answer |
|---|---|
| Pick a role | The table at the top |
| Put a bar on every monitor | The example at the top |
| Show and hide a surface | Bind visible to named state, then mantle toggle <name> |
| Start a surface fresh each time it opens | Reset on close |
| Keep different state per monitor | Per-output child |
| See which surfaces a config declares | mantle check -c <dir> prints each role and id (CLI) |
| Let clicks through the empty part of a surface | Nothing to do; see input region |
| Make a transparent area catch clicks | A full-size rect with on_click (input region) |
| Change a panel’s layer or anchors at run time | Declare two panels and toggle their visible, or edit the file |
Gotchas
| Trap | Fix |
|---|---|
layer = state(...) or a signal anchor is refused | Structural 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 it | The 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 id | Surface ids are unique across every role; rename one |
A function child on a window or popup is refused | Only 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).
| Property | Type | Default | Behaviour |
|---|---|---|---|
id | string | Required | The 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" | Required | Stacking level, bottom to top. "overlay" draws over fullscreen windows |
anchor | { top?: boolean, bottom?: boolean, left?: boolean, right?: boolean } | All false | Edges to pin to; an absent edge is false. None pinned centres the surface; one edge centres it along that edge |
output | string | "all" | A connector name, "all" or "active": which outputs get an instance (output) |
namespace | string | "mantle-{id}" | The layer namespace compositor rules match (Hyprland layerrule, niri layer-rule) |
width | Length|Bound, [0, 8192] | Content | The surface’s size (size) |
height | Length|Bound, [0, 8192] | Content | The surface’s size (size) |
exclusive_zone | boolean|integer|"ignore"|Bound | false | The space reserved from other windows (exclusive zones) |
keyboard_interactivity | "none"|"on_demand"|"exclusive"|Bound | "none" | Whether it takes the keyboard (keyboard focus) |
margin | number|Edges|Bound | 0 | Offset from the anchored edges, not layout margin; one on an edge the panel is not anchored to does nothing |
visible | boolean|Bound | true | Hiding destroys the layer surface; showing recreates it |
child | Node|fun(output: string): Node?|Bound | None | The root’s content. A function runs per output instance with its connector name; nil leaves that instance empty (per-output child) |
on_escape | fun() | None | Escape 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
| Value | Instances | Notes |
|---|---|---|
"all" | One per output: bar@DP-1, bar@HDMI-A-1 | Follows hotplug |
"DP-1" | One, bar@DP-1, while that output is connected | An unknown connector logs a warning and creates nothing |
"active" | One, bare id bar, on the output the compositor picks | Picked 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 axis | Both edges of that axis anchored | One or no edge anchored |
|---|---|---|
| Omitted | The compositor’s span; the root fills it too | Measured from the content |
"fill" | The compositor’s span | Protocol error. The panel stays hidden with a warning, or keeps its previous size on a live change |
px or "NN%" | That size | That 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_zone | Reserves | Covers others’ zones |
|---|---|---|
false | Nothing | No, stays inside them |
true | The 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 0 | No |
| Positive integer | That many px whatever the surface’s size; for a tall surface whose top strip is the bar | No |
"ignore" | Nothing | Yes |
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.
| Mode | Behaviour |
|---|---|
"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 behaviour | What 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 mapped | The 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 changes | Raise 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…
| Task | Answer |
|---|---|
| Put a bar on every monitor | The example at the top |
| Put a bar or dock on one monitor | Dock on one output |
| Open a launcher overlay that takes the keyboard | Keyboard focus |
| Close an overlay when the user clicks outside its card | Close an overlay on an outside click |
| Show a volume or brightness OSD | OSD |
| Draw a wallpaper per output | Per-output content |
| Stack cards in a screen corner | Corner stack |
| Draw over fullscreen windows | layer = "overlay" |
| Hide the bar from a keybind | visible = state("bar_visible", true), then mantle toggle bar_visible |
| Reserve only the bar’s strip of a taller surface | exclusive_zone = 32 (exclusive zones) |
| Match the panel in compositor rules | mantle-{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
| Trap | Fix |
|---|---|
| A panel anchored to both sides needs a content-sized background | Put 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 log | Anchor both edges of that axis, or give a size |
height = "50%" or a function child on an output = "active" panel is refused | Use "fill" with anchors and margins, or px |
exclusive_zone = true on a corner-anchored panel reserves nothing | Anchor one edge, alone or with both perpendicular edges, or give a px count |
exclusive_zone = 0, -1 or 32.5 is refused | false, "ignore", or a whole px count |
margin = { top = 8 } on a bottom-anchored panel does nothing | The offset applies only to anchored edges |
| Clicks on the bar’s empty background reach the window below | The 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 mapped | Use "on_demand" unless the panel must hold every key; it still takes focus when it maps |
A panel on output = "HDMI-A-1" never appears | The 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.
| Property | Type | Default | Behaviour |
|---|---|---|---|
id | string | Required | The 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 |
title | string|Bound | "" | The window title |
app_id | string|Bound | "mantle-{id}" | What compositor window rules match |
min_size | { width: number, height: number }|Bound, [0, 8192] | None | Advisory 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] | None | Advisory, 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_inset | number|Edges|Bound, [0, 256] | 0 | Room 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_request | fun() | None | The user asked to close. The window stays open until the config sets visible = false; without a handler a close request does nothing |
visible | boolean|Bound | true | Opens and closes the window; state and id survive |
width | Length|Bound, [0, 8192] | Fill the window | The root’s size inside the window, not the window’s (size) |
height | Length|Bound, [0, 8192] | Fill the window | As width |
on_escape | fun() | None | Escape 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) |
child | Node|Bound | None | The 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).
| Compositor | Opening size |
|---|---|
| Tiling (niri, a tiled Hyprland window) | The tile the compositor sends |
| Floating, leaving an axis to the client | A 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…
| Task | Answer |
|---|---|
| Open a settings window from a keybind | Bind visible to named state, as in the example; mantle toggle settings_open |
| Close it when the user clicks the close button | on_close_request = function() open:set(false) end |
| Ask before closing | Confirm before closing |
| Make it float, or place it | A Hyprland windowrule or niri window-rule matching app_id |
| Give it a starting size | min_size, or a compositor rule |
| Scroll content taller than the window | A column { height = "fill", scroll = scroll("name") } (scroll) |
| Close it from a button inside it | Set its visible state to false from on_click |
| Draw my own title bar | Custom title bar |
| Draw a shadow and rounded corners around my own frame | Client-side decoration |
| Open a menu from it | A 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).
| Method | Does |
|---|---|
: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.
| Key | Type | Meaning |
|---|---|---|
activated, maximized, fullscreen, resizing | boolean | The xdg_toplevel state flags |
tiled | { left, right, top, bottom } | The edges a tiling compositor tiled the window against |
bounds | { width, height } or nil | The 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
| Trap | Fix |
|---|---|
| The close button does nothing | Add on_close_request and set visible to false in it |
min_size doesn’t stop the root shrinking | It is advisory to the compositor; layout does not enforce it |
min_size = { width = 400 } is refused | Name both axes; 0 leaves one unconstrained |
max_size below min_size is refused | Keep every non-zero max_size axis at or above min_size’s, or 0 |
width = 600 on the window doesn’t resize it | That 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 it | Put the background on a "fill" child, not the window (input region) |
| No title bar under a compositor without server-side decorations | The engine draws none; draw your own row (custom title bar), or use compositor rules |
decorations = "server" still leaves a bare window | The compositor has no zxdg_decoration_manager_v1 or chose client-side; check toplevel(id):state().decoration |
toplevel("main"):move() warns and does nothing | Call 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.
| Property | Type | Default | Behaviour |
|---|---|---|---|
id | string | Required | The 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 |
parent | string | Required | The 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_rect | Rect|Bound | Required | In 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 |
width | number|Bound | Content | Pixels 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+) |
height | number|Bound | Content | As width; each axis is independent |
grab | boolean|Bound | true | Takes an input grab so an outside click dismisses it (grab). false for a tooltip |
on_dismiss | fun() | None | The 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 |
visible | boolean|Bound | true | Opens and closes the popup; state and id survive |
on_escape | fun() | None | Escape 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) |
child | Node|Bound | None | The 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…
| Task | Answer |
|---|---|
| Show a dropdown under a bar button | The example at the top |
| Open a submenu from a menu | Nested menus |
| Show a tooltip on hover | Tooltip |
| Anchor to a node without clicking or hovering it | Anchor to a node’s geometry |
| Open a context menu on right click | on_click = function(rect, which) if which == "right" then ... end end (pointer) |
| Fade it out before it closes | Keep visible true with delay while the child’s opacity animates (delay) |
| Open it from a keybind | grab = false, since a keybind is no pointer press; it then stays until the config hides it |
| Keep it on screen near an edge | constraint_adjustment = { "flip_x", "flip_y", "slide_x", "slide_y" } |
| Give it a fixed size | width and height in px |
| Open it from a window | parent = "<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
| Trap | Fix |
|---|---|
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 it | Set its visible state to false in on_dismiss |
A popup that is visible at startup, or opened from a keybind, never opens | grab = true needs a click; open it from on_click, or set grab = false |
| A popup whose parent is hidden does not open | Show the parent first; the popup opens on the next pass |
| A submenu reopens after its menu closed | Clear the submenu’s state wherever the menu closes, on_dismiss included |
width = "fill" or "50%" is refused | px, or omit it to size to the content |
anchor_rect from a click in a popup is placed wrong on the bar | A 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 compositor | xdg_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.
| Property | Type | Default | Behaviour |
|---|---|---|---|
id | string | Required | The 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 |
child | Node|fun(output: string): Node?|Bound | None | The root’s content. A function runs per output instance with its connector name; nil leaves that instance empty (per-output child) |
width | nil | None | Refused: the lock covers each output |
height | nil | None | Refused, as width |
visible | nil | None | Refused: 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.
| Condition | Result |
|---|---|
The config declares no lock | Refused |
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 lock | The compositor denies it; reported in error and mantle.rescue |
| While locked, a reload leaves no lock instance with that single field | The reload is refused and the lock screen on screen stays |
| The compositor ends a held lock by its own mechanism | The session is unlocked; the reason goes to mantle.rescue only |
The field’s keystrokes go to PAM and never reach Lua.
How do I…
| Task | Answer |
|---|---|
| Lock from a keybind | action("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 checking | Read authenticating |
| Animate the lock screen out | set_unlock_animation with the animation’s length, and drive opacity or scale from unlocking (lock capability) |
| Animate it in | Drive the same property from active; animate.from covers the first frame |
| Show the desktop wallpaper behind it | An image in the per-output child, keyed by output (per-output content) |
| Put a clock on it | mantle.clock:map(function(clock) return os.date("%H:%M", clock.time) end, "") |
| Add an unlock button beside the field | A 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 suspend | Invoke lock from your idle or suspend handler (idle) |
Gotchas
| Trap | Fix |
|---|---|
mantle call lock does nothing | Read 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 refuse | One shown secure field per lock tree; hide the others |
| A reload while locked is ignored | It removed the lock’s password field; the running lock screen stays. Fix the file |
Renaming the lock’s id while locked is refused | Save the rename again after unlocking |
visible, width, height, output or anchor on a lock is refused | Remove them; the lock always covers every output |
| The card’s exit animation is cut off | The 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.
| Kind | Page | Box | Children |
|---|---|---|---|
rect | rect | ✓ | Stacked |
row, column | row and column | ✓ | Flow |
list | list | Flow, from data | |
text | text | Leaf | |
icon | icon | Leaf | |
image | image | Leaf | |
capture | capture | Leaf | |
path | path | Leaf | |
shader | shader | Leaf | |
textfield | textfield | Leaf |
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
| Rule | Detail |
|---|---|
| Types | A 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 |
| Signals | A 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 |
nil | A signal reading nil leaves its property absent, at its default. Capabilities read nil until their first push, so binding one never fails layout |
| Numbers | Finite. A value outside a property’s range is an error, not a clamp |
| Colours | "#RRGGBB" or "#RRGGBBAA" (colours) |
| Strings | Capped at 64 KB |
| Arrays | children, 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 |
| Tables | A 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 booleans | Every 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.
| Kind | Children | Main axis |
|---|---|---|
row | Flow left to right | Horizontal |
column | Flow top to bottom | Vertical |
list | Flow, generated from items | direction: vertical by default |
rect, every surface | Stack: each child gets the whole content box and aligns in it on its own. Later children paint over earlier ones | None |
text, icon, image, capture, path, shader, textfield | None (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.
| Value | Size |
|---|---|
| Omitted | Content: text and icon measure themselves, containers wrap their children, other leaves are 0 |
| Number | Pixels, [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
| Property | Meaning |
|---|---|
padding | Inside the node’s box, around its children or text |
margin | Outside the box; part of the room the node takes in its parent |
spacing | Gap 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:
| Where | align_h / align_v does |
|---|---|
| A child in a stacking parent | Places the child in the parent’s content box on that axis |
| A child in a flow, across its main axis | Places the child across the row’s height or the column’s width |
| A child in a flow, along the main axis | Ignored: 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).
| Property | Type | Default | Behaviour |
|---|---|---|---|
width | Length|Bound, [0, 8192] | Content | See sizes |
height | Length|Bound, [0, 8192] | Content | See sizes |
max_width | number|Bound, [0, 8192] | None | Pixel ceiling, CSS max-width. Content past it overflows; a scroll on the same node scrolls it (sizes) |
max_height | number|Bound, [0, 8192] | None | Pixel ceiling, as max_width |
min_width | number|Bound, [0, 8192] | None | Pixel floor, CSS min-width; wins over a lower max_width |
min_height | number|Bound, [0, 8192] | None | Pixel floor, as min_width |
margin | number|Edges|Bound | 0 | Outside the box; part of the room the node takes in its parent. A number sets all four edges; not range-checked (spacing) |
padding | number|Edges|Bound, [0, 8192] | 0 | Inside 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 |
visible | boolean|Bound | true | false removes the node from layout, paint and spacing and freezes its subtree (showing and hiding) |
opacity | number|Bound, [0, 1] | 1 | Multiplied down the tree. At 0 the node still takes space and input |
z | number|Bound | 0 | Sibling paint and hit order. Higher paints later and hits first; ties keep declaration order. Layout and focus ignore it; animate refuses it |
scale | number|Axes|Bound, [0, 64] | 1 | About origin; a missing axis is 1. Paint only: layout and geometry see the unscaled box; hit-testing follows the painted one |
rotate | number|Bound, [-8192, 8192] | 0 | Degrees clockwise about origin. Paint only |
translate | Axes|Bound, [-8192, 8192] | { x = 0, y = 0 } | Pixel offset per axis, a missing one 0, applied after scale and rotate. Paint only |
origin | Axes|Bound, [0, 1] | { x = 0.5, y = 0.5 } | Pivot for scale and rotate as box fractions; a missing axis is 0.5 |
shadows | ShadowLayer[]|Bound | None | Drop 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 |
effect | Effect|Bound | None | Pixel 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 |
animate | Animations|Bound | None | Per-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 |
settled | Bound | None | A settled(name) signal the engine sets to whether every animate tween on this node is at rest (settled) |
id | string | None | Unique among siblings; matches this node across passes (identity). Never a signal |
accessible_name | string|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_target | FocusHandle | None | A 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 |
autofocus | boolean|Bound | false | Takes 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_ring | boolean|Bound | true | false keeps the engine’s focus outline off this node |
focused | Bound | None | A focused(name) signal the engine sets while this node or its children hold control focus |
focus_visible | Bound | None | A focus_visible(name) signal the engine sets while the focus outline would show on this node or its children, even with focus_ring = false |
hover | Bound | None | A hover(name) signal the engine sets while the pointer is over this node or its children (hover) |
pointer | Bound | None | A 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) |
geometry | Bound | None | A geometry(name) signal the pass writes this node’s surface-local rect into (geometry) |
cursor | Cursor|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 arrow | One of the cursor names. The innermost node under the pointer that sets one wins |
on_hover | fun(hovered: boolean) | None | Called 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_key | fun(key: KeyPress): boolean? | None | A 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_release | fun(key: KeyRelease): boolean? | None | A key release, routed like on_key; true stops it (key handlers). Needs accessible_name to take focus |
on_click | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | On 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_click | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | 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; 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_press | fun(rect: Rect, button: "left"|"right"|"middle"|"back"|"forward", pointer: { x: number, y: number }, modifiers: Modifiers) | None | On 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_drag | fun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end", modifiers: Modifiers, velocity: { x: number, y: number }) | None | Left-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_wheel | fun(rect: Rect, steps: number, modifiers: Modifiers) | None | Vertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins; on one node, the scroll |
submits_secure | boolean|Bound | false | A click also submits the armed secure field, like Enter. Works without on_click and runs before it |
hittable | boolean|Bound | Inherited; true at the root | false 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.
| Group | Names |
|---|---|
| General | default, context-menu, help, pointer, progress, wait |
| Selection | cell, crosshair, text, vertical-text |
| Drag and drop | alias, copy, move, no-drop, not-allowed, grab, grabbing |
| Resize | e-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 |
| Other | all-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).
| Child | Matches |
|---|---|
With an id | The old sibling with the same id, wherever it moved. No such sibling: a new node |
Without an id | The old id-less siblings, in order |
| Either, with a different kind | Nothing: 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
imagewithretainortransition, acapture, atextfield. - 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…
| Task | Answer |
|---|---|
| Split a bar into left, centre and right | The bar at the top: two "fill" rows around a content-sized middle |
| Centre something | rect: centre something |
| Put a badge over an icon | rect: a stacking parent with the badge aligned to a corner |
| Push items to the far end of a row | row and column: a "fill" spacer |
| Show a progress bar | The meter: a percentage-width rect in a "fill" track |
| Truncate long text | width (or "fill") plus elide = "end"; see text |
| Make something clickable | Give it on_click; to make a whole row clickable, put on_click on the row (pointer) |
| Build rows from data, or a grid | list |
| Scroll a long list | list: scroll a long list |
| Show an app’s icon | icon |
| Round an image’s corners | image: round an image’s corners |
| Switch between tabs | Switching views with ids |
| Toggle a section in place | visible = signal; see showing and hiding |
| Read where a node ended up | geometry = geometry("name") (geometry) |
| Press feedback that does not re-lay out | Tween scale or translate (animation) |
Gotchas
| Trap | Fix |
|---|---|
width = "content" is refused | Omit the property; content size is the default |
An image, capture, path, shader or textfield does not appear | They have no intrinsic width. Give width (and height, except for a textfield), or "fill" in a sized parent |
A "fill" child is 0 wide | Its parent is content-sized along that axis, or fixed siblings already overflow. Size the parent |
"50%" resolves to 0 | The parent has no definite size on that axis |
Items in a rect overlap | They stack their children; put a row inside for side by side |
| A switched view snaps in without its entry or exit animation | Same kind at the same position is reused, not replaced. Give each view its own id |
duplicate id error | Sibling ids, and list keys, must be unique |
on_click = cond and fn raises expected a function | A false cond yields false: write cond and fn or nil |
children = { a, cond and b, c } raises children[2]: expected a node table | A 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 update | A 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 clicks | Use 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
children | Node[]|Bound | None | Array 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…
| Task | Answer |
|---|---|
| Layer a badge over an icon | The example above |
| Centre something | Below |
| Draw a divider line | rect { width = "fill", height = 1, background = "#45475A" } |
| Round an image’s corners | image: radius on the image |
| Dim everything behind a dialog | A full-size rect with a translucent background, the dialog as its child (paint) |
| Overlap two views while they swap | Make 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
| Trap | Fix |
|---|---|
Children of a rect sit on top of each other | That is stacking. Use a row or column to lay them out side by side |
An empty rect draws nothing | With no children and no size it is 0 × 0. Give it width and height |
A background tween snaps in instead of fading | An 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
children | Node[]|Bound | None | Array 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 |
spacing | number|Bound | 0 | Px between visible children; negative values overlap them. Not range-checked |
scroll | Bound | None | A scroll(name) signal; makes the node a scrolling viewport along its main axis (scroll) |
homogeneous | boolean|Bound | false | true gives every visible child one equal main-axis slot, as GTK’s homogeneous; see equal slots |
wrap | boolean|Bound | false | true flows children onto new lines when the next does not fit the main axis, CSS flex-wrap; see wrapping. Refused with scroll |
line_spacing | number|Bound | 0 | Px between lines under wrap, 0 by default; negative values overlap them. Ignored without wrap |
How the container packs its children:
| Axis | Set by | Effect |
|---|---|---|
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 |
| Cross | Each 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…
| Task | Answer |
|---|---|
| Show a progress bar | The meter above |
| Push items apart | Below |
| Split a bar into three groups | The bar: two "fill" rows around a content-sized middle |
| Centre items in a row | align_h = "center" on the row itself |
| Make children equal width | homogeneous = true on the row, or equal slots |
| Scroll overflowing content | Bound the axis (height or max_height on a column), then scroll = scroll("name") (scroll) |
| Overlap items, like stacked avatars | Negative spacing |
| Flow chips or tiles onto lines | wrap = true, below |
Equal slots
homogeneous = true gives every visible child a slot of the same size along the main axis.
| Container | Slot |
|---|---|
| Content-sized on the main axis | The 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.
| Property | Effect |
|---|---|
spacing | Between children within a line |
line_spacing | Between lines; 0 by default |
align_h / align_v | The container’s main-axis value packs each line; its cross-axis value packs the lines. "stretch" packs as "start" |
| Child cross alignment | Places the child within its line, not within the container |
"fill" child | Counts as zero when lines break, then takes what its line leaves |
| Content-sized cross axis | Grows with the lines |
homogeneous | Every cell is the largest child’s size across all lines, a grid |
scroll | Refused: 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
| Trap | Fix |
|---|---|
align_h = "center" on a child of a row does nothing | The 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 top | A 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 wide | The row has no leftover space to share. Give the row a width or "fill" |
direction = "horizontal" on a column is refused | direction is a list property. Use a row |
A scroll row or column never scrolls | Its 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
content | string|TextRun[]|Bound | "" | A string, or an array of up to 10000 runs, drawn as one paragraph |
font | string|Bound | The fonts chain | Family placed before the fonts chain. "" raises; an unknown family falls back to the chain |
font_size | number|Bound, [1, 8192] | 12 | Text size in logical pixels. A textfield sizes its placeholder with it |
line_height | number|Bound, [0.1, 10] | 1.2 | Line height as a multiple of font_size |
letter_spacing | number|Bound, [-100, 100] | 0 | Extra space between characters in logical pixels. Negative values tighten text |
font_weight | number|Bound, [1, 1000] | 400 | Font weight from 1 to 1000. A run with bold = true uses weight 700 |
italic | boolean|Bound | false | Use the family’s italic face when available. A run with italic = true stays italic |
font_variations | table<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 |
foreground | Color|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_lines | number|Bound | 0 | Line 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" |
elided | Bound | None | An elided(name) signal; layout writes whether elide or max_lines removed content |
on_link | fun(href: string) | None | Click 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:
| Field | Values | Default | Behaviour |
|---|---|---|---|
text | String | Required | A run without it is refused; an empty one is skipped |
bold, italic | Boolean | false | bold uses weight 700; italic enables italic. Other node-level styles carry through |
underline | Boolean | false | Underline in the run’s colour |
color | Colour | The node’s foreground | |
href | String | None | Handed 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),
}
| Rule | Behaviour |
|---|---|
| Measure and paint | Both use the same axis values, so advances and outlines agree |
| Range | A value outside the face’s range clamps to it |
| Missing axis | Ignored, so a fallback face that lacks it draws as before |
wght | Overrides font_weight and bold runs; without it, font_weight sets wght |
| Change | Snaps: 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…
| Task | Answer |
|---|---|
| Truncate a long title | width (or "fill") plus elide = "end" |
| Keep a file extension visible | width plus elide = "middle" |
| Show at most two lines | wrap = "word", max_lines = 2, elide = "end" and a bounded width, as in the card |
| Bold one word | A run with bold = true |
| Make a clickable link | A run with href plus on_link on the node (process.detach to open it) |
| Use an icon font glyph | font = "Symbols Nerd Font" (any installed family) with the glyph as content |
| Fill a Material Symbols icon | font_variations = { FILL = 1 } (variable fonts) |
| Centre text in a fixed-width box | text_align = "center" with a width |
| Show a live value | Bind content to a signal: content = volume:map(function(v) return v and tostring(v) or "" end) |
Gotchas
| Trap | Fix |
|---|---|
A wrap = "word" text runs off the edge on one line | Wrapping needs a bounded width: set width, "fill", or put it in a fixed-width column. A content-sized row offers none |
elide never ellipsizes | Same cause: the box is as wide as the text. Bound the width |
max_lines has no effect | It applies only under wrap = "word" |
text_align = "center" does nothing | The box is exactly as wide as the text. Give it a width, or centre the node with align_h |
font = "" raises | Omit font to use the chain |
| A link run is not clickable | Links need on_link on the same text; without it the click goes to the on_click around it |
content = 42 raises | content 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
name | string|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 |
size | number|Bound | 12 | The box is size × size px; not range-checked |
foreground | Color|Bound | The file’s own colours | Colour 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…
| Task | Answer |
|---|---|
| Show an app’s icon | The example above |
| Tint a symbolic icon | foreground = "#CDD6F4" on a -symbolic name |
| Show a tray item’s icon | name = item.icon_name or item.icon_path: both spellings work (tray) |
| Show a notification’s app icon | name = notification.app_icon (notifications) |
| Make an icon button | Give 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 icon | Layer them in a rect |
Gotchas
| Trap | Fix |
|---|---|
| An icon draws nothing | The 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 icon | Only SVGs that use currentColor (symbolic icons) take it |
| The wrong theme’s icons appear | Set 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 box | It draws at the shorter side of width/height. Keep them equal, or use size alone |
| An icon given as a relative path draws nothing | A 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
source | string|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 |
async | boolean|Bound | false | false 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 |
retain | boolean|Bound | false | Keep drawing the last picture while a new source decodes, and on a failed decode. Needs async = true and a stable id |
transition | Transition|Bound | None | Cross 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_blur | number|Bound, [0, 8192] | 0 | Blur 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 |
radius | number|Corners|Bound, [0, 8192] | 0 | Corner 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_smoothing | number|Bound, [0, 1] | 0 | Continuous 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 |
playing | boolean|Bound | true | false 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 |
status | Bound | None | An image_status(name) signal. The engine writes the load of source; see load status |
transition
| Field | Values | Default | Behaviour |
|---|---|---|---|
duration | ms, [1, 60000] | Required | Length of the cross |
easing | An easing | "in_out_quad" | Drives u_progress |
shader | Absolute, or ~/ under $HOME, .frag path | Built-in cross-dissolve | Replaces 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:
| Name | What |
|---|---|
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_rect | Each 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…
| Task | Answer |
|---|---|
| Crossfade a wallpaper | The example above: async, transition and a stable id |
| Wipe instead of fade | transition = { duration = 700, shader = mantle.config_dir .. "/shaders/wipe.frag" } with a .frag that mixes mantle_from and mantle_to |
| Round an image’s corners | Below |
| Make a circular avatar | The same, with radius half the size (paint) |
| Show many thumbnails without stutter | async = true on each, in a list |
| Blur a wallpaper once | source_blur = 20 |
| Freeze an animation until asked | playing = live, a boolean state the config turns on |
| Show a file that ships with the config | source = mantle.config_dir .. "/img/logo.png" |
| Fade in once decoded, show a load error or a resolution | Load 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
| Trap | Fix |
|---|---|
| The image does not appear | It has no intrinsic size. Give width and height |
An image flashes blank when its source changes despite retain | retain needs async = true and a node that survives: give it a stable id |
| The shell stutters while images load | Inline decode blocks drawing. Set async = true |
source = "firefox" draws nothing | source is a path. Use icon for theme names |
A relative source draws nothing | It resolves against the Renderer’s working directory, not the config. Build paths from mantle.config_dir |
transition.params or transition.images is refused | Both need a shader |
| An animation restarts from its first frame | Playback, 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 frame | Past 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 backend | Window capture |
|---|---|
| Hyprland | Supported when the mapping and ext toplevel capture protocols are advertised |
| Niri | Unsupported. Its foreign-toplevel identifiers match its window IDs, but its ext capture implementation currently handles outputs only |
| wlr | Unsupported. 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
output | string|Bound | "" | Connector name, e.g. "DP-1"; "" draws nothing. An unknown name draws nothing and warns once. Changing it starts a fresh capture |
window | string|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 |
live | boolean|number|Bound | false | false: 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 |
region | Rect|Bound, [0, 8192] | The whole output | Part 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_cursor | boolean|Bound | false | Include 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…
| Task | Answer |
|---|---|
| Preview a monitor | The first example above |
| Preview a window | window = entry.id from mantle.windows; currently Hyprland only |
| Preview every monitor | A list over mantle.screens, key = the screen’s name, one capture per item |
| Show a part of the screen | region = { x = 0, y = 0, width = 960, height = 540 } |
| Keep CPU low | Leave live = false for a still, or cap it: live = 10 |
| Include the mouse pointer | paint_cursor = true |
| Round the corners | Wrap it in a rect with radius and clip = "rounded", as above |
Gotchas
| Trap | Fix |
|---|---|
| Nothing draws | Give it a size; check output against mantle.screens names; check mantle log for a missing-protocol warning |
| The preview is frozen | live is false, which captures once. Set true or a frame rate |
live = 0 is refused | Use false for a single frame |
A region shows the whole output | The output is rotated or flipped and only ext-image-copy-capture-v1 is offered |
region = { width = 100, height = 100 } is refused | All four keys are required |
window and output are both set | Only one may be nonempty; region is output-only |
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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
source | string|Bound | "" | Absolute, or ~/ under $HOME, .frag path; relative is refused, "" draws nothing. Compiling, errors and reloads: the .frag file |
progress | number|Bound, [-8192, 8192] | 0 | Becomes u_progress. There is no clock uniform: animate this for motion; the wide range lets a spring overshoot |
params | table<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 |
images | table<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.
| Name | Type | What |
|---|---|---|
v_uv | in vec2 | Box coordinate, 0..1, top-left origin, y down |
fragColor | out vec4 | Premultiplied RGBA. The engine multiplies it by the node’s opacity afterwards |
u_progress | float | The node’s progress |
u_size | vec2 | The node’s size in logical px |
uniform float, vec2, vec3, vec4 of your own, or arrays of them | Set 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.
| Event | Result |
|---|---|
| Compile or link fails | Logged once, draws nothing until the file changes |
A .frag under the config directory is saved | The config reloads, which recompiles it. A file elsewhere recompiles at the surface’s next pass |
mantle check | Passes: it has no GPU and compiles no GLSL. The first compile is in the running shell |
| The shader hangs the GPU | The 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.
}
| Rule | Detail |
|---|---|
| Declared for you | uniform sampler2D <name>; and uniform vec2 <name>_size; (pixels). Do not declare them |
| Names | A 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 |
| Sampling | texture(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 file | Samples vec4(0.0) with name_size of vec2(1.0), logged once per path. The node still draws |
| Changed file | A new modification time or length reloads it, as an image does |
| Elsewhere | effect.shader takes the same key, on input = "content" and "backdrop" |
| Sharing | Two nodes naming one path share one texture, held by the image cache and freed with it |
How do I…
| Task | Answer |
|---|---|
| Fade an effect in and out | Bind progress to 0 or 1 and tween it with animate, as above |
| Loop an animation | animate = { progress = { keyframes = { 0, 1 }, duration = 2000, loops = "infinite" } } (keyframes) |
| Pass a colour | A vec3 or vec4 uniform, params = { tint = { r, g, b } } in 0..1 |
| Pass many values, like a visualizer’s bars | uniform 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 pixels | v_uv * u_size is the fragment’s position in logical px |
| Click a shader | Give it on_click; without a handler it is transparent to the pointer |
| Round its corners | Wrap it in a rect with radius and clip = "rounded" (clip) |
Gotchas
| Trap | Fix |
|---|---|
Draws nothing, and check passed | Read mantle log for the compile error. Check the node has a size and an absolute or ~/ source |
| The shader is static | There is no time uniform. Animate progress |
A uniform int refuses the shader | Declare it float and pass the integer as a number |
| Colours glow too bright where alpha is low | fragColor is premultiplied: multiply RGB by alpha |
A params change jumps | params 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.
| Property | Type | Default | Behaviour |
|---|---|---|---|
commands | PathCommand[]|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 |
fill | Color|Gradient|Bound | None | Fill colour or gradient across the node box. Open subpaths close for filling |
stroke | Color|Gradient|Bound | None | Stroke colour or gradient across the node box |
stroke_width | number|Bound, [0, 8192] | 1 | Stroke 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_start | number|Bound, [0, 1] | 0 | Where the stroke starts, as a fraction of the length of every subpath in order, closing segments included. The fill is untrimmed |
trim_end | number|Bound, [0, 1] | 1 | Where 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 |
shift | Axes|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.
| Property | Type | Default | Behaviour |
|---|---|---|---|
items | any[]|Bound | Empty | Array; 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 |
each | fun(item: any): Node | Required | Builds a node for every built item: all of them, or near the viewport in a keyed list with scroll |
key | fun(item: any): string | None | Unique UTF-8 key per item; replaces the node’s id. Duplicates are refused. Without it items match by position |
limit | integer|Bound | None | Build 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 |
spacing | number|Bound | 0 | Px between visible items along direction; negative values overlap them |
scroll | Bound | None | A 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.
| Change | Runs again |
|---|---|
A write to items, or to a signal under a map or computed bound to it | Every 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 reload | Every 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 hover | The getters that read it, no each |
| A write to anything else, even on the same surface | Nothing |
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…
| Task | Answer |
|---|---|
| Lay out a grid | The example above: a list of rows, several items per row |
| Scroll a long list | Below |
| Keep items’ animations when the order changes | Give key a stable per-element string (an id from the data) |
| Show only the top N matches | limit = 50 |
| Lay items out horizontally | direction = "horizontal" |
| Filter as the user types | Bind items to a map of the query, as the textfield example does |
| Show an empty state | A 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
| Trap | Fix |
|---|---|
A 2000-item list makes every update slow | Without 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 changed | Its 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 updating | each 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 refused | Set limit, or page the items |
duplicate key error | key must return a different string for every element |
key returning a number is refused | Return a string: tostring(item.id) |
| Items lose their state when one is added at the top | Without key they match by position. Add key |
background on a list is refused | A 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:
| Property | Type | Default | Behaviour |
|---|---|---|---|
font | string|Bound | The fonts chain | Family placed before the fonts chain. "" raises; an unknown family falls back to the chain |
font_size | number|Bound, [1, 8192] | 12 | Text size in logical pixels. A textfield sizes its placeholder with it |
line_height | number|Bound, [0.1, 10] | 1.2 | Line height as a multiple of font_size |
letter_spacing | number|Bound, [-100, 100] | 0 | Extra space between characters in logical pixels. Negative values tighten text |
font_weight | number|Bound, [1, 1000] | 400 | Font weight from 1 to 1000. A run with bold = true uses weight 700 |
italic | boolean|Bound | false | Use the family’s italic face when available. A run with italic = true stays italic |
font_variations | table<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 |
foreground | Color|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 |
placeholder | string|Bound | "" | Shown while the field is empty, focused or not. Never submitted |
placeholder_color | Color|Bound | foreground | Colour of the placeholder |
caret | Caret|Bound | None | The 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 |
selection | Selection|Bound | None | The 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 |
disabled | boolean|Bound | false | Renders 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_length | number|Bound | 0 | Most 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 |
multiline | boolean|Bound | false | Plain 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_lines | number|Bound | 1 | Ignored without multiline: the fewest rows the field is tall; 0 is 1, a negative value is refused |
max_lines | number|Bound | 0 | Ignored 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_text | string|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_change | fun(text: string) | None | Full text after every edit |
on_submit | fun(text: string) | None | Enter (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_cancel | fun(cleared: boolean) | None | Escape; 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 }|Bound | None | Makes 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_character | string|Bound | "•" | Drawn per typed character in a secure_submit field. Only the first character counts; "" hides the length |
masked | boolean|Bound | true | false 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…
| Task | Answer |
|---|---|
| Filter a list as the user types | The example above: on_change sets a state, the list’s items maps it |
| Move a selection with the arrow keys | on_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 opens | autofocus = true and a panel with keyboard_interactivity |
| Return keys to the field after a click | One local h = focus_target("name"): focus_target = h on the field, h:request() in the on_click (input) |
| Close on a second Escape | on_cancel(cleared): close only when cleared is false |
| Ask for a password | secure_submit = { capability = "lock", action = "authenticate" } (secure fields) |
| Submit a password from a button | submits_secure = true on the clickable node |
| Style the box around the field | Wrap it in a rect with background, radius and border_*; the field draws only text and caret |
| Debounce a search | signals |
Gotchas
| Trap | Fix |
|---|---|
| The field does not appear | It has no intrinsic width. Give it width |
| Typing does nothing | The surface needs keyboard focus (keyboard_interactivity on a panel), and the field needs on_change, on_submit or secure_submit |
on_cancel alone never fires | It does not make the field focusable. Add on_change or on_submit |
| You cannot read the draft from Lua | It 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 field | A secure_submit field sends to its capability instead |
font on a textfield is refused | Fields use the fonts chain |
background on a textfield is refused | It 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
| Term | Meaning |
|---|---|
| Box kind | A node that paints a box: rect, row, column and the four surface roles (panel, window, popup, lock) |
| Repaint | Mantle redraws the changed part of a surface’s buffer; an unchanged surface is not redrawn |
| Offscreen pass | The subtree is drawn into a temporary texture, filtered or masked, then composited back. Costs a texture and an extra draw |
| Layer | The 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 |
| Glass | A box with effect.backdrop.blur or a backdrop colour filter |
| Sigma | A 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.
| Properties | Taken by |
|---|---|
shadows, effect, opacity | Every node, including text, icon, image, list, textfield |
background, radius, corner_shape, shape, border_color, border_width, ring, clip, mask, shadow_mode, compositor_blur | Box kinds only |
effect.backdrop | Box kinds only; the key is refused elsewhere, naming it |
source_blur | image only |
radius | Box kinds and image |
foreground (text, icon, textfield), z, scale, rotate, translate, origin, visible | Also 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
| Property | Type | Default | Behaviour |
|---|---|---|---|
background | Color|Gradient|BackgroundLayer[]|Bound | None | A 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 |
mask | Mask|Bound | None | Multiplies the alpha of this node and its subtree; see Mask |
radius | number|Corners|Bound, [0, 8192] | 0 | Corner 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_smoothing | number|Bound, [0, 1] | 0 | Continuous 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 |
shape | Shape|Bound | None | The box’s shape as one closed contour or a melt, in place of radius; see Shape |
border_color | Color|BorderColors|Gradient|Bound | None | A 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_width | number|Edges|Bound, [0, 8192] | 0 | Px per edge; a number sets all four, a missing edge is 0. Borders draw inside the box and take no layout space |
ring | Ring|Bound | None | A stroke of width px and color around the box, offset px outside its edge; see Ring |
compositor_blur | boolean|Bound | false | Ask 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:
| Form | Means |
|---|---|
12 | px 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" } },
}
| Key | Rule |
|---|---|
gradient | "linear", "radial" or "conic" |
angle | Degrees clockwise from the top, as in CSS. "linear" default 180 (top to bottom), "conic" default 0 (starts at twelve o’clock). "radial" refuses it |
stops | At 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" } } },
}
| Shape | Geometry |
|---|---|
"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).
| Value | Children are cut to | Cost |
|---|---|---|
"box" | The box’s rectangle | Free (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 surface | Free |
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.
| Form | Alpha taken from |
|---|---|
| A gradient table | The 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 = true | The 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 key | Values | Default |
|---|---|---|
color | Colour | "#000000" |
blur | CSS blur radius in px [0, 8192]; the Gaussian’s sigma is half of it | 0 |
offset | { x, y } px, each [-8192, 8192], missing axis 0 | { x = 0, y = 0 } |
spread | px [-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 instead | 0 |
inset | true: CSS box-shadow: inset, see Inset shadows | false |
| Property | Values | Default |
|---|---|---|
shadow_mode | Box 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.
| Case | How it draws |
|---|---|
| Box mode on a round box, any fill | One 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 mode | The same gradient quad; the box covers what is under it |
| Content mode on anything else, any non-box node, an opaque scoop | An offscreen layer: the subtree is drawn, blurred and tinted each layer’s color |
| Box mode on a translucent scoop | A 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.
| Property | Reads | When it runs | Cost | Pick it for |
|---|---|---|---|---|
compositor_blur = true (box kinds) | The desktop behind the surface: other windows and the wallpaper, not this surface’s own pixels | Continuously, in the compositor | The compositor’s | A 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 desktop | Every repaint that touches the box or what it reads, on the GPU | A copy and a blur per repaint; not cached | Glass over the surface’s own wallpaper, image or animated content |
effect = { blur = sigma } (every node) | The node’s own subtree | On repaint, on the GPU, into an offscreen layer | A blur when the subtree changes; an unchanged layer is reused. Large sigmas downsample first | A blurred or blur-in element, tweened with animate |
source_blur = sigma (image) | The image file’s pixels | Once, on the CPU, when the source decodes | Nothing per frame | A 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,
},
},
}
| Key | Type | Default | Behaviour |
|---|---|---|---|
source | string | required | Absolute, 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) |
params | table<string, number|number[]> | {} | Uniforms by name, as on a shader node; missing ones are 0 |
images | table<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 |
progress | number, [-8192, 8192] | 0 | Becomes u_progress, as on a shader node. animate effect to move it |
padding | number, [0, 512] | 0 | Logical 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.
| Name | Type | What |
|---|---|---|
v_uv | in vec2 | Box coordinate, top-left origin, y down. 0..1 over the box, and outside it over the padding |
u_size | vec2 | The node’s box in logical px, without the padding |
mantle_input(uv) | vec4 | The input at a box coordinate, premultiplied; transparent outside the padded area |
mantle_input_blurred(uv) | vec4 | The 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_rect | sampler2D, sampler2D, vec4 | The 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) | float | Signed 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_progress | float | progress |
mantle_opacity | Not 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;
}
| Case | Result |
|---|---|
| The shader fails to compile or link | Logged once per revision of the file; the node draws as if it had no shader |
The .frag is saved | The config reloads, which recompiles it and repaints the node |
params, images or a file change | The layer is redrawn; an unchanged layer is reused |
animate on effect | Tweens 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 animation | animate = { effect = { keyframes = { at(0), at(1) }, duration = 2000, loops = "infinite" } }, with at(p) returning { shader = { source = ..., progress = p } } (keyframes) |
| Hit testing and input regions | Ignore 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" } },
},
}
| Case | Result |
|---|---|
| A pixel this surface covers opaquely | Where 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 desktop | Presented 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 glass | A 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 |
| Cost | At 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 |
| Memory | About 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 inward | Placed from its anchor and margins only, so the capture is offset by the zone |
A window or popup, or a compositor without wlr-screencopy | No capture: the program reads this surface’s pixels alone, as "backdrop" |
Inside a rotate | The 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" } },
}
| Where | Blends with |
|---|---|
blend on a node | Its 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 layer | The layers below it and what is under the box, as a Figma fill does; CSS background-blend-mode would isolate the box |
A shadows layer | What 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.
- Backdrop (
effect.backdrop): replaces the pixels under the box with their blur, thensaturate,brightnessandcontrast; a backdrop shader instead replaces them with its output, from the same copy. It reads what precedes the node, so it comes first. - Shadow, when it is a gradient quad or a silhouette.
- Body: fill, children in
zorder, border. With amaskor aclip = "rounded"the body goes through an offscreen pass. A blendedbackgroundorshadowslayer blends as it draws. - Layer: for a content
effect.shader,effect.blur, a colour filter, a layered shadow or a nodeblend, the shadow and body are drawn offscreen, run through the shader, the content shadow cast from that, then blurred and recoloured. - 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 appliesmix-blend-modeafterfilter. - Transform (
scale,rotate,translate) wraps all of the above.
| Combination | What happens | Do this |
|---|---|---|
mask and effect.backdrop.blur on one node | The mask fades the fill, border and subtree, not the node’s own glass or box shadow | effect.backdrop.mask for the glass; a child of the masked node for the rest |
effect.blur and effect.backdrop.blur on one node | The glass stays sharp; only the fill, border and subtree blur | Expected |
effect.backdrop.blur inside a parent with mask, effect.blur or a Content-mode shadow | The glass sees only what that parent has drawn so far, not what is under the parent | Move the glass out of the effect parent, or accept it |
effect.backdrop.blur inside clip = "rounded" without a mask | The glass sees what is under the parent, as without the clip | Nothing to do |
effect.backdrop.blur on a surface root | It blurs transparency: it never reads the desktop | compositor_blur = true |
Shadow and effect.blur on one node | The shadow is cast from the sharp content, then the content is blurred | Expected |
| Box-mode shadow on a translucent box | One gradient quad, cut out under the box; children do not cast | shadow_mode = "content" to cast from what is painted |
| Content-mode shadow on a masked node | Cast from the masked result | Expected |
Content-mode shadow or effect.blur over an image, icon, capture, image mask or glass | The layer is redrawn every repaint instead of reused | Keep those out of animated layers, or accept the cost |
| Anything under a glass changes | The 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 paint | Give that parent padding |
opacity on a node with effects | Multiplied into every draw once; layers and clips composite at full alpha, so nothing fades twice | Expected |
opacity < 1 on a group whose children overlap | Each 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 shadow | The backdrop, shadow and body move together; the glass reads under its transformed position | Expected |
How do I…
| Task | Answer |
|---|---|
| Frosted glass panel over windows | Glass sheet below, or the blur bar |
| Frost a picture inside my own surface | The frosted pill: an image, then a sibling with effect.backdrop.blur |
| Card with a shadow | The card at the top; lift on hover below |
| Pill button | Pill button |
| Gradient border | Gradient ring |
| Fade a list’s edges | The edge-fade mask |
| Circular avatar | Avatar |
| Dim the background behind a modal | Scrim |
| Tint a gradient from a signal | Map 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
| Trap | Fix |
|---|---|
clip = "rounded" changes nothing | It 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 animate | Only single colours ease; see Animation |
| Rounded corners, scoops and masks still take clicks in the cut-away area | Hit-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,
loopsorrunstart 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.
| Situation | Result |
|---|---|
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 } table | Tweens 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 commands | Tweens 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 entry | After 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 pass | Starts at the entry’s from, else snaps. from needs the node to set the property itself |
| Target changes mid-flight | An 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 animate | Its 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 accept | Refused: 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:
| Shape | Properties |
|---|---|
| Number | width, 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 |
| Colour | background, 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 list | shadows, layer by layer (layered shadows) |
| Path commands | commands 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 on | Each frame |
|---|---|
opacity, colours, radius, translate, scale, rotate, origin, progress, commands, fill, stroke, stroke_width, shadows, ring, effect | Repaints; 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.
| Key | Values | Rules |
|---|---|---|
duration | Whole ms, [1, 60000] | Required unless spring is set. With keyframes it is the default length of each segment |
easing | A name, { x1, y1, x2, y2 }, or { steps = n } | Default "in_out_quad". Not with spring |
delay | Whole ms, [0, 60000] | Holds the start value first, like CSS transition-delay. Offsets a keyframe run once, not each loop |
from | A value of the property’s shape | Start 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) |
keyframes | At least 2 frames | See keyframes |
loops | Whole count [1, 10000] or "infinite" | Default 1. Only with keyframes |
run | A number | A 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.
| Family | Names |
|---|---|
| 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 easing | Meaning |
|---|---|
{ 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.
| Damping | Behaviour |
|---|---|
< 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.
| Rule | Detail |
|---|---|
| Frames | A 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 frame | Where 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 |
| Jump | A frame with duration = 0 (allowed only on a frame) cuts straight to its value |
| Hold | A segment between two equal values holds still for its duration |
| List | At least 2 frames, no holes ({ [1] = 0, [3] = 1 } is refused), at least one segment that takes time |
| End | A counted run holds its last frame as long as the entry stays. An "infinite" run never ends |
| Continuity | The 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 } }
| Rule | Detail |
|---|---|
| Keys | duration or spring, plus optional easing and delay, all as in entry keys. Every other key is a property name and its target value |
| Checked | On 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 value | The 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 tweens | Stop where they are. The exit block alone decides how long the node lives |
| What moves | Everything painted: opacity, colours, radius, translate, scale, rotate, origin, shadows, ring, blurs, progress, and pixel width/height. margin, padding and spacing change nothing visible |
| While leaving | Painted 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 |
| Identity | A leaving node is never matched again. Returning the same id builds a new node beside it |
| Scope | Only the dropped child runs its block; descendants leave with it and their own blocks never run |
| Not triggered by | visible = 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.
| Rule | Detail |
|---|---|
| Initial | true. The pass that starts a tween writes false; the frame that ends the last writes true |
| Scope | The 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 |
| Writes | On 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 |
| Keyframes | false 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 leaving | true. 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…
| Task | Answer |
|---|---|
| Grow a button on hover | scale = 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 spinner | The spinner under keyframes |
| Bounce on click | The pulse example under keyframes |
| Slide an on-screen display in and out | The example under exit |
| Fade a popup in and out | Fade a tooltip |
| Stagger a list’s entrance | Stagger |
| Slide a notification out when dismissed | Slide out |
| Fade content in after a panel grows, but out at once on close | Different timing per direction |
| Run phases in order: content out, container resizes, content swaps | Sequence phases |
| Grow a card out of a small shape | Grow 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.
| Time | Signal | Moves |
|---|---|---|
| 0 ms | page | Content fades out (150 ms) |
| 150 ms | delay(page, 150) | Container height eases to the new page’s (250 ms) |
| 400 ms | delay(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
| Trap | Fix |
|---|---|
| A node’s first value snaps; nothing fades in | Give the entry from |
from does nothing | The node must set the property too: opacity = 1 beside opacity = { from = 0, ... } |
| An exit never plays | Exit 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 place | children 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 behind | Use a spring; it keeps its velocity through each new target |
| A keyframe run plays once and never again | Same 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 short | Removing 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 run | The click only extends the window; the entry never leaves, so the run does not restart. Use run |
Sliding with margin stutters on a large surface | Tween translate: it skips layout |
| Removing a keyed item makes later items snap into the gap | Put animate.move on each item |
A looping motion driven by a timer costs CPU on every tick | Use 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.
| Rule | Detail |
|---|---|
| Transforms | A node is hit where it is painted, after scale, rotate and translate |
| Stacking | Siblings are asked topmost first: higher z, then later in declaration order |
| Clipping | A 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 |
| Skipped | visible = false subtrees and nodes playing an exit. opacity = 0 is still hit |
| Pass-through | hittable = 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 |
| Edges | Half-open: two nodes sharing an edge never both take it |
| Rects | Every 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.
| Handler | Arguments | Contract |
|---|---|---|
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’), unclamped | Fires 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_click | Fires 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_click | Fires 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 second | Left button only. See below |
on_wheel(rect, steps, modifiers) | steps is a number of wheel notches | Vertical 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.
| API | Contract |
|---|---|
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 |
cursor | The 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.
| API | Contract |
|---|---|
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 |
| Cost | Motion 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.
| Rule | Detail |
|---|---|
| Axis | A column or vertical list scrolls with the vertical wheel, a row or horizontal list with the horizontal one only |
| Distance | One wheel notch is 39 px; a touchpad scrolls the distance it reports |
| Bound | The 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 |
| Cost | While 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 |
| Inertia | Fingers lifting from a touchpad coast to a stop in 0.75 s; no animate needed. Wheel notches don’t |
| Smooth | animate = { 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 }.
| Field | Meaning |
|---|---|
name | The 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 |
text | What the key types, nil when it types nothing: Return, arrows, F-keys and every Ctrl chord |
modifiers | Which of Ctrl, Shift, Alt and Super are held. mantle input key sets Ctrl and Shift only |
repeat | true 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:
| Condition | Detail |
|---|---|
| The surface has keyboard focus | A panel needs keyboard_interactivity = "on_demand" or "exclusive" (keyboard focus); a popup shown under the focused surface shares its keys |
| The field can use keys | It 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.
| Property | Contract |
|---|---|
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 |
escape | What 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 |
autofocus | true: 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_target | A 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_text | Seeds 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 |
disabled | true: 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_length | Most 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_character | See secure fields |
placeholder, placeholder_color, font_size, foreground, text_align | Appearance; see textfield |
| Key | Plain field | Secure field |
|---|---|---|
| Text | Inserts at the caret, replacing a selection; on_change | Appends |
| Enter | on_submit, then clears | Sends |
| Escape | Clears; with on_cancel, also drops focus | Clears, stays armed, on_cancel |
| Backspace, Delete | One character, or the selection | Backspace only |
| Ctrl+Backspace, Ctrl+Delete | One word | Nothing |
| Left, Right, Home, End | Move the caret; Ctrl+Left/Right by word; Shift selects. Left/Right with nowhere to go (and no Shift) pass up to on_key | Nothing |
| Ctrl+A | Selects all | Nothing |
| Ctrl+Z, Ctrl+Shift+Z, Ctrl+Y | Undo or redo the last plain edits; on_change | Nothing |
| Ctrl+C | Copies selected text | Nothing |
| Ctrl+X | Copies selected text and deletes it as one undo step; on_change. Nothing without a selection | Nothing |
| Ctrl+V | Replaces selection with clipboard text; on_change | Appends clipboard text to the native buffer |
| Up, Down, Page Up, Page Down | Pass up to on_key | Nothing |
| Multiline: Return, Up, Down, Home, End | Return 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’s | Never multiline |
| Tab, Shift+Tab | Moves 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 chord | Left to the compositor | Same |
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.
| Target | Effect |
|---|---|
{ 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 pair | Refused when the field is laid out, so no password is typed into nowhere |
| Rule | Detail |
|---|---|
| Arming | When 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 |
| Keys | Typed 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 |
| Sending | Enter, 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 |
| Focus | A 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 |
| Priority | While a secure field is armed, plain fields in the same focus take no keys |
on_escape | A 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_character | Drawn 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 |
masked | false 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…
| Task | Answer |
|---|---|
| Make a slider | The example under pointer |
| Move a selection through a list with the arrow keys | on_key on the field (key handlers); the app launcher adds scroll(name):reveal |
| Close a search box on a second Escape | The same example: on_cancel(cleared) closes only when cleared is false |
| Let Escape reach the surface from a field | escape = "pass" on the textfield |
| Handle arrows or shortcuts on a custom control | on_key (key handlers) |
| Close a dialog or menu on Escape | on_escape on the surface (keyboard controls) |
| Ask for a password | The lock example under secure fields |
| Reach a button by keyboard or screen reader | Give the node with on_click or submits_secure an accessible_name (keyboard controls) |
| Hide or restyle the focus outline | focus_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 hover | Tooltip, with hover_rect as the anchor |
| Open a menu on right click | Below |
| Reorder a list by dragging | Below |
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
| Trap | Fix |
|---|---|
A textfield is invisible or cannot be clicked | It has no intrinsic width. Give it width |
| A field in a panel shows no caret and takes no keys | Set the panel’s keyboard_interactivity to "on_demand" (or "exclusive" for a modal) |
A field with only on_key/on_cancel ignores clicks | Add on_change or on_submit |
| Tab skips a button, or Tab does nothing | Give the button an accessible_name, and the panel a keyboard_interactivity other than "none" |
A clicked or autofocus field shows no focus outline | The 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_key | With 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 row | Rows 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 scrolls | Bound its size on the scroll axis; content-sized means nothing overflows |
on_hover is refused | Add hover = hover("name") on the same node |
| A container’s hover stays on while the pointer is over a child | Hover covers the whole subtree. Give the child its own hover for innermost-only behaviour |
| A click is lost when the node grows on press | The release must land on the same laid-out box. Animate scale instead |
| A tooltip or menu anchored to a scaled node is off | Rects are laid-out boxes before transforms. Anchor on an untransformed parent |
| Lua needs to prefill or clear a field | Call focus_target(name):set_text(text); "" clears. autofocus also re-arms empty, and Escape and Enter clear |
A typed password shows up in on_change | It 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¤t_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.capture | session_process | process.detach | |
|---|---|---|---|
| Output | Line by line to on_line; capture: one string at the end | Inherited stdio: mantle log | None: /dev/null |
| Exit | on_exit(code); capture: on_done(stdout, code) | running and exit_code signals | None |
| Instances | One per call | One per name; start is a no-op while it runs | One per call |
| Owner | The evaluation that started it | The Supervisor | Nobody: own session, reparented to init |
| Reload | Killed, on_exit(nil) | Keeps running | Unaffected |
| Renderer crash | Killed, no on_exit | Keeps running | Unaffected |
| Shell stops | Killed | Stopped with stop_signal, SIGKILL after 5 s | Unaffected |
| Typical uses | curl, getent, a poll every N seconds, a --follow stream | A screen recorder, a daemon the shell owns | Apps, 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.
| Part | Contract |
|---|---|
| Signature | process.run(cmd, args, on_line, on_exit) → handle |
cmd | Program name, looked up on PATH. No shell: no globbing, pipes, ~, $VAR or quoting |
args | List 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 |
| Handle | handle:kill(): SIGTERM to the whole process group, SIGKILL 100 ms later. on_exit still fires. A no-op after exit |
| stdio | stdin /dev/null, so a prompt fails instead of hanging; stdout and stderr piped |
| Environment | Inherited from the shell. The working directory is the shell’s and unspecified: use absolute paths. ~/ is not expanded in args |
| Lifetime | Until 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 |
| Limits | A 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)
| Part | Contract |
|---|---|
| Signature | process.capture(cmd, args, on_done) → handle |
cmd, args, handle, stdin, environment | As 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 |
| Failure | A signal, a failed spawn or a reload gives on_done(nil, nil); the reload calls it at once, as for process.run |
| stderr | Dropped |
| Limits | Past 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" } },
},
}
| Part | Contract |
|---|---|
| Signature | process.detach(cmd, args?, opts?) → nothing |
cmd, args | As 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 code | None. 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):
| Signal | Value |
|---|---|
running | Whether it is up. When false, the rest describe the last run |
pid | Process id, also its group id; kept after exit |
started_at | Unix seconds the current or last run began |
exit_code | Last run’s exit status; nil while running, before any run, or after a signal ended it |
start_error | Why the last start spawned nothing, e.g. cmd not on PATH; "" when it spawned |
Handle methods (call with :):
| Method | Effect |
|---|---|
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…
| Task | Answer |
|---|---|
| Fetch JSON over HTTP | The example at the top |
| Poll a command every N seconds | Below |
| Follow a long-running command’s output | Below |
| Run a recorder that survives reloads | session_process |
| Stop a child | handle:kill(), or save: a reload kills them all |
| Open an app or URL | process.detach |
| Read a file | file.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
| Trap | Fix |
|---|---|
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_line | Output arrives one line at a time. Collect lines and decode once in on_exit |
Treating kill() as cancel | on_exit still fires, usually with nil. Tag requests with a counter and ignore stale ones |
on_exit never arrives | It 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 save | A reload kills it. Use session_process |
| A failure logged on every save | A reload’s kill calls on_exit(nil). Report only a non-zero code |
Invalid stop_signal, or signal(name) with one | Raises 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
| To | Use |
|---|---|
| Run a command, launch an app, or keep a program running | processes |
| Remember a setting across restarts | persistent_table |
| Do something later, or retry | timer |
| Repeat every N ms | interval |
| Run code from a keybind or script | action + mantle call |
| Parse or write JSON | json.decode, json.encode |
| Write to the shell’s log | log.* |
| Rank search results | fuzzy |
| Pull colours out of an image | palette.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 file | file |
| Set the font fallback chain | fonts |
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) },
},
},
}
| Part | Contract |
|---|---|
| Signature | persistent_table { path, defaults? } → store. Another key raises |
path | Absolute, or ~/ under $HOME; relative, ending in /, or with an empty, . or .. component raises. Its directory is created if missing. |
defaults | Fills 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 |
| Identity | One table per file: another call with the same path returns it, across reloads too |
| Raw state | mantle.storage (capabilities) |
On disk:
| Behaviour | Detail |
|---|---|
| Save | 1 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 run | A missing file is created from defaults |
| Outside edits | Watched with inotify. Another writer’s version replaces the in-memory one whole, dropping unsaved writes |
| Broken file | Not 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()
| Part | Contract |
|---|---|
| Signature | timer(ms, fn) → handle |
ms | 1 to 86400000 (one day), fractions allowed, monotonic clock; outside raises |
fn | Called with no arguments under the Lua CPU budget. A raise or blown budget is logged as a warning |
| Handle | handle:cancel() disarms it. A no-op once fired or cancelled. Dropping the handle does not disarm |
| Order | Timers due at the same moment fire in the order they were armed; one may cancel another in the same batch |
| Lifetime | Every 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.
| To | Use |
|---|---|
| Retry with backoff, or act once after a delay | timer |
| Update a clock | timer, re-armed for the next minute, as above |
| Poll something with no capability and no output to follow | interval at the slowest rate that stays useful (poll a command) |
| React to a program’s output | Follow it with process.run instead of polling (follow) |
| Hide something a few seconds after the last change | timer, cancelling the previous handle on each change; or delay |
| Show a value late, or blink it | delay or pulse |
| Move, spin, pulse or morph something | animate (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)
| Part | Contract |
|---|---|
| Signature | action(name, fn) → nothing |
name | Any non-empty string; nothing splits on .. Empty, or declared twice in one evaluation, raises |
| Arguments, return, failure | As 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 |
| Limits | Lua CPU budget |
| Lifetime | Cleared 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.
| JSON | Lua |
|---|---|
null field | Absent key |
null array element | A hole: ipairs stops there, # may still count past it |
Top-level null | nil, same as a failure without the message |
| Non-UTF-8 input | nil, 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.
| Lua | JSON |
|---|---|
Table whose keys are all positive integers, {} included | Array; a hole inside it is null. More than half holes raises |
| Table with string keys | Object |
| Table mixing both, or with other keys | Raises |
nil | null |
| Integer, float | 3, 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 key | Raises |
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 },
},
},
}
| Part | Contract |
|---|---|
| Signature | fuzzy(haystack, needle) → score, start |
| Match | Integer 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 match | nil, nil, also for non-UTF-8 input |
| Empty needle | 0, 0 |
| Case | Smart: an all-lowercase needle ignores case; one uppercase character makes the whole comparison exact. Don’t lowercase the query |
| Non-ASCII | A 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),
},
}
| Part | Contract |
|---|---|
| Signature | palette.quantize(path, opts?, on_done) → handle |
path | Local raster image; no SVG or URL |
opts.depth | 0 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 |
opts | Only depth, method and rescale; another key raises |
opts.rescale | Longest 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 |
| Handle | handle: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 } },
}
| Part | Contract |
|---|---|
| Signature | palette.scheme(seed, opts?) → roles |
seed | "#RRGGBB"; anything else raises |
opts.dark | Boolean, 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 |
opts | Only those three keys; another raises |
| Roles | A 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)
| Part | Contract |
|---|---|
| Signature | palette.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 |
| Result | Up 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" }
| Part | Contract |
|---|---|
| Signature | palette.hct(color) → { hue, chroma, tone } (PaletteHct in the stubs) |
color | "#RRGGBB"; anything else, alpha included, raises |
hue | Degrees, 0 to under 360; a grey still gets a number |
chroma | Colourfulness from 0. Greys stay under 5, the cutoff below which Score ignores a colour |
tone | Lightness, 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.
| Part | Contract |
|---|---|
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 |
path | Absolute, or ~/ under $HOME. Anything else raises at the call |
| Order | Writes 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 |
| Handle | handle:cancel() drops the callback. A started write still lands |
| Reload | Drops pending callbacks, so keep what they touch in named state. A write begun before the reload still lands |
mantle check | Calls 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" }
| Part | Contract |
|---|---|
| Argument | Dense array of family-name strings. A hole, a named key or a non-string raises |
| Resolution | Through 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) |
| Default | Without a call: sans-serif, Noto Sans CJK JP, Noto Color Emoji |
| Per node | A text node’s font goes in front of the chain (nodes); font_variations sets a variable font’s axes (variable fonts) |
| Uncovered glyph | fontconfig 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) |
| Lifetime | Read once at startup. Last call wins; an edit needs a shell restart |
How do I…
| Task | Answer |
|---|---|
| Fetch JSON over HTTP | curl through process.run, then json.decode the output |
| Poll a command every N seconds | processes |
| Search as you type | fuzzy; for a slow source, debounce the query with delay |
Gotchas
| Trap | Fix |
|---|---|
| Callbacks from before a reload | palette callbacks run the old closures after a reload. Keep what they touch in named state |
timer or action declared only inside a callback | Every 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 something | Each tick runs Lua and resolves again. Use animate (Lua cost) |
| Two modules declare the same action | Raises. Pick unique names |
store.key:get() right after store:set | Still the old value. The signal updates on the next push |
store.key is nil at startup | The key has no defaults entry and mantle.storage has not pushed, or the file lacks it. Give it a default |
Storing a key named set | store.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 goes | Pass |
|---|---|
A property, such as background | The token |
A gradient stop’s colour, a shader params entry | The 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 table | One :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.
| Member | Contract |
|---|---|
: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
| Stage | What 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 push | Every read is nil. A missing backend may keep it nil for good |
| Running | A 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 readers | audio, audio_levels and privacy share one PipeWire thread; workspaces and windows share one compositor reader. Whichever is read first starts it |
| Buses | One 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 |
| Pushes | On backend events. clock, sysinfo, updates, notification expiry, mpris’s position recheck and the brightness fallback also run timers |
| Renderer replaced | The 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 backend | Logged; 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 boot | lock, 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 name | mantle.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.
| Mistake | Result |
|---|---|
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 name | Raises 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 clock | Raises: they have no actions |
| A function or userdata argument | Raises at the call, naming its slot |
| Wrong type, range or argument count | Raises 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 out | Raises at the call: an empty name, a relative folders:watch path, a negative hold_expiry |
A float where an integer goes | Raises: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer |
| Arguments to an action that takes none | Raises: mantle.network:scan(1) is refused |
| An ID, profile or MAC the Supervisor does not know | Does 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.
| Convention | Rule |
|---|---|
| Targets | Pass the ID from the snapshot (sinks[].id, feed[].id, players[].id, windows[].id). IDs are opaque: compare them, never build them |
| Levels | Percent, 100 for full: brightness and battery read 0 to 100; setters cap output volume at 150 and input and app volume at 100 |
| Indices | Zero-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
| Name | What it gives you | Note |
|---|---|---|
android_connect | Android device synchronization through KDE Connect | No devices until daemon runs |
appearance | Dark mode, accent colour, contrast and reduced motion from the desktop portal | Defaults without a portal |
applications | Desktop entries, window app_id index, launching | |
audio | Output and input volume and mute, devices, per-app streams, Bluetooth codecs | |
audio_levels | Peak levels of chosen sinks and sources, for VU meters | nil before the first audio:set_level_meters this session |
battery | Charge, state, time estimates | Check present first |
bluetooth | Adapter power, devices, discovery, pairing prompts | |
brightness | Screen backlight percentage | nil without a backlight |
folders | Listings of watched folders | Lists nothing until watch |
idle | Who holds the session awake; idle thresholds and inhibits | Methods, not actions |
keyboard | Active layout, lock keys, keyboard backlight | nil until the compositor reports a layout or a lock key changes |
lock | Lock held, authentication progress, last failure | No unlock: only a correct password unlocks |
mpris | Media players, metadata, position | |
network | Connectivity, Wi-Fi scan, join progress | Secured joins take the key through secure_submit |
notifications | The newest 20 notifications, do-not-disturb | Mantle is the notification daemon |
polkit | The pending authentication request | Mantle is the polkit agent; the password goes through secure_submit |
power | Power profiles, on battery, power draw | |
privacy | Apps using the camera, microphone or screen capture | |
processes | Programs declared with session_process | Use session_process, not its actions |
radio | rfkill soft and hard blocks per radio kind; airplane mode | nil without /dev/rfkill |
secrets | Status of named Secret Service writes | Values go through secure_submit, never Lua |
storage | Each persistent_table file | Use persistent_table, not its actions |
sysinfo | CPU, memory, swap, temperatures, disks, GPU, network throughput | nil until configure |
clock | Wall and monotonic clocks, once a second | |
tray | Tray items, artwork, menus | Mantle hosts the StatusNotifierWatcher |
updates | Pending packages, install progress, reboot needed | No schedule until configure |
windows | Every toplevel: title, app ID, workspace, output, state | |
workspaces | Per-output workspaces, specials, focused window | niri, Hyprland, sway or mango only |
Renderer members
Five members come from the Renderer, not a backend, so they are never nil and start nothing.
| Member | Kind | Contract |
|---|---|---|
mantle.screens | Signal | Connected 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.rescue | Signal | { 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.version | Plain table | { major, minor, patch } integers, for guarding newer API |
mantle.config_dir | Plain string | Directory shell.lua was loaded from, for naming files shipped beside it |
mantle.pid | Plain integer | The Supervisor’s pid, as mantle list shows it. mantle stop --pid with it ends this shell |
Screen
| Field | Type | Meaning |
|---|---|---|
name | string | Connector name, e.g. "eDP-1", as a panel’s output takes it; "output-N" below wl_output v4 |
x | integer | Left edge in compositor space: xdg_output’s logical position, else wl_output’s |
y | integer | Top edge, on the same terms as x |
width | integer | Logical pixels, already divided by scale; the mode’s pixels when no logical size is known |
height | integer | Logical pixels, on the same terms as width |
scale | integer | Integer scale factor, e.g. 2 on HiDPI |
fractional_scale | number | Real scale, e.g. 1.5: mode width over logical width; scale without both |
refresh | number | Refresh rate in Hz; 0 without a current mode, e.g. a virtual output |
orientation | Orientation | The wl_output transform |
model | string | Monitor model, e.g. "DELL U2720Q"; stable across connector renames. Empty when unadvertised |
description? | string | The compositor’s human label; format varies (Hyprland’s has the serial). Absent below wl_output v4 |
Orientation
| Value | Meaning |
|---|---|
"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…
| Task | Answer |
|---|---|
| Show a value that may not have arrived yet | Guard nil in the map: battery label |
| Change volume or brightness with the wheel | on_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 clock | os.date over mantle.clock.time: clock |
| Give each monitor its own bar and workspaces | A function child gets the connector name; match it in workspaces.outputs: workspaces |
| Show CPU and memory use | configure once at top level, then map: sysinfo |
| Name or iconify the focused app | workspaces.active_client.app_id through applications.by_app_id: applications |
| Play or pause whatever is playing | control on the id of the player whose play_state is "playing": mpris |
| Show a microphone or camera indicator | privacy |
| Keep the screen awake (caffeine) | idle |
| Know whether an action worked | Watch the state it changes: a failed Wi-Fi join |
Gotchas
| Trap | Fix |
|---|---|
attempt to index a nil value in a :map at startup | Guard the whole payload before its fields |
An optional field is nil | A 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 nil | Bind the state the action changes; read mantle log for refused commands |
An action raises bad argument #N: expected ... or expects N arguments, got M | Wrong argument type or count, often a float where an integer goes (keyboard:switch_layout(1.0)) |
| An action silently does nothing | Its 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 == nil | That 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 changed | Every 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.
| Field | Type | Description |
|---|---|---|
available | boolean | Whether the KDE Connect daemon (io.github.hepp3n.kdeconnect) is running on the session bus. |
commands | AndroidCommand[] | Commands paired devices can run on this PC with sh -c, from ~/.config/kdeconnect/runcommand.json. |
devices | AndroidDevice[] | Known devices. Unordered; sort before drawing. |
transfer_progress? | integer | Percent 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.
| Field | Type | Description |
|---|---|---|
command | string | Shell command line this PC runs with sh -c when a device asks. |
id | string | Unique identifier, the key remove_command takes. |
name | string | Label the device shows. |
AndroidDevice
A remote Android or paired device known to the daemon.
| Field | Type | Description |
|---|---|---|
battery? | integer | Battery 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? | string | Why the last browse failed, such as missing sshfs. Clears on the next browse or a mount. |
charging? | boolean | Whether the battery is charging, or nil if unknown. |
clipboard_auto_share | boolean | Whether every desktop clipboard change goes to this device. On unless switched off. |
device_type | DeviceType | Device form-factor / category. |
disabled_plugins | string[] | 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. |
id | string | Unique KDE Connect device identifier. |
mounted | boolean | Whether the device’s SFTP storage is currently mounted. |
name | string | Device’s advertised or configured friendly name. |
paired | boolean | Whether the device has established mutual pairing. |
pairing_requested | boolean | Whether 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_sent | boolean | Whether 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. |
reachable | boolean | Whether the device is currently reachable on the network. |
reconnecting | boolean | Whether the device is reconnecting to apply a plugin switch. Clears when it connects again, or after 30 s. |
signal_strength? | integer | Cellular 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.
| Action | Arguments | Description |
|---|---|---|
broadcast | Broadcasts identity over UDP to discover nearby devices and prompt them to re-announce. | |
ring | id: string | Rings the device to locate it. |
ping | id: string, message?: string | Sends a ping notification with an optional custom message. |
send_clipboard | id: string, text: string | Sends text to the remote device’s clipboard. |
share_clipboard | id: string | Sends the current desktop clipboard to the device. |
send_files | id: string, files: string[] | Sends existing local files to the device. |
browse | id: string | Mounts the device’s storage via SFTP and opens it in the file manager. |
unmount | id: string | Unmounts the device’s SFTP storage. |
pair | id: string | Initiates pairing with an unpaired device. |
unpair | id: string | Unpairs a paired device. |
accept_pairing | id: string | Accepts an incoming pairing request from a device. |
reject_pairing | id: string | Rejects an incoming pairing request from a device. |
set_plugin_enabled | id: string, plugin: string, enabled: boolean | Switches 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_command | name: string, command: string | Offers 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_command | id: string | Stops offering the command with this commands[].id, then resends the list to every connected paired device. An unknown id does nothing. |
set_clipboard_auto_share | id: string, enabled: boolean | Switches clipboard_auto_share for the device. Takes effect at the next clipboard change. |
Backend
kdeconnect-service (io.github.hepp3n.kdeconnect) on the session bus.
| Contract | Behavior |
|---|---|
| State | Tracked 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 |
| Security | send_files takes absolute paths of existing regular files; one relative path, missing file or directory rejects the whole batch |
| Daemon files | clipboard_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.
| Field | Type | Description |
|---|---|---|
accent? | string | System accent colour as "#rrggbb" (sRGB), or nil when none is set. |
color_scheme | ColorScheme | Preferred colour scheme; "default" means no preference. |
contrast | Contrast | Preferred contrast level. |
reduced_motion | boolean | true 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.
| Value | Description |
|---|---|
"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.
| Value | Description |
|---|---|
"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
| Field | Portal key (org.freedesktop.appearance) | Default |
|---|---|---|
color_scheme | color-scheme: 1 is "dark", 2 is "light" | "default" |
accent | accent-color: an sRGB triple in [0, 1], as "#rrggbb" | nil, also for a triple out of range |
contrast | contrast: 1 is "high" | "normal" |
reduced_motion | reduced-motion: 1 is true | false |
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
| Trap | Fix |
|---|---|
| Every field stays at its default | No 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 false | The 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.
| Field | Type | Description |
|---|---|---|
by_app_id | table<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. |
entries | AppSummary[] | 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.
| Field | Type | Description |
|---|---|---|
comment? | string | Comment=, unlocalized, e.g. "Web Browser"; nil without the key. |
generic_name? | string | GenericName=, unlocalized, e.g. "Text Editor"; nil without the key. |
icon? | string | Icon= as written, a theme name or absolute path, both accepted by icon { name }; nil without the key. |
id | string | Desktop file id, e.g. "org.telegram.desktop"; the argument of "launch". |
keywords | string[] | Keywords= split on ;, for search; empty without the key. |
name | string | Name=, unlocalized: Name[xx] is not read. |
no_display | boolean | NoDisplay=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.
| Action | Arguments | Description |
|---|---|---|
refresh | Rescans installed desktop entries. The directories are watched, so only a failed watch (logged) needs this. | |
launch | id: string, opts?: LaunchOpts | Launches entries[].id, detached; Terminal=true entries run in $TERMINAL. opts.env sets a variable to a string or unsets it with false. |
open_url | url: string | Opens 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.
| Field | Type | Description |
|---|---|---|
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…
| Task | Answer |
|---|---|
| Hide an app from a launcher | Copy its .desktop file to ~/.local/share/applications and add NoDisplay=true; filter entry.no_display in the launcher |
Gotchas
| Trap | Fix |
|---|---|
launch of a terminal app does nothing | Terminal=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.
| Field | Type | Description |
|---|---|---|
apps | AppStream[] | Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures. |
cards | AudioCard[] | 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. |
sinks | AudioDevice[] | Every output device; the active one is the default. |
sources | AudioDevice[] | Every input device; the active one is the default. |
AppStream
One app’s playback or recording stream. Streams without a pid are left out.
| Field | Type | Description |
|---|---|---|
binary? | string | application.process.binary, e.g. "firefox". |
icon? | string | XDG icon name from application.icon-name, else media.icon-name, e.g. "firefox". |
id | integer | PipeWire node id, the first argument of set_app_volume, set_app_muted and set_app_target. |
media_name? | string | media.name, which tells one app’s streams apart, e.g. a browser tab’s "YouTube Music". Follows the stream as the client renames it. |
muted | boolean | Stream mute; false until volume is known. |
name? | string | application.name, if the client set one. |
pid | integer | Owning process id, from application.process.id. |
pinned_to? | integer | The 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. |
recording | boolean | A capture stream, such as a call’s microphone, rather than playback. |
target? | integer | The sinks[].id a playback stream plays into, or the sources[].id a recording reads; nil while it is linked to neither. |
volume? | number | Stream volume in percent; nil until PipeWire reports the stream’s Props. |
AudioCard
One ALSA or BlueZ card and every profile it offers.
| Field | Type | Description |
|---|---|---|
active? | integer | index of the active profile; nil before PipeWire reports it. |
device | integer | PipeWire device id, the first argument of set_card_profile. |
mac? | string | A BlueZ card’s MAC address, from its bluez_card.* name with _ turned to :; joins mantle.bluetooth. nil on an ALSA card. |
name | string | device.description, e.g. "Built-in Audio", else device.nick, else device.name. |
ports | CardPort[] | Every port, unplugged ones included, ordered by index. A device’s port is its active one. |
profiles | CardProfile[] | 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.
| Field | Type | Description |
|---|---|---|
index | integer | Zero-based index for set_channel_volume. |
position? | string | Speaker position, e.g. "front_left" or "front_right". |
volume | number | Reported level in percent. Sink writes stop at 150; only the default output is corrected above it. |
AudioDevice
One sinks or sources entry.
| Field | Type | Description |
|---|---|---|
active | boolean | This 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? | string | device.bus, e.g. "pci", "usb", "bluetooth". |
card? | integer | The 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? | string | device.form-factor, e.g. "headset". |
icon? | string | device.icon-name theme name, e.g. "audio-card-analog". |
id | integer | PipeWire node id, the first argument of every device action; not reboot-stable. |
muted | boolean | false before its first report. |
name | string | node.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name. |
node_name | string | node.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? | string | The active card route’s port.type, e.g. "headphones", "hdmi", "mic". |
volume? | number | Volume 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.
| Field | Type | Description |
|---|---|---|
active | boolean | The 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. |
available | boolean | false when PipeWire reports the port unavailable, such as an unplugged jack. |
description | string | PipeWire’s description, e.g. "Headphones". |
direction | PortDirection | "output" for a sink’s port, "input" for a source’s. |
index | integer | Port index, the second argument of set_port. |
name | string | PipeWire’s untranslated id, e.g. "analog-output-headphones". |
profiles | integer[] | profiles[].index values the port works under. |
type? | string | port.type, e.g. "speaker", "headphones", "hdmi", "mic". |
CardProfile
One entry of AudioCard.profiles.
| Field | Type | Description |
|---|---|---|
available | boolean | false when PipeWire reports the profile unavailable, such as HDMI with no display plugged in. |
codec? | string | The Bluetooth codec the profile names, e.g. "AAC", "LDAC", "mSBC"; nil when it names none, as off and every ALSA profile. |
description | string | PipeWire’s description, e.g. "Digital Stereo (HDMI) Output". |
index | integer | Profile index, the second argument of set_card_profile. |
inputs | integer | Sources the profile makes; 0 for an output-only profile or off. |
name | string | PipeWire’s untranslated id, e.g. "output:hdmi-stereo" or "off". |
outputs | integer | Sinks the profile makes; 0 for an input-only profile or off. |
PortDirection
Whether a port plays or records.
| Value | Description |
|---|---|
"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.
| Action | Arguments | Description |
|---|---|---|
set_volume | id: integer, volume: number | Sets 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_muted | id: integer, muted: boolean | Sets a sinks[].id or sources[].id mute. |
toggle_mute | id: integer | Toggles a sinks[].id or sources[].id mute. |
set_balance | id: integer, balance: number | Sets a sinks[].id or sources[].id balance, -1.0 (left) to 1.0 (right), clamped; the louder side keeps its level. |
set_default_sink | id: integer | Makes this sinks[].id the default output. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it. |
set_default_source | id: integer | Makes this sources[].id the default input. WirePlumber saves the choice, so a volume change, re-plug or restart keeps it. |
set_channel_volume | id: integer, index: integer, volume: number | Sets one channels[] level of a sinks[].id or sources[].id in percent, clamped as set_volume. Other channels stay unchanged. |
set_app_volume | id: integer, volume: number | Sets an apps[].id stream’s volume in percent, clamped to [0, 100]. |
set_app_muted | id: integer, muted: boolean | Sets an apps[].id stream’s mute. |
set_app_target | id: integer, device?: integer | Moves 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_profile | device: integer, index: integer | Switches a cards[].device to one of its profiles[].index. WirePlumber keeps the choice. |
set_port | card: integer, index: integer | Routes 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_devices | devices?: 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_meters | ids?: 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.
| Field | Type | Description |
|---|---|---|
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? | string | node.description, the name a mixer shows. |
kind | VirtualKind | |
name | string | node.name, unique among the declared devices. |
VirtualKind
What makes a virtual device.
| Value | Description |
|---|---|
"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 object | Feeds |
|---|---|
Audio/Sink, Audio/Source nodes and the default metadata’s default.audio.sink/source | sinks, sources, their active, channels, volume, muted and balance; card is the node’s device.id |
default.configured.audio.sink/source in the default metadata | Written 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 nodes | apps, minus the streams its field lists |
Link globals | an app’s target: the sink or source its links reach |
target.object in the default metadata, keyed by the stream’s id | An 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 profiles | cards; 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 EnumRoute | cards[].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 child | set_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 names | audio_levels, pushed apart from this state |
Each ALSA and BlueZ device’s active Route | port, 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.
| Field | Type | Description |
|---|---|---|
levels | AudioLevel[] | One entry per metered device that has sent audio since the call, ordered by id. |
AudioLevel
One metered sink or source.
| Field | Type | Description |
|---|---|---|
id | integer | The sinks[].id or sources[].id. |
peak | number | The 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
| Source | Feeds |
|---|---|
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 replaces | levels: 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_metersagain frommantle.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.privacylists the Supervisor amongmicrophone_users. A sink’s monitor is not listed. peakis linear in full scale, whilevolumeis cubic. A sink’s monitor carries its streams before the sink’s own volume, unless the sink setsmonitor.channel-volumesas 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.
| Field | Type | Description |
|---|---|---|
health? | integer | Battery health: full charge as a percent of design capacity, 0 to 100, combined over the system batteries. nil when UPower reports no design capacity. |
percent | integer | UPower’s Percentage, rounded to 0 to 100; a spurious 0 while not draining keeps the last value. |
peripherals | PeripheralBattery[] | UPower batteries outside the system supply, ordered by object path. This may include devices also shown by mantle.bluetooth. |
present | boolean | UPower’s display device is a present battery. Check it before drawing system charge, state or time estimates. |
state | BatteryStatus | What the battery is doing; see BatteryStatus. |
time_to_empty? | integer | Seconds until flat, or nil while UPower has no estimate. |
time_to_full? | integer | Seconds until full, or nil while UPower has no estimate. |
BatteryStatus
battery.state: UPower’s Device.State by name, e.g. b.state == "pending_charge".
| Value | Description |
|---|---|
"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.
| Field | Type | Description |
|---|---|---|
icon? | string | UPower icon name, or nil when absent. |
id | string | UPower object path; stable only while this UPower owner holds the device. |
kind | string | UPower device type, e.g. "mouse", "keyboard", "headset". |
level? | string | Coarse charge level, including "unknown", or nil when UPower says to use percent. |
name | string | UPower model, vendor, or object name. |
percent? | integer | Charge percentage, or nil when UPower reports a coarse level or has no usable reading. |
state | BatteryStatus | Charge state; "unknown" when UPower has no recognized value. |
Actions
None: read-only, so any method but get, map and on_change raises.
Backend
| Contract | Behavior |
|---|---|
| Source | UPower’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 |
| Updates | Re-reads the display device on PropertiesChanged; follows peripheral add, remove and property changes; retries failed enumeration after one second |
| No system battery | present = false, percent = 0, state = "unknown", no time estimates; peripherals may remain |
| No UPower | The 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.
| Field | Type | Description |
|---|---|---|
available | boolean | BlueZ has an adapter; false without one or without bluetoothd. |
connected_devices | ConnectedDevice[] | Paired, connected devices. Unordered and may reshuffle on any push: sort before drawing. |
discoverable | boolean | Other devices can find this adapter. BlueZ turns it off after DiscoverableTimeout (180 s by default). |
discovered_devices | DiscoveredDevice[] | Unpaired devices BlueZ knows, unordered. Kept after stop_discovery; BlueZ expires unseen temporary ones after TemporaryTimeout (30 s by default). |
discovering | boolean | The adapter is scanning, whichever client started it. |
enabled | boolean | The adapter is powered. |
paired_devices | PairedDevice[] | Paired devices that are not connected. Unordered like connected_devices. |
pairing_request? | PairingRequest | The pairing question to show, or nil. Entry requests use secure_submit; confirmation, authorization and service requests use answer_pairing. |
ConnectedDevice
| Field | Type | Description |
|---|---|---|
battery? | integer | Battery percentage, or nil when the device reports none. |
busy? | DeviceAction | Same as DiscoveredDevice.busy. |
category | DeviceCategory | |
mac | string | MAC address, e.g. "00:1A:7D:DA:71:11"; every bluetooth action takes it. |
name | string | The 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.
| Value | Description |
|---|---|
"keyboard" | |
"mouse" | |
"headphones" | |
"headset" | |
"phone" | |
"computer" | |
"generic" | Any class not listed. |
DiscoveredDevice
| Field | Type | Description |
|---|---|---|
blocked | boolean | BlueZ refuses to pair with or connect to the device until it is unblocked. |
busy? | DeviceAction | The action this shell is running on the device, or nil; another client’s never shows. |
mac | string | MAC address, the argument of pair. |
name | string | Advertised name, often empty when the device broadcasts only an address. |
PairedDevice
| Field | Type | Description |
|---|---|---|
blocked | boolean | BlueZ refuses every connection to or from the device until it is unblocked. |
busy? | DeviceAction | Same as DiscoveredDevice.busy. |
category | DeviceCategory | |
mac | string | MAC address, the argument of connect and forget. |
name | string | The 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.
| Field | Type | Description |
|---|---|---|
code? | string | Six-digit passkey for "confirm", passkey or PIN for "display", else nil. |
id | string | Unique for this Supervisor session. Include it with the device MAC in a secure entry target. |
kind | PairingKind | "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. |
mac | string | The device’s MAC address. |
name | string | The device’s advertised name, or empty. |
Actions
Call each as mantle.bluetooth:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_enabled | enabled: boolean | Powers the adapter on or off. |
set_discoverable | discoverable: boolean | Makes the adapter findable by other devices, or not. |
start_discovery | Clears discovered_devices and scans. The request holds, so a scan starts once the adapter powers on and pauses while a pair runs. | |
stop_discovery | Stops discovery; discovered_devices stays. | |
pair | mac: string | Pairs a discovered device, then trusts and connects it. |
connect | mac: string | Trusts and connects a paired device. |
disconnect | mac: string | Disconnects a connected device. |
forget | mac: string | Removes a device from BlueZ, unpairing it. |
answer_pairing | mac: string, accept: boolean | Accepts 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.
| Contract | Behavior |
|---|---|
| State | org.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 |
| Agent | Mantle 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 |
| Discovery | start_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, category | Battery1 gives battery; the Class major and minor bits give category |
| Codecs | PipeWire 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
| Trap | Fix |
|---|---|
| A device pairing from its own side gets rejected | Mantle 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.
| Field | Type | Description |
|---|---|---|
percent | integer | Screen 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.
| Action | Arguments | Description |
|---|---|---|
set | percent: number | Sets the screen backlight, 0 to 100, fractions allowed; values outside clamp to it. |
Backend
| Contract | Behavior |
|---|---|
| Device | One /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 device | Stays nil until one appears; set is logged and ignored. A removed device leaves its last reading, since no value means “gone” |
| Updates | A udev backlight watch re-reads sysfs brightness and pushes on change. If the watch cannot start, a 30 s poll replaces it |
| Writes | logind’s Session.SetBrightness, so no udev rule or group is needed. logind refuses it from an inactive session; the refusal is logged |
Gotchas
| Trap | Fix |
|---|---|
set to 0 writes raw 0, which turns the backlight off on many panels | Clamp 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.
| Field | Type | Description |
|---|---|---|
watched | table<string, Folder> | One entry per "watch", keyed by its absolute path (~/ expanded) minus trailing slashes; nil until watched. |
FileEntry
| Field | Type | Description |
|---|---|---|
modified | integer | Modification time in Unix seconds; 0 when unavailable. |
name | string | File name, e.g. "sunrise.jpg". |
path | string | Absolute path. |
size | integer | Size in bytes; a symlink reports its target’s size. |
Folder
| Field | Type | Description |
|---|---|---|
entries | FileEntry[] | 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? | string | Why 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. |
ready | boolean | false 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.
| Action | Arguments | Description |
|---|---|---|
watch | path: string, extensions?: string[] | Keeps watched[path] listing a folder, absolute or ~/ under $HOME. extensions match case-insensitively, dot optional; omitted means every file. |
unwatch | path: string | Stops 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
| Trap | Fix |
|---|---|
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 watch | The key drops trailing slashes: watch("/walls/") lands at watched["/walls"] |
error is set but the folder exists | It 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.
| Field | Type | Description |
|---|---|---|
compositor_hold_stale | boolean | The 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. |
inhibited | boolean | True 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. |
inhibitors | IdleInhibitor[] | 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.
| Field | Type | Description |
|---|---|---|
who | string | Free-text holder name, e.g. "mpv"; draw it, never match it. |
why | string | Free-text reason, e.g. "Playing video"; often empty. |
Actions
None: read-only, so any method but get, map and on_change raises.
Methods
| Method | Contract |
|---|---|
: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.
| Event | Thresholds | Inhibit holds |
|---|---|---|
| In-place reload | Dropped 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 period | Kept |
| Renderer replacement | Dropped | Dropped |
Backend
| Part | Behaviour |
|---|---|
| Thresholds | ext_idle_notifier_v1 on the Supervisor’s own Wayland connection. Missing protocol, or setup over 5 s: thresholds never fire, logged once |
| Inhibit | Every hold, from any generation, shares one logind Inhibit("idle", "block") fd, closed when the last hold goes |
| ScreenSaver | Hosts 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 |
| Gate | Mantle, 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 holds | A 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
| Trap | Fix |
|---|---|
register_threshold inside on_change, a timer or a click handler | Each 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 ends | Holds survive reloads and are counted. Record the hold in a state and release once per inhibit |
| A threshold never fires while a video plays | Something 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.
| Field | Type | Description |
|---|---|---|
active_layout | string | Layout display name, e.g. "English (US)"; empty before the compositor answers or without one. |
active_layout_index | integer | 0-based position of the active layout, as switch_layout takes it. |
backlight_percent? | integer | Keyboard 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_lock | boolean | Caps Lock is on. |
layout_count | integer | Configured layout count; below 2 there is nothing to switch. |
num_lock | boolean | Num Lock is on. |
scroll_lock | boolean | Scroll Lock is on. |
Actions
Call each as mantle.keyboard:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_backlight | percent: number | Sets the keyboard backlight, 0 to 100, fractions allowed; values outside clamp to it. |
switch_layout | index: integer | Switches to the 0-based configured layout index. |
Backend
| Part | Source | Without it |
|---|---|---|
| Lock keys | EV_LED events from the first /dev/input device with a Caps Lock LED; a replugged keyboard is reopened | sysfs *::capslock, *::numlock, *::scrolllock polled every 250 ms; with none of those, false |
| Backlight | Reads sysfs *::kbd_backlight, writes through logind’s SetBrightness | backlight_percent = nil; set_backlight is logged and ignored |
| Layout | The 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 name | active_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
| Trap | Fix |
|---|---|
caps_lock changes up to 250 ms after the key | The Supervisor cannot read /dev/input, so it polls sysfs. Add the user to the input group |
backlight_percent misses a change another program made | It refreshes only on hardware hotkeys and set_backlight. Change it through set_backlight |
switch_layout on niri with an index above 255 does nothing | niri 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.
| Field | Type | Description |
|---|---|---|
active | boolean | The Renderer confirmed the session locked; a requested lock stays false until then. |
attempts | integer | Rejected passwords since this lock was confirmed; reset by the next lock. |
authenticating | boolean | An answer is with PAM, or typed before its prompt and held for it. A second submit is refused while true. |
error | string | Last 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? | FingerprintState | The 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. |
messages | PamNotice[] | PAM’s info and error lines, oldest first, at most 8. Cleared by the next answer and when the lock ends. |
prompt? | PamPrompt | PAM’s pending question, or nil while it owes nothing. |
unlocking | boolean | PAM said yes and the lock is still up: the window for an out-animation. |
FingerprintState
mantle.lock.fingerprint while its conversation runs.
| Field | Type | Description |
|---|---|---|
messages | PamNotice[] | 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.
| Field | Type | Description |
|---|---|---|
kind | PamNoticeKind | "info" or "error". |
text | string | What the module said, as it said it. |
PamNoticeKind
What a PamNotice reports.
One of "info", "error".
PamPrompt
A question a PAM module asked.
| Field | Type | Description |
|---|---|---|
echo | boolean | The answer is meant to be seen: set the secure field’s masked to not echo. |
id | integer | Unique for this session, so two identical prompts in a row still differ. |
text | string | PAM’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.
| Action | Arguments | Description |
|---|---|---|
lock | Locks the session; a no-op while active. | |
set_unlock_animation | ms?: integer | Keeps the lock up ms after a correct password for an out-animation. Clamped to 600; omitted is 0. |
Backend
| Contract | Behavior |
|---|---|
| Ownership | The Supervisor decides lock and unlock; the Renderer holds and paints ext_session_lock_v1. Built at boot, unlike other capabilities |
| Triggers | The 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 |
| Unlock | Only 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 |
| Conversation | Starts 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 |
| Fingerprint | Starts 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 animation | set_unlock_animation delays the release by up to 600 ms; the value persists across reloads |
| Crash | A dead Renderer never unlocks; the replacement retakes the lock. $XDG_RUNTIME_DIR/mantle/session-locked carries the lock across a Supervisor restart |
| Refused or lost | Sets mantle.rescue with the reason |
| Reload | An edit that would recreate a lock surface is refused while locked (lock surface) |
How do I…
| Task | Answer |
|---|---|
| Show “wrong password” | error and attempts: lock surface example |
| Ask for a second factor | Show 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 notice | messages, each { kind = "info" or "error", text }, cleared by the next answer |
| Show a fingerprint hint | fingerprint.messages, the same shape, when fingerprint is not nil: Lock screen recipe |
| Turn fingerprint off | Replace 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 out | set_unlock_animation with the animation’s length, then drive the fade from unlocking: Lock screen recipe |
Gotchas
| Trap | Fix |
|---|---|
Removing set_unlock_animation from the config keeps the old delay | The value outlives reloads. Call set_unlock_animation with no argument to reset it to 0 |
| A 1 s out-animation is cut short | The 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 scan | Use 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 suspend | The 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.
| Field | Type | Description |
|---|---|---|
players | PlayerState[] | 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.
| Value | Description |
|---|---|
"none" | Plays through once. |
"track" | Repeats the current track. |
"playlist" | Repeats the playlist. |
PlayState
MPRIS PlaybackStatus.
One of "playing", "paused", "stopped".
PlayerState
| Field | Type | Description |
|---|---|---|
album | string | Album title; empty when unset. |
album_art_path | string | Cover art as an existing local path, or empty when unavailable. |
album_artist | string | Album artists joined with ", "; empty when unset. |
art_url | string | mpris:artUrl as sent, e.g. an https:// image to fetch and cache; empty when unset. |
artist | string | Artists joined with ", "; empty when unset. |
can_go_next | boolean | MPRIS CanGoNext. |
can_go_previous | boolean | MPRIS CanGoPrevious. |
can_pause | boolean | MPRIS CanPause. |
can_play | boolean | MPRIS CanPlay. |
can_quit | boolean | MPRIS CanQuit on the root interface. |
can_raise | boolean | MPRIS CanRaise on the root interface. |
can_seek | boolean | MPRIS CanSeek. |
desktop_entry | string | The player’s .desktop basename, e.g. "firefox", for app matching; empty when unset. |
genre | string | Genres joined with ", "; empty when unset. |
id | string | Bus-name suffix after org.mpris.MediaPlayer2., e.g. "spotify"; every action takes it. |
identity | string | Display name, e.g. "Spotify"; empty if unanswered. |
length? | integer | Track length in microseconds, or nil when unknown, as for a live stream. |
loop_status? | LoopStatus | MPRIS loop mode; nil when the player does not report one. |
maximum_rate | number | MPRIS maximum playback rate, or 0 when unavailable. |
minimum_rate | number | MPRIS minimum playback rate, or 0 when unavailable. |
pid? | integer | Process 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_state | PlayState | MPRIS PlaybackStatus; keeps the last value when a read fails, "stopped" if none. |
playlists | PlaylistsState | One bounded page from the optional MPRIS Playlists interface. |
position? | integer | Playback offset in microseconds as of position_updated_at, not polled while playing: add elapsed time. nil when unknown. |
position_updated_at | integer | CLOCK_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. |
rate | number | MPRIS playback rate. |
shuffle | boolean | MPRIS shuffle setting. |
title | string | Track title; empty when unset, normal between tracks. |
track_list | TrackListState | Nearby tracks from the optional MPRIS TrackList interface. |
url | string | xesam:url as sent, e.g. a file:// path or an https:// page; empty when unset. |
volume | number | MPRIS volume in percent, 100 when unreported. The protocol permits amplification above 100. |
PlaylistSummary
| Field | Type | Description |
|---|---|---|
icon | string | Icon URI, or empty if absent. |
id | string | Stable playlist object path. |
name | string | User-facing playlist name. |
PlaylistsState
| Field | Type | Description |
|---|---|---|
active? | PlaylistSummary | Active playlist, if the player reports one. |
count | integer | Total playlists reported by the player. |
index | integer | Index used for the current page. |
order | string | Ordering used for the current page. |
orderings | string[] | Ordering names accepted by playlists_get. |
page_size | integer | Number of entries requested for the current page, at most 100. |
playlists | PlaylistSummary[] | At most 100 playlist entries. |
reverse | boolean | Whether the page is reversed. |
TrackListState
| Field | Type | Description |
|---|---|---|
can_edit | boolean | Whether the player permits add and remove calls. |
current_track | string | Current TrackList object path, or empty if unknown. |
tracks | TrackSummary[] | At most 100 tracks around the current track; the full playlist remains player-owned. |
TrackSummary
| Field | Type | Description |
|---|---|---|
artist | string | Track artists joined with ", ". |
id | string | TrackList object path, used by track_list_go_to and track_list_remove_track. |
length? | integer | Track length in microseconds, or nil when unknown. |
title | string | Track title, empty when the player has none. |
Actions
Call each as mantle.mpris:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
control | id: string, cmd: PlayerCommand | Sends a playback command to players[].id. |
seek | id: string, position_us: integer | Seeks to an absolute position in microseconds, clamped to [0, length] (only >= 0 when length is nil). |
seek_relative | id: string, offset_us: integer | Seeks by a signed offset in microseconds, unclamped; past the end may skip to the next track. |
raise | id: string | Calls Raise; check players[].can_raise before calling. |
quit | id: string | Calls Quit; check players[].can_quit before calling. |
open_uri | id: string, uri: string | Opens an absolute URI in the player. |
set_volume | id: string, value: number | Sets MPRIS Volume in percent; finite values at or above zero are accepted. |
set_loop_status | id: string, value: LoopStatus | Sets MPRIS LoopStatus. |
set_shuffle | id: string, value: boolean | Sets MPRIS Shuffle. |
set_rate | id: string, value: number | Sets a positive finite MPRIS playback rate. |
track_list_add_track | id: string, uri: string, after_track: string, set_as_current: boolean | Inserts a URI after a track id, or after /org/mpris/MediaPlayer2/TrackList/NoTrack to prepend. |
track_list_remove_track | id: string, track_id: string | Removes a track by its TrackList object path. |
track_list_go_to | id: string, track_id: string | Starts the track identified by its TrackList object path. |
playlists_get | id: string, index: integer, count: integer, order: string, reverse: boolean | Reads a bounded playlist page into players[].playlists. |
playlists_activate | id: string, playlist_id: string | Activates a playlist by its object path. |
PlayerCommand
One of "play", "pause", "play_pause", "next", "previous", "stop".
Backend
| Contract | Behavior |
|---|---|
| Discovery | Session bus ListNames once, then NameOwnerChanged for org.mpris.MediaPlayer2.*. Skips playerctld and any player reporting CanControl = false |
| Pushes | On playback, metadata, control-property, TrackList or Playlists changes and on Seeked. A status change re-reads Position 100 ms later. Nothing polls |
| Seek | seek calls SetPosition with the cached mpris:trackid. A player without one gets a relative Seek from a live Position read |
| Controls | Read 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 |
| Artwork | Uses existing local file:// paths. Remote artwork URLs are unsupported |
How do I…
| Task | Answer |
|---|---|
| Show a live progress bar | Compare position_updated_at / 1e6 with mantle.clock:now(), or stamp each new report in on_change and add the seconds since: Media player |
Gotchas
| Trap | Fix |
|---|---|
position stands still while playing | It is the offset at position_updated_at, not polled. Extrapolate, as above |
position_updated_at compared with mantle.clock.monotonic is off by a million | Same 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.
| Field | Type | Description |
|---|---|---|
connect_error? | JoinError | The 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. |
connected | boolean | A connection carries the default route; false means offline. |
connecting_ssid? | string | The current join’s SSID on any Wi-Fi device, or nil; clears on a verdict or abort_connect. |
ethernet_enabled | boolean | A wired device is activated; set_ethernet_enabled’s read-back, unlike carrier. |
ethernet_ip? | string | The first activated wired device’s IPv4 address without prefix, or nil. |
ethernet_present | boolean | At least one wired device exists, cable or not. |
ethernet_speed? | integer | That wired device’s link speed in Mb/s; nil when unknown or none is activated. |
networking_enabled | boolean | NetworkManager networking is on (NetworkingEnabled). |
password_ssid? | string | The 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? | VpnError | The last failed VPN activation, or nil before any or after the next connect_vpn. |
vpn_secret? | VpnSecretRequest | The one pending VPN secret request, or nil. Answer it with network/vpn_secret secure fields or drop it with cancel_vpn_secret. |
vpns | VpnInfo[] | Saved VPN and WireGuard profiles, ordered by name. |
wifi_devices | WifiDeviceInfo[] | Every Wi-Fi interface, primary first. IDs are interface names, never NetworkManager object paths. |
wifi_enabled | boolean | Wi-Fi radio power (WirelessEnabled); can be true with no Wi-Fi hardware, see wifi_present. |
wifi_present | boolean | At least one Wi-Fi device exists. |
AccessPointInfo
One scanned network in available_networks.
| Field | Type | Description |
|---|---|---|
active | boolean | The Wi-Fi device is associated with this SSID. |
band | string | "2.4 GHz", "5 GHz", "6 GHz", or empty for a frequency outside those bands. |
saved | boolean | A saved NetworkManager profile compatible with this device names this SSID. |
secure | boolean | Needs a key: WEP, WPA or RSN. |
ssid | string | Network name, "" for hidden networks; one entry per SSID, from its strongest access point. |
strength | integer | Signal strength, 0 to 100. |
JoinError
A failed join, as connect_error.
| Field | Type | Description |
|---|---|---|
message | string | Display text, such as "wrong password" or "network not found". |
ssid | string | The network the join was for. |
VpnError
A failed VPN activation, as vpn_error.
| Field | Type | Description |
|---|---|---|
message | string | Display text, such as "login failed" or "connection timed out". |
uuid | string | The profile the activation was for. |
VpnInfo
One saved NetworkManager VPN profile in vpns.
| Field | Type | Description |
|---|---|---|
activating | boolean | The profile is being activated, including while it waits for a secret. |
active | boolean | The profile is activated. |
id | string | Profile name. |
kind | VpnKind | |
uuid | string | Profile UUID; pass it to connect_vpn and disconnect_vpn. |
VpnKind
vpns[].kind.
| Value | Description |
|---|---|
"vpn" | A plugin VPN. |
"wireguard" | A native WireGuard profile. |
VpnSecretRequest
NetworkManager asking for VPN secrets, as vpn_secret.
| Field | Type | Description |
|---|---|---|
fields | string[] | Secret keys still to enter, such as "password"; each entered key leaves the list. |
id | string | Request ID. A secure field for fields[i] uses name = request.id .. "/" .. fields[i]. |
retry | boolean | NetworkManager rejected the previous secrets and asks again. |
uuid | string | UUID of the profile being activated. |
vpn_id | string | Name 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".
| Field | Type | Description |
|---|---|---|
available_networks | AccessPointInfo[] | Visible networks on this interface: one per SSID, at most 20, ordered associated, then saved, then strongest. |
connect_error? | JoinError | Failed join on this interface, or nil; cleared by the next join or cancel_connect. |
connected | boolean | This interface has an activated connection. |
connecting_ssid? | string | SSID of this interface’s current join, or nil. |
id | string | Interface name, such as "wlan0"; pass it to actions ending in _device. |
password_ssid? | string | SSID awaiting a password on this interface, or nil. |
scanning | boolean | A scan is in flight on this interface. |
ssid? | string | Associated SSID, or nil. |
strength | integer | Associated access point strength, 0 to 100. |
wifi_ip? | string | IPv4 address without prefix, or nil. |
Actions
Call each as mantle.network:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_networking_enabled | enabled: boolean | Turns NetworkManager networking on or off. |
set_wifi_enabled | enabled: boolean | Powers the Wi-Fi radio. |
set_ethernet_enabled | enabled: boolean | false disconnects every wired device; true activates each one’s autoconnect profile, and a device without one stays down. |
scan | Requests a Wi-Fi scan; a no-op without Wi-Fi hardware. | |
scan_device | id: string | Requests a scan on the named Wi-Fi interface. |
connect | ssid: string, hidden: boolean | Joins a network. Without a saved profile, a secured, hidden or out-of-range one sets password_ssid and waits for a key. |
connect_device | ssid: string, hidden: boolean, id: string | Joins through the named Wi-Fi interface; a removed ID is never retargeted. |
cancel_connect | Drops the one current password request, on any Wi-Fi device; a join already running continues. | |
abort_connect | Stops the one current join on any Wi-Fi device, deleting a profile the join created. | |
forget | ssid: string | Deletes every saved profile for this SSID. |
disconnect_wifi | Disconnects Wi-Fi; NetworkManager does not autoconnect it again until the next join. | |
disconnect_wifi_device | id: string | Disconnects the named Wi-Fi interface. |
connect_vpn | uuid: string | Activates the saved VPN or WireGuard profile with this UUID. A missing secret raises vpn_secret. |
disconnect_vpn | uuid: string | Deactivates the VPN or WireGuard profile with this UUID. |
cancel_vpn_secret | Declines the pending vpn_secret request, failing that activation. |
Backend
| Contract | Behavior |
|---|---|
| Updates | Every 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 |
| Devices | wifi_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 |
| Toggles | Networking through Enable, Wi-Fi through WirelessEnabled |
| Scan | RequestScan. The device’s scanning turns true on the call and false when LastScan moves or NetworkManager refuses |
| Access points | The associated one’s strength is live. The others’ are read when they appear and after each scan, when NetworkManager updates them |
| Connect | A 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 verdict | Watched 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 |
| Abort | abort_connect deletes a profile the join created, else deactivates the join |
| VPN profiles | vpns 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 activation | connect_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 secrets | The 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 scope | Only 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 |
| Missing | Stays 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
| Trap | Fix |
|---|---|
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 open | Its 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.
| Field | Type | Description |
|---|---|---|
dnd | boolean | Do-not-disturb: mutes non-critical sounds only. Hiding popups is the config’s call. |
feed | Notification[] | 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.
| Field | Type | Description |
|---|---|---|
actions | NotificationAction[] | Buttons in sender order, at most 8, excluding default and inline-reply. |
app_icon? | string | Application icon for icon { name = ... }: a theme name such as "firefox" or an absolute path, or nil. |
app_name | string | Sending application, truncated to 64 bytes. |
body | NotificationSpan[] | Parsed body markup; the raw body is truncated to 512 bytes first. |
desktop_entry? | string | Sender’s desktop id, e.g. "org.telegram.desktop", for mantle.applications.by_app_id; nil when absent or containing /. |
expired | boolean | The 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_action | boolean | Clicking the card may :invoke_action(id, "default"). |
has_reply | boolean | The sender accepts mantle.notifications:reply(id, text). |
id | integer | Server id, from 1; a replacement keeps the id it replaces. |
image_path? | string | Attached picture (album art, avatar) as an existing absolute path, or nil. Never a theme name. |
reply_placeholder? | string | Placeholder for an empty reply field, e.g. "Reply to Alice", capped at 64 bytes; nil when unset. |
summary | string | Title as sent, truncated to 128 bytes. Not markup-parsed: the spec makes it plain text. |
timestamp | integer | Arrival time, Unix seconds; age is mantle.clock.time - timestamp. A replacement restamps it. |
transient | boolean | Popup-only: removed on expiry instead of retired to history. |
urgency | Urgency | "normal" when the sender set none. "critical" never expires and plays sound through DND. |
NotificationAction
One action button.
| Field | Type | Description |
|---|---|---|
icon_name? | string | Theme icon name (the key) when the sender set action-icons, else nil. Never a path. |
key | string | Opaque key for :invoke_action(id, key). |
label | string | Button 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.
| Field | Type | Description |
|---|---|---|
bold? | boolean | Whether the run was inside <b>. |
href? | string | <a href> target, or nil when not a link. |
image_path? | string | Existing absolute path under an icon root; images elsewhere are dropped. |
italic? | boolean | Whether the run was inside <i>. |
kind | "text"|"image" | Which variant this is; each other field belongs to one variant. |
text? | string | Unescaped text; empty runs are omitted. |
underline? | boolean | Whether 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.
| Action | Arguments | Description |
|---|---|---|
dismiss | id: integer | Removes a queued notification. |
invoke_action | id: integer, key: string | Invokes an actions[].key, or "default"; removes the notification unless it is resident. |
reply | id: integer, text: string | Sends reply text to a notification with has_reply; removes it unless it is resident. |
set_sound | urgency: Urgency, path: string | Sets 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_dnd | enabled: boolean | Gates non-critical notification sounds. |
set_quiet | enabled: boolean | Mutes non-critical sounds like set_dnd, without changing dnd. |
set_app_muted | app: string, muted: boolean | Silences every sound from an app, critical included, matched exactly on app_name or desktop_entry. |
hold_expiry | seconds: integer | Pauses every expiry countdown for seconds, capped at 300; 0 releases the hold. |
Backend
Mantle is the org.freedesktop.Notifications server on the session bus.
| Contract | Behavior |
|---|---|
| Name | Requested with DoNotQueue. If mako, dunst or another daemon owns it, the server stays off for the run and feed stays empty |
| Retention | 100-entry FIFO; feed shows the newest 20 |
| Expiry | A negative expire_timeout means 5 s. Critical and 0 never expire |
| Close reasons | NotificationClosed sends 1 expired, 2 dismiss or reply, 3 CloseNotification or invoke_action, 4 evicted from the FIFO. reply also emits NotificationReplied(id, text) |
| Body markup | Keeps <b>, <i>, <u>, <a href>, <img src>. Other tags lose their markup and keep their text; script and style lose both |
| Images | A 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 |
| Sound | Nothing 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.
| Field | Type | Description |
|---|---|---|
action_id | string | Action being authorized, e.g. org.freedesktop.systemd1.manage-units. |
active | boolean | polkitd is waiting for the user to authenticate. |
authenticating | boolean | An answer is with PAM, or typed before its prompt and held for it. A second submit is refused while true. |
error | string | Drawable reason for the last failure, e.g. "authentication failed". The prompt stays open to retry. |
icon_name | string | Themed icon name, or empty when the caller set none. |
identities | PolkitIdentity[] | The accounts polkit accepts, as it offered them; groups arrive already expanded. |
identity | integer | The uid the next helper authenticates: this session’s user if offered, else the first. |
message | string | The action’s prompt, e.g. "Authentication is required to ...", in en_US: the locale the agent registers with. |
messages | PamNotice[] | The helper’s info and error lines, as on mantle.lock. |
prompt? | PamPrompt | The 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.
| Field | Type | Description |
|---|---|---|
kind | PamNoticeKind | "info" or "error". |
text | string | What the module said, as it said it. |
PamNoticeKind
What a PamNotice reports.
One of "info", "error".
PamPrompt
A question a PAM module asked.
| Field | Type | Description |
|---|---|---|
echo | boolean | The answer is meant to be seen: set the secure field’s masked to not echo. |
id | integer | Unique for this session, so two identical prompts in a row still differ. |
text | string | PAM’s own text, e.g. "Password: " or "Verification code:". |
PolkitIdentity
One account a polkit challenge accepts.
| Field | Type | Description |
|---|---|---|
name | string | Login name, or empty when the uid has no passwd entry. |
uid | integer |
Actions
Call each as mantle.polkit:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
cancel | Dismisses the prompt; the requesting program sees the request cancelled. | |
select_identity | uid: integer | Authenticates 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
| Trap | Fix |
|---|---|
| A second program’s prompt never shows | One 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 agent | polkit-gnome, hyprpolkitagent or similar registered first. Stop it and restart Mantle |
| The prompt stays open after a wrong password | By design: error says why, and the next submit starts a new helper |
| Another admin should authenticate | List 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 typed | polkit’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.
| Field | Type | Description |
|---|---|---|
active_profile? | string | Active platform profile, e.g. "balanced". |
energy_rate? | number | UPower’s display-device EnergyRate in watts; direction is mantle.battery.state. |
on_battery? | boolean | UPower’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.
| Action | Arguments | Description |
|---|---|---|
set_profile | name: string | Switches to one of profiles; a name not in it, or one the daemon rejects, is logged and active_profile stays. |
Backend
| Half | Source | Missing |
|---|---|---|
active_profile, profiles | power-profiles-daemon: org.freedesktop.UPower.PowerProfiles, else net.hadess.PowerProfiles | Both fields absent; set_profile is logged and ignored |
on_battery, energy_rate | UPower’s OnBattery and its DisplayDevice’s EnergyRate | Both 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
| Trap | Fix |
|---|---|
| power-profiles-daemon started after the capability never shows up | The daemon is looked up once, on the first read. Restart mantle after installing it |
energy_rate has no sign | It 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.
| Field | Type | Description |
|---|---|---|
camera_users | PrivacyUser[] | 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_users | PrivacyUser[] | Apps with a running PipeWire audio capture, one per name. Idle streams and sink-monitor captures are absent; a muted microphone still counts. |
screencast_users | PrivacyUser[] | 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.
| Field | Type | Description |
|---|---|---|
app_name | string | PipeWire 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
| Source | Feeds |
|---|---|
Running Stream/Input/Audio PipeWire nodes | microphone_users |
Running Stream/Output/Video and portal Video/Source PipeWire nodes | screencast_users |
Running Stream/Input/Video PipeWire nodes linked to a camera Video/Source | camera_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 device | camera_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
| Trap | Fix |
|---|---|
grim or wf-recorder never shows in screencast_users | wlr-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.
| Field | Type | Description |
|---|---|---|
sessions | table<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.
| Field | Type | Description |
|---|---|---|
exit_code? | integer | Exit status of the last run; nil while running, before any run, or when a signal killed it. |
pid? | integer | Process id, also its process group id; kept after exit, nil before a spawn or after a failed start. |
running | boolean | Whether it is up now. Otherwise the fields below describe the last run. |
start_error | string | Why the last start failed to spawn, e.g. a cmd not on PATH; empty when it spawned. |
started_at? | integer | Unix 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.
| Action | Arguments | Description |
|---|---|---|
declare | name: string, stop_signal?: SignalName | Registers name (required before start) and sets its stop signal, default TERM. Redeclaring updates the signal without touching a running program. |
start | name: 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. |
signal | name: string, signal: SignalName | Sends signal to the program’s process (not its group); no-op when not running. |
stop | name: string | Sends 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.
| Field | Type | Description |
|---|---|---|
radios | Radio[] | 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.
| Field | Type | Description |
|---|---|---|
hard_blocked | boolean | Hardware switch block, which software cannot clear: any device of this kind is blocked. |
kind | RadioKind | |
soft_blocked | boolean | Software 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.
| Action | Arguments | Description |
|---|---|---|
set_blocked | kind: RadioKind, blocked: boolean | Soft-blocks or unblocks every device of kind. A hardware block stays. |
set_all_blocked | blocked: boolean | Soft-blocks or unblocks every radio (airplane mode). |
Backend
| Contract | Behavior |
|---|---|
| Source | /dev/rfkill only: one event per existing device at start, then live changes. No polling |
| Kinds | One 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 node | Stays nil for good; actions are logged and ignored |
| No radios | Stays nil until one appears: the kernel sends nothing to read |
| Writes | One 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
| Trap | Fix |
|---|---|
set_blocked(kind, false) leaves hard_blocked = true | A hardware switch overrides software. Only the switch clears it; show it instead |
A session without a seat cannot write /dev/rfkill | State 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.
| Field | Type | Description |
|---|---|---|
entries | table<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.
| Field | Type | Description |
|---|---|---|
contents | table<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.
| Action | Arguments | Description |
|---|---|---|
open | path: 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. |
set | path: string, key: string, value?: any | Sets 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.
| Field | Type | Description |
|---|---|---|
cpu_percent | integer | CPU utilization across all cores, 0 to 100, rounded down; 0 until two samples form a delta. |
disks | DiskDevice[] | Physical block devices and their mounted partitions. |
gpu? | GpuTelemetry | GPU telemetry (load, VRAM, temperature) if a supported backend was detected; nil otherwise. |
net_rx_bytes_sec | integer | Download rate across active non-loopback interfaces in bytes per second; 0 until two samples form a delta. |
net_tx_bytes_sec | integer | Upload rate across active non-loopback interfaces in bytes per second; 0 until two samples form a delta. |
ram_percent | integer | Physical memory in use (MemTotal - MemAvailable), 0 to 100, rounded down. |
swap_percent | integer | Swap in use, 0 to 100, rounded down; also 0 without swap. |
temp_cores | integer[] | 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? | integer | amdgpu, 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.
| Field | Type | Description |
|---|---|---|
name | string | Kernel block device name, e.g. “nvme0n1” or “sda”. |
partitions | DiskPartition[] | Mounted partitions under this block device. |
percent | integer | Aggregate percentage in use, 0 to 100. |
total_bytes | integer | Aggregate total bytes across mounted partitions. |
used_bytes | integer | Aggregate bytes in use across mounted partitions. |
DiskPartition
One mounted partition under a physical block device.
| Field | Type | Description |
|---|---|---|
mount_point | string | Mountpoint path, e.g. “/” or “/home”. |
percent | integer | Percentage in use, 0 to 100. |
total_bytes | integer | Total bytes on this filesystem. |
used_bytes | integer | Bytes in use on this filesystem. |
GpuTelemetry
Telemetry metrics for the first detected GPU device.
| Field | Type | Description |
|---|---|---|
mem_total? | integer | Total VRAM in bytes, or nil if shared or unavailable. |
mem_used? | integer | Dedicated/used VRAM in bytes, or nil if shared or unavailable. |
name | string | Device model name reported by driver/tool, e.g. “Raptor Lake-P (Iris Xe Graphics)” or “NVIDIA GeForce RTX 4070”. |
temp? | integer | GPU temperature in whole Celsius, or nil if no sensor reported. |
util_percent? | integer | GPU core utilization percentage, 0 to 100. |
Actions
Call each as mantle.sysinfo:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
configure | intervals: SysinfoConfigure | Sets 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.
| Field | Type | Description |
|---|---|---|
cpu_interval? | integer | Seconds between CPU reads; 0 (the default) stops them. |
disk_interval? | integer | Seconds between disk space reads; 0 (the default) stops them. |
gpu_interval? | integer | Seconds between GPU telemetry reads; 0 (the default) stops them. |
net_interval? | integer | Seconds between network throughput reads; 0 (the default) stops them. |
ram_interval? | integer | Seconds between memory and swap reads; 0 (the default) stops them. |
temp_interval? | integer | Seconds between temperature reads; 0 (the default) stops them. |
Backend
| Field | Read from | Interval |
|---|---|---|
cpu_percent | /proc/stat’s cpu line, the delta between two reads | cpu_interval |
ram_percent, swap_percent | /proc/meminfo | ram_interval |
temp_cores | hwmon k10temp Tccd* or coretemp Core * sensors, else that chip’s first sensor, else acpitz’s | temp_interval |
temp_gpu | The first sensor of hwmon amdgpu, nouveau, nvidia, i915 or xe | temp_interval |
disks | lsblk --json --bytes block devices and mounted partitions (FSUSED and FSSIZE) | disk_interval |
gpu | nvtop -s, nvidia-smi or DRM sysfs | gpu_interval |
net_rx_bytes_sec, net_tx_bytes_sec | /proc/net/dev non-loopback interface counter deltas | net_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
| Trap | Fix |
|---|---|
sysinfo stays nil | Nothing 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 moves | Each field updates on its own interval, and an unset one is 0 (off). Set ram_interval too |
gpu stays nil | No supported GPU tool (nvtop, nvidia-smi) or DRM sysfs was found, or gpu_interval is unset |
disks stays empty | lsblk is missing or returned no mounted partitions, or disk_interval is unset |
net_rx_bytes_sec stays 0 | Rate calculation needs two consecutive samples. Set net_interval to 1 or higher |
| Network rates look high with bridges or tunnels | The 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.
| Field | Type | Description |
|---|---|---|
monotonic | number | System-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. |
time | integer | Unix epoch seconds, as os.date takes them. |
Actions
Call each as mantle.clock:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
configure | settings: ClockConfigure | Sets 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.
| Field | Type | Description |
|---|---|---|
interval? | integer | Seconds 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.
| Field | Type | Description |
|---|---|---|
items | TrayItem[] | Registered items in registration order, oldest first; updates never reorder them. |
MenuItem
One tray.items[].menu entry.
| Field | Type | Description |
|---|---|---|
children | MenuItem[] | Submenu entries, empty for a leaf. An app that fills submenus lazily sends them only after menu_will_show. |
enabled | boolean | false for a greyed-out entry; draw it, but clicking does nothing. |
icon_name? | string | Theme icon name, a spooled PNG path from raw icon-data, or nil. |
id | integer | DBusMenu id, the second argument of activate_menu_item and menu_will_show. |
label? | string | Entry text as sent, or nil. _ mnemonic markers remain ("_Quit"); strip them to draw. |
menu_type | MenuType | |
toggle_state? | integer | 0 off, 1 on, -1 indeterminate or unreported; nil exactly when toggle_type is. |
toggle_type? | ToggleType | nil for an entry that is not a toggle. |
MenuType
tray.items[].menu[].menu_type.
| Value | Description |
|---|---|
"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.
| Value | Description |
|---|---|
"checkmark" | A check box. |
"radio" | One of a group of mutually exclusive entries. |
TrayItem
| Field | Type | Description |
|---|---|---|
attention_icon_name? | string | Artwork to draw while status == "needs_attention", paired with attention_icon_path like the base icon; both nil when unset. |
attention_icon_path? | string | File half of the attention artwork. |
icon_name? | string | Theme icon name for icon { name = ... }. At most one of it and icon_path is set. |
icon_path? | string | Icon file for image { source = ... }: one from the item’s IconThemePath, or its pixmap spooled to a PNG. |
id | string | Item identity for every tray action, e.g. "1.234/StatusNotifierItem". Opaque. |
item_is_menu | boolean | Left 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. |
name | string | SNI Title, or its Id when the title is empty. |
overlay_icon_name? | string | Badge to draw over the icon’s corner, paired with overlay_icon_path; both nil when unset. |
overlay_icon_path? | string | File half of the badge. |
status | TrayStatus | SNI Status. |
tooltip? | string | Tooltip title and text joined by a newline, or nil when both are empty. |
TrayStatus
SNI Status.
| Value | Description |
|---|---|
"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.
| Action | Arguments | Description |
|---|---|---|
activate | id: string, x: integer, y: integer | Left-click activation at screen coordinates x, y; a no-op when item_is_menu. |
context_menu | id: string, x: integer, y: integer | Context-menu activation at screen coordinates x, y. |
secondary_activate | id: string, x: integer, y: integer | Middle-click activation at screen coordinates x, y. |
scroll | id: string, delta: integer, orientation: ScrollOrientation | Scrolls the icon by delta along orientation. |
activate_menu_item | id: string, menu_item_id: integer | Clicks the item’s MenuItem.id. |
menu_will_show | id: string, submenu_id: integer | Tells 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.
| Contract | Behavior |
|---|---|
| Name | Requested without DoNotQueue: if another watcher owns it, Mantle queues behind it |
| Adoption | At start, adopts items already on the bus at /StatusNotifierItem, /StatusNotifierItem/1 or /org/chromium/StatusNotifierItem/1, for apps that never re-register |
| Removal | An item leaves, and its spooled PNGs are deleted, when its bus name loses its owner |
| Icon | IconName 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/ |
| Bounds | Strings 256 bytes; menus 1024 nodes, depth 32 |
| Menus | com.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
| Trap | Fix |
|---|---|
activate does nothing on some items | The item set item_is_menu, and Mantle skips Activate for it. Open menu on left click |
| A dynamic menu or submenu is empty | Some 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 nothing | The 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.
| Field | Type | Description |
|---|---|---|
aur_error? | string | Why 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? | string | AUR helper found at start, "paru" or "yay", or nil; used only once configure sets aur. |
check_error? | string | Why the last check failed, or nil after a success. A check never modifies the system. |
checking | boolean | A check is running. |
consecutive_check_failures | integer | Check failures in a row; a success resets it to 0. |
install_current_package | string | Package being installed; empty before the first step line. |
install_current_step | integer | 1-based number of the package being installed, e.g. pacman’s (2/5); 0 before the first. |
install_error? | string | Why the package manager could not be run or waited on, or nil. Its own failures are install_exit_code. |
install_exit_code? | integer | Package manager’s exit code for the last install (0 success); nil while running, before one, or when a signal killed it. |
install_finished_at? | integer | Unix seconds when the last install’s process ended, whatever its status; nil while running, before one, or when it failed to spawn. |
install_log | string[] | The last 200 lines of install output, stdout and stderr interleaved, newest last; cleared when an install starts. |
install_total_steps | integer | Packages in the transaction; 0 until the first step line, so draw progress as indeterminate. |
installing | boolean | An install is running; the install_* fields describe the latest run. |
last_successful_check? | integer | Unix seconds of the last successful check (or the checked_at seed), else nil. |
package_manager? | string | Package manager, e.g. "pacman", from the first push, which comes at start; nil when none is supported, and then every action is ignored. |
packages | UpdateCandidate[] | Pending upgrades. A failed check keeps the last good list. |
reboot_required | boolean | /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.
| Field | Type | Description |
|---|---|---|
download_size | integer | Bytes to fetch; 0 when already cached. |
installed_size | integer | Bytes the new version occupies installed; not a delta. |
name | string | Package name. |
new_version | string | Version on offer. |
old_version | string | Installed version. |
repository? | string | Source 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.
| Action | Arguments | Description |
|---|---|---|
check | Checks for upgrades now, even when dormant; ignored while checking. | |
configure | config: UpdatesConfigure | Sets the check schedule and AUR use, and seeds a remembered check. |
install | Runs 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.
| Field | Type | Description |
|---|---|---|
aur? | boolean | Also check the AUR and install through aur_helper. Sends every foreign package name to aur.archlinux.org and builds without PKGBUILD review. |
checked_at? | integer | Persisted Unix seconds of the last successful check. Seeds last_successful_check only while that is nil, so a restart need not recheck at once. |
interval | integer | Seconds 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.
| Part | pacman (Arch) | dnf (Fedora) | apt (Debian, Ubuntu) |
|---|---|---|---|
| Check | Repo databases download to $XDG_RUNTIME_DIR/mantle/pacman | Metadata goes to your cache (~/.cache/libdnf5) | Package lists download to $XDG_RUNTIME_DIR/mantle/apt |
| AUR | With 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 yay | None: aur_helper is nil, aur only sets aur_error | Same as dnf |
| Install | env LC_ALL=C pkexec pacman -Syu --noconfirm, or env LC_ALL=C <aur_helper> -Syu --noconfirm --sudo pkexec with aur on | pkexec env LC_ALL=C dnf upgrade -y --refresh | pkexec 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 |
| Progress | pacman’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 column | Each 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:
| Field | pacman | dnf | apt |
|---|---|---|---|
repository | The repo, or "aur" | The repo id | The suites carrying the new version, comma-joined: "noble-updates,noble-security" |
download_size | 0 once cached | The package size | The .deb’s size even when cached |
installed_size | From -Si, rounded to ~5 KiB | Exact | Exact to the KiB |
| Left out | IgnorePkg entries | excludepkgs entries | New 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 |
| Duplicates | None | One entry per architecture when two of one package upgrade | None |
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
| Trap | Fix |
|---|---|
packages still lists everything after a successful install | install does not recheck. Invoke check from on_change when installing falls with install_exit_code == 0 |
install ends at once with install_exit_code 127 | No 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.
| Field | Type | Description |
|---|---|---|
source | string | "niri", "hyprland", "sway", "mango", or "wlr_foreign_toplevel". |
windows | WindowEntry[] | 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.
| Field | Type | Description |
|---|---|---|
app_id | string | Wayland app_id (Hyprland’s class); empty when unset. |
floating? | boolean | Whether the window floats rather than tiles; nil on wlr. |
focused | boolean | Whether the window has keyboard focus. |
fullscreen? | boolean | Whether the window is fullscreen; nil on niri. |
id | string | Opaque, backend-shaped id for the windows actions; compare it, never parse it. |
maximized? | boolean | Whether the window is maximized; nil on niri. |
minimized? | boolean | Whether the window is minimized; nil except on wlr. |
output? | string | Connector name; nil when unknown. On wlr, the earliest-entered output the window is still on. |
title | string | Window title; empty when unset. |
urgent | boolean | Whether the window is asking for attention; always false on wlr, which has no such state. |
workspace_id? | string | WorkspaceEntry.id; nil on wlr and on Hyprland special workspaces. |
Actions
Call each as mantle.windows:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
focus | id: string | Focuses a window. |
close | id: string | Asks the compositor to close the window. |
set_fullscreen | id: string, fullscreen: boolean | Sets fullscreen on or off; no-op on niri. |
set_minimized | id: string, minimized: boolean | Sets minimized on or off; wlr only. |
set_maximized | id: string, maximized: boolean | Sets maximized on or off; no-op on niri, sway and mango. |
move_to_workspace | id: string, workspace_id: string | Moves 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).
| Backend | Reports | Writes |
|---|---|---|
| niri | floating | focus, close, move_to_workspace |
| Hyprland | floating, fullscreen, maximized | focus, close, set_fullscreen, set_maximized, move_to_workspace |
| sway | floating, fullscreen | focus, close, set_fullscreen, move_to_workspace |
| mango | floating, fullscreen, maximized, minimized | focus, close, set_fullscreen |
| wlr foreign-toplevel | fullscreen, maximized, minimized | focus, 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
| Trap | Fix |
|---|---|
window.app_id is a class on sway | Xwayland windows have no app_id; sway reports their X11 class instead |
if window.fullscreen == false never matches on niri | The flag is nil there. Test truthiness, or branch on source |
output is nil for a window on a monitor plugged in after startup | wlr 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.
| Field | Type | Description |
|---|---|---|
active_client? | ActiveClient | The focused window, or nil when none has focus. One per session, not per output. |
compositor | string | "niri", "hyprland", "sway" or "mango". |
outputs | OutputWorkspaces[] | One entry per output, sorted by connector name. |
overview_open? | boolean | Whether 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.
| Field | Type | Description |
|---|---|---|
app_id | string | Wayland app_id, e.g. "firefox"; the key of applications.by_app_id. Empty when unset. |
floating | boolean | Whether the window floats rather than tiles. |
fullscreen? | boolean | Whether the window is fullscreen (maximized is false); nil on niri, which does not report it. |
title | string | Window title; empty when unset. |
OutputWorkspaces
One output’s workspaces.
| Field | Type | Description |
|---|---|---|
active_workspace | string | WorkspaceEntry.id shown on this output. |
focused_workspace? | string | WorkspaceEntry.id with focus, present only on the focused output. |
name | string | Connector name, e.g. "eDP-1", as in mantle.screens and a panel’s output. |
workspaces | WorkspaceEntry[] | 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.
| Field | Type | Description |
|---|---|---|
app_id? | string | app_id of its representative window, chosen as WorkspaceEntry.app_id is. |
name | string | Full name, "special:scratch" or "special"; the argument of "toggle_special". |
populated | boolean | Whether at least one window sits on it. |
shown_on? | string | Connector showing it, or nil while hidden. |
window_id? | string | window_id of its representative window, chosen as WorkspaceEntry.app_id is. |
WorkspaceEntry
One workspace. Draw number or name, send id.
| Field | Type | Description |
|---|---|---|
app_id? | string | app_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. |
id | string | Opaque 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? | string | Workspace name; nil when unnamed, always on mango, or on Hyprland when the name is just the number. |
number? | integer | The 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. |
populated | boolean | Whether a window sits here. |
urgent | boolean | Whether 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? | string | window_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.
| Action | Arguments | Description |
|---|---|---|
focus | id: string | Focuses 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_special | name: string | Shows 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.
| Capability | niri | Hyprland | sway | mango | None |
|---|---|---|---|---|---|
workspaces | IPC event stream | .socket2.sock events, then one re-read per burst over .socket.sock; a title change alone patches in place | i3 IPC subscription (workspace, window, input events), then GET_WORKSPACES and GET_TREE re-read per event | watch all-monitors snapshots plus one get all-clients each | nil for the run |
windows | Same event stream | Same re-read | Same re-read | Same snapshots | zwlr_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
| Trap | Fix |
|---|---|
| Labels show large or odd numbers | Draw 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 compositors | Hyprland 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 names | Sway 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.56 | Writes 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
| Step | Command | Tells you |
|---|---|---|
| 1 | mantle check | Syntax 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) |
| 2 | mantle log | Evaluation errors, layout errors, errors raised in callbacks, failed mantle calls and refused mantle set/toggle writes (output and logging) |
| 3 | MANTLE_DUMP_LAYOUT=<id>@<output> mantle -vvv | Every visible node’s kind and rect on that surface after each pass (how do I) |
Nothing shows
| Symptom | Cause | Fix |
|---|---|---|
| No surface appears at all after starting | The startup evaluation raised, so there is no scene. The error is in the log | Evaluation, reload and generations |
mantle check passes, but a surface is empty or lays out wrong | check 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 log | What check covers, then the layout dump and the layout model |
| A node shows before its data arrives | The map returns nil for visible, which counts as absent, and visible defaults to true | signals gotchas |
| Two bars on screen | Two shells are running, one per mantle start | mantle list, then mantle stop --pid <pid> (shell selection) |
| The shell vanishes and comes back only after 30 s | The Renderer died three times within 60 s, so the next respawn waits | Fix the error in mantle log (limits) |
A save or a click does nothing
| Symptom | Cause | Fix |
|---|---|---|
Saving a file leaves the old UI on screen, and mantle.rescue.is_rescue is true | The reload failed: an evaluation or apply error keeps the previous scene, sets mantle.rescue and logs the error. The next reload that applies clears it | Find 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 quiet | The 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 timers | Evaluation, reload and generations |
An on_change, timer, process.run, palette or idle callback does nothing | Its error is a warning, and so is a blown CPU budget in on_change or timer. Read mantle log | Output and logging |
mantle.<cap>:<action>(...) returns nil and nothing changes | Actions 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 line | Read mantle log; actions |
A keybind running mantle set or mantle toggle does nothing | It may have been refused (an undeclared name, a bare toggle on a non-boolean). The compositor discards the error | Run the command in a terminal or read mantle log (values and arguments) |
Saving a .json or an image beside shell.lua does not reload | Only .lua and .frag changes reload; a byte-identical save and an unreadable directory (changes inside it will not reload) do not either | Evaluation, reload and generations |
An edit to fonts { ... } does nothing | The font chain is read when the Renderer starts | Restart the shell (fonts) |
A textfield shows no caret and takes no keys | The panel does not take keyboard focus | text fields, panel |
Values are wrong or stale
| Symptom | Cause | Fix |
|---|---|---|
A :get() raises attempt to index a nil value at startup, or a capability reads nil | Every 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 nil | The one rule, capabilities |
| A text never updates | It holds a :get() snapshot, not the signal | The one rule |
| A setting is lost after the shell restarts | Named state lives in the Renderer and dies with it | persistent_table |
| A switched view keeps old state, or snaps in without its animation | visible = false freezes the subtree in place; two id-less views of the same kind are reused | Switching views, nodes |
Errors in the log
| Message | Cause | Fix |
|---|---|---|
surface 2 is a string, not a node | require returned the module and its path into the surface list | Modules and require |
exceeded the 50ms CPU limit for one evaluation | A map, computed, handler or timer ran away or did far too much work | Limits and budgets |
signal nesting exceeded its maximum depth of 32 levels | A derived chain reads itself or nests too deep | Errors |
a Signal resolved to another Signal | A map returned a signal | Errors |
`margin.left` is a Signal handle | A signal inside a table a signal returned, or under a metatable, is not read | signals gotchas |
`mantle` asked to write state ... and was refused | mantle set/toggle named an undeclared state, or wrote a value it refuses | Values and arguments |
Running processes
| Symptom | Cause | Fix |
|---|---|---|
process.run prints nothing and on_exit gets nil | The spawn failed, usually a command not on PATH. mantle log has the reason | process.run |
| A program runs twice after a save | A top-level process.detach launches again on every reload | session_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.
| Symptom | Cause | Fix |
|---|---|---|
A capability reads nil or stays inert | Its 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 ran | Start 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 false | UPower’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 icons | Config 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 started | Read mantle.tray; stop the other host; restart an SNI app so it registers again. An XEmbed-only app cannot show |
| Notifications not showing | Another daemon (mako, dunst, swaync) owns org.freedesktop.Notifications (another notification daemon already owns this name), or the config never reads mantle.notifications | Stop and disable the other daemon, then restart the shell. busctl --user status org.freedesktop.Notifications names the owner |
| No notification sound | DND 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 registered | notifications backend |
| Polkit prompts not appearing | Config never reads mantle.polkit, so the agent never registers; another agent (polkit-gnome, hyprpolkitagent) registered first; $XDG_SESSION_ID unset | Read 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 run | Check that the installed polkit provides that socket |
| Idle never fires | A 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_v1 | mantle.idle.inhibited and inhibitors name the holder (empty who is the compositor) |
| Unlock refuses the right password | PAM stack login in use and pam_nologin or pam_shells refusing | Install the mantle PAM stack (install) |
| Locked session with no lock screen | The 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 nothing | No supported compositor, only one layout configured (layout_count 1), or the compositor refused the switch | Configure several layouts in the compositor; on Hyprland, MANTLE_LOG=debug shows the reply to switchxkblayout main <i> |
Caps/Num Lock always false | No readable /dev/input keyboard with LEDs and no sysfs LED | Give the user read access to the input device |
brightness reads nil | No /sys/class/backlight device; external monitors are not covered | None; brightness is backlight-only |
| Brightness writes ignored | Session not active (another VT), so logind refuses SetBrightness | Switch back to the session |
| Pairing prompt never shows | The adapter is not visible and this shell did not start the pairing | Make 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
| Term | Meaning |
|---|---|
| Supervisor | The 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. |
| Renderer | The mantle-renderer process: the Lua VM, the scene, the Wayland client and painting. One per generation. See runtime. |
| Generation | One 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 mode | mantle 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 process | A 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
| Term | Meaning |
|---|---|
| Evaluation | One run of shell.lua and the modules it requires, producing the surface list. |
| In-place reload | A re-evaluation in the same generation and VM, then one apply. See runtime. |
| Surface fingerprint | A 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 registration | action, 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. |
| Rollback | A 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. |
| Rescue | mantle.rescue, { is_rescue, error_log }, set while the shell runs on a scene it could not replace. See runtime. |
Surfaces
| Term | Meaning |
|---|---|
| Surface | A top-level declaration returned by shell.lua, with one role and one or more instances. See surfaces. |
| Surface role | panel (layer-shell), window (xdg_toplevel), popup (xdg_popup) or lock (session lock). |
| Surface instance | One 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 property | A 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 surface | The lock declaration’s instance on one output, alive only while the session is locked. |
Nodes and paint
| Term | Meaning |
|---|---|
| Node | An element in a surface’s tree: row, text, list and the other kinds. |
| Node identity | How 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 property | A property whose change repaints without relayout (opacity, colours, radius, shadows, blurs, transforms, progress). Its tweens tick without a layout pass. |
| Shader node | shader: a config .frag drawn as a node. Editing the file reloads. |
| Capture node | capture: a live preview of an output or a window. |
| Control | A 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 focus | The surface the compositor sends keys to, plus the popups shown under it. Only its controls take keys. See panel. |
| Path node | path: vector commands filled or stroked in the node’s box, without an SVG file. |
Signals and state
| Term | Meaning |
|---|---|
| Signal | A reactive value. Pass it to a property to keep that property live; :get() is a snapshot. See signals. |
| Derived signal | A signal computed from others: :map, computed, delay, pulse. See derived signals. |
| Named state | A 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 signal | An engine-written, name-keyed signal: hover, hover_rect, pointer, scroll, geometry. Survives reloads like named state. |
| Change handler | An 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 table | persistent_table: a JSON file read as signals and written one key at a time. |
| Idle threshold | An inactivity duration with idle and resume callbacks, registered on mantle.idle and cancellable by its handle. |
| Idle inhibit | A hold that stops idle actions, shared by the config and org.freedesktop.ScreenSaver clients. |
Animation
| Term | Meaning |
|---|---|
| Tween | A property moving from its displayed value to a newly resolved one, advanced per frame without Lua. See animation. |
| Spring | A tween driven by stiffness and damping instead of duration and easing; it keeps its velocity when the target moves. |
| Keyframes | A property walked through a list of values, once or looped, driven by elapsed time rather than a resolved target. |
| Leaving node | A removed child, painted at its last rect with no layout or input while its animate.exit runs. |
| Cross-dissolve | An image blending from the picture it holds to a newly decoded one over its transition. |
| Transition shader | transition.shader: a config fragment shader that draws an image’s cross-dissolve. |
Capabilities
| Term | Meaning |
|---|---|
| Capability | A backend owning one slice of platform state and its actions, read in Lua as mantle.<name>. See capabilities. |
| Capability start | The 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. |
| Snapshot | A 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. |
| Push | A capability sending a new snapshot. Every property reading that capability re-resolves. |
| Hydration | The Supervisor replaying its last snapshots to a new generation. Before its first snapshot a capability reads nil. |
| Action | A 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 namespace | The mantle table: capabilities, plus the Renderer’s screens, rescue, version, config_dir and pid. |
| Secure submit | A secret text field sending its buffer straight to a capability action, never through Lua. See secure fields. |
| Toplevel window | Another application’s window, listed by the windows capability. Not the window surface role. |
| Do-not-disturb | A 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.lockandmantle.polkitpublish their idle state at startup, socomputedand:mapover them run without waiting for a lock or authentication request. After a restart while locked,mantle.lockstaysniluntil the lock is taken again.
0.13.2 - 2026-11-10
- Fixed:
iconnodes use the icon theme set withgsettings set org.gnome.desktop.interface icon-theme, read through the desktop portal, and redraw when it changes. They used to fall back tohicolorwithout a GTKsettings.ini, and a theme change needed a restart. - Fixed:
mantle.updatesreports the current package and install step counts when pacman runs without a terminal. - Fixed:
mantle.audio:set_default_sinkandset_default_sourcechoices stick. A volume change no longer moves the default back to the highest-priority device.
0.13.1 - 2026-11-09
- Fixed:
process.run’son_exitandcapture’son_donefire 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:
imageandiconnodes load.icoand.bmpfiles, so an app whose only icon is a.ico(/usr/share/pixmaps/<name>.ico) shows it. - Fixed: an
imagenode’s SVG draws its embedded<image>PNG, JPEG, GIF and WebP pictures. They used to draw blank. - Changed:
mantle.keyboard’scaps_lock,num_lockandscroll_lockstay live without theinputgroup, up to 250 ms late. They used to freeze at their startup values.
0.13.0 - 2026-11-06
- Added:
mantle.audio.cardslists each ALSA and Bluetooth sound card with every profile it offers (index,name,description,available) and theactiveone, so a config can switch one card between analog and HDMI. Unplugged profiles stay listed withavailable = false. - Breaking:
mantle.audio:set_bluetooth_profile(device, index)is nowmantle.audio:set_card_profile(device, index), and takes anycards[].device. Rename the call. - Breaking:
mantle.audio.bluetoothis gone; Bluetooth cards are inmantle.audio.cards, with amacto joinmantle.bluetooth. Each profile carries thecodecit names, if any, and unavailable ones stay listed: a codec picker readsprofileswherecodec and available, instead ofcodecs. - Added: Each
mantle.audiosink and source carries its ownvolume,mutedandbalance, thecard(acards[].device;nilfor a virtual device) it comes from, and its reboot-stablenode_name, so a mixer can show and set every output, not only the default, and group outputs by card. - Breaking:
mantle.audio’svolume,muted,balance,source_volumeandsource_mutedare gone: read theactiveentry ofsinksorsources. - Breaking:
mantle.audio:set_volume,set_muted,toggle_muteandset_balancetake asinks[].idorsources[].idfirst:set_volume(sink.id, 50).set_source_volume,set_source_mutedandtoggle_source_muteare gone; pass a source’s id to the same actions.set_sink_channel_volumeandset_source_channel_volumeare now oneset_channel_volume(id, index, volume). - Added:
mantle.audio.cards[].portslists each card’s ports (index,name,description,type,direction,available,profiles), andmantle.audio:set_port(card, index)switches one, such as speakers to headphones. Unplugged ports stay listed withavailable = false. - Added:
mantle.audio.apps[].targetis the sink an app plays into, or the source it records from, andmantle.audio:set_app_target(id, device)moves it;nilreturns 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 insinksorsources, PipeWire’s module arguments pass throughargs, and a reload keeps them until the next call. - Added:
mantle.audio.cards[].ports[].activemarks the port a card routes through now, such as headphones rather than speakers. - Added:
mantle.audio.apps[].pinned_tois the device an app is pinned to withset_app_target, or by another mixer;nilwhen it follows the default or the device it names is gone.targetstays the device it plays through. - Added: Each
mantle.audio.cards[].profiles[]entry carriesoutputsandinputs, 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, andmantle.audio_levelscarries 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 checkfails 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 readsnil. It covers reads made while it evaluates and lays out the config;on_click,actionandtimerhandlers do not run. - Changed:
mantle checkfails when a capabilityon_changehandler raises on sample data; before, it only logged a warning. - Fixed: A content-sized node with
animate.heightoranimate.widthgrows 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-binlistsfprintdas optional, the.debsuggestsfprintdandlibpam-fprintd, and the.rpmsuggestsfprintdandfprintd-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-fingerprintPAM stack.mantle.lock.fingerprintisnilwhile fingerprint is off, else{ messages }with pam_fprintd’s lines. A missed scan never counts as an attempt. - Added:
mantle.polkit.identitieslists the accounts a polkit request accepts,identityis the one it authenticates as (your own when offered), andmantle.polkit:select_identity(uid)switches to another. - Added:
mantle.lockandmantle.polkitgainprompt({ id, text, echo }, PAM’s own question,nilwhile PAM owes nothing) andmessages(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’smaskedtonot prompt.echoto show a visible answer. - Changed: The lock screen starts PAM when the session locks, so
promptis ready before you type. A password typed before PAM asks is held for its first masked prompt, andauthenticatingnow means an answer is with PAM. - Changed: A polkit prompt starts PAM on the first submit, so its
promptisniluntil then. - Changed: The release tarball no longer writes to
/etc, so an upgrade keeps your edits. It puts the PAM stacks, with-debianvariants for Debian and Ubuntu, and the polkit rule in/usr/local/share/mantle/; copy them to/etconce. - 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 = falseon asecure_submittextfield 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 (
.apngfiles too) now play and loop inimagenodes, like GIFs, andplayingpauses them. Masks and a shader’simagesstill draw the first frame.source_bluris 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.windowsand the special workspace list stayed empty whenever a special workspace existed. Hyprland 0.57 sends noidfor 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
lsblknow logs a warning instead of leavingmantle.sysinfo.diskssilently empty. - Fixed: Unreadable
/dev/inputdevices log a warning naming theinputgroup, instead ofcaps_lockandnum_lockquietly 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.networkdrops its flatscanning,strength,wifi_ipandavailable_networks, which repeated the primary Wi-Fi interface. Read them fromwifi_devices[1](nilwithout Wi-Fi hardware). - Added:
mantle.android_connectlists 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 needskdeconnect-servicerunning.
0.10.0 - 2026-10-22
- Breaking:
file.write(path, text, on_done?)is nowfile.write(path, text, opts?, on_done?). Addnil,before an existing callback; a function in theoptsslot 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 } })andmantle.applications:launch(id, { env = ... })set or unset environment variables for the program they start; a string sets,falseunsets. - Added:
:mapandcomputedsignals, and sopersistent_tablekeys, haveon_change(fn(current, previous)), run like a state’s when a source’s write changes the value.delayandpulsestill have none. - Changed:
file.readno longer logs its errors; handleerrinon_done.
0.9.1 - 2026-10-21
- Fixed: a
mantle.mprisFirefox or zen player’spositioncould fall up to a second behind after a play, pause or seek, and step back mid-track. Those browsers reportpositionin whole seconds; Mantle now keeps the precise offset they send with each seek. - Fixed: a
computedreading anothercomputedthat had just changed ran its function a second time on the next unrelated layout pass.
0.9.0 - 2026-10-20
- Added:
mantle.mprisplayers carrypid, the process that owns the player, so a config can match a player to itsmantle.audio.apps[]stream bypidinstead of by name. It isnilwhen the bus does not report one. - Added:
mantle.audio.apps[].media_name, PipeWire’smedia.namefor 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, andmantle checkpassed.
0.8.1 - 2026-10-19
- Changed: the LuaLS stubs type
mantle.traymenu_typeandtoggle_type,mantle.networkvpns[].kindandmantle.bluetoothdevicecategoryas their literal values, so the editor flags a misspelled comparison. A tray menu entry with an unknownmenu_typenow reads as"standard", and one with an unknowntoggle_typehasnilfor bothtoggle_typeandtoggle_state(the state was-1). - Added:
on_click,on_double_clickandon_pressreport 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’su_inputandu_input_blurredmay be larger than their content, sotexture()on them reads past its edge. Read them withmantle_inputandmantle_input_blurred. - Changed: a
listwith bothkeyandscrollbuilds 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 namedstate. - Changed: the
--profileidle report’smscolumns start withhandlers=: due timers, signal wakes, off-thread results and scroll steps. They used to count towardresolve=, which could show 130 ms with no resolve pass. - Changed:
ToplevelHandle:resizetypes its edge as a union of the eight edge names, so LuaLS flags a misspelled edge. - Fixed: a
shapewithcornercommands 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
~/Pictureswallpaper) redrew the whole surface twice a second. Mantle watches image files with inotify, so an edited, replaced or deleted file repaints at once, shaderimagesincluded. A symlink is still checked twice a second. - Fixed: a
"fill"scroll container under awindow,lockor spanningpanelwith 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 includescommon-authandcommon-accountinstead.
0.7.5 - 2026-10-16
- Changed: a getter,
on_changehandler, 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-binon 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
--profileon the NVIDIA proprietary driver, the supervisor leaked one file descriptor per sample (since 0.7.1). After about 2.5 h at--profile=10it could not spawn processes, so the lock screen could not unlock.
0.7.3 - 2026-10-16
- Changed: a looping
keyframesanimation or adelaycosts no frames while it holds one value. A blink that rests 3.6 s no longer costs a tick per refresh. - Changed:
mantle checkprints the source line under each error that names afile.lua:N, indented after|. - Changed: a touchpad scroll over a
scrollcontainer coasts to a stop after the fingers lift. It used to stop dead. Wheel notches are unchanged. - Added: a
keyframesentry takes"current"as its first frame, to start the run on the value on screen. See Keyframes. - Added:
runon akeyframesentry. 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 isfalsewhile ananimatetween on that node runs andtrueonce the last one ends. Swap content when a collapse finishes instead of after a guessedtimer. - Added:
effect.shader.input = "desktop"gives a shader on apanelbox the desktop behind it, for lens and refraction effects. It captures through wlr-screencopy. Returnmantle_inputto 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 aboutkpx. The fillets stay tangent throughanimate.shape. Paint, borders, clip, shadows, hit testing and regions follow the joined shape. See Melt. - Added:
mantle checkwarns 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 apanelmeets the output edge is exempt. The exit status is unchanged. - Added: a warning when another node replaces an id-less child with
animate.exitin place, which skips its exit. - Fixed: a
compositor_blurregion 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_blurfollowsrotateand 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.privacynames apps using the camera through PipeWire (browsers, Snapshot, OBS) instead of listingpipewire.
0.7.2 - 2026-10-13
- Changed: an icon name containing
/no longer resolves through the icon theme. - Changed:
mantle.secretstalks 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_outmeans a service call took over 25 s, and it is final. - Fixed: icon lookup finds
.svgztheme 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
moveortranslatepaints 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_hoverfires 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
textfieldthat does not hold focus focuses it. It used to reachon_key. - Added:
sizein bytes on eachmantle.foldersentry. A symlink reports its target’s size. - Added:
textelideaccepts"start"(…then the end) and"middle"(start and end around a…, the end taking the odd grapheme). Both cut at grapheme boundaries, andelided(name)is true for either. Underwrap = "word"both act as"end". - Added:
on_key_release(key)on any node hears a key going up whose press was offered toon_key, routed the same way.keyis{ name, text, modifiers }.mantle input keysends the release after the press. See key handlers. - Added:
on_double_click(rect, button, pointer, modifiers)fires afteron_clickon a second click on the same node with the same button, within 400 ms and 4 px. The innermost node withon_double_clicktakes it, so single clicks still reach an ancestor’son_click. See pointer. - Added:
on_dragtakes 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’sstatus, reports the load of itssource:"empty","loading","ready"with the file’s ownwidthandheight, or"failed". See image_status. - Added:
image.playing, defaulttrue.falseholds an animated GIF on its current frame (the first, if it never played) and stops redrawing it.trueresumes 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.
| Old | New | Why |
|---|---|---|
mantle.files, field folders | mantle.folders, field watched | It lists watched folders, and folders.folders read badly |
mantle.system | mantle.clock | It holds the wall and monotonic clocks, nothing else |
mantle.battery capacity | health | It is full charge over design capacity; capacity read as the charge level |
mantle.storage files | contents | The values are each file’s contents, keyed by path |
list source | items | It names the data; source is a file path on image and shader |
list itemfn | each | A plain word |
window on_close | on_close_request | The window stays open until the config hides it |
behind_blur | compositor_blur | The 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_point | mantle_shape, mantle_shape_len, mantle_shape_point | They carry the shape property’s points |
node submit | submits_secure | It 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 callfnwith a capability’s pre-pushnil. Until every capability dependency has pushed, the derived signal holdsbefore(defaultnil). Move whatfnreturned for thatnil({},0,"") intobeforeand drop the guard. Withoutbeforethe signal readsnil, which breaks#tor arithmetic downstream.:get()andon_changeare unchanged.persistent_table { path, name }becomespersistent_table { path }, wherepathis the file path, absolute or starting with~/.nameraises 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’smonotonicisCLOCK_MONOTONICseconds since boot, as a fractional number. It used to be whole seconds since the capability started.mantle.mpris’sposition_updated_atuses the same clock in microseconds, so divide it by1e6to compare.- The internal
--detachedflag is gone and raises as an unknown argument.
Changed, no edit needed
- A leading
~/means$HOMEin every path a config passes:persistent_table { path },mantle.folders:watch/unwatch,mantle.storage:open/set,mantle.notifications:set_sound,palette.quantize, and theimagesource,iconname,shadersourceandimages,transition.shaderandimages,effect.shaderandmask.sourceproperties. persistent_tablekeys read their declareddefaultsentry untilmantle.storagepushes that file, instead ofnil.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:watchon a missing folder, or on one deleted later, waits for it to appear and lists it then.errorholds until it does. It used to stay unwatched until a secondwatch.
Added
file.read(path, on_done)andfile.write(path, text, on_done?)read and write whole files off the Lua thread. A write that would change nothing is skipped and reportschanged = false. Writes land in call order, through a symlink to its target, keeping the file’s mode. Seefileand Colour your apps.process.capture(cmd, args, on_done)runs a program to its end and callson_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 givesnil, nil.palette.from_hct{ hue, chroma, tone }andpalette.harmonize(color, source)return"#RRGGBB".mantle.clock:now()reads the monotonic clock at the call, before the first push too.computedaccepts plain strings, numbers, booleans and tables among its dependencies and passes them tofnunchanged. 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 endedline says whether the supervisor process is still running. - Fixed:
mantle setandtoggleread a joined--pid=Nor--profile=Nafter the state name as an option. It was stored as the value. - Fixed: a notification
image-pathorapp_icongiven as afile://URL with escapes (%20) or alocalhosthost 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_logtext must update. - Breaking:
package.searchersholds 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,popuporlocknames 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
childrenvalue:shell.lua:2: text: no property `contnet`; did you mean `content`?. - Changed: a layout error from
mantle checkor 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. Achildrenentry readstext[2], counting from 1 like Lua, and a lonechildno longer shows a made-up[0]. - Changed:
mantle checkputs the pass on its own last line and no longer repeatslayout:after the pass name. - Changed: argument and value errors read like Lua’s. An error from a global function, capability action or
mantle checkleads with thefile:lineof 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, asmethod reveal. A caughtpcallkeeps the caller’sfile:line, and a rejected property value of the wrong type names its type, asstring "tall"ornil. - Changed: every out-of-range value reads like
timer: ms must be within [1, 86400000], got integer 0, unit in the subject. A float readsnumber 2.5and a whole floatnumber 1.0. This coverstimer,interval,delay,pulse,loops, gradient stops andidle:register_threshold. - Changed: a config handler that raises logs where it was defined (
widgets/bar.lua:12) and folds identical repeats intoraised again (N times). - Changed:
error({...})names the value type. A missingrequirelists config-relative paths without the C-modules line. - Changed: a misspelt table key or choice value (
anchor = { lefft = true }) ends with “did you meanleft?”.
0.5.5 - 2026-10-10
- Added:
mantle.workspaces,mantle.windowsand keyboard layouts on Sway, over$SWAYSOCK. A workspace’sidis its name, and an Xwayland window reports its X11 class asapp_id. Sway ignoresset_maximizedandtoggle_special. - Added: MangoWC support for
mantle.workspaces,mantle.windowsand themantle.keyboardlayout name. Each tag is a workspace with id"<output>:<tag>". - Fixed:
mantle.idlereports a compositor hold taken while the seat is already idle, such as a video resumed from a phone.inhibitedstayed false until input came and went.
0.5.4 - 2026-10-09
- Added:
mantle.mprisplayers carryart_url, the cover URL as the player sent it, so a config can fetch remote artwork such as Spotify’s. - Fixed:
paddingon atextortextfieldinsets 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,setandinputfail at once when no shell is connected. They used to wait 5 s. - Fixed: a
process.runchild printing faster than the shell reads no longer gets the shell restarted. The child waits on its pipe. - Fixed:
mantle.bluetoothdevice names are BlueZ’sAlias. A rename shows, and a nameless device shows its address-style alias instead of an empty name. - Fixed: a Bluetooth device’s
Classchange refreshes its category. - Fixed:
mantle.privacysees a camera plugged in after start or replugged. - Fixed:
mantle.appearancekeeps its last value when the portal call fails. - Fixed:
workspacesandwindowsrecover 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
effectunderanimatekeeps itsbackdrop.maskwhile it tweens. It was dropped from the first frame on.
0.5.0 - 2026-10-06
- Breaking: workspace ids are strings, and
WorkspaceEntry.idxis replaced bynumber. Treatid,active_workspace,focused_workspace,windows[].workspace_idand thefocusandmove_to_workspacearguments as opaque strings to pass back, never to compute with. On Hyprland a numbered workspace’s id is its number ("3"), sofocus(tostring(n))still creates one.numberis what a keybind targets (niri’s position on the output, Hyprland’s number), andnilfor a Hyprland named workspace. - Changed: the engine’s focus outline follows the node’s
radius,corner_smoothingandoutline. - Changed: a rounded
borderhas 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 strokeoffsetpx outside the box (inside when negative) with a clear gap, for Material-style focus rings. It followsradius,corner_smoothingandoutline, takes no layout space and tweens underanimate. - Added: double-click in a plain
textfieldselects 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
textfieldcalls the field’s ownon_pressand, on release, its ownon_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 andsecure_submitfields 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:
textfieldtakesmultiline = true. The draft wraps and takes newlines, and the field grows frommin_linestomax_linesrows, then scrolls.submit_key("ctrl+return"or"return") picks the chord that submits.multilinewithsecure_submitis refused. See Multiline. - Added:
focus_targetandautofocuswork on any focusable control, not only a plaintextfield.:set_textstays textfield-only. - Added:
focus_target(name):request()works fromon_keyand from a field’son_changeandon_submit. A request from a key callback shows the focus outline, and one fromon_clickdoes not. - Added:
toplevel(id)takes:set_maximized(on),:set_minimized()and:set_fullscreen(on), which need no press. The result arrives throughstate(). - Added:
textfieldtakesselection = { background, foreground }, the highlight colour and the selected glyphs’ colour. The highlight is drawn as given, so tint it with alpha. Unset, the highlight staysforegroundat 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,contrastandreduced_motion. Without a portal every field holds its default. - Added:
effect.backdrop.masktakes a nodemask’sgradientorsource(andinvert) 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. Anodemask is refused. - Added:
on_click,on_press,on_dragandon_wheeltake a trailingmodifiersargument, the{ ctrl, shift, alt, super }tableon_keyreports, 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’srectis the node’s current box on every call, not its box at the press.rect.x + pointer.xstays the surface position when the node moves under its own drag (a splitter). - Fixed: Hyprland named workspaces (
workspace = "name:chat") are listed inmantle.workspacesandmantle.windowsafter the numbered ones instead of dropped, andfocusreaches them (issue #1).
0.4.0 - 2026-10-05
- Breaking:
content_blurandbackdrop_blurare replaced by oneeffecttable,{ blur, backdrop = { blur } }, like CSSfilterandbackdrop-filter.content_blur = 4, backdrop_blur = 8becomeseffect = { blur = 4, backdrop = { blur = 8 } }, andanimate = { content_blur = 200 }becomesanimate = { effect = 200 }. Each blur is a sigma in[0, 8192]and bindable. Only box kinds takebackdrop. Unknown keys are refused with the accepted ones listed.animate.effecttweens both blurs, and a key only one side sets tweens from or to0. - Breaking:
textfield.on_navigateis removed. The field’s ownon_keyhears the same keys, and so do its ancestors’.on_navigate = function(k) if k == "down" then ... end endbecomeson_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 anon_keyhandler hears it. - Breaking:
textfield.caret_coloris replaced bycaret = { color, width, height, radius }.caret_color = cbecomescaret = { color = c }. See textfield for each key’s default. - Changed:
shadowstakes up to 16 layers, up from 8. - Added: box kinds take
outline = { commands = { .. } }in place ofradius, any shape as one closed contour ofpathcommands: 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 andbehind_blurfollow it.animate.outlinetweens point by point between lists of the same commands, springs included, and snaps otherwise. 0.7.0 renames itshape. - Added:
pathcommands take{ op = "corner", points = { x, y }, radius = r, corner_smoothing = s }, which rounds the turn at one point as a continuous corner. - Added:
effect.shadertakesprogress([-8192, 8192], default0), which becomesu_progressas on ashadernode.animate = { effect = .. }tweens it, keyframes andloops = "infinite"included, so a content or backdrop shader animates without Lua running per frame. The rest of theshadertable snaps to the target. - Added:
focus_visible(name), a read-only signal likefocused(name), bound as a node’sfocus_visible. It istruewhile the node or one inside it holds focus that Tab, Shift+Tab or an assistive-technology action moved there (CSS:focus-visible). A press,autofocusorfocus_target(name):request()makes itfalse.focus_ring = falsedoes not affect it, so a custom ring bound to it stays hidden after a mouse click. - Added:
window.geometry_insetadds 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 totoplevel(id):state()to drop the band when maximized, fullscreen or tiled. See Client-side decoration. - Added:
shader,effect.shaderandtransition.shadertakeimages = { name = "/abs/path.png" }, up to 8.png,.jpg,.jpegor.webpfiles for the.fragto sample: a normal map, a noise texture or a LUT. Each entry declaresuniform sampler2D nameanduniform vec2 name_size. Changing a file reloads it. See Images for naming rules and sampling. - Added:
toplevel(id):state(), a read-only signal of awindow’s configure state:activated,maximized,fullscreen,resizing,tiled,bounds,capabilitiesanddecoration. It returns to its defaults when the window closes. See Window state. - Added:
window.decorationspicks who draws the frame,"server"(the default, as before) or"client". A reload that changes it asks the compositor again. - Added:
blendsets a CSSmix-blend-mode("multiply","screen","plus_lighter"and more; default"normal") on a node, abackgroundlayer ({ fill = .., blend = .. }) or ashadowslayer. It composites onto what the surface painted under it, never the desktop. A node’s blend applies after everyeffectfilter and takes its shadows with it.blendsnaps underanimate. See Blend modes. - Added:
effect.shadertakesinput = "backdrop"on box kinds. The program reads what the surface painted under the box throughmantle_input, and theeffect.backdropcopy throughmantle_input_blurred. Its output replaces that area before the node paints, mixed with the original by the node’sopacity. Returnmantle_input(uv)to leave a pixel unchanged. See Backdrop input. - Added: a
shadowslayer withinset = trueworks like CSSbox-shadow: inset. It shades the inside of the padding box with the samecolor,blur,offsetandspread, above the background and under the children and border. It needsshadow_mode = "box"and a box kind. See Inset shadows. - Added:
textfield.escapepicks what Escape does:"clear"(the default, as before),"blur"or"pass"."blur"keeps the draft, leaves the field and callson_cancel(false)."pass"keeps the draft and focus, and Escape goes up throughon_key, thenon_escape. Asecure_submitfield ignores it. - Added:
on_key(key)on any node or surface hears key presses and repeats, for custom controls.keyis{ name, text, modifiers = { ctrl, shift, alt, super }, repeat }, withnamean xkb keysym name ("Down","Return","a"). A key goes to the focused node, each ancestor, then the surface, and a handler returningtruestops it. A focusedtextfieldkeeps the keys it edits with and passes the rest up. A named node withon_keytakes 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,nilotherwise. Bind it as a node’spointer. It costs nothing while unread. - Added:
mantle [-c DIR] input <surface> move | press | release | leave | click | drag | wheel | key | typeinjects pointer and keyboard input into one surface of a running config, for testing. Lock surfaces andsecure_submitfields refuse it. See Injecting input. - Added:
backgroundtakes a list of up to 16 layers, first on top, like CSS multiple backgrounds. Each is a colour, a gradient or{ fill = .. }.animate.backgroundtweens colour layers pairwise and fades in one a side lacks. Gradient layers snap. - Added:
textfieldtakesfont,font_weight,italic,letter_spacing,line_heightandfont_variations, withtext’s ranges and defaults. The caret, the selection and IME placement follow the metrics. An unsetheightisfont_size * line_height. - Added:
wrap = trueon aroworcolumnflows children onto new lines, like CSSflex-wrap.line_spacingsets the gap between lines.wrapwithscrollis refused. See Wrapping. - Added:
homogeneous = trueon aroworcolumngives every visible child one equal slot along the main axis, like GTK’shomogeneous. See Equal slots. - Added:
toplevel(id)returns a handle for awindow’s own frame::move(),:resize(edge)and:show_menu(). They work only from anon_pressor anon_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, beforeon_click. A press on atextfielddoes not call it. - Added:
border_colortakes a gradient table,{ gradient = "linear" | "radial" | "conic", angle, stops }asbackgrounddoes, painted along the whole outline. A gradient per edge is refused, and a gradient border snaps underanimate. - Added:
effect.shader = { source, input, params, padding }runs a fragment shader over a node’s painted subtree, which ashadernode cannot read. The.fragreads the subtree throughmantle_input(uv)and the distance to the node’s outline throughmantle_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,0to1, gives box kinds andimagecontinuous corners like Figma’s corner smoothing.radius = 24, corner_smoothing = 0.6is close to iOS, and0is the circular arc as before.animatetweens it. See Continuous corners. - Added:
effecttakes the CSS colour filterssaturate,brightnessandcontrast, each in[0, 8]with1off, on the node and inbackdrop(effect = { backdrop = { blur = 45, saturate = 2 } }). They apply after the blur, are bindable, andanimate.effecttweens them. - Fixed:
textandtextfieldglyphs that run past a tightline_height(descenders below the line box) are no longer cut flat at it.line_heightsets layout only, and an ancestor’sclipstill cuts the ink.
0.3.0 - 2026-10-05
- Breaking:
clipdefaults to"none", like CSSoverflow: visible. Children, their shadows and their transforms paint and take the pointer past a box that does not set it. A scrollingrow,columnorlist, a surface and a box with amaskstill cut to the box. Addclip = "box"where a box must cut, such as a button holding a ripple or a fixed-widthrectholding a longtext. - Breaking:
shadow_color,shadow_blur,shadow_offsetandshadow_spreadare replaced byshadows, a list of{ color, blur, offset, spread }layers like CSSbox-shadow: a, b. Wrap the old four in one layer:shadow_blur = 8, shadow_offset = { y = 2 }becomesshadows = { { blur = 8, offset = { y = 2 } } }, andanimate = { shadow_blur = 200 }becomesanimate = { shadows = 200 }. The first layer is on top, up to 8. Each field is bindable, andanimate.shadowstweens layer by layer. - Changed:
palette.quantizeuses Material’s Celebi quantizer instead of median cut, so the same image gives different swatches (colours andshares).opts.method = "wu"picks Wu alone. - Changed: a parent’s
clipstays put when a child moves withtranslate,scaleorrotate. 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.rescueuntil the value is fixed. A reload andmantle checkstill 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 frommantle.rescueincluded. The failure is logged as an error, once until its message changes. A reload that fails anywhere is still refused whole. - Changed:
on_clickandon_dragmeasurepointerin the node’s own untransformed box, so a translated, scaled or rotated node reports coordinates within its size. - Changed: each
textfieldkeeps 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_colorcolours the caret, not the selection highlight. It defaults toforeground, as before. - Changed: a
textfieldwithoutheightis one line offont_sizetall instead of 0 px, so its placeholder shows.widthis 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
textoriconcapped bymax_widthormax_heightsizes its content-sized parent at the cap instead of its full content, so an elided title in a content-sizedrowstays centred. - Changed:
battery.capacityreports battery health: full charge as a percent of the design capacity,nilwhen UPower does not know it. - Changed: the wheel stops at the end of the content the last layout measured, so a
mapof ascrollsignal runs once per wheel event at an end instead of twice. - Changed: an
iconwithforegroundrecolours every shape of a-symbolicicon that never usescurrentColor, as GTK does, so themes that hard-code symbolic colours (Tela) follow it. Icons that usecurrentColorkeep 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"makestrim_startandtrim_endfractions of the node’s width. The stroke keeps what lies inside that band, cut at its edges withstroke_capon every cut end. - Added:
path.shiftmoves the geometry inside the node before trimming. Animate it to scroll a shape through a fixed window. - Added: a
keyframesentry, or one frame, takesspring = { stiffness, damping }in place ofeasing. The segment springs from rest in real time and lands on its frame when itsdurationends. - 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 changedidorkeyis a new field.autofocusresets the draft toinitial_text(""when unset) and callson_changewith it. It is refused withsecure_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 underanimate.scroll.scroll_byadds 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.scrollon a scrollingrow,columnorlisteases each mouse-wheel notch toward a target that stops at the ends. Thescrollsignal holds the offset on screen each frame, so its readers reflow continuously.:revealeases the same way. Touchpads, high-resolution wheels andreset_on_closestill move it at once. - Added: a signal may sit inside a property table, such as
margin = { top = sig }, a gradient stop’s colour, ananimateentry or a text run’s flag, and is read again once it is written.children, alist’ssource, structural fields such as a panel’sanchor, tables with a metatable and tables a signal returns still refuse one. - Added:
textfield.disabledkeeps a field drawn but out of keyboard focus (Tab, press,autofocus,focus_target). - Added:
textfield.max_lengthcaps the draft in grapheme clusters for typing, paste, IME andset_text,secure_submitfields included. - Added:
image.radiusrounds the picture’s corners with no clipping wrapper, as a number or a per-corner table likerect.radius. It rounds what shows, sofit = "contain"rounds the fitted picture. - Added:
pathtakesstroke_cap("butt","round","square"),stroke_join("miter","round","bevel"), andtrim_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 thatfocus_target, hidden and disabled ones too. It calls noon_changeand never touches asecure_submitfield. - Added:
textfield.placeholder_colorcolours the placeholder. It defaults toforeground, as before. - Added:
texttakesfont_variations, a table of OpenType axis tags to values such as{ FILL = 1, GRAD = 0, opsz = 24 }, for variable fonts like Material Symbols. An explicitwghtoverridesfont_weight. - Added:
on_escape()onpanel,windowandpopupfires 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 oron_cancelkeeps Escape. - Added:
palette.hct(color)returns a colour’s{ hue, chroma, tone }in HCT, so a config can apply its own seed rule. Eachpalette.scoreseed is one input swatch’s colour. - Added:
palette.scheme(seed, opts?)returns the 49 Material 3 colour roles for a seed, withdark,variantandcontrastoptions. - Added:
palette.score(swatches)rankspalette.quantizeswatches into seed colours as Material 3 does. - Added:
animate.widthandanimate.heightease 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:
radiustakes per-corner values,{ top_left, top_right, bottom_right, bottom_left }, whichanimatetweens. Corners too big for a side shrink together, as in CSS. A shadow rounds by the mean corner. - Added:
hittable = falselets the pointer through a node and its descendants to what is underneath. A descendant’shittable = truetakes it back. - Added:
on_clicktakes the pointer as a third argument,{ x, y }in node-local coordinates likeon_drag’s. A keyboard activation passes the node’s centre. - Fixed: a
mapof ascrollsignal kept a stale offset when layout clamped it (a wheel past either end) or a:revealmoved it, so offset-sized children jumped at the ends. It lays out again in the same frame. - Fixed: a
textwhosecontentruns table orfont_variationstable was edited in place and set again kept its old size and glyphs. It re-measures. - Fixed:
audiorecovers when PipeWire restarts or starts after Mantle. While PipeWire is down,audioreads as a machine with no audio hardware andprivacyclears its microphone and screencast users. - Fixed:
networkrebinds its devices when NetworkManager restarts, sowifi_devicesand wired state drop the old daemon’s values. - Fixed: the Bluetooth pairing agent rejects calls from anyone but bluetoothd.
- Fixed:
brightnesspicks 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
panelwidth or height fills the configured root axis when both opposite edges are anchored. To keep a deliberately narrow root, wrap its content in a sizedchildand 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", …), mprisplay_state("playing"),loop_statusandset_loop_status("none","track","playlist"). POSIX signal names and SVG path ops keep their case. - Breaking: panel
outputkeywords are"all"and"active"."All"names a connector and logs that none is connected. - Breaking:
tray.items[].statusis"active","passive"or"needs_attention". An item that sends another value, or none, reads"active". - Breaking:
panel’smonitoris renamedoutputandexclusiveis renamedexclusive_zone, with the same values. - Breaking:
marginon awindow,popuporlockroot, andalign_h/align_von any surface root, are refused as unknown properties instead of ignored. Set them on the child.animateon alockrefuseswidth,heightandvisible, which a lock already refuses. - Breaking: node property
blurbecomesbehind_blur.focus(name)becomesfocus_target(name), and the textfield propertyfocusbecomesfocus_target. - Breaking: every volume is a percent.
audio.volume(0 to 150),audio.source_volume,apps[].volumeand mprisplayers[].volumeread100for 100%, andset_volume,set_source_volume,set_app_volumeandmantle.mpris:set_volumetake the same scale. Multiply old fractions by 100. - Breaking:
mantle.mpris:set_loop_statustakes"none"|"track"|"playlist"and raises on anything else.players[].loop_statusisnilinstead of""when unreported, andplay_statereads"stopped"instead of""before its first reading. - Breaking:
mantle.tray:scrollraises on an orientation other than"vertical"or"horizontal". - Breaking: unknown values are
nilinstead of-1:sysinfo.temp_gpu, bluetooth connected-devicebattery, and mprisposition,lengthand track-listlength.network.ethernet_speedisnilinstead of0when unknown or no wired device is active. - Breaking:
keyboard.backlight_pctis renamedbacklight_percent,nilwithout a readable backlight.workspaces.active_clientfieldsclass,is_floatingandis_fullscreenare renamedapp_id,floatingandfullscreen, matchingwindowsentries. - Breaking:
mantle.updates.countand the bluetoothDiscoveredDevice.pairedfield are removed. Use#updates.packages. A discovered device is never paired. - Breaking: list properties such as popup
constraint_adjustmentand textcontentruns reject a list with anilhole or named keys. Entries after a hole used to be dropped. - Breaking:
dofileandloadfileare unavailable in configs, because their synchronous file reads can stall the renderer. Userequirefor Lua modules orprocess.runfor 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
pathsubpath is solid regardless of winding, so an inner subpath drawn in the opposite direction no longer cuts a hole. Mark it withhole = trueon its first command. - Changed:
mantle.power:set_profilewarns and sends nothing for a name not inprofiles. - 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’ssizeuniform stays logical whilegl_FragCoordcounts buffer pixels. - Changed: a resized
imagewithasynckeeps drawing its previous size, scaled, until the new size decodes, instead of blanking. A changedsource_blur, or a changedsourcewithoutretain, still blanks. - Changed: stubs give
panel,window,popupandlockaSurfacereturn type and node constructors aNodeone, so a surface used as a child is a type error. - Added:
network.vpnslists saved NetworkManager VPN and WireGuard profiles withactiveandactivating.connect_vpn(uuid)anddisconnect_vpn(uuid)toggle one, andvpn_errorreports a failed activation. - Added: a NetworkManager secret agent raises
vpn_secretwhen an activation needs secrets. Answer it withsecure_submit = { capability = "network", action = "vpn_secret", name = request.id .. "/" .. field }fields, orcancel_vpn_secret(). - Added:
workspacesandwindowsentries haveurgent. niri reports it as set. Hyprland sets it on anurgentevent and clears it when the window gains focus or closes. It is alwaysfalseon wlr-foreign-toplevel. - Added: a
radiocapability.mantle.radio.radioslists each rfkill kind withsoft_blockedandhard_blocked, andset_blocked(kind, blocked)andset_all_blocked(blocked)write/dev/rfkill. - Added: Bluetooth
pairing_requesthas anidand supports"pin_entry"and"passkey_entry". Answer these withsecure_submit = { capability = "bluetooth", action = "pair", name = request.id .. "/" .. request.mac }. - Added:
network.wifi_deviceslists Wi-Fi interfaces by name.scan_device,connect_deviceanddisconnect_wifi_devicetarget one. Flat join fields describe that join, while other flat Wi-Fi fields and the old actions use the primary interface. - Added: plain
textfieldsupports Ctrl+Z undo and Ctrl+Shift+Z and Ctrl+Y redo, withon_changefor restored drafts. - Added: plain
textfieldcomposes through text-input-v3 where available. Preedit stays local until commit. Secure fields do not use an input method. - Added:
animate.moveeases 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 stableidor listkey. - Added:
audio.sinks[]andaudio.sources[]report per-channelindex,positionand percentvolume.set_sink_channel_volumeandset_source_channel_volumeset one channel without changing the others. - Added:
battery.peripheralslists 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 withtimer’s range,canceland per-evaluation lifetime. - Added:
process.runandprocess.detachacceptnilforargs. - Added:
accessible_namemakes 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 = falseturns the outline off.focused(name), bound as a node’sfocused, reports focus within a node for custom styles. - Added:
pathcommandstween point by point underanimatebetween lists with the same ops andholeflags,keyframesincluded, so shapes morph without Lua per frame. Any other change still snaps. - Added:
pathAdraws a circular arc from centre, radius, start and sweep in degrees.hole = trueon a subpath’s first command cuts it out of the fill. - Added: a list of up to 4096 numbers in
shaderandtransitionparamsfills afloatorvec2-vec4uniform array, enough for a 256-bar visualizer. - Added:
idleon_resumereceives"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.transformcomposes nested transforms. - Fixed: a per-edge
border_widthorborder_coloron a rounded box, and any border on acorner_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 checkcatches Lua and layout errors before a run.mantle initsets 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.
| Item | Why / what’s left | ADR |
|---|---|---|
| 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 revert | 0119, 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 draft | 0236, 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 IDs | 0247, 0248 |
Won’t do
| Item | Instead | ADR |
|---|---|---|
| Native FFT or audio peak metering | Stream Cava output into state and draw it | 0316 |
| Weather, currency, geolocation, HTTP, sockets or watched file contents | process.run streaming curl, socat or tail -f, then json.decode; file.read reads a file once; folders watches folders | — |
| MPRIS remote artwork | curl the player’s art_url into a cache file through process.run and draw that path | 0036 |
| Generic IPC state read, subscription or per-panel commands | An action that returns the value, read with mantle call; mantle set and toggle write state | 0197 |
| Child stdin, cwd or env | process.run("env", { "-C", dir, "K=V", cmd }); sh -c for one-shot stdin | 0175, 0188 |
| Per-surface idle inhibition | idle:inhibit while the surface shows, release_inhibit when it hides | — |
| Clipboard capability | process.detach("wl-copy", { text }): a selection needs a process that stays alive to serve it | 0188 |
| Video encoding or global input capture | A recorder or input backend under session_process, streamed in | 0175 |
| Wallpaper capability | A "background"-layer panel, an image with async/retain/transition, folders for the folder, persistent_table for the choice | 0055 |
| Rust widgets (sliders, calendars, launchers, settings) | Lua components over existing nodes | — |
| Framework settings schema | persistent_table with config-declared files | — |
| Deferred surface loader | Wayland objects are created when shown; the Lua CPU budget guards one signal resolve, not a whole evaluation | 0157 |
| Shaders over the desktop behind a surface | The 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 contracts | 0184, 0253, 0336 |
| Display manager (PAM as root, sessions, seats) | greetd; see Greeter | — |
| X11 or i3 | The 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
| Command | Does |
|---|---|
just docs | Serves the book at http://localhost:3000, rebuilt on save |
just book | Builds the book as CI publishes it, then checks every link and anchor |
just stubs | Regenerates every lua-meta/*.lua, every docs/capabilities/<name>.md and the generated property tables |
cargo test -p renderer every_lua_block_in_the_docs | Runs every Lua block in docs/ and checks every screenshot (part of just check) |
just shots | Re-renders the screenshots that changed, deletes orphans, lists what moved |
just rustdoc | Rustdoc 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 string | The block |
|---|---|
lua | Runs 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,shot | A lua block the book shows a screenshot of (screenshots) |
lua,fragment | Only parses. For a snippet that needs context the page has not given, like a require of another file |
lua,must-fail | Must fail to evaluate or lay out. For showing a mistake |
lua,no-check | Skipped. 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.
| Part | Rule |
|---|---|
| Image | Every 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 |
| Still | Drawn with every tween finished |
<!-- shot: frames=0..400/20 --> on the line above | An 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 directive | Describes 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.lua | Runs 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 block | That 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 |
| Pinned | Fonts, 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.
| Failure | Fix |
|---|---|
<name>.png differs by up to N per channel | The render moved. Compare <name>.new.png beside it. Intended: just shots. Not: fix the regression |
is missing or a different size | A new shot, or its size changed: just shots, then look at the image |
no lua,shot block draws this image | A shot was removed or renumbered: just shots deletes it |
| An APNG passes on one GPU and differs by 100+ on another | A 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 file | Lands 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 marker | All 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. -->
| Column | From the field |
|---|---|
| Type | Its Rust type’s LuaCATS, as the stub declares it; range after it |
| Default | absent |
| Behaviour | The 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
| File | Holds |
|---|---|
shell.lua | The storyboard: captions, edits and beats in order, and the runner |
takes.lua | The edit list, the modules and assets copied beside the scratch shell, prune |
stages/take.tpl | The scratch shell’s shell.lua, once, with markers naming the edit that adds each block |
stages/*.lua | Modules the scratch shell requires, some with their own marked blocks |
edits.lua | Turns one edit into typing: lines matching the edit’s type substrings are typed, the rest pasted |
feeds.lua | Mock data the director feeds through mantle set, shaped like each capability |
layout.lua | Pane, 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
- Edit
take.tplor a module, add or change the entry intakes.lua, and the beat inshell.lua. just demo-check: every checkpoint throughmantle checkat 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.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.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.