antle
Introduction
Mantle runs a desktop shell written in Lua on Wayland. The engine evaluates your shell.lua,
which returns the surfaces to show (bars, windows, popups, a lock screen).
Each surface holds a tree of nodes, and any node property can be a live
signal that updates itself when a capability (audio,
workspaces, the clock) pushes new state.
Build the first shell below, then find the rest by topic or by task. The glossary defines every term.
Your first shell
1. Create the config
mantle init -c ~/.config/mantle
It writes a starter shell.lua and a .luarc.json that points lua-language-server at the API
stubs, so your editor completes and type-checks. The config is a directory, not a file
(init).
2. A first bar
shell.lua runs top to bottom and returns one surface or an array of them. This bar shows a
launcher button, the workspaces of the first output and a clock. The button and a keybind share
one piece of named state, launcher_open, which shows a second
panel.
local launcher_open = state("launcher_open", false)
local clock = text {
content = mantle.system:map(function(system)
return os.date("%H:%M", system and system.time)
end),
foreground = "#cdd6f4",
}
local workspaces = list {
direction = "Horizontal",
spacing = 4,
source = mantle.workspaces:map(function(ws)
return ws and ws.outputs[1] and ws.outputs[1].workspaces or {}
end),
key = function(workspace) return tostring(workspace.id) end,
itemfn = function(workspace)
return button {
padding = { left = 6, right = 6 },
radius = 4,
background = workspace.populated and "#45475a" or "#00000000",
on_click = function()
mantle.workspaces:focus(workspace.id)
end,
children = { text { content = tostring(workspace.idx), foreground = "#cdd6f4" } },
}
end,
}
local launcher_button = button {
padding = { left = 8, right = 8 },
background = launcher_open:map(function(open) return open and "#89b4fa" or "#313244" end),
on_click = function() launcher_open:set(not launcher_open:get()) end,
children = { text { content = "Apps", foreground = "#cdd6f4" } },
}
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
exclusive = true,
width = "Fill",
height = 32,
background = "#1e1e2ee6",
child = row {
width = "Fill",
align_v = "Center",
spacing = 8,
padding = { left = 8, right = 8 },
children = { launcher_button, workspaces, rect { width = "Fill" }, clock },
},
},
panel {
id = "launcher",
layer = "Overlay",
visible = launcher_open,
keyboard_interactivity = "OnDemand",
width = 400,
height = 300,
background = "#1e1e2e",
radius = 12,
child = text { content = "Launcher", foreground = "#cdd6f4" },
},
}
| Line | Why |
|---|---|
mantle.system:map(...) | A derived signal; content re-resolves on every push (once a second unless configure says otherwise). :get() would freeze it |
system and system.time | Capabilities read nil until their first push, so every map handles nil |
list { source, itemfn, key } | Rebuilds one button per workspace when the list changes (list) |
:focus(id) | Fire and forget; the new active workspace arrives in the next push (actions) |
width = "Fill" on the panel and the row | The panel’s root spans the anchored edges only when asked (size); the "Fill" rect then pushes the clock right (alignment) |
visible = launcher_open | The launcher panel maps and unmaps with the state |
3. Run it
| Command | Does |
|---|---|
mantle check | Evaluates and lays out the config with no Wayland, with every capability nil and again with sample data, then exits; 1 on error. Run after every edit. What it misses |
mantle -d | Starts the shell detached and prints its pid |
mantle log -f | Follows the running shell’s output, print included |
mantle | Runs it in the foreground instead |
4. Edit it live
Saving any .lua or .frag file under the config directory re-evaluates shell.lua in the same
process, and named state keeps its value. A reload whose evaluation raises keeps the previous
scene on screen, logs the error and sets mantle.rescue, which a config can draw as an
error banner. The next successful reload
clears it. What a reload keeps: what survives it.
5. Split into modules
require("widgets.clock") loads widgets/clock.lua from the config directory, and nothing
outside it. Bind every require to a local before listing it in a table: it returns the module
and its file path. Example and rules: modules.
6. Bind a key
mantle toggle <name> flips a declared boolean state in the running shell, so the launcher above
opens from the compositor:
# Hyprland (hyprland.conf)
bind = SUPER, A, exec, mantle toggle launcher_open
# Hyprland (Lua config)
hl.bind("SUPER + A", hl.dsp.exec_cmd("mantle toggle launcher_open"))
# niri (config.kdl, inside binds {})
Mod+A { spawn "mantle" "toggle" "launcher_open"; }
mantle set writes any value, and mantle call runs an action
(commands).
Topic index
The sidebar’s order. Every guide, surface and node page ends with How do I… and Gotchas tables.
Editor completion comes from the lua-meta/ stubs that mantle init wires up;
mantle.lua is generated from the Rust capability types.
DECISIONS.md records why each contract is what it is, cited as ADR-NNNN.
How do I…
| Task | Answer |
|---|---|
| Install Mantle and start it with the session | Install, run the shell |
| Open UI from a compositor keybind | Bind a key, drive UI from a keybind |
| Make a keybind run Lua and print a result | action |
| Show a reload error in the bar | Error banner |
| Find why a reload or budget failed | Limits and budgets, output and logging |
| Show a live value from the system | First bar, one rule |
| Combine two sources into one value | Derive from two capabilities |
| Debounce a search or hold a value | Debounce a search, delay |
| Flash a node when a value changes | pulse |
| Switch between tabs or views | Switching views, with ids |
| Draw different content per monitor | Per-output content, per-output child |
| Type into a panel | Keyboard focus, text fields |
| Close an overlay or popup on an outside click | Close an overlay, dismissal |
| Show a dropdown under a button | Anchor to a node’s geometry, nested menus |
| Show a tooltip on hover | Tooltip |
| Build a lock screen | lock, secure fields, recipe |
| Centre or space out items | Centre something, alignment, push items apart |
| Draw a progress meter | row and column |
| Show an app’s icon | icon |
| Crossfade a wallpaper | transition |
| Build a list from data | list |
| Scroll a long list | Scroll a long list, scroll |
| Write a shader effect | shader |
| Round and clip content | Round an image’s corners, clip |
| Blur the desktop behind a bar | Blurs |
| Fade or slide a node | animation, spring |
| Show a spinner | Keyframes |
| Animate a node out before it goes | Exit |
| Make a slider or wheel control | Pointer |
| Run a command and read its output | process.run |
| Launch an app that outlives the shell | process.detach |
| Keep a daemon running for the session | session_process |
| Remember a setting across restarts | persistent_table |
| Repeat something every few seconds | timer |
| Search a list as you type | fuzzy |
| Theme from the wallpaper | palette.quantize |
| Handle a capability that has not pushed yet | Reading and acting |
| React to a capability change (OSD, sound) | on_change, Volume OSD |
| Copy a complete bar, launcher or lock screen | Cookbook |
| Find why something shows nothing or does nothing | FAQ |
Source: init, starter, CLI, watcher, reload and rescue, require path.
Installation
What Mantle needs, how to install it, and how to start it with the session. The introduction walks through a first config.
mantle init # ~/.config/mantle/shell.lua and a .luarc.json for lua-language-server
mantle check # evaluate it with no Wayland; exits 1 on an error
mantle -d # run the shell detached
mantle log -f # follow its output
Requirements
| Feature | Needs |
|---|---|
| Surfaces | A Wayland compositor with wlr-layer-shell-v1; ext-session-lock-v1 for lock |
window, popup surfaces | xdg-shell; skipped when absent |
capture node | ext-image-copy-capture-v1, else wlr-screencopy-v1 |
blur = true | ext-background-effect-v1; ignored when absent |
| Fonts | fontconfig (fc-match) |
| Build | Rust 1.89+, PipeWire, PAM, udev, EGL, GBM, xkbcommon, libwayland-client, libwayland-egl. Lua 5.4 is vendored |
| Editor completion | lua-language-server |
A capability starts on the config’s first mantle.<name> read and
needs its backend only from then. What each one does without it is in its page’s Backend section.
| Capability | Needs |
|---|---|
notifications, tray | A session bus, and no other notification daemon or tray host holding the name |
mpris | A session bus |
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 or Hyprland for layouts |
workspaces | niri or Hyprland |
windows | niri or Hyprland, else wlr-foreign-toplevel-management-v1 |
idle | ext-idle-notify-v1, logind |
lock | ext-session-lock-v1, logind, the mantle PAM stack (below) |
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 or nvidia for GPU |
updates | pacman and the curl it depends on, dnf or apt-get; pkexec, answered by the polkit agent; paru or yay for AUR |
applications | $TERMINAL for Terminal=true entries, xdg-open for open_url |
system, files, storage and processes need nothing beyond the paths and programs the config
names.
Install
| Route | Steps |
|---|---|
| Arch | mantle-git from the AUR builds main and installs the PAM stack |
| Ubuntu, Fedora | A release’s sudo apt install ./mantle_<version>_amd64.deb or sudo dnf install ./mantle-<version>-1.x86_64.rpm: under /usr, with its libraries as dependencies and the PAM stack. Ubuntu 26.04 and Fedora 44 or later, since it needs glibc 2.43 |
| Release tarball | sudo tar -xzf mantle-<version>-x86_64-linux.tar.gz -C / installs under /usr/local, with the PAM stack and polkit rule under /etc; the libraries are yours to install. Needs glibc 2.43 |
| From source | cargo build --workspace --release, then copy target/release/mantle and target/release/mantle-renderer into one directory on PATH, such as ~/.local/bin |
| From a checkout, for development | just run [config] builds and runs config (default share/starter). just swap builds an optimised pair into $CARGO_HOME/bin and restarts the running shell |
mantle starts mantle-renderer from its own directory, so both must come from one build
(binaries).
A source build needs a C compiler and pkg-config for the vendored Lua, clang for PipeWire’s
bindings, and the development files of what the binaries link:
| 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 |
Rust 1.89 or later comes from rustup where the distro’s cargo is older.
Mantle is developed and run on Arch. The Fedora and Debian lists build the workspace in a
container; running the shell, and the updates capability’s dnf and apt backends, are untested
on Fedora and Ubuntu for now.
Two optional system files ship in packaging/:
| File | Install to | Without it |
|---|---|---|
pam.d/mantle | /etc/pam.d/mantle | Unlock authenticates against the login stack, whose pam_nologin or pam_shells may refuse the right password. Polkit prompts use polkit’s own stack either way |
polkit-1/rules.d/50-mantle-pacman.rules | /etc/polkit-1/rules.d/ | updates installs through pacman ask for the password on every pkexec; with it, a wheel user approves once per run |
Set up a config
mantle init writes a starter shell.lua (a one-clock bar) and a .luarc.json into the config
directory, ~/.config/mantle by default (which config). It
keeps an existing file unless given --force.
The .luarc.json points lua-language-server at the lua-meta/ stubs, which give
completion and type checks for every node, surface and capability. A package installs them under
$PREFIX/share/mantle/lua-meta. Otherwise init writes the binary’s embedded copy to
$XDG_DATA_HOME/mantle/lua-meta, rewriting any stub that differs, and mantle check says when
they are out of date. The .luarc.json also raises LuaLS’s type-check, unbalanced, strict and global groups
and unused-local to warnings in every file, so a wrong type or a dead require shows up.
Run the shell
| Want | Do |
|---|---|
| Start with the session | niri: spawn-at-startup "mantle". Hyprland: exec-once = mantle |
| Start from a terminal | mantle (foreground) or mantle -d (detached) |
| Use another config | mantle -c DIR (which config) |
Launch mantle from inside the compositor session: workspaces, windows and keyboard find
niri or Hyprland through the environment the session sets. Every other command is on the
CLI page.
Gotchas
| Trap | Fix |
|---|---|
cargo run -p supervisor runs a stale Renderer and reports the mismatch as a config error | just run, which builds both binaries |
| Another notification daemon, tray host or polkit agent is running | Stop it: Mantle takes those names only when they are free, and the tray waits in the queue (FAQ) |
See also: introduction, CLI, FAQ.
Source: init, binaries, capability wiring, PAM worker.
CLI
The mantle binary starts the shell, checks a config without starting it, and lets a
compositor keybind reach a running shell. Reach for set/toggle when a key should change what
the shell shows, and call when it should make the shell do something.
A keybind workflow. The config declares the names:
-- `mantle toggle launcher_open` flips it; `mantle set launcher_open false` closes it.
local launcher_open = state("launcher_open", false)
-- `mantle toggle modal settings` opens "settings", or closes it when it is already open.
local modal = state("modal", "")
-- `mantle call volume.up` or `mantle call volume.up 0.1`.
action("volume.up", function(step)
local audio = mantle.audio:get()
if not audio or not audio.volume then
error("no default output yet")
end
local volume = math.min(1.0, audio.volume + (step or 0.05))
mantle.audio:set_volume(volume)
return string.format("%d%%", math.floor(volume * 100 + 0.5))
end)
return panel {
id = "launcher",
layer = "Overlay",
keyboard_interactivity = "OnDemand",
visible = launcher_open,
width = 480,
height = 320,
background = "#1e1e2eff",
child = text {
content = modal:map(function(name) return name == "" and "launcher" or name end),
foreground = "#cdd6f4ff",
},
}
The compositor binds keys to the commands. Hyprland (hyprland.conf):
bind = SUPER, Space, exec, mantle toggle launcher_open
bind = SUPER, Escape, exec, mantle set launcher_open false
bind = SUPER, Comma, exec, mantle toggle modal settings
bind = , XF86AudioRaiseVolume, exec, mantle call volume.up
Hyprland with a Lua config (0.56+):
hl.bind("SUPER + Space", hl.dsp.exec_cmd("mantle toggle launcher_open"))
hl.bind("SUPER + Comma", hl.dsp.exec_cmd("mantle toggle modal settings"))
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("mantle call volume.up"))
niri (config.kdl, inside binds { }):
Mod+Space repeat=false { spawn "mantle" "toggle" "launcher_open"; }
Mod+Escape { spawn "mantle" "set" "launcher_open" "false"; }
Mod+Comma repeat=false { spawn "mantle" "toggle" "modal" "settings"; }
XF86AudioRaiseVolume allow-when-locked=true { spawn "mantle" "call" "volume.up"; }
In a terminal, mantle call volume.up 0.1 prints the handler’s return value, such as 60%.
Commands
| 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 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 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, 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. --detached is the flag
-d passes to the copy it starts; typed by hand, it is ignored and the shell runs in the
foreground.
Log levels for a run:
| 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 | 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 | 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 5 ms 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 an undeclared name, a bare toggle on a non-boolean, or a value that fails the
scalar checks. A refusal prints state `name` refused: <reason>
on stderr, exits 1 and is also a warning in mantle log. What toggle <name> <value> compares and restores follows
named state: scalars compare by value (1 equals 1.0), and a table
never equals, so toggling to a table always sets it.
What check covers
mantle check evaluates shell.lua and its requires exactly as a start does, with no
Wayland, no GPU, and every capability reading nil. Then it lays every surface out twice with the
real layout code, on one 1920x1080 output plus one per monitor name a panel pins:
| Pass | Capabilities read | Catches |
|---|---|---|
before capability data | nil, as at start before the first push | Code 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 | Typos and bad properties in a list itemfn or a branch that only shows with data |
It prints <path>: ok, N surface(s) and one <role> <id> line per surface, preceded by anything
the config printed. An error prints as <config dir>: <error> and exits 1; files in it are named
relative to the config directory. A layout error names its pass, <config dir>: <pass>: layout: <error>, once per failing pass. With more than one broken node, <error> is N nodes failed: and
then one node per line: the first 20, then and N more. A mistake repeated on every output, or by
every item of a list, is listed once. The path names the line that built each node and, for a
failing :map or computed, the line that created the signal:
/home/me/.config/mantle: widgets/bar.lua:4: attempt to perform arithmetic on a nil value
stack traceback:
widgets/bar.lua:4: in function 'widgets.bar.build'
shell.lua:3: in main chunk
/home/me/.config/mantle: before capability data: layout: invalid value for `children`: on `bar@DP-1`: row[0] (shell.lua:7) > children[0]: shell.lua:2: `text` has no property `contnet`; did you mean `content`?
/home/me/.config/mantle: before capability data: layout: invalid value for `content`: on `bar@DP-1`: text[0] (shell.lua:9) > Signal getter on a `text` node failed: signal created at shell.lua:3: shell.lua:4: attempt to index a number value (local 'n')
stack traceback:
shell.lua:4: in function <shell.lua:3>
| Caught | Not caught |
|---|---|
| Lua syntax errors, in any required module | Handler errors: on_click, action and timer never fire, and a capability on_change that raises on the sample push only logs a warning |
Runtime errors at the top level of shell.lua and its modules | Branches that need a particular value: the samples take the first enum value, true and non-empty lists |
A top-level return that is not surfaces, including require’s second 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 itemfns 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.
It starts no programs and writes no state. On failure it prints only the error, not the config’s
print output.
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, -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"' |
| A keybind does nothing and the terminal shows no error | The compositor discards the command’s stderr. Run it in a terminal, or mantle log and look for asked to write state |
mantle toggle modal is refused on a string 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 failed reload clears actions. Fix the config and save (runtime) |
| Two bars on screen | Two shells are running. mantle list, then mantle stop --pid <pid> |
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 client, state writes, check, init, log, levels.
Runtime
What runs a config: the Lua VM and its libraries, how require finds modules, what a reload
re-runs and what it keeps, and the limits the engine enforces. Read it before splitting a config
into modules, when a reload does something unexpected, or when a log line names a budget.
Only shell.lua is required. This layout puts the bar in bar.lua and its clock in
widgets/clock.lua:
-- shell.lua
local bar = require("bar")
return { bar }
-- bar.lua
local clock = require("widgets.clock") -- widgets/clock.lua
return panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
height = 32,
background = "#1e1e2eff",
child = row { padding = 8, children = { clock } },
}
-- widgets/clock.lua
return text {
content = mantle.system:map(function(system)
return system and os.date("%H:%M", system.time) or "" -- nil before the first push
end),
foreground = "#cdd6f4ff",
}
Saving any of the three re-runs shell.lua in place, and the edited module is loaded fresh.
The VM
Mantle runs as two processes (glossary). The Supervisor is the mantle
process: it owns the backends, watches the config and restarts the other one. The Renderer
holds the Lua VM, the scene and the Wayland connection, all on one thread. There is one Lua 5.4 VM
per generation. Anything that blocks it freezes every surface
on every monitor, so the blocking parts of the standard library are removed (ADR-0048).
| Library | Available | Missing |
|---|---|---|
| Base | assert, collectgarbage, dofile, error, getmetatable, ipairs, load, loadfile, next, pairs, pcall, print, rawequal, rawget, rawlen, rawset, require, select, setmetatable, tonumber, tostring, type, warn, xpcall, _G, _VERSION ("Lua 5.4") | None. warn logs like log.warn, on by default; warn("@off") / warn("@on") toggle it |
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, 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. dofile and loadfile remain and read any path, but
they are synchronous file I/O on the render thread. To read a file, use
process.run or persistent_table. To
run a program, use process.run. For os.difftime(a, b), write a - b.
The engine adds these globals. Every module sees the same ones.
| Global | Kind | Owning page |
|---|---|---|
panel, window, popup, lock | Surface constructors | surfaces |
rect, row, column, text, icon, image, capture, shader, button, list, textfield | Node constructors | nodes |
state, computed, delay, pulse, geometry | Signal constructors | signals |
hover, hover_rect, scroll | Input signals | 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 the scene rejects it | No scene. mantle.rescue is set |
| Reload evaluation raises (syntax error, runtime error, bad top-level return) | The previous scene stays on screen. mantle.rescue becomes { is_rescue = true, error_log = "<the error>" }. The error is logged |
| Reload evaluates but the scene rejects it (bad property value, a map over budget) | The previous scene stays. mantle.rescue is set. The error is logged, one line per broken node, as mantle check prints it |
| A live update fails later (a pushed value breaks a map) | The previous scene stays. mantle.rescue is set until a pass applies. Warning logged |
| The session lock is refused, or the compositor ends 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 |
A failed reload keeps only the scene. The actions, timers, on_change handlers and idle
thresholds of the evaluation that drew it are already cleared (what survives).
A failed evaluation leaves none registered. A failed apply keeps the new evaluation’s actions,
handlers and thresholds but no timers. Fix and save to get them back.
mantle.rescue clears when a reload applies. A rescue from a failed live update or startup apply
also clears when a later pass over the same scene applies. A config can draw its own error banner:
local rescue = mantle.rescue
return panel {
id = "rescue",
layer = "Overlay",
anchor = { bottom = true, left = true, right = true },
visible = rescue:map(function(state) return state ~= nil and state.is_rescue end),
background = "#f38ba8ff",
padding = 8,
child = text {
content = rescue:map(function(state) return state and state.error_log or "" end),
foreground = "#11111bff",
},
}
What survives a reload
| 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 exit_cb(nil) runs before the new evaluation; no out_cb follows | Killed with its process group | Killed |
process.detach program | Unaffected | Unaffected | Unaffected |
timer | Cleared. The new evaluation’s timers start when its result is applied | Cleared | Gone |
action, mantle.<cap>: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 budget | 5 ms of thread CPU time | Each :map and computed recompute, each delay/pulse read, each on_change handler, action handler and timer callback. Nested reads share the outermost deadline | The call raises exceeded the 5ms CPU budget for one evaluation. pcall inside the callback does not hide it |
| 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 itemfns and function child builders | The pass fails and the previous scene stays |
| 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 fails the pass |
| Numeric properties | [0, 8192] logical px for most sizes. [-8192, 8192] for translate, rotate, shader progress, shadow offset and spread. opacity and origin [0, 1], scale [0, 64], font_size [1, 8192]. margin, padding, spacing and icon size are unbounded (a tween still clamps them) | Node and surface properties (nodes) | The pass fails, naming the property |
| Array length | 10,000 | children of one node, items of one list (source, and limit is clamped to it), runs in one text content | The pass fails |
delay, pulse duration | [1, 60000] ms | delay(signal, ms), pulse(signal, ms) (signals) | Raises at the call |
timer delay | [1, 86400000] ms (one day) | timer(ms, fn) (scripting) | Raises at the call |
| Action 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, on_dismiss), process.run callbacks,
palette callbacks and idle callbacks. A slow one stalls every surface until it returns.
Keep maps cheap. Build lookup tables once at the top level, which has no budget, and do only an index and a format inside the map:
-- Evaluation has no CPU budget: build lookup tables here, once.
local levels = { "empty", "low", "half", "high", "full" }
-- The map runs on every push, under 5 ms: one nil check, one index, one format.
local battery_label = mantle.battery:map(function(battery)
if not battery or not battery.present then
return "" -- nil until the first push; no battery on a desktop
end
local level = levels[math.min(#levels, battery.percent // 20 + 1)]
return string.format("%s %d%%", level, battery.percent)
end)
Work that is slow by nature (parsing a large file, searching many entries) belongs in a program
started with process.run, whose output callback sets a state.
Output and logging
| 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, 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 blowing the 5 ms budget | 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 5ms CPU budget | Move the heavy work to the top level or to process.run. The map should only index and format |
dofile("/big/file") stutters every frame it runs | Read files through process.run or persistent_table |
| 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 reload clears actions, timers and handlers. 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 clock = mantle.system:map(function(system)
if not system then return "--:--" end -- nil until the first push
return os.date("%H:%M", system.time)
end)
return panel {
id = "bar", layer = "Top", anchor = { top = true, left = true, right = true }, height = 28,
child = row { spacing = 12, children = {
text { content = clock }, -- live: follows every push
text { content = clock:get() }, -- snapshot: "--:--" until the next reload
} },
}
| Property 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 } | Refused at layout. Derive the whole table: sig:map(function(v) return { left = v } end) |
A capability (mantle.<name>, see capabilities) is a signal too. It reads nil
until its first snapshot arrives (hydration), and in mantle check’s first pass, before a sample push.
Every function that reads a capability must handle nil. When a signal resolves to nil, its
property counts as absent and takes the property’s default.
Reference
| Expression | Returns | Contract |
|---|---|---|
sig:get() | value | The current value, read once. nil before a capability’s first push |
sig:map(fn) | signal | fn(value), run again on every read. 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) |
cap:on_change(fn), cap:<action>(...) | nothing | Capabilities only (capabilities) |
computed({ a, b, ... }, fn) | signal | fn(a_value, b_value, ...): the values in list order, not the signals. Each entry must be a signal or capability: a nil, another value or a named key raises, naming the entry |
state(name, initial) | state 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 |
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 |
delay and pulse take ms in [1, 60000] rounded to whole milliseconds, and raise outside that
range. Both compare values with ==, so a table value (every capability payload, for example)
counts as a new value on every push.
Who writes each kind
Only state can be written from Lua. :set on any other kind raises an error that names the kind.
:reveal works only on a scroll signal.
| 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 | ||
| Hover | hover(name), hover_rect(name) (input) | The pointer | ||
| 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 / is a table | That computed list entry is not a signal or capability; nil is often a misspelled variable |
computed() dependencies: key | The computed list has a named key; list the signals in fn’s order |
delay() takes a Signal / pulse() takes a Signal | The first argument is not a signal or capability |
delay() hold must be within [1, 60000] ms / pulse() window 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, scroll or geometry signal |
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 / takes a 1-based child index | :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 5ms CPU budget for one evaluation | A map or computed body ran too long (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 property table (see gotchas) |
Derived signals
:map and computed run again once a signal they read is written, and their readers re-resolve
only when the result changed (what a node reads again): a scalar or a
plain-data table by value, anything holding a function, signal or metatable on every run. An
HH:MM label or a { { text = hour, bold = true } } run list mapped from a per-second snapshot
re-resolves once a minute. Within one pass, a derived signal read by
several properties runs once. Keep their functions cheap and side-effect free: no :set, no
process, no action. They run under the CPU budget and nesting limit described in
runtime. Side effects belong in on_click, a capability’s
on_change (capabilities) or a timer (scripting).
A derived colour:
local battery_color = mantle.battery:map(function(battery)
if not battery or not battery.present then return "#6c7086" end
return battery.percent <= 20 and "#f38ba8" or "#a6e3a1"
end)
text { content = "●", foreground = battery_color }
Use computed to combine two sources, here a capability and a state that a click toggles:
local show_seconds = state("show_seconds", false)
local clock = computed({ mantle.system, show_seconds }, function(system, seconds)
if not system then return "" end
return os.date(seconds and "%H:%M:%S" or "%H:%M", system.time)
end)
button {
on_click = function() show_seconds:set(not show_seconds:get()) end,
children = { text { content = clock } },
}
A dropdown under a button is a popup bound to a state the click toggles: dismissal.
delay: hold a value
delay answers the old value until the new one has held for ms. That makes it a trailing
debounce, and also a close-hold. OR-ing a signal with its delayed copy keeps a surface mapped for
ms after it closes, long enough for an exit fade to play. Hiding a surface or node plays no exit
animation of its own (animation).
local open = state("menu_open", false)
local mapped = computed({ open, delay(open, 150) }, function(now, was) return now or was end)
local menu = popup {
id = "menu", parent = "bar", anchor_rect = { x = 0, y = 0, width = 60, height = 28 },
anchor = "Bottom", gravity = "Bottom",
visible = mapped, -- stays mapped 150 ms after `open` goes false
on_dismiss = function() open:set(false) end,
child = column {
padding = 8, background = "#1e1e2e",
opacity = open:map(function(is_open) return is_open and 1 or 0 end), -- fades out while held
animate = { opacity = { duration = 150, from = 0 } },
children = { text { content = "Settings" } },
},
}
pulse: mark a change
pulse reports that a change just happened. Use it to fire a one-shot flash or a keyframe
animation, which a config cannot restart any other way (animation):
local count = state("count", 0)
local flash = pulse(count, 300) -- true for 300 ms after each change
button {
padding = 6,
background = flash:map(function(on) return on and "#f9e2af" or "#313244" end),
animate = { background = 300 },
on_click = function() count:set(count:get() + 1) end,
children = { text { content = count:map(tostring) } },
}
To fire on only one edge, combine the pulse with its source:
computed({ pulse(plugged, 400), plugged }, function(fired, on) return fired and on end).
geometry: read a node’s laid-out rect
Layout writes the rect after it solves. A change triggers one follow-up pass over the surfaces that read the rect, and never two passes in a row, so a binding that feeds its own measurement cannot loop.
local track = geometry("track")
column { width = 200, children = {
rect { geometry = track, width = "Fill", height = 4, background = "#45475a" },
text { content = track:map(function(rect) return string.format("%d px wide", math.floor(rect.width)) end) },
} }
Named state
state(name, initial) is the config’s own writable value. Its identity is its name. Every call
with the same name returns the same signal, from any module and across reloads.
| Rule | Detail |
|---|---|
| Identity | One name, one signal. hover, scroll and geometry 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 |
| 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 |
Derived signals (:map, computed, delay, pulse) have no name. Each evaluation builds them
fresh, so a reload drops a pending delay and closes an open pulse window. See
runtime for everything else a reload keeps.
How re-resolution works
While a surface instance (one surface on one output) resolves, the engine records every signal it
reads, including reads inside :map and computed bodies. A write marks the written signal
dirty, and the next pass re-resolves only the instances that read it.
| 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 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 |
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, itemfn, key or function child must
answer from its arguments and the signals it reads; anything else it reads is taken as it was at the
node’s last resolve:
| Read inside the function | Kept until a signal it read changes | Instead |
|---|---|---|
os.time(), os.date() with no time, os.clock(), math.random() | ✓ | mantle.system:map(function(s) return s and os.date("%H:%M", s.time) or "" end), or a state a timer writes |
A local or global changed without :set | ✓ | Keep it in a state |
| A file | ✓ | Read it in a timer and :set a state |
A table changed in place, such as a list’s source | ✓ | :set the table again, or build a new one |
A delay or 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.
How do I…
| Task | Answer |
|---|---|
| Show a live clock | The one rule |
| Colour a node from a capability | Derived signals |
| Derive one value from two capabilities | Below |
| Debounce a search field | Below |
| Open a dropdown under a button | Dismissal |
| Keep a popup mapped while its exit plays | delay |
| Flash a node when a value changes | pulse |
| Open or close UI from a compositor keybind | Below |
| Switch tabs | Switching views with ids |
| Size one node from another’s layout | geometry |
| Keep a toggle across shell restarts | Named state is lost with the Renderer; use persistent_table (scripting) |
| Run a side effect when a capability changes | on_change (capabilities), never a map |
Derive from two capabilities
List every source in computed. Each one reads nil until its first push.
local status = computed({ mantle.network, mantle.audio }, function(network, audio)
if not network or not audio then return "..." end
local net = network.connected and "online" or "offline"
local sound = audio.muted and "muted" or string.format("%d%%", math.floor((audio.volume or 0) * 100))
return net .. " / " .. sound
end)
text { content = status }
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 |
A map errors with attempt to index a nil value at startup | Capabilities read nil before hydration and in mantle check’s first pass; return a fallback for nil |
visible = cap:map(function(c) return c and c.on end) shows the node before hydration | nil means absent, and visible defaults to true; return false explicitly |
margin = { left = sig } fails at layout: `margin.left` is a Signal handle | Signals inside a property table do not resolve. Derive the whole table with :map or computed; the error’s :get() advice gives a snapshot |
A map that returns a signal fails with a Signal resolved to another Signal | Resolution happens once; return a plain value, or combine the sources with computed |
layer, anchor, monitor, namespace, parent or an id bound to a signal is 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. Declare it in one module and require that |
delay(mantle.system, 2000) never updates | Each push is a fresh table, so the hold restarts every second. Delay a scalar derived with :map |
pulse(cap, ms) fires on every push | Table payloads are never ==; pulse a mapped scalar |
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.system’s time (what a node reads again) |
See also: runtime (budgets, reload), capabilities,
input (hover, scroll), animation, cli,
glossary (generation, hydration).
Source: signal core, globals, read tracking, re-resolve, property resolution, kept nodes, frozen subtrees.
Surfaces
A surface is a top-level Wayland object that holds one node tree. shell.lua returns one surface or
a list of them, and every other node lives under some surface’s child. This page holds the rules
all four roles share; each role has its own page.
| You are building | Role | Protocol | Instances |
|---|---|---|---|
| Bar, dock, wallpaper, OSD, launcher overlay, notification stack | panel | zwlr_layer_surface_v1 | One per matched output, id id@output |
| 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 = true,
width = "Fill",
child = function(output)
return row {
width = "Fill",
height = "Fill",
padding = { left = 12, right = 12 },
background = "#1e1e2e",
children = { text { content = output, foreground = "#cdd6f4", align_v = "Center" } },
}
end,
}
return { bar }
Properties every role takes
Every surface takes id (required) and one child node. The root is itself a box node, so it
also takes the common and box node properties: its own background, radius,
padding, border_*, paint and animate. Any other
key is refused, and the error names the closest accepted one or, with none close, lists them all.
| Property | Values | Default | Behaviour |
|---|---|---|---|
id | String | Required | The surface’s identity across reloads and the prefix of its instance ids. Structural. Unique across every role; a duplicate is refused |
child | One node; function(output) on a panel or lock (per-output child) | None | The root’s one child. nil leaves the surface empty |
visible | Boolean or signal | true | Creates and destroys the protocol object, not a hidden map. Retained state and id survive. lock refuses it |
The surface root
The root has no parent, so a few node properties mean something else on it:
| 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 measured size, so they cap a content-sized panel or popup |
margin | A panel’s offset from its anchored edges. Ignored on the other roles |
align_h, align_v | Ignored: 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, monitor, 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, monitor, 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 monitor = "Active" panel has no output name and refuses a
function child. Example: per-output wallpaper.
Input region
A surface takes pointer input only where its content is solid; everywhere else clicks, hover and focus-follows-mouse pass through to what is below. The engine rebuilds this region on every pass.
| Node under the root | Claims input |
|---|---|
A box (rect, row, column, button) with a background or a non-zero border_width | Its whole box, painted bounds under its own transform. #00000000 counts |
text, icon, image, capture, textfield | Its box |
A button with on_click, on_drag, on_wheel or submit = true | Its box, even with nothing painted |
A shader | Nothing; its alpha is unknown to the engine. Put a button over it for a hit area |
| A transparent container | Nothing; its children are asked instead |
| The surface root itself | Nothing, even with a background |
Anything on a layer = "Background" panel | Only such a button |
A claiming box that clips its children ends the walk there; visible = false subtrees claim
nothing. To make an empty area catch clicks, put a button { width = "Fill", height = "Fill", on_click = ... } there (click outside to close).
How do I…
| 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> |
| 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 button 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 width = "Fill", height = "Fill" child; on a panel, make the panel "Fill" on those axes too |
two surfaces declare an id | Surface ids are unique across every role; rename one |
margin or align_h on a window or popup root does nothing | Set it on the child |
A function child on a window or popup is refused | Only panel (not monitor = "Active") and lock have an output to pass |
See also: nodes, paint, input, signals, runtime, CLI.
Source: surface parsing, accepted properties, fingerprints, instances, function child and root size, input region, reload and hotplug.
panel
A layer-shell surface (zwlr_layer_surface_v1) pinned to screen edges: bars, docks, wallpapers,
OSDs, launcher overlays, notification stacks. Rules every role shares are in surfaces.
A 32 px bar across the top of every output, reserving its height so windows start below it:
local clock = mantle.system:map(function(system)
return system and os.date("%H:%M", system.time) or ""
end)
local bar = panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 12, right = 12 },
background = "#1e1e2e",
children = {
text { content = "Workspaces", foreground = "#cdd6f4", align_v = "Center" },
rect { width = "Fill" },
text { content = clock, foreground = "#cdd6f4", align_v = "Center" },
rect { width = "Fill" },
text { content = "Tray", foreground = "#cdd6f4", align_v = "Center" },
},
},
}
return { bar }
The panel’s width = "Fill" makes the root node as wide as the surface (size), and the two
"Fill" spacers centre the clock. The background sits on the row, not the panel, so the whole
bar takes clicks (input region).
Properties
Beyond the shared properties. Structural fields, the types
without Bound, refuse a signal and rebuild the surface when edited; live ones take a signal and
update it in place (reload).
| 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}"; monitor = "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 |
monitor | string | "All" | A connector name, "All" or "Active": which outputs get an instance (monitor) |
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 | boolean|integer|"Ignore"|Bound | false | The space reserved from other windows (exclusive zones) |
keyboard_interactivity | "None"|"OnDemand"|"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) |
monitor
| 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, same as "Fill" | 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. The root node inside it is laid out like any
node: omitted means content-sized even when the surface spans the output. On a
spanned axis write "Fill" on the panel so the root, its background and its "Fill" children
cover the surface.
Measured content is capped at the output minus the margins on the anchored edges, and by the root’s
max_width/max_height. Other clients’ exclusive zones are not subtracted, so content wider than
the space they leave is clipped by the compositor.
Exclusive zones
exclusive | 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 |
The zone counts from the output edge, so it includes the panel’s margin on that edge.
Keyboard focus
Hiding a panel destroys its layer surface and showing creates a fresh one, so a constant
keyboard_interactivity applies at every show. Bind it to a signal only to change the mode while
the panel stays shown. This launcher opens on mantle toggle launcher_open from a compositor
keybind, on the output the compositor picks, and closes on Escape:
local open = state("launcher_open", false)
local launcher = panel {
id = "launcher",
layer = "Overlay",
monitor = "Active",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
visible = open,
keyboard_interactivity = "Exclusive",
background = "#11111b99",
child = column {
width = 480, align_h = "Center", align_v = "Center",
padding = 16, radius = 12, background = "#1e1e2e",
children = {
textfield {
width = "Fill",
height = 32,
placeholder = "Search",
autofocus = true,
on_change = function(query) end,
on_cancel = function() open:set(false) end,
},
},
},
}
return { launcher }
See named state and text fields.
| Mode | Behaviour |
|---|---|
"None" | Never takes the keyboard |
"OnDemand" | Takes it when the user clicks it; other windows can take it back |
"Exclusive" | Takes it while mapped on Top or Overlay; nothing else gets keys |
| Compositor 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 = "Ignore",
width = "Fill",
height = "Fill",
child = function(output)
return image {
source = state("wallpaper_" .. output, "/usr/share/backgrounds/default.jpg"),
width = "Fill",
height = "Fill",
}
end,
}
return { wallpaper }
mantle set wallpaper_DP-1 /path/to/picture.jpg changes one output’s picture
(CLI, image). On the Background layer only a button
with a handler takes input (input region), so the desktop stays click-through.
OSD
A card bottom-centre on the output the compositor picks, shown for 1.5 s after the level
changes. No left/right anchor, so the width is measured and the protocol centres it:
local level = state("osd_level", 0.5)
local osd = panel {
id = "osd",
layer = "Overlay",
monitor = "Active",
anchor = { bottom = true },
margin = { bottom = 96 },
visible = pulse(level, 1500),
background = "#1e1e2ee6", radius = 12, padding = 12,
child = row {
spacing = 12,
children = {
text { content = "Volume" },
rect {
width = 200, height = 8, align_v = "Center", radius = 4, background = "#45475a",
children = {
rect {
width = level:map(function(value) return string.format("%d%%", math.floor(value * 100)) end),
height = "Fill", radius = 4, background = "#89b4fa",
},
},
},
},
},
}
return { osd }
mantle set osd_level 0.7 shows it. Drive it from a capability by mapping, for example,
mantle.audio to the level; see pulse. For a slide-in,
animate the root’s translate, not the surface (animation).
How do I…
| 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 = 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 48 px plus its 8 px margin;
exclusive = true would reserve only the 48 px (exclusive zones):
local dock = panel {
id = "dock",
layer = "Bottom",
monitor = "DP-1",
anchor = { bottom = true },
margin = { bottom = 8 },
exclusive = 56,
background = "#1e1e2e", radius = 12, padding = 8,
child = row { spacing = 8, children = {
icon { name = "web-browser", size = 32 },
icon { name = "folder", size = 32 },
icon { name = "utilities-terminal", size = 32 },
} },
}
return { dock }
Close an overlay on an outside click
A full-screen panel with a transparent button as its first child catches clicks everywhere; the
card, declared after it, is on top and takes its own clicks:
local open = state("overlay_open", false)
local overlay = panel {
id = "overlay",
layer = "Top",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
visible = open,
keyboard_interactivity = "OnDemand",
child = rect {
width = "Fill",
height = "Fill",
children = {
button { width = "Fill", height = "Fill", on_click = function() open:set(false) end },
column {
width = 320, padding = 16, radius = 12, background = "#1e1e2e",
margin = { top = 40, left = 40 },
children = { text { content = "Quick settings", foreground = "#cdd6f4" } },
},
},
},
}
return { overlay }
A rect stacks its children, so the card lies over the catcher. A click on the card lands on the
card’s own buttons or on nothing; it never reaches the catcher.
Corner stack
Anchored to two edges, so both axes are measured and the panel grows with its cards:
local items = state("toasts", { "Build finished", "Battery at 20%" })
local stack = panel {
id = "toasts",
layer = "Overlay",
anchor = { top = true, right = true },
margin = { top = 8, right = 8 },
child = list {
source = items,
spacing = 8,
itemfn = function(message)
return rect {
width = 320, padding = 12, radius = 12, background = "#1e1e2e",
children = { text { content = message, foreground = "#cdd6f4" } },
}
end,
},
}
return { stack }
An empty list still maps a 1×1 px surface; bind visible to whether the list has items.
Gotchas
| Trap | Fix |
|---|---|
| A panel anchored to both sides shows its background only behind its content | Omitted size spans the surface but not the root node; set width = "Fill" (or height) on the panel |
"Fill" on an axis with only one edge anchored leaves the panel hidden with a warning in mantle log | Anchor both edges of that axis, or give a size |
height = "50%" or a function child on a monitor = "Active" panel is refused | Use "Fill" with anchors and margins, or px |
exclusive = true on a corner-anchored panel reserves nothing | Anchor one edge, alone or with both perpendicular edges, or give a px count |
exclusive = 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 of a "Fill" panel (input region) |
On Hyprland, other surfaces stop taking clicks while an "Exclusive" panel is mapped | Use "OnDemand" unless the panel must hold every key; it still takes focus when it maps |
A panel on monitor = "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 function tab(name)
return button {
width = "Fill",
padding = { left = 12, right = 12, top = 8, bottom = 8 },
radius = 8,
background = page:map(function(current) return current == name and "#313244" or "#00000000" end),
on_click = function() page:set(name) end,
children = { text { content = name, foreground = "#cdd6f4" } },
}
end
local settings = window {
id = "settings",
title = page:map(function(name) return "Settings: " .. name end),
app_id = "org.example.settings",
min_size = { width = 480, height = 360 },
visible = open,
on_close = function() open:set(false) end,
child = row {
width = "Fill",
height = "Fill",
background = "#1e1e2e",
children = {
column { width = 160, height = "Fill", padding = 8, spacing = 4, background = "#181825",
children = { tab("General"), tab("Display"), tab("Sound"), tab("Power") } },
column { width = "Fill", padding = 24,
children = { text { content = page, font_size = 20, foreground = "#cdd6f4" } } },
},
},
}
return { settings }
mantle toggle settings_open opens it; the compositor’s close button or keybind closes it through
on_close. The title follows the selected tab.
Properties
Beyond the shared properties. Every field but id and
on_close takes a signal and updates the open window in place; a change while it is closed applies when it next
opens.
| 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}"; monitor = "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 |
on_close | 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 |
child | Node|Bound | None | The one root node; a function child is refused |
The engine requests server-side decorations and draws none itself. A compositor that insists on client-side decorations gets an undecorated window, with a log line.
Size
The window’s size is the compositor’s configure. The root fills it on each axis where it has no
width/height of its own; a set one sizes the root inside the window.
| 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 = 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 |
| Open a menu from it | A popup with parent set to the window’s id |
Confirm before closing
on_close is a request, so it can open a question instead of closing:
local open = state("editor_open", true)
local confirming = state("editor_confirm", false)
local editor = window {
id = "editor",
title = "Editor",
visible = open,
on_close = function() confirming:set(true) end,
child = column {
width = "Fill", height = "Fill", padding = 16, spacing = 8, background = "#1e1e2e",
children = {
text { content = "Unsaved changes", foreground = "#cdd6f4" },
row {
spacing = 8,
visible = confirming,
children = {
button { padding = 8, background = "#f38ba8",
on_click = function() confirming:set(false); open:set(false) end,
children = { text { content = "Discard" } } },
button { padding = 8, background = "#313244",
on_click = function() confirming:set(false) end,
children = { text { content = "Cancel", foreground = "#cdd6f4" } } },
},
},
},
},
}
return { editor }
Gotchas
| Trap | Fix |
|---|---|
| The close button does nothing | Add on_close 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 root 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, or use compositor rules |
See also: surfaces, popup, nodes, signals.
Source: window spec, window, root size.
popup
An xdg_popup on a shown panel, window or popup: a dropdown, context menu or tooltip. The
compositor places it against a rectangle in the parent, keeps it on screen and, with a
grab, dismisses it on an outside click, which a second panel cannot do. A hidden popup has
no Wayland object. Rules every role shares are in surfaces.
local menu_open = state("menu_open", false)
local menu_anchor = state("menu_anchor", { x = 0, y = 0, width = 1, height = 1 })
local bar = panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill", height = "Fill", background = "#1e1e2e",
children = {
button {
padding = 8,
on_click = function(rect)
menu_anchor:set(rect)
menu_open:set(not menu_open:get())
end,
children = { text { content = "Menu", foreground = "#cdd6f4" } },
},
},
},
}
local menu = popup {
id = "menu",
parent = "bar",
anchor_rect = menu_anchor,
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 4 },
visible = menu_open,
on_dismiss = function() menu_open:set(false) end,
child = column {
width = 160, padding = 12, spacing = 10, radius = 10, background = "#1e1e2e",
border_width = 1, border_color = "#45475a",
children = {
text { content = "Settings", foreground = "#cdd6f4" },
text { content = "Log out", foreground = "#cdd6f4" },
},
},
}
return { bar, menu }
A dropdown under a bar button. on_click’s rect is the button in its surface’s coordinates,
which is what anchor_rect takes (pointer input). An outside click
dismisses it and on_dismiss clears the state.
Properties
Beyond the shared properties. Every field but id, parent
and on_dismiss takes a signal.
| 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}"; monitor = "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"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound | "Center" | The point on anchor_rect the popup hangs from |
gravity | "Top"|"Bottom"|"Left"|"Right"|"TopLeft"|"TopRight"|"BottomLeft"|"BottomRight"|"Center"|Bound | "Center" | The direction it extends from that point: "Bottom" hangs it below, "BottomRight" below and to the right |
constraint_adjustment | ("SlideX"|"SlideY"|"FlipX"|"FlipY"|"ResizeX"|"ResizeY")[]|Bound | { "FlipY", "SlideX" } | How the compositor may keep it on screen; {} for none, order is ignored |
offset | { x?: number, y?: number }|Bound | { x = 0, y = 0 } | Pixel nudge after anchor and gravity; an absent axis is 0, negative moves up or left |
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 |
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 left, right or middle press or release on one of the shell’s surfaces in the same
turn, so open a grabbing popup from on_click. Without one the popup stays closed and a warning
is logged. A compositor that denies the grab dismisses the popup, and on_dismiss runs.
Dismissal
A compositor dismissal destroys the popup but leaves your visible signal true. The engine
latches it shut until the next pointer press or release on the shell, then opens it again. Clear
your state in on_dismiss, as the example at the top does.
Hiding or dismissing a popup also closes the popups open under it and latches them the same way,
so each one whose visible stays true reopens on the next press.
How do I…
| 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 = { "FlipX", "FlipY", "SlideX", "SlideY" } |
| 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 = button {
padding = 4,
on_click = function(rect)
sub_anchor:set(rect)
sub_open:set(true)
end,
children = { text { content = "Power ›" } },
}
local submenu = popup {
id = "power_menu",
parent = "menu",
anchor_rect = sub_anchor,
anchor = "TopRight",
gravity = "BottomRight",
constraint_adjustment = { "FlipX", "SlideY" },
visible = sub_open,
on_dismiss = function() sub_open:set(false) end,
background = "#1e1e2e", padding = 8,
child = column { spacing = 4, children = { text { content = "Suspend" }, text { content = "Reboot" } } },
}
power_row goes inside the menu popup. on_click’s rect is in the menu’s own coordinates, which
is what a child popup’s anchor_rect expects. Set sub_open to false wherever the menu closes
(its on_dismiss included), or the submenu reopens on the next click.
Tooltip
A hover opens no grab, so grab = false. hover_rect tracks the
hovered node and is 1×1 before the first hover, which keeps anchor_rect valid:
local clock = text { content = "12:30", padding = 8, hover = hover("clock") }
local tooltip = popup {
id = "clock_tooltip",
parent = "bar",
anchor_rect = hover_rect("clock"),
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 4 },
grab = false,
visible = hover("clock"),
background = "#1e1e2e", radius = 6, padding = 6,
child = text { content = "Thursday, 24 September" },
}
clock goes inside the bar panel.
Anchor to a node’s geometry
Bind geometry. It reads zero before
the first layout, so map it to a 1×1 fallback. With no click to open it, it needs grab = false:
local battery = text { content = "87%", padding = 8, geometry = geometry("battery") }
local details = popup {
id = "battery_details",
parent = "bar",
anchor_rect = geometry("battery"):map(function(rect)
return rect.width > 0 and rect or { x = 0, y = 0, width = 1, height = 1 }
end),
anchor = "Bottom",
gravity = "Bottom",
grab = false,
visible = state("battery_open", false),
child = text { content = "2 h 10 min left" },
}
Gotchas
| 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, authenticating, attempts, error, unlocking) and actions are on
the lock capability. Rules every role shares are in surfaces.
mantle.lock:set_unlock_animation(250)
local up = mantle.lock:map(function(lock) return lock ~= nil and lock.active and not lock.unlocking end)
local hint = mantle.lock:map(function(lock)
if lock == nil then return "" end
if lock.error ~= "" then return string.format("%s (%d)", lock.error, lock.attempts) end
if lock.authenticating then return "Checking…" end
return lock.active and "Enter your password" or "Locking…"
end)
local lock_screen = lock {
id = "lock",
background = "#11111b",
child = function(output)
return column {
width = "Fill", height = "Fill", align_v = "Center", spacing = 12,
opacity = up:map(function(on) return on and 1 or 0 end),
animate = { opacity = { duration = 200, from = 0 } },
children = {
text { content = output, foreground = "#6c7086", align_h = "Center" },
rect {
width = 320, align_h = "Center", padding = 8, radius = 18, background = "#1e1e2e",
children = {
textfield {
width = "Fill",
height = 20,
placeholder = "Password",
mask_character = "•",
secure_submit = { capability = "lock", action = "authenticate" },
},
},
},
text { content = hint, foreground = "#a6adc8", align_h = "Center" },
},
}
end,
}
action("lock", function() mantle.lock:lock() end)
return { lock_screen }
mantle call lock from a keybind (action) locks the session. Each
output gets its own card; the field is typable as soon as the compositor gives the lock the
keyboard, with no click. After a correct password the lock stays up 250 ms while the card fades
out, then the session unlocks.
Properties
A lock takes id, child and the common and box node properties, minus
the ones the protocol owns.
| 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}"; monitor = "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 |
monitor and anchor are refused too: the protocol owns coverage and lifetime. The root is the
output’s size; give children "Fill" to cover it. A reload that renames id while the session is
locked is refused with a warning in mantle log. A config declares at most one lock; a second
is refused at evaluation.
When a lock is refused
The compositor keeps the session locked if the shell dies, so a lock screen with no way to type a
password leaves only a VT switch. mantle.lock:lock() checks the lock screen before it
asks the compositor. A refusal leaves the session unlocked and puts the reason in mantle.lock’s
error and in mantle.rescue.
| 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.system:map(function(system) return system and os.date("%H:%M", system.time) or "" end) |
| Add an unlock button beside the field | A button { submit = true } sends the field like Enter (pointer) |
| 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, monitor 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 bar = panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
exclusive = 32,
width = "Fill",
height = 32,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1E1E2ECC",
children = {
row { width = "Fill", align_v = "Center", spacing = 6, children = {
text { content = "left", foreground = "#CDD6F4" },
} },
text { content = clock, align_v = "Center", foreground = "#CDD6F4" },
row { width = "Fill", align_h = "End", align_v = "Center", spacing = 6, children = {
text { content = "right", foreground = "#CDD6F4" },
} },
},
},
}
return { bar }
The two side rows are "Fill", so they split what the clock leaves equally, and the clock sits at
the exact centre whatever its width. The right row packs its children at its end.
Kinds
Every kind accepts the common properties. Box kinds also accept the
box properties. Any other key raises an error: a typo such as aling_v asks
“did you mean align_v?”, and a key close to nothing lists what the kind accepts.
| Kind | Page | Box | Children | Own properties |
|---|---|---|---|---|
rect | rect | ✓ | Stacked | children |
row, column | row and column | ✓ | Flow | children, spacing, scroll |
button | button | ✓ | Stacked | children, on_click, on_drag, on_wheel, submit |
list | list | Flow, from data | source, itemfn, key, limit, direction, spacing, scroll | |
text | text | Leaf | content, font, font_size, foreground, text_align, elide, wrap, max_lines, on_link | |
icon | icon | Leaf | name, size, foreground | |
image | image | Leaf | source, fit, async, retain, transition, source_blur | |
capture | capture | Leaf | output, fit, live, region, paint_cursor | |
shader | shader | Leaf | source, progress, params | |
textfield | textfield | Leaf | placeholder, font_size, foreground, text_align, autofocus, on_change, on_submit, on_cancel, on_navigate, secure_submit, mask_character |
The four surface roles (panel, window, popup, lock) are node kinds too: they take the common
and box properties and stack their one child (surfaces).
Values
| 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, scroll and geometry take the signal handle itself. A signal inside a table property is refused: derive the whole table |
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.source and text runs take at most 10000 elements. A nil hole in children is an error; in list.source and runs it ends the array |
| Tables | A table property (padding, anchor, shadow_offset, transition, an animate entry, a run, …) refuses a key it does not take, so { topp = 4 } names topp |
| Callbacks and booleans | Every on_* takes only a function, and every boolean property (visible, submit, …) only true or false. on_click = "x" or submit = 1 is an error. For a conditional handler write cond and fn or nil: false is refused too |
Layout model
A pass is one resolve of a surface: the engine re-runs the node tree’s signals, re-lays it out and repaints. It happens after a signal the surface reads changes (signals). Layout is flexbox, solved by taffy on every pass. Each container either flows its children along one axis or stacks them on top of each other.
| Kind | Children | Main axis |
|---|---|---|
row | Flow left to right | Horizontal |
column | Flow top to bottom | Vertical |
list | Flow, generated from source | direction: vertical by default |
rect, button, 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, 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.
Of the leaves, only text and icon measure themselves. image, capture, shader and
textfield have no intrinsic size: without width and height they are 0 × 0 and draw nothing.
Sizes
width and height take the same values.
| 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
spill out, cut by the parent’s clip, and "Fill" siblings get 0. A "Fill" child along the main
axis of a content-sized parent also gets 0: there is no remainder to share. Across the axis,
"Fill" in a content-sized parent takes the largest sibling’s size.
min_width, min_height, max_width and max_height are pixels [0, 8192] (not "Fill" or
percents). They clamp every size, content, fixed and "Fill" alike, as in CSS: a "Fill" capped
by max_width leaves the rest to its "Fill" siblings. A floor above a ceiling wins. Content past
a ceiling overflows; a scroll on the same node scrolls it (scroll).
Spacing, padding and margin
| 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. Neither is range-checked, so negatives are accepted. A hidden child adds no gap.
Alignment
align_h and align_v take "Start", "Center", "End" or "Stretch", default "Start", and
act by axis:
| 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
| 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 | Inside the box, around its children or text. A number sets all four edges; not range-checked (spacing) |
align_h | "Start"|"Center"|"End"|"Stretch"|Bound | "Start" | See alignment |
align_v | "Start"|"Center"|"End"|"Stretch"|Bound | "Start" | See alignment |
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 |
shadow_color | Color|Bound | "#000000" | A drop shadow (shadows). Draws when alpha > 0 and shadow_blur, shadow_offset or shadow_spread is set |
shadow_blur | number|Bound, [0, 8192] | 0 | CSS box-shadow blur radius in px |
shadow_offset | Axes|Bound, [-8192, 8192] | { x = 0, y = 0 } | Shadow offset in px per axis. Follows the node’s transform |
shadow_spread | number|Bound, [-8192, 8192] | 0 | Px the shadow grows per side; negative shrinks it. On non-box content it scales the shadow about the box centre |
content_blur | number|Bound, [0, 8192] | 0 | Gaussian sigma in px over this node’s painted subtree, CSS filter: blur() (blurs). Clipped like a shadow |
animate | Animations|Bound | None | Per-property tweens and an exit block (animation). Only a node already on screen animates, unless the entry has from |
id | string | None | Unique among siblings; matches this node across passes (identity). Never a signal |
hover | Bound | None | A hover(name) signal the engine sets while the pointer is over this node or its children (hover) |
geometry | Bound | None | A geometry(name) signal the pass writes this node’s surface-local rect into (geometry) |
cursor | Cursor|Bound | "pointer" on a button with a handler or submit 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 changes under a still pointer do not call it. Refused without hover on the same node |
scale, rotate and translate act like CSS transform: the subtree draws moved, while layout,
siblings and geometry see the untransformed box. A node scaled to 0 takes no input. Tween them for
motion that skips re-layout.
Cursor names
cursor takes the CSS cursor names that the Wayland cursor-shape protocol (wp_cursor_shape_v1)
also uses. Any other string is refused.
| 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, button and the four surface roles also take background, radius,
corner_shape, border_color, border_width, clip, mask, blur, backdrop_blur and
shadow_mode. They are documented on paint. Leaves and list
take none of them: wrap one in a rect for a background, border or rounded clip.
Identity and reconciliation
Each pass walks the declared tree and matches it against the nodes on screen, one parent at a time.
A matched node keeps its state: running tweens, a held image, a capture stream, a text field’s
draft, and its resolved properties until a signal they read is written. An unmatched old node is
removed, after its animate.exit if it has one (exit).
| 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, label)
return column {
id = name, -- a new id per view: the old view leaves and fades instead of being reused
padding = 12,
opacity = 1, -- `from` needs the property set
animate = { opacity = { duration = 150, from = 0 }, exit = { duration = 150, opacity = 0 } },
children = { text { content = label } },
}
end
local views = {
wifi = function() return page("wifi", "Wi-Fi networks") end,
bluetooth = function() return page("bluetooth", "Bluetooth devices") end,
}
local body = rect {
width = 300,
children = tab:map(function(current) return { views[current]() } end),
}
return body
How do I…
| 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 | Wrap it in a button with on_click |
| 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, shader or textfield does not appear | They have no intrinsic size. Give width and height, 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 button or 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 |
A signal inside a table property (padding = { top = sig }) raises an error | Map the whole table: padding = sig:map(function(v) return { top = v } end) |
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[1]: expected a node table, counting from 0 like the rest of the path | 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 and reconciliation,
property resolution,
geometry parsers,
transforms,
hit testing and cursors (names from the cursor-icon crate),
check.
rect
A plain box that stacks its children: each one gets the whole content box and places itself with
align_h/align_v. Reach for it for a filled shape, a background behind something, or layering
one node over another. Side by side needs a row or column instead.
A bell icon with an unread badge in its top-right corner:
local unread = state("unread", 3)
local bell = rect {
width = 28,
height = 28,
children = {
icon { name = "notification-symbolic", size = 20, foreground = "#CDD6F4",
align_h = "Center", align_v = "Center" },
rect {
visible = unread:map(function(n) return (n or 0) > 0 end),
align_h = "End",
align_v = "Start",
padding = { left = 4, right = 4 },
radius = 7,
background = "#F38BA8",
children = { text { content = unread:map(tostring), font_size = 10, foreground = "#11111B" } },
},
},
}
return bell
The badge comes second, so it paints over the icon.
Properties
rect takes the common and box
properties, plus:
| 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: a rect with radius and clip = "Rounded" |
| 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 rect takes no clicks | Only a button does |
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, button, paint.
Source: vocabulary, children, box paint.
row and column
Boxes that flow their children along one axis: a row left to right, a column top to bottom.
They are the main layout tools; everything else is placed inside one. The two accept the same
properties and differ only in their main axis. For children built from data, use a list.
A meter: a "Fill"-wide track with a percentage-wide fill that follows a signal.
local volume = state("volume", 0.45)
local meter = row {
width = "Fill",
height = 6,
radius = 3,
background = "#313244",
children = { rect {
width = volume:map(function(v) return string.format("%d%%", math.floor((v or 0) * 100 + 0.5)) end),
height = "Fill",
radius = 3,
background = "#89B4FA",
animate = { width = 150 },
} },
}
return column { width = 240, children = { meter } }
Properties
row and column take the common and
box properties, plus:
| 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) |
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 | Give each width = "Fill" |
| 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 |
Push items apart
A "Fill" child takes the space its siblings leave, so a bare rect makes a spacer:
local header = row {
width = 300,
spacing = 8,
children = {
text { content = "Wi-Fi", font_size = 14, foreground = "#CDD6F4", align_v = "Center" },
rect { width = "Fill" }, -- takes the space left over, pushing what follows to the end
text { content = "Connected", foreground = "#A6ADC8", align_v = "Center" },
},
}
return header
Gotchas
| 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.
button
A box that takes clicks, left-button drags and the wheel; no other kind has these handlers. It
stacks its children like a rect, so put a row inside for an icon beside a label.
Which button wins, when a click cancels and when a drag ends are on input.
A volume chip: left click mutes, the wheel changes the level.
local muted = state("muted", false)
local volume = state("volume", 0.5)
local mute_button = button {
padding = { left = 10, right = 10, top = 6, bottom = 6 },
radius = 8,
background = muted:map(function(m) return m and "#F38BA8" or "#313244" end),
on_click = function(_, which)
if which == "left" then muted:set(not muted:get()) end
end,
on_wheel = function(_, steps)
volume:set(math.max(0, math.min(1, volume:get() + steps * 0.05)))
end,
children = { row { spacing = 6, align_v = "Center", children = {
icon { name = "audio-volume-high-symbolic", size = 16, foreground = "#CDD6F4", align_v = "Center" },
text { content = volume:map(function(v) return string.format("%d%%", math.floor(v * 100 + 0.5)) end),
align_v = "Center" },
} } },
}
return mute_button
Properties
button takes the common and box
properties, plus the ones below. rect in the callbacks is the button’s surface-local laid-out box
{ x, y, width, height }, before transforms.
| 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 |
on_click | fun(rect: Rect, button: "left"|"right"|"middle") | None | On release over the same button that was pressed, with the same mouse button. rect is the button’s surface-local box, before transforms |
on_drag | fun(rect: Rect, pointer: { x: number, y: number }, phase: "start"|"move"|"end") | None | Left-button drag. pointer is button-local and unclamped. "start" on press, "end" on release (before on_click) or when the pointer leaves the surface |
on_wheel | fun(rect: Rect, steps: number) | None | Vertical wheel in notches, positive away from the user, fractional on touchpads. The innermost handler or scroll container wins |
submit | boolean|Bound | false | A click also submits the armed secure field, like Enter. Works without on_click and runs before it |
A button with none of on_click, on_drag, on_wheel and submit = true ignores the pointer:
clicks fall through to what is under it, and it sets no cursor.
How do I…
| Task | Answer |
|---|---|
| Toggle something on click | The example above |
| Open a menu on right click | Check button == "right" and open a popup at rect (input) |
| Make a slider | on_drag for the value, on_wheel for steps (input) |
| Show hover feedback | Bind background or shadow_* to a hover signal and tween it with animate (paint) |
| Submit a password with a button | submit = true (secure fields) |
| Change the cursor | cursor = "grab" or any cursor name |
| Make a whole row clickable | Make the button the row’s parent, width = "Fill", with a row inside |
Gotchas
| Trap | Fix |
|---|---|
| Icon and label overlap | A button stacks its children. Put a row inside |
| A click lands on the node behind the button | The button has no handler and no submit = true, so it is transparent to the pointer |
| A click is lost when the button moves or resizes on press | A click cancels if the laid-out box moved between press and release. Give press feedback with scale or translate, not width or margin |
on_click never fires for a link inside the button | A text with on_link takes the click on a link run first |
A press on a textfield inside the button does not click | Presses on a field go to the field |
See also: input, rect, surfaces: popup.
Source: vocabulary, pointer dispatch, hit testing, takes_pointer.
text
One paragraph drawn in the fonts chain or a named family: labels,
clocks, notification bodies. It sizes to its content, wraps and elides inside a bounded width, and
mixes bold, italic, colour and links through runs. For typing, use a textfield.
Two notification cards. In the first, the title elides and the body wraps to two lines, eliding the second; the second card’s short texts fit.
local function card(icon_name, title, body)
return row {
width = "Fill",
padding = 12,
spacing = 10,
radius = 12,
background = "#313244",
children = {
icon { name = icon_name, size = 32, foreground = "#CDD6F4", align_v = "Center" },
column { width = "Fill", align_v = "Center", spacing = 2, children = {
text { content = title, width = "Fill", font_size = 14, elide = "End" },
text { content = body, width = "Fill", foreground = "#A6ADC8",
wrap = "Word", max_lines = 2, elide = "End" },
} },
},
}
end
return column { width = 340, spacing = 8, children = {
card("dialog-information-symbolic", "Firmware update ready for the USB-C dock",
"3 packages can be installed. Restart to finish the kernel upgrade and load the new graphics driver."),
card("battery-caution-symbolic", "Battery low", "12% left"),
} }
The middle column is "Fill" so the texts have a bounded width; the icon keeps its 32 px.
Properties
text takes the common properties, plus:
| 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 | Each line is 1.2 × font_size tall |
foreground | Color|Bound | "#FFFFFF" | A colour; a run’s color overrides it |
text_align | "Start"|"Center"|"End"|Bound | "Start" | Aligns lines inside the node’s own box; Start/End follow each line’s reading direction. Matters only when the box is wider than the text |
wrap | "None"|"Word"|Bound | "None" | "Word" breaks at words, mid-word when one word is too wide. Needs a bounded width (width, "Fill" or a stretched cross axis) |
max_lines | number|Bound | 0 | Line cap under wrap = "Word"; 0 is unlimited, a negative value is refused. Ignored without wrap |
elide | "None"|"End"|Bound | "None" | "End" ends an over-long line with an ellipsis; under wrap it applies to the last kept line |
on_link | fun(href: string) | None | Click on a run with an href; the engine never opens it. Takes the click from any ancestor button; plain text passes it through |
Runs
Each run in a content array is a table:
| Field | Values | Default | Behaviour |
|---|---|---|---|
text | String | Required | A run without it is refused; an empty one is skipped |
bold, italic | Boolean | false | Uses the family’s bold or italic face when one exists |
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,
}
Size
A text node measures its content: one line per paragraph line, 1.2 × font_size each, as wide as
the widest line. wrap and elide need a box narrower than the text, so give the node a width,
"Fill", or a stretched cross axis (a text in a fixed-width column wraps at the column’s width).
In a content-sized row, the text measures one line and overflows instead.
How do I…
| Task | Answer |
|---|---|
| Truncate a long title | width (or "Fill") plus elide = "End" |
| 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 |
| 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 = "End" 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 button 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. Reach for it for app icons, status
glyphs and tray items. Symbolic (SVG) icons take a tint. For photos and artwork at their own aspect
ratio, use an image.
The focused window’s icon and title. mantle.applications maps a
window’s app_id to its desktop entry, whose icon is a theme name:
local focused_icon = computed({ mantle.applications, mantle.workspaces }, function(apps, workspaces)
local client = workspaces and workspaces.active_client
if apps == nil or client == nil then return "" end
local index = apps.by_app_id[client.class] or apps.by_app_id[string.lower(client.class)]
return index and apps.entries[index].icon or ""
end)
local app_badge = row {
spacing = 8, padding = { left = 8, right = 12, top = 6, bottom = 6 }, radius = 8, background = "#313244",
children = {
icon { name = focused_icon, size = 20, align_v = "Center" },
text { content = mantle.workspaces:map(function(w)
return w and w.active_client and w.active_client.title or ""
end), max_width = 200, elide = "End", align_v = "Center", foreground = "#CDD6F4" },
},
}
return app_badge
Properties
icon takes the common properties, plus:
| Property | Type | Default | Behaviour |
|---|---|---|---|
name | string|Bound | "" | An icon theme name ("firefox", "audio-volume-high-symbolic"), looked up at the drawn size, or an absolute image path, used as is. "" or a name the theme lacks draws nothing |
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. Full-colour icons ignore it |
An explicit width or height overrides that axis of the square; the icon draws at the shorter
side, centred.
The theme is gtk-icon-theme-name from $XDG_CONFIG_HOME/gtk-4.0/settings.ini, else
gtk-3.0/settings.ini, else hicolor. It is read once per Renderer process, so a theme
change shows after a shell restart, not a reload. Files load as PNG, JPEG, WebP, GIF, SVG or SVGZ.
How do I…
| 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 | Put the icon in a button |
| 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 |
foreground does not change a colour icon | Only SVGs that use currentColor (symbolic icons) take it |
| The wrong theme’s icons appear | The theme comes from GTK settings, read at Renderer start. Set gtk-icon-theme-name 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.
A wallpaper that crossfades when the path changes:
local path = state("wallpaper", "/usr/share/backgrounds/a.jpg")
return { panel {
id = "wallpaper",
layer = "Background",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
child = image {
id = "wallpaper_image", -- keeps the node, and so the held picture, across source changes
source = path,
async = true,
transition = { duration = 600, easing = "InOutCubic" },
width = "Fill",
height = "Fill",
},
} }
Properties
image takes the common properties, plus:
| Property | Type | Default | Behaviour |
|---|---|---|---|
source | string|Bound | "" | A file path (mantle.config_dir .. "/img/a.png"), never a theme name; "" draws nothing. PNG, JPEG, WebP, GIF, SVG or SVGZ; animated GIFs loop |
fit | "cover"|"contain"|"stretch"|Bound | "cover" | "cover" fills the box and crops, "contain" fits inside it, "stretch" distorts to it |
async | boolean|Bound | false | false decodes in the frame that first draws it. true decodes on a worker and draws nothing until ready; use it for many or large images |
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. Animated GIFs ignore it. Under async, a change blanks the image until the re-decode lands; retain does not cover it |
transition
| Field | Values | Default | Behaviour |
|---|---|---|---|
duration | ms, [1, 60000] | Required | Length of the cross |
easing | An easing | "InOutQuad" | Drives u_progress |
shader | Absolute .frag path | Built-in cross-dissolve | Replaces the dissolve. Recompiled when the file changes |
params | { name = number | { 2 to 4 numbers } } | {} | Uniforms for that shader, as on a shader node. Refused without shader |
The first picture appears without a transition. A transition shader gets everything a shader node gets, plus:
| 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.
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 |
| Show a file that ships with the config | source = mantle.config_dir .. "/img/logo.png" |
Round an image’s corners
An image has no radius. Put it in a box with radius and clip = "Rounded"
(clip):
local cover = rect {
width = 96,
height = 96,
radius = 12,
clip = "Rounded", -- cut the image to the corners
children = {
image { source = "/usr/share/backgrounds/a.jpg", fit = "cover", async = true, width = "Fill", height = "Fill" },
},
}
Gotchas
| 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 |
radius on an image is refused | It is not a box. Wrap it, as above |
transition.params is refused | params needs a shader |
See also: icon, shader, paint, animation.
Source: vocabulary, content parsers, transition, shader stage, decode.
capture
A live preview of one output (monitor) through ext-image-copy-capture-v1, else wlr-screencopy.
Reach for it for an overview, a monitor picker or a screenshot preview. Without either protocol it
draws nothing and logs one warning.
A rounded preview of the first screen at up to 30 frames per second:
local first_output = mantle.screens:map(function(screens)
return screens and screens[1] and screens[1].name or ""
end)
local preview = rect {
width = 320,
height = 180,
radius = 8,
clip = "Rounded",
background = "#000000",
children = {
capture { output = first_output, live = 30, fit = "contain", width = "Fill", height = "Fill" },
},
}
mantle.screens lists the connected outputs by connector name.
Properties
capture takes the common properties, plus:
| 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 |
fit | "cover"|"contain"|"stretch"|Bound | "cover" | As on image |
live | boolean|number|Bound | false | false: capture on show and on each output change. true: every frame, one in flight. A number: at most that many fps, (0, 1000]. Hiding the node or unmapping its surface drops the capture; showing starts a fresh one |
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 the screen changes. A capture that fails pauses, with one warning, until the output
list changes.
A region prefers wlr-screencopy, which crops at the source. Through ext-image-copy-capture-v1
the engine crops instead, and on a rotated or flipped output it cannot: it draws the whole output
and logs one warning.
How do I…
| Task | Answer |
|---|---|
| Preview a monitor | The example above |
| 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 |
Source: vocabulary, content parsers, capture.
shader
Runs a fragment shader from the config over the node’s box: a glow, an animated gradient, a procedural pattern. It reads no textures and takes no input. To run a shader between two pictures, use an image transition.
A band that glows in over 400 ms when pulse_on turns true:
local pulse_on = state("pulse_on", false)
local glow = shader {
width = 200,
height = 40,
source = mantle.config_dir .. "/shaders/glow.frag",
progress = pulse_on:map(function(on) return on and 1 or 0 end),
params = { tint = { 0.54, 0.71, 0.98 } },
animate = { progress = 400 },
}
return glow
shaders/glow.frag in the config directory:
uniform vec3 tint;
void main() {
// Distance from the horizontal centre line, 0 at the middle, 1 at the edges.
float edge = abs(v_uv.y - 0.5) * 2.0;
float alpha = (1.0 - edge) * u_progress;
fragColor = vec4(tint * alpha, alpha); // premultiplied
}
Properties
shader takes the common properties, plus:
| Property | Type | Default | Behaviour |
|---|---|---|---|
source | string|Bound | "" | Absolute .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, 2-4 numbers for vec2-vec4. Missing ones are 0. Not tweened |
It has no intrinsic size: without width and height it draws nothing. opacity, transforms,
shadow_* and content_blur apply to it.
The .frag file
GLSL ES 3.00 without the header. The engine prepends #version 300 es, precision highp float and
the declarations below, then compiles the file as written. Error line numbers count from the file’s
first line.
| 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 | Set from params by name, 0 when params leaves one out. A params name with no uniform is ignored; a wrong component count is padded or truncated and logged once |
Write void main(). params never sets a uniform named u_* or mantle_*. A uniform the shader reads of any
other type, such as an int or a sampler2D, refuses the whole shader.
| 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 |
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 |
| Work in pixels | v_uv * u_size is the fragment’s position in logical px |
| Click a shader | Wrap it in a button |
| 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 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.
list
A row or column whose children come from data: one itemfn(item) call per element of source.
Reach for it for anything with a count you do not know up front: workspaces, notifications, search
results, a thumbnail grid. For a fixed set of children, a row or column is
simpler.
A scrolling thumbnail grid: a vertical list of two-image rows, decoded off-thread.
local paths = state("wallpapers", { "/usr/share/backgrounds/a.jpg", "/usr/share/backgrounds/b.jpg",
"/usr/share/backgrounds/c.jpg", "/usr/share/backgrounds/d.jpg" })
-- Two per row: a vertical list of rows, keyed by the paths they hold.
local rows = paths:map(function(all)
local out = {}
for i = 1, #all, 2 do out[#out + 1] = { all[i], all[i + 1] } end
return out
end)
local grid = list {
width = 420,
height = 300,
spacing = 8,
scroll = scroll("thumbs"),
source = rows,
key = function(pair) return table.concat(pair, "\n") end,
itemfn = function(pair)
local tiles = {}
for i, path in ipairs(pair) do
tiles[i] = image { source = path, async = true, fit = "cover", width = 206, height = 116 }
end
return row { spacing = 8, children = tiles }
end,
}
return grid
Keyed workspace buttons from a capability: workspaces cookbook.
Properties
list takes the common properties, plus the ones below. It takes no
box properties: wrap it in a rect or column for a background.
| Property | Type | Default | Behaviour |
|---|---|---|---|
source | 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 |
itemfn | fun(item: any): Node | Required | Builds a node for every built item, visible or not |
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 itemfn and reads none of the items’ signals; it lays the kept items out again,
about a third of the cost of building them. A change one item read builds that item alone; a change
the list itself read builds every item, scrolled out of view or not.
| Change | Builds again |
|---|---|
A write to source, or to a signal under a map or computed bound to it | Every item |
A write to a signal key read with :get() | Every item |
A new source, itemfn or key value, a new limit, or a reload | Every item |
A write to a signal an item’s itemfn call read with :get(), or bound to a property of that item at any depth, such as its hover | That item |
| A write to anything else, even on the same surface | Nothing |
key carries each item’s state (tweens, a held image, a text field’s draft) onto its rebuilt node,
and across reorders. Cap a long list with limit (a launcher’s top 50 matches), or hide it while
closed so it freezes.
The engine sees signal reads only. An itemfn, key or item map that reads the clock, a mutable
variable or a source table changed in place keeps what it read until a signal it read is written.
A delay or pulse builds the items that read it again on every pass while one is pending or open. What to read
instead: what a node reads again.
How do I…
| 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 source 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"),
source = names,
itemfn = function(name)
return text { content = name, width = "Fill", padding = 6 }
end,
}
Gotchas
| Trap | Fix |
|---|---|
A 2000-item list makes every update slow | Every item is laid out on every pass, and built whenever anything it read changes, visible or not. Cap it with limit, filter the source, or hide the list while it is closed |
A list rebuilds though nothing it shows changed | Its itemfn or key is a new function each time the builder around it runs, as inside a function child that reads a signal. Define them once, outside the builder |
| A relative time (“3 min ago”) in an item stops updating | itemfn read the clock with os.time(). Bind the text to a map of mantle.system instead (what a node reads again) |
A list of more than 10000 elements is refused | Set limit, or page the source |
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 single-line text input: a search box, a launcher query, a password. The engine holds what the user types (the draft); Lua sees it only through callbacks and cannot set it. Focus, editing keys, the draft’s lifetime and password fields are on input.
A launcher: the field filters a list as the user types, the arrow keys move a selection, Enter launches.
local apps = { "Firefox", "Files", "Terminal", "Text Editor", "Settings" }
local query = state("query", "")
local selected = state("selected", 1)
local matches = query:map(function(q)
local out = {}
for _, name in ipairs(apps) do
if name:lower():find((q or ""):lower(), 1, true) then out[#out + 1] = name end
end
return out
end)
local launcher = column { width = 320, padding = 12, spacing = 8, background = "#1E1E2E", radius = 12, children = {
rect { width = "Fill", padding = { left = 10, right = 10 }, radius = 8, background = "#313244", children = {
textfield {
width = "Fill",
height = 36,
font_size = 14,
foreground = "#CDD6F4",
placeholder = "Search…",
autofocus = true,
on_change = function(text) query:set(text); selected:set(1) end,
on_navigate = function(key)
if key == "down" then selected:set(math.min(#matches:get(), selected:get() + 1))
elseif key == "up" then selected:set(math.max(1, selected:get() - 1)) end
end,
on_submit = function() print("launch", matches:get()[selected:get()]) end,
},
} },
list {
width = "Fill",
source = matches,
key = function(name) return name end,
itemfn = function(name)
return rect {
width = "Fill", padding = { left = 10, right = 10, top = 6, bottom = 6 }, radius = 8,
background = selected:map(function(index)
return matches:get()[index] == name and "#45475A" or "#00000000"
end),
children = { text { content = name, foreground = "#CDD6F4" } },
}
end,
},
} }
return { panel { id = "launcher", layer = "Top", anchor = { top = true },
keyboard_interactivity = "OnDemand", child = launcher } }
The panel needs keyboard_interactivity for the field to get keys (panel).
Properties
textfield takes the common properties, plus:
| Property | Type | Default | Behaviour |
|---|---|---|---|
placeholder | string|Bound | "" | Shown while the field is empty, focused or not. Never submitted |
font_size | number|Bound, [1, 8192] | 12 | Size of the text and placeholder |
foreground | Color|Bound | "#FFFFFF" | Colour of the text and placeholder |
text_align | "Start"|"Center"|"End"|Bound | "Start" | Aligns the text inside the field’s box |
autofocus | boolean|Bound | false | Plain fields only: take the keyboard, empty, when the surface gets it or the field appears, calling on_change(""). The first in document order wins; never steals from a field already typing or one a press just left |
on_change | fun(text: string) | None | Full text after every edit |
on_submit | fun(text: string) | None | Enter with the full text; the field stays focused and clears. Never fires on a secure_submit field |
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 |
on_navigate | fun(key: "up"|"down"|"left"|"right"|"page_up"|"page_down"|"tab"|"backtab") | None | Keys a single-line field does not use, for moving a list selection; repeats while held. "left"/"right" only when the caret cannot move that way and Shift is up |
secure_submit | { capability: string, action: string }|Bound | None | Makes the field masked; keys never reach Lua. Both non-empty UTF-8 strings: only lock/authenticate, polkit/authenticate and network/connect; any other pair or key is an error (secure fields) |
mask_character | string|Bound | "•" | Drawn per typed character in a secure_submit field. Only the first character counts; "" hides the length |
The field has no intrinsic size: give it width and height. It draws one line of text and a
caret, vertically centred, in the fonts chain; there is no font
property. It reads wl_keyboard, not an input method, so there is no CJK composition and no dead
keys.
A field with none of on_change, on_submit and secure_submit never takes focus. The draft
follows the node, so give the field a stable id when siblings before it come and go
(identity).
How do I…
| Task | Answer |
|---|---|
| Filter a list as the user types | The example above: on_change sets a state, the list’s source maps it |
| Move a selection with the arrow keys | on_navigate, 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 |
| 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 | A button with submit = true |
| 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 size. Give width and height |
| 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 or on_navigate alone never fires | Neither makes the field focusable. Add on_change or on_submit |
| You cannot set or clear the draft from Lua | The draft is the engine’s. 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 {
padding = 16,
spacing = 8,
background = "#1E1E2EF2",
radius = 12,
border_width = 1,
border_color = "#FFFFFF1A",
shadow_color = "#00000099",
shadow_blur = 18,
shadow_offset = { x = 0, y = 8 },
children = {
text { content = "Battery", font_size = 14, foreground = "#CDD6F4" },
text { content = "82% · 3 h 10 min left", foreground = "#A6ADC8" },
},
}
A card: a translucent rounded fill, a hairline border and a soft shadow below it.
Terms
| Term | Meaning |
|---|---|
| Box kind | A node that paints a box: rect, row, column, button 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 content_blur and some shadows use. Unlike other offscreen passes, Mantle keeps it and reuses it while the subtree does not change |
| Glass | A box with backdrop_blur |
| Sigma | A Gaussian blur’s standard deviation in logical px. The blur reaches about 3 sigma |
Who takes what
A property on a kind that does not take it is refused, naming the closest property the kind takes or, with none close, listing them all.
| Properties | Taken by |
|---|---|
shadow_color, shadow_blur, shadow_offset, shadow_spread, content_blur, opacity | Every node, including text, icon, image, list, textfield |
background, radius, corner_shape, border_color, border_width, clip, mask, shadow_mode, backdrop_blur, blur | Box kinds only |
source_blur | image only |
foreground (text, icon, textfield), z, scale, rotate, translate, origin, visible | Also affect paint; documented on Nodes |
Every property can be a signal. A signal nested inside a table (a gradient stop, one
border edge) is refused, so derive the whole table with :map. A malformed value fails the pass
instead of drawing a default: the previous scene stays and the error goes to mantle log
(runtime).
Colours
Colours are strings "#RRGGBB" or "#RRGGBBAA", hex digits in either case. There are no named
colours and no short #RGB form.
Box properties
| Property | Type | Default | Behaviour |
|---|---|---|---|
background | Color|Gradient|Bound | None | A colour or gradient. Absent draws nothing; "#00000000" is an explicit transparent fill. A gradient snaps under animate |
mask | Mask|Bound | None | Multiplies the alpha of this node and its subtree; see Mask |
radius | number|Bound, [0, 8192] | 0 | Corner radius px. Above half the shorter side it clamps, so radius = 999 makes a pill or circle |
corner_shape | "Round"|"Scoop"|Bound | "Round" | "Scoop" cuts each corner inward as a quarter circle centred on the corner point; fill, clip, glass, shadow and the blur region follow |
border_color | Color|BorderColors|Bound | None | A string sets all four edges; a missing edge has none. An edge draws only with both a colour and a width |
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 |
blur | boolean|Bound | false | Ask the compositor to blur the desktop behind this box; see Blurs. Never inferred from a translucent background |
backdrop_blur | number|Bound, [0, 8192] | 0 | Gaussian sigma in px over what this surface already painted under the box, CSS backdrop-filter; see Blurs |
shadow_mode | "Box"|"Content"|Bound | "Box" | "Box": CSS box-shadow of the box shape. "Content": CSS drop-shadow of everything painted. See Shadows |
clip | "Box"|"Rounded"|"None"|Bound | "Box" | "Box" cuts children to the rectangle, "Rounded" also to radius, "None" leaves them on the parent’s clip. See Clip |
A uniform border (same width and colour on all four edges) on a round corner follows radius.
A per-edge border, or any border on a scoop, draws as four straight rectangles with square corners:
local function tile(label, props)
props.width, props.height, props.radius = 88, 56, 14
props.background = "#313244"
props.children = { text { content = label, foreground = "#CDD6F4", align_h = "Center", align_v = "Center" } }
return rect(props)
end
return row {
spacing = 12,
children = {
tile("Round", { border_width = 2, border_color = "#89B4FA" }),
tile("Scoop", { corner_shape = "Scoop", border_width = 2, border_color = "#89B4FA" }),
tile("Per-edge", { border_width = { bottom = 3 }, border_color = "#89B4FA" }),
},
}
Gradients
background and mask take a gradient table.
background = {
gradient = "Linear",
angle = 90,
stops = { { 0, "#CBA6F7" }, { 0.5, "#F38BA8" }, { 1, "#89B4FA" } },
}
| 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 |
| 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 = 6,
children = {
rect { width = 96, height = 64, radius = 8, background = fill },
text { content = label, foreground = "#A6ADC8" },
},
}
end
return row {
spacing = 12,
children = {
swatch("Linear, 90", { gradient = "Linear", angle = 90, stops = stops }),
swatch("Radial", { gradient = "Radial", stops = stops }),
swatch("Conic", { gradient = "Conic", stops = stops }),
},
}
Clip
clip decides what a box cuts its children to.
| Value | Children are cut to | Cost |
|---|---|---|
"Box" | The box’s rectangle | Free (a scissor) |
"Rounded" | The box’s radius and corner_shape. With radius = 0 it is "Box" | An offscreen pass every repaint of the box |
"None" | Whatever the parent cuts to, so children and their shadows can overflow this box | 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 |
Either, plus invert = true | The complement: kept and cut swap |
Name exactly one of source or a gradient. A masked box draws its subtree offscreen every repaint
and always cuts children to its box (to radius too under clip = "Rounded"), even with
clip = "None".
local items = {}
for i = 1, 12 do
items[i] = rect { width = "Fill", padding = 10, radius = 8, background = "#313244",
children = { text { content = "Row " .. i, foreground = "#CDD6F4" } } }
end
return column {
width = 200,
height = 240,
spacing = 6,
scroll = scroll("feed"),
mask = {
gradient = "Linear",
stops = { { 0, "#00000000" }, { 0.08, "#000000" }, { 0.92, "#000000" }, { 1, "#00000000" } },
},
children = items,
}
A scrolling list whose rows fade out at the top and bottom edges.
Shadows
A shadow draws when shadow_color has alpha above 0 and at least one of shadow_blur,
shadow_offset or shadow_spread is set. The terms are CSS’s box-shadow.
| Property | Values | Default |
|---|---|---|
shadow_color | Colour | "#000000" |
shadow_blur | CSS blur radius in px [0, 8192]; the Gaussian’s sigma is half of it | 0 |
shadow_offset | { x, y } px, each [-8192, 8192], missing axis 0 | { x = 0, y = 0 } |
shadow_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 | 0 |
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(mode)
return column {
padding = 14,
radius = 12,
border_width = 1,
border_color = "#89B4FA",
shadow_mode = mode,
shadow_color = "#000000",
shadow_blur = 4,
shadow_offset = { x = 5, y = 6 },
children = { text { content = mode, font_size = 20, foreground = "#CDD6F4" } },
}
end
return row {
padding = 24,
spacing = 24,
background = "#585B70",
children = { card("Box"), card("Content") },
}
"Box" casts the rounded box and cuts the shadow out under it; "Content" casts the border ring
and the glyphs.
| 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 content_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 shadow_color |
| Box mode on a translucent scoop | A layer of the scoop’s silhouette, cut out under the box |
Blurs
Four properties blur four different things. Sigmas are in logical px, [0, 8192], 0 is off.
source_blur is a fast box approximation; the others are Gaussian.
| Property | Reads | When it runs | Cost | Pick it for |
|---|---|---|---|---|
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 |
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 |
content_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 |
blur = true. Mantle sends the compositor a region, through ext-background-effect-v1, made
of every blur = true box on the surface: rounded to radius (or scooped), cut by ancestor
clips, moved by transforms, and dropped while the node is invisible or at opacity 0. It ignores
mask. The compositor decides strength, noise, xray and whether to blur at all; a compositor
without the protocol or its blur capability gives nothing, and no error. It is never inferred from
a translucent background; the background alpha only decides how much of the blurred desktop
shows through.
source_blur. The blur is baked into the decoded pixels, which are stored cropped to the box
under fit = "cover", so it is exact there. Under "contain" or "stretch" the stored pixels
are rescaled and the blur with them. Animated GIFs ignore it. Changing it re-decodes; under async = true the image draws nothing until that lands, and retain does not cover it (the source did not change). See
image.
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
height = 36,
exclusive = true,
background = "#1E1E2E99",
blur = true,
child = row { width = "Fill", padding = { left = 12, right = 12 }, children = { clock } },
}
A bar whose 60% fill tints the compositor-blurred desktop behind it.
rect {
width = 320,
height = 180,
children = {
image { source = "/usr/share/backgrounds/default.png", width = "Fill", height = "Fill", async = true },
row {
align_h = "Center",
align_v = "Center",
padding = { left = 14, right = 14, top = 6, bottom = 6 },
radius = 999,
background = "#FFFFFF1F",
border_width = 1,
border_color = "#FFFFFF33",
backdrop_blur = 12,
children = { text { content = "12:45", font_size = 18, foreground = "#FFFFFF" } },
},
},
}
A frosted pill: the image is painted first, so the pill’s backdrop_blur blurs the image under
its rounded shape, and the fill tints it. The same pattern over a full-screen image frosts a lock
screen’s wallpaper.
Combining effects
One node paints in this order, each step over the last:
- Backdrop (
backdrop_blur): replaces the pixels under the box with their blur. - 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. - Layer: for
content_bluror a layered shadow, the body is drawn offscreen, its shadow cast from it, then the body blurred. - Transform (
scale,rotate,translate) wraps all of the above.
| Combination | What happens | Do this |
|---|---|---|
mask and backdrop_blur on one node | The mask fades the fill, border and subtree, not the node’s own glass or box shadow | Put the glass on a child of the masked node |
content_blur and backdrop_blur on one node | The glass stays sharp; only the fill, border and subtree blur | Expected |
backdrop_blur inside a parent with mask, content_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 |
backdrop_blur inside clip = "Rounded" without a mask | The glass sees what is under the parent, as without the clip | Nothing to do |
backdrop_blur on a surface root | Nothing is under it on the surface, so it blurs transparency | Use blur = true for the desktop |
blur = true and backdrop_blur on one box | The compositor blurs the desktop; the backdrop blurs this surface’s pixels. Neither sees the other | Pick by what is underneath: desktop or own content |
Shadow and content_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 content_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 content_blur near the parent’s edge | Cut at the parent’s clip, like any child paint | Give the parent padding, or clip = "None" on it |
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 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 Gotchas |
Frosted glass panel
panel {
id = "sheet",
layer = "Top",
anchor = { top = true, right = true },
margin = 8,
child = column {
width = 280,
padding = 16,
spacing = 8,
radius = 16,
background = "#1E1E2EB3",
border_width = 1,
border_color = "#FFFFFF2E",
blur = true,
children = { text { content = "Wi-Fi", font_size = 14, foreground = "#CDD6F4" } },
},
}
The compositor blurs the desktop under the rounded sheet only; the rest of the surface stays clear. A faint light border separates glass from glass.
Card that lifts on hover
local lifted = hover("card_hover")
column {
hover = lifted,
padding = 16,
radius = 12,
background = "#313244",
shadow_color = "#00000099",
shadow_blur = lifted:map(function(on) return on and 36 or 12 end),
shadow_offset = lifted:map(function(on) return { x = 0, y = on and 20 or 6 } end),
animate = { shadow_blur = 200, shadow_offset = 200 },
children = { text { content = "Hover me" } },
}
hover drives the shadow and animate eases it. Leave room
around the card: the parent clips the shadow.
Pill button
local hovered = hover("save_hover")
button {
hover = hovered,
padding = { left = 16, right = 16, top = 6, bottom = 6 },
radius = 999,
background = hovered:map(function(on) return on and "#89B4FA59" or "#89B4FA33" end),
border_width = 1,
border_color = "#89B4FA66",
animate = { background = 150 },
on_click = function() print("saved") end,
children = { text { content = "Save", foreground = "#CDD6F4" } },
}
A radius past half the height makes the ends round whatever the label’s width.
Gradient border
rect {
padding = 2,
radius = 14,
background = { gradient = "Linear", angle = 135, stops = { { 0, "#CBA6F7" }, { 1, "#89B4FA" } } },
children = {
column {
padding = 14,
radius = 12,
background = "#1E1E2E",
children = { text { content = "Pro", foreground = "#CDD6F4" } },
},
},
}
border_color takes only flat colours, so paint the gradient as an outer fill and cover all but a
2px ring with an opaque inner box. Keep the inner radius the outer radius minus the ring width.
Circular avatar
rect {
width = 64,
height = 64,
radius = 32,
clip = "Rounded",
border_width = 2,
border_color = "#89B4FA",
children = { image { source = "/var/lib/AccountsService/icons/user", width = "Fill", height = "Fill" } },
}
clip = "Rounded" cuts the image to the circle, and the border paints over the image’s edge.
Dim the background behind a modal
panel {
id = "modal",
layer = "Overlay",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
exclusive = "Ignore",
keyboard_interactivity = "OnDemand",
child = rect {
width = "Fill",
height = "Fill",
children = {
rect { width = "Fill", height = "Fill", background = "#11111B99" },
column {
align_h = "Center",
align_v = "Center",
width = 360,
padding = 24,
spacing = 8,
radius = 16,
background = "#1E1E2EE0",
blur = true,
shadow_color = "#00000080",
shadow_blur = 32,
children = {
text { content = "Log out?", font_size = 18, foreground = "#CDD6F4" },
text { content = "Unsaved work in open apps will be lost.", foreground = "#A6ADC8" },
},
},
},
},
}
A full-screen panel whose first child is a translucent scrim and whose
second is the dialog. Dim with a colour rather than blur = true on the scrim: the compositor’s
blur does not fade with opacity, so a fading scrim would blur at full strength until it hits 0.
Gotchas
| Trap | Fix |
|---|---|
| A shadow is cut off at one edge | The parent clips it. Pad the parent, or set clip = "None" on it |
backdrop_blur shows no desktop behind a translucent panel | It only reads this surface’s pixels. Use blur = true |
blur = true does nothing | The compositor lacks ext-background-effect-v1 or its blur capability. No error is raised |
A blur = true box fades out but its blur stays at full strength | The blur region ignores opacity until it reaches 0. Dim with a translucent colour, or let the blurred box pop |
| A per-edge border or a border on a scoop has square corners | Only a uniform border follows radius, and a scoop’s border is always square |
| A border covers content | Borders take no layout space. Add padding at least the border’s width |
clip = "Rounded" changes nothing | It needs a non-zero radius, and only clips children |
Children still clipped with clip = "None" and a mask | A mask always cuts to its box |
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. Shrink the button or accept it |
| A signal inside a gradient stop or border edge is refused | Map the whole table: background = accent:map(function(c) return { gradient = "Linear", stops = { { 0, c }, { 1, "#00000000" } } } end) |
See also: nodes, surfaces, animation, input, glossary.
Source: allowlist, parsers, paint style, paint order, canvas, effects, shapes, blur region, compositor push.
Animation
animate makes a node’s properties move to a new value instead of snapping: on a hover, a level
or a toggle, as a node enters or leaves the tree, or in a loop like a spinner. A tween is one
property moving from the value on screen to the value a new pass resolves.
The engine runs every tween on its own surface’s compositor frames, so a panel on a 60 Hz output
moves at 60 Hz beside one at 165 Hz; no Lua runs between the pass that starts a tween and its last
frame.
local open = hover("tray")
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
child = row {
hover = open,
height = 28,
radius = 14,
background = "#313244",
width = open:map(function(on) return on and 160 or 28 end),
spacing = open:map(function(on) return on and 6 or 0 end),
animate = { width = { duration = 200, easing = "OutCubic" }, spacing = 200 },
children = {
icon { name = "network-wireless-symbolic", size = 16, margin = 6 },
icon { name = "bluetooth-active-symbolic", size = 16, margin = 6 },
},
},
}
How a tween starts
animate is a table from property names to entries. When a pass resolves a different value for a
named property, the node moves from the value on screen to the new one. A pass that re-resolves the
same value leaves a running tween alone, so an unrelated signal does not restart the motion.
animate itself may be a signal (animate = shown:map(...)), but the entries inside it are plain
values: a signal nested in an entry does not resolve.
| Situation | Result |
|---|---|
Number, "NN%" size, "#rrggbb[aa]" colour, number edge table { top, right, bottom, left }, { x, y } table | Tweens against a new value of the same shape. A missing edge or axis reads as 0 (1 for scale, 0.5 for origin) |
"Fill", booleans, strings that are not colours, per-edge colour tables, gradients, or a change of shape (2 to { x = 2 }, "50%" to "Fill") | Snaps |
| New node, or a property the node did not set last pass | Starts at the entry’s from, else snaps. from needs the node to set the property itself |
| Target changes mid-flight | Eased and keyframe motion 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 |
z, animate, or a name the node kind does not accept | Refused: the pass fails with an error naming the entry |
What can animate
Any property the node’s kind accepts (nodes) can be named; the value’s shape decides whether it moves. The ones that do:
| Shape | Properties |
|---|---|
| Number | width, height, min_*, max_*, padding, margin, opacity, scale, rotate, shadow_blur, shadow_spread, content_blur; on boxes radius, border_width, backdrop_blur; spacing on row, column, list; font_size on text and textfield; size on icon; progress on shader |
"NN%" | width, height |
| Colour | background, border_color (single colour), shadow_color, foreground |
{ top, right, bottom, left } | padding, margin, border_width as tables |
{ x, y } | translate, scale, origin, shadow_offset |
An image crossfading between sources uses its own transition property, not animate
(image).
Range clamp
Every frame’s value is clamped to the property’s range, which
catches overshoot from Back, Elastic, a Bezier with y outside [0, 1], or a spring. Only
margin, translate, rotate, progress, shadow_offset and shadow_spread may go negative.
padding, spacing and icon size have no range as plain values but tween within [0, 8192].
Layout cost
| Tween on | Each frame |
|---|---|
opacity, colours, radius, translate, scale, rotate, origin, progress, shadow_*, content_blur, backdrop_blur | 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.
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).
| 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 "InOutQuad". 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 |
spring | { stiffness, damping } | stiffness in (0, 100000], damping in (0, 10000], both required. Refuses duration, easing, keyframes and loops |
keyframes | At least 2 frames | See keyframes |
loops | Whole count [1, 10000] or "Infinite" | Default 1. Only with keyframes |
A duration or delay that is not a number ("200") is refused rather than read as absent.
Easing names. A name is case-sensitive; an unknown one is refused with the list.
| Family | Names |
|---|---|
| Linear | Linear |
| Quad, Cubic, Quart, Quint | InQuad, OutQuad, InOutQuad, InCubic, OutCubic, InOutCubic, InQuart, OutQuart, InOutQuart, InQuint, OutQuint, InOutQuint |
| Sine, Expo, Circ | InSine, OutSine, InOutSine, InExpo, OutExpo, InOutExpo, InCirc, OutCirc, InOutCirc |
| Back, Elastic, Bounce | InBack, OutBack, InOutBack, InElastic, OutElastic, InOutElastic, InBounce, OutBounce, InOutBounce |
In starts slow, Out ends slow, InOut does both. Back and Elastic overshoot, and the
range clamp above catches it; Bounce stays inside the range.
| Table 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 |
The same 600 ms width change under six easings. OutBack passes the target and comes back:
local go = state("go", false)
local function race(label, easing)
return row {
spacing = 8,
children = {
text { content = label, width = 80, font_size = 12, foreground = "#a6adc8" },
rect {
height = 12,
radius = 6,
background = "#89b4fa",
width = go:map(function(on) return on and 200 or 12 end),
animate = { width = { duration = 600, easing = easing } },
},
},
}
end
return column {
spacing = 6,
children = {
race("Linear", "Linear"),
race("InOutQuad", "InOutQuad"),
race("OutCubic", "OutCubic"),
race("OutBack", "OutBack"),
race("OutBounce", "OutBounce"),
race("steps = 4", { steps = 4 }),
},
}
Spring
A spring has no duration: stiffness and damping decide how it settles. Use one for a target
that changes mid-flight, like a held volume key or a pointer-following highlight. The spring
carries its velocity into the new motion; an eased tween restarts from a standstill and lags
behind. A spring that replaces an eased tween starts at rest.
| 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 knob(label, damping)
return row {
spacing = 8,
children = {
text { content = label, width = 130, font_size = 12, foreground = "#a6adc8" },
rect {
width = 176,
height = 16,
radius = 8,
background = "#313244",
children = {
rect {
width = 16,
height = 16,
radius = 8,
background = "#cba6f7",
translate = go:map(function(on) return { x = on and 100 or 0 } end),
animate = { translate = { spring = { stiffness = 400, damping = damping } } },
},
},
},
},
}
end
return column {
spacing = 8,
children = {
knob("damping = 12, rings", 12),
knob("damping = 40, critical", 40),
knob("damping = 120, crawls", 120),
},
}
Keyframes
A keyframes entry walks a list of values instead of easing to the resolved one. While it runs, it
owns the property: the value the pass resolves is ignored.
| 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 |
| First frame | Where the run starts; its own duration and easing are never read |
| 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 or loops starts a new run from the first frame |
To replay a finished run, take the entry away and put it back. pulse does both in
one expression: it reads true for a window after its source changes.
local taps = state("taps", 0)
-- Three 120 ms segments: `duration` times each one, so the run takes 360 ms.
local BOUNCE = { scale = { duration = 120, easing = "OutQuad", keyframes = { 1, 1.25, 0.9, 1 } } }
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
padding = 4, -- room for the overshoot: a scaled node paints past its box
child = button {
width = 32,
height = 32,
radius = 8,
background = "#313244",
on_click = function() taps:set(taps:get() + 1) end,
-- pulse is true for 400 ms after each tap: the entry appears, plays once, then goes.
animate = pulse(taps, 400):map(function(on) return on and BOUNCE or {} end),
children = { icon { name = "starred-symbolic", size = 16, foreground = "#CDD6F4", align_h = "Center", align_v = "Center" } },
},
}
An endless spinner needs no signal. A hidden spinner stops requesting frames by itself:
local busy = state("busy", true)
local SPIN = { rotate = { duration = 1000, easing = "Linear", keyframes = { 0, 360 }, loops = "Infinite" } }
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
padding = 4, -- room for the corners as it turns
child = icon {
name = "view-refresh-symbolic",
size = 16,
foreground = "#CDD6F4",
visible = busy,
animate = SPIN,
},
}
Exit
animate.exit animates a child after its parent stops returning it: a notification removed from a
list, or a card dropped from children. The block holds one timing for every property, and the
values to ease to.
exit = { duration = 150, easing = "InQuad", opacity = 0, translate = { y = 16 } }
| 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, shadow_*, 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 = rect {
width = 240,
height = 48,
radius = 12,
background = "#1e1e2ee6",
opacity = 1,
translate = { y = 0 },
children = { text { content = "Volume 42%", align_h = "Center", align_v = "Center", foreground = "#CDD6F4" } },
animate = {
opacity = { duration = 200, from = 0 },
translate = { duration = 200, easing = "OutCubic", from = { y = 16 } },
exit = { duration = 150, easing = "InQuad", opacity = 0, translate = { y = 16 } },
},
}
return panel {
id = "osd",
layer = "Overlay",
anchor = { bottom = true },
width = 240,
height = 64, -- room for the exit's 16 px slide
visible = mapped,
child = column {
height = "Fill",
children = shown:map(function(on) return on and { card } or {} end),
},
}
How do I…
| 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 a tooltip popup in and out
A popup closing skips the exit, so
switch the card out of children and let delay hold the popup open while it fades.
local over = hover("clock")
-- Stay mapped 120 ms after the pointer leaves, so the card's exit can play.
local mapped = computed({ over, delay(over, 120) }, function(now, was)
return now == true or was == true
end)
local card = rect {
width = 180,
height = 32,
radius = 6,
background = "#1e1e2e",
opacity = 1,
animate = { opacity = { duration = 120, from = 0 }, exit = { duration = 120, opacity = 0 } },
children = { text { content = "Thursday, 24 September", padding = 8 } },
}
return {
panel { id = "bar", layer = "Top", anchor = { top = true }, child = text { content = "12:30", padding = 8, hover = over } },
popup {
id = "clock_tooltip",
parent = "bar",
anchor_rect = hover_rect("clock"),
anchor = "Bottom",
gravity = "Bottom",
grab = false,
visible = mapped,
child = rect {
children = over:map(function(on) return on and { card } or {} end),
},
},
}
Stagger a list’s entrance
Give each item a delay that grows with its index. delay holds
the from value, so a card waits invisible for its turn.
local go = state("go", false)
local titles = { "Battery low", "Update ready", "Download complete" }
local function card(index, title)
local wait = (index - 1) * 80
return rect {
width = 200,
padding = 10,
radius = 8,
background = "#1e1e2e",
opacity = 1,
translate = { x = 0 },
animate = {
opacity = { duration = 200, delay = wait, from = 0 },
translate = { duration = 200, delay = wait, easing = "OutCubic", from = { x = -24 } },
},
children = { text { content = title, foreground = "#cdd6f4" } },
}
end
return column {
spacing = 6,
children = go:map(function(on)
local cards = {}
for index, title in ipairs(on and titles or {}) do
cards[index] = card(index, title)
end
return cards
end),
}
Slide a notification out
Removing an item from a keyed list makes it
leave. The remaining cards close up at once; only the leaving one moves.
local notes = state("notes", { "Battery low", "Update ready", "Download complete" })
local function dismiss(title)
local kept = {}
for _, other in ipairs(notes:get()) do
if other ~= title then kept[#kept + 1] = other end
end
notes:set(kept)
end
local function card(title)
return button {
width = 280,
padding = 12,
radius = 12,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
on_click = function() dismiss(title) end,
animate = { exit = { duration = 200, easing = "InCubic", opacity = 0, translate = { x = 300 } } },
children = { text { content = title, foreground = "#cdd6f4" } },
}
end
return panel {
id = "notifications",
layer = "Overlay",
anchor = { top = true, right = true },
width = 300,
height = 400,
child = list { spacing = 8, source = notes, itemfn = card, key = function(title) return title end },
}
Gotchas
| 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 |
| 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. Toggle the entry off and on, for example 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 |
Sliding with margin stutters on a large surface | Tween translate: it skips layout |
width will not overshoot below 0 with OutBack | 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, easing, spring, keyframes, leaving nodes, range clamps.
Input
Pointer and keyboard input: clicks, drags and the wheel on a button, hover, scrolling
containers, and typing into a textfield, including password fields whose keys never reach Lua.
There is no key-handler property and no touch input; keys reach a config only through a focused
textfield. A handler usually writes a named state, and the next
pass shows the result.
local clicks = state("clicks", 0)
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
child = button {
padding = 8,
background = "#313244",
on_click = function(rect, which)
if which == "left" then clicks:set(clicks:get() + 1) end
end,
children = { text { content = clicks:map(function(n) return "Clicked " .. n end) } },
},
}
Hit testing
Every pointer event asks which nodes lie under the pointer, from the surface down.
| 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 reaches none of its children, unless the node has clip = "None" |
| Skipped | visible = false subtrees and nodes playing an exit. opacity = 0 is still hit |
| Edges | Half-open: two buttons 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
Only a button takes clicks, drags and the wheel. For each event the innermost button with a
handler for that event wins; a button without one is transparent, so a handle inside a draggable
track leaves the track draggable.
| Handler | Arguments | Contract |
|---|---|---|
on_click(rect, button) | button is "left", "right" or "middle" | Fires on release over the same button that was pressed, with the same mouse button. Other mouse buttons are ignored |
on_drag(rect, pointer, phase) | pointer is { x, y } relative to the button, unclamped; phase is "start", "move" or "end" | Left button only. See below |
on_wheel(rect, steps) | steps is a number of wheel notches | Vertical wheel only. See below |
submit = true | — | Sends the armed secure field on click, like Enter; works without on_click and runs before it |
Click. A press arms the click and the release fires it. Leaving the button and coming back
before release still clicks; the pointer leaving the surface cancels. The click also cancels if the
button’s laid-out box moved between press and release, so give press feedback with scale or
translate rather than width or margin. A press on a textfield never clicks the button
around it, and a link in a text (on_link) takes the click before any button around it.
Drag. A left press on an on_drag button calls "start" at once, so clicking a slider track
also seeks. Every pointer motion on that surface then calls "move", wherever the pointer is.
"end" comes on the left release, when the pointer leaves the surface, or when the surface closes.
rect stays the box from the press for the whole drag. On release, "end" fires first and the
click (if the button also has on_click) after it; a leave ends the drag and cancels the click.
Wheel. steps is positive away from the user (scroll up) and negative toward. One notch is
1; high-resolution wheels send fractions of a notch, and touchpads send distance divided by one
notch’s 39 px. Horizontal motion never reaches on_wheel. The innermost on_wheel button or
scroll container under the pointer takes the whole event, with no chaining to a parent.
local level = state("level", 0.5)
local function clamp(value) return math.max(0, math.min(1, value)) end
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
child = button {
width = 200,
height = 12,
radius = 6,
clip = "Rounded",
background = "#45475a",
-- A press is "start", so clicking the track also seeks.
on_drag = function(rect, pointer, phase) level:set(clamp(pointer.x / rect.width)) end,
on_wheel = function(_, steps) level:set(clamp(level:get() + steps * 0.05)) end,
children = {
rect {
height = "Fill",
background = "#89b4fa",
width = level:map(function(value) return string.format("%d%%", math.floor(value * 100 + 0.5)) end),
},
},
},
}
Hover
hover(name) returns a read-only boolean signal, false until the pointer first arrives. Bind it
to a node’s hover and the engine writes it as the pointer moves; read the same signal anywhere
else to react. The name is the identity: every hover("wifi") call returns the same signal, and it
survives reloads.
| 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 nodes under a still pointer updates hover but does not call it. Refused unless the same node has hover |
hover_rect(name) | Read-only signal of the node’s rect from the last time its hover turned on. It keeps that rect after the pointer leaves, reads { x = 0, y = 0, width = 1, height = 1 } before the first hover, and is updated before on_hover runs. Use it as a tooltip popup’s anchor_rect |
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 = button {
padding = 6,
radius = 6,
hover = over,
background = over:map(function(on) return on and "#45475a" or "#313244" end),
on_hover = function(inside) log.debug("wifi hovered:", inside) end,
on_click = function() process.detach("nm-connection-editor", {}) end,
children = { icon { name = "network-wireless-symbolic", size = 16 } },
},
}
Scroll
scroll(name) returns a read-only signal holding a scroll offset in px, 0 at first. Bind it to
the scroll property of a row, column or list and the wheel moves that container’s children.
Like hover, the name is the identity and survives reloads.
| 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 | Layout clamps the offset to [0, content − viewport] and writes the clamped value back. The container needs a bounded size on its axis (fixed, "Fill" or max_*); one sized by its content has nothing to scroll |
| 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 |
:reveal(index) | On the next pass, scrolls the least distance that shows the index-th visible child (1-based; a list’s items in source order). An index past the end does nothing; below 1 raises. Only a scroll signal has it |
Text fields
A textfield is a single-line text input. The engine holds what the user types (the draft); Lua
sees it only through callbacks and cannot set it. The field with focus is the one keys go to. It
has no size of its own, so give it width and height (nodes).
A field takes the keyboard only when both hold:
| Condition | Detail |
|---|---|
| The surface has keyboard focus | A panel needs keyboard_interactivity = "OnDemand" 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 or on_navigate) never takes focus, and a press on it acts like a press on empty space |
A press on the field focuses it and puts the caret under the pointer. autofocus focuses it without
a press.
| 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_navigate(key) | "up", "down", "page_up", "page_down", "tab", "backtab", and "left"/"right" when the caret cannot move that way and Shift is up. Repeats while held. The draft is untouched |
autofocus | true: take the keys, with an empty draft and a call to on_change(""), when the surface gains keyboard focus or the field appears under it. The first visible such field in document order wins. It never takes over from a field that is already typing, and never re-takes a field the user just clicked away from |
secure_submit, mask_character | See secure fields |
placeholder, 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) call on_navigate | Nothing |
| Ctrl+A | Selects all | Nothing |
| Up, Down, Page Up, Page Down, Tab, Shift+Tab | on_navigate ("backtab" for Shift+Tab) | Nothing |
| Any other Ctrl chord | Left to the compositor | Same |
Selection and clipboard. Dragging or Shift+clicking with the pointer selects too. There is no
clipboard in a field, and Tab does not move focus between fields; it only reaches on_navigate.
Every key but Enter repeats while held.
Draft lifetime. Clicking elsewhere, or the surface losing the keyboard, stops typing but keeps
the draft; clicking the field again resumes it. Enter and Escape clear it. An autofocus arm
starts it empty. It is dropped when the field’s node leaves the tree or its surface closes.
local APPS = { "firefox", "foot", "nautilus", "pavucontrol", "thunderbird", "zed" }
local query = state("query", "")
local selected = state("selected", 1)
local LIST = scroll("results")
local open = state("launcher_open", true)
local results = query:map(function(needle)
local found = {}
for _, app in ipairs(APPS) do
if fuzzy(app, needle) then found[#found + 1] = app end
end
return found
end)
local function pick(index)
selected:set(index)
LIST:reveal(index)
end
local search = textfield {
width = "Fill",
height = 32,
placeholder = "Search apps",
autofocus = true,
on_change = function(text)
query:set(text)
pick(1)
end,
on_submit = function()
local app = results:get()[selected:get()]
if app then process.detach(app, {}) end
end,
-- First Escape clears the text; a second one, on an empty field, closes.
on_cancel = function(cleared)
if not cleared then open:set(false) end
end,
on_navigate = function(key)
local step = ({ up = -1, down = 1 })[key]
if step then pick(math.max(1, math.min(#results:get(), selected:get() + step))) end
end,
}
local function app_row(app)
return row {
width = "Fill",
padding = 6,
background = computed({ results, selected }, function(found, index)
return found[index] == app and "#45475a" or nil
end),
children = { text { content = app } },
}
end
return panel {
id = "launcher",
layer = "Overlay",
keyboard_interactivity = "OnDemand",
width = 320,
visible = open,
child = column {
width = "Fill",
children = {
search,
list { width = "Fill", height = 120, scroll = LIST, source = results, itemfn = app_row, key = function(app) return app end },
},
},
}
Secure fields
secure_submit = { capability, action } turns a textfield into a password field. Its keys go to
a native buffer and leave the Renderer as one message to that target; no Lua value ever holds the
secret or its length.
| 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) |
| 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, selection or on_navigate; on_change and on_submit never fire |
| Sending | Enter, or a click on a submit = true button, sends the buffer and wipes it. An empty buffer is sent only to network/connect, where it joins an open network |
| Focus | A click on anything but a field keeps the field armed, so a submit button works. Focusing another field, plain or secure, or the keyboard leaving the surface, disarms it and wipes the buffer |
| Priority | While a secure field is armed, plain fields in the same focus take no keys |
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 |
return lock {
id = "lock",
child = column {
width = "Fill",
height = "Fill",
align_h = "Center",
align_v = "Center",
spacing = 12,
background = "#11111b",
children = {
textfield {
width = 280,
height = 40,
placeholder = "Password",
mask_character = "•",
secure_submit = { capability = "lock", action = "authenticate" },
},
button {
padding = 8,
radius = 8,
background = "#89b4fa",
submit = true,
children = { text { content = "Unlock", foreground = "#11111b" } },
},
},
},
}
How do I…
| Task | Answer |
|---|---|
| Make a slider | The example under pointer |
| Move a selection through a list with the arrow keys | The launcher under text fields: on_navigate plus scroll(name):reveal |
| Close a search box on a second Escape | The same launcher: on_cancel(cleared) closes only when cleared is false |
| Ask for a password | The lock example under secure fields |
| 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 button’s rect, which is what a
popup’s anchor_rect wants. The click is a pointer release, so the popup may
take its grab. The menu hangs from the button, not the click point: no handler reports the pointer
position of a click.
local menu_open = state("context_open", false)
local menu_at = state("context_at", { x = 0, y = 0, width = 1, height = 1 })
local function item(label, run)
return button {
width = "Fill",
padding = 6,
radius = 4,
on_click = function()
menu_open:set(false)
run()
end,
children = { text { content = label } },
}
end
local files = button {
padding = 8,
on_click = function(rect, which)
if which == "right" then
menu_at:set(rect)
menu_open:set(true)
else
process.detach("nautilus", {})
end
end,
children = { text { content = "Files" } },
}
return {
panel { id = "bar", layer = "Top", anchor = { top = true }, child = files },
popup {
id = "files_menu",
parent = "bar",
anchor_rect = menu_at,
anchor = "Bottom",
gravity = "Bottom",
visible = menu_open,
on_dismiss = function() menu_open:set(false) end,
child = column {
width = 140,
padding = 4,
background = "#1e1e2e",
children = {
item("New window", function() process.detach("nautilus", { "--new-window" }) end),
item("Downloads", function() process.detach("nautilus", { os.getenv("HOME") .. "/Downloads" }) end),
},
},
},
}
Drag to reorder. Each row is an on_drag button in a keyed list. The drag
keeps the row’s box from the press, so pointer.y divided by the row pitch counts the rows moved.
The dragged row jumps slot by slot rather than following the pointer; to make it follow, also bind
its translate to the drag offset. There is no drag-and-drop between surfaces or applications, and
no drag image.
local items = state("order", { "Music", "Mail", "Files", "Terminal" })
local STEP = 32 -- row height 28 + spacing 4
local started_at = 1
local function index_of(name)
for index, other in ipairs(items:get()) do
if other == name then return index end
end
end
local function row_for(name)
return button {
width = 200,
height = 28,
padding = 6,
background = "#313244",
cursor = "grab",
on_drag = function(_, pointer, phase)
if phase == "start" then
started_at = index_of(name)
return
end
-- `pointer` is relative to the row's box at the press, so the offset counts rows moved.
local order = { table.unpack(items:get()) }
local target = math.max(1, math.min(#order, started_at + math.floor(pointer.y / STEP)))
local now = index_of(name)
if target ~= now then
table.insert(order, target, table.remove(order, now))
items:set(order)
end
end,
children = { text { content = name } },
}
end
return panel {
id = "bar",
layer = "Top",
anchor = { top = true },
child = list { spacing = 4, source = items, itemfn = row_for, key = function(name) return name end },
}
Gotchas
| Trap | Fix |
|---|---|
A textfield is invisible or cannot be clicked | It has no intrinsic size. Give it width and height |
| A field in a panel shows no caret and takes no keys | Set the panel’s keyboard_interactivity to "OnDemand" (or "Exclusive" for a modal) |
A field with only on_navigate/on_cancel ignores clicks | Add on_change or on_submit |
The mouse wheel does nothing over a scrolling row | Rows scroll on the horizontal axis. Use a column, or an on_wheel button 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 button grows on press | The release must land on the same laid-out box. Animate scale instead |
| A tooltip or menu anchored to a scaled button is off | Rects are laid-out boxes before transforms. Anchor on an untransformed parent |
| Lua needs to prefill or clear a field | Not possible: the draft belongs to the engine. autofocus re-arms empty; 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 (button, textfield, list), surfaces
(keyboard_interactivity, popups, lock), signals (state the handlers write),
animation (press and hover motion), capabilities (lock,
polkit, network).
Source: pointer, wheel, keyboard, text fields, secure fields, hit testing, hover, scroll.
Processes
Run other programs from Lua: read a command’s output, launch an app the user keeps, or hold a
long-running program across reloads. Call them from event handlers (on_click, on_change, a
timer) or a module’s top level. Storage, timers, actions and the utilities are on
scripting; Supervisor, Renderer and push are in the
glossary.
A temperature from an HTTP API that refreshes on click, with process.run,
json, log and a
named state:
local temperature = state("temperature", "--")
local function refresh()
local body = {}
process.run("curl", { "-fsS", "--max-time", "5",
"https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤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 = button {
on_click = refresh,
children = { text { content = temperature, font_size = 14 } },
},
}
Which one do I use
process.run | session_process | process.detach | |
|---|---|---|---|
| Output | Line by line to out_cb | Inherited stdio: mantle log | None: /dev/null |
| Exit | exit_cb(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, exit_cb(nil) | Keeps running | Unaffected |
| Renderer crash | Killed, no exit_cb | 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 |
- Need the output or the exit code:
process.run. A long-running one, liketail -for a subscribe loop, restarts with each save, which is how to follow a stream. - One instance that must outlive a save or a crash:
session_process. - A program the user keeps after the shell:
process.detach.
What else survives a reload, a crash and a stop: runtime.
process.run
Spawns a helper, streams its output line by line, and reports its exit.
| Part | Contract |
|---|---|
| Signature | process.run(cmd, args, out_cb, exit_cb) → 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 |
out_cb(line, stream) | Once per line, newline stripped. stream is "stdout" or "stderr". Invalid UTF-8 is replaced; a final line without a newline still arrives |
exit_cb(code) | Once, after both streams close and the process exits. code is the exit status, or nil when a signal ended it or the spawn failed |
| Handle | handle:kill(): SIGTERM to the whole process group, SIGKILL 100 ms later. exit_cb 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 |
| Lifetime | Until the next reload, failed ones included, which kills its group as kill() does and calls exit_cb(nil) at once, before the new evaluation runs; no out_cb follows. A Renderer replacement or shell exit reaps its group without calling exit_cb |
| 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
exit_cb(nil) with no output; the reason is logged as a warning.
process.detach
Starts a program that stops being the shell’s: its own session, reparented to init, stdio on
/dev/null. It outlives reloads, Renderer replacement and the shell itself.
return panel {
id = "dock", layer = "Top", anchor = { bottom = true },
child = button {
padding = 8,
on_click = function()
process.detach("xdg-open", { os.getenv("HOME") or "/" })
end,
children = { text { content = "Home folder" } },
},
}
| Part | Contract |
|---|---|
| Signature | process.detach(cmd, args) → nothing |
cmd, args | As process.run |
| 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 = button {
on_click = toggle,
children = {
text {
content = computed({ recording, recorder.start_error }, function(on, err)
if err and err ~= "" then return "Recorder failed: " .. err end
return on and "● REC" or "Record"
end),
foreground = recording:map(function(on) return on and "#F38BA8" or "#CDD6F4" end),
},
},
},
}
session_process { name, stop_signal? } returns a handle. name must be non-empty; declaring
the same name again, on reload or from another module, returns the same handle and re-reads only
stop_signal. stop_signal defaults to "TERM". Any other key raises.
Signal names drop the SIG prefix: TERM, INT, HUP, QUIT, USR1, USR2, KILL, STOP,
CONT.
Handle signals, each nil until mantle.processes first pushes (and in mantle check):
| 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 | process.run("cat", { path }, ...), collecting lines, or persistent_table for JSON settings |
Poll a command every N seconds
A reload kills the df in flight and clears the timer, and the top-level call starts one fresh
chain.
local disk = state("disk_usage", "")
local function poll()
local lines = {}
process.run("df", { "--output=pcent", "/" }, function(line, stream)
if stream == "stdout" then lines[#lines + 1] = line end
end, function(code)
if code == 0 and lines[2] then disk:set(lines[2]:match("%d+%%") or "") end
end)
timer(30000, poll)
end
poll()
return panel {
id = "disk", layer = "Top", anchor = { top = true },
child = text { content = disk:map(function(value) return "/ " .. value end) },
}
Follow a long-running command’s output
A process.run child that never exits keeps calling out_cb. Start it at the top level: each
save kills the old one and starts one fresh. The retry below runs after the child exits on its
own; the one a reload’s exit_cb(nil) arms is cleared with the old timers.
local title = state("now_playing", "")
local function follow()
process.run("playerctl", { "--follow", "metadata", "--format", "{{artist}} - {{title}}" },
function(line, stream)
if stream == "stdout" then title:set(line) end
end,
function() timer(5000, follow) end)
end
follow()
return panel {
id = "media", layer = "Top", anchor = { top = true },
child = text { content = title, elide = "End", max_width = 300 },
}
Gotchas
| 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 out_cb | Output arrives one line at a time. Collect lines and decode once in exit_cb |
Treating kill() as cancel | exit_cb still fires, usually with nil. Tag requests with a counter and ignore stale ones |
exit_cb 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 exit_cb(nil). Report only a non-zero code |
Invalid stop_signal | The Supervisor refuses the declaration with a warning, and start then does nothing. Use a name from the list above |
See also: scripting (timer, json, log, persistent_table), runtime
(reloads, logging), signals (state), processes capability
(mantle.processes), cli (mantle log).
Source: process, spawn and reap, process registry, session process, processes controller.
Scripting
The imperative globals that are neither UI nor processes: keep settings on disk, schedule work,
answer keybinds, and a few utilities. Reach for them from event handlers (on_click,
on_change, callbacks) or a module’s top level. Running other programs is on
processes; everything that renders goes through signals and
nodes instead.
Terms used below (Supervisor, Renderer, generation, push) are in the glossary.
Which one do I use
| To | Use |
|---|---|
| Run a command, launch an app, or keep a program running | processes |
| Remember a setting across restarts | persistent_table |
| Do something later, repeat, or retry | timer |
| Run code from a keybind or script | action + mantle call |
| Parse JSON | json.decode |
| Write to the shell’s log | log.* |
| Rank search results | fuzzy |
| Pull colours out of a wallpaper | palette.quantize |
| 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 state_home = os.getenv("XDG_STATE_HOME")
if not state_home or state_home == "" then
state_home = (os.getenv("HOME") or "") .. "/.local/state"
end
local settings = persistent_table {
path = state_home .. "/myshell",
name = "settings.json",
defaults = { clock_24h = true, recorder = { fps = 60, audio = "desktop" } },
}
-- nil until the first push, so pick the default here too.
local clock_24h = settings.clock_24h:map(function(value) return value ~= false end)
return panel {
id = "clock", layer = "Top", anchor = { top = true },
child = button {
on_click = function() settings:set("clock_24h", not clock_24h:get()) end,
children = {
text { content = clock_24h:map(function(on) return on and "24h" or "12h" end) },
},
},
}
| Part | Contract |
|---|---|
| Signature | persistent_table { path, name, defaults? } → store. Another key raises |
path | Absolute directory; relative raises. Created if missing. Build it from os.getenv or mantle.config_dir |
name | One file name, no /; empty raises |
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: nil before mantle.storage first pushes, then the stored value |
store:set(key, value) | Writes one key; nil deletes it until the next reload re-fills its default. The signal updates on the next push, not inside set |
| Identity | One table per file: another call with the same path/name 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. Re-arm inside the callback to repeat.
local status = state("status", "checking")
local BACKOFF_MS = { 2000, 4000, 8000 }
local function check(attempt)
attempt = attempt or 1
process.run("ping", { "-c", "1", "-W", "2", "1.1.1.1" }, function() end, function(code)
if code == 0 then
status:set("online")
elseif BACKOFF_MS[attempt] then
status:set("retrying")
timer(BACKOFF_MS[attempt], function() check(attempt + 1) end)
else
status:set("offline")
end
end)
end
check()
-- A repeating timer re-arms itself; calling it at the top level restarts the chain on reload.
local clock = state("clock", "")
local function tick()
clock:set(os.date("%H:%M"))
timer(60000 - (os.time() % 60) * 1000, tick)
end
tick()
| 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 5 ms 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.
action
Names a function that mantle call <name> [args...] runs, usually from a compositor keybind.
Write a state when the shell should look different; call an action when it should do
something.
-- Keybind: mantle call volume.up 0.1
action("volume.up", function(step)
local audio = mantle.audio:get()
if not audio or not audio.volume then
return "no output device"
end
local volume = math.min(1.5, audio.volume + (tonumber(step) or 0.05))
mantle.audio:set_volume(volume)
return string.format("%d%%", math.floor(volume * 100 + 0.5))
end)
| 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 | 5 ms 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 |
There is no json.encode; build JSON arguments with string.format.
log
log.error(...), log.warn(...), log.info(...), log.debug(...). Arguments join with tabs
like print, and the line is stamped with time, level and the config subsystem. All levels
print by default. Filter with MANTLE_LOG=config=warn (or config=off); read with mantle log.
fuzzy
fzf’s scorer for one candidate. Iterating, sorting and tiebreaking stay in Lua.
local APPS = { "Firefox", "Files", "GIMP", "Terminal", "System Monitor" }
local query = state("query", "")
local results = query:map(function(needle)
local scored = {}
for _, name in ipairs(APPS) do
local score, start = fuzzy(name, needle)
if score then
scored[#scored + 1] = { name = name, score = score, start = start }
end
end
table.sort(scored, function(left, right)
if left.score ~= right.score then return left.score > right.score end
if left.start ~= right.start then return left.start < right.start end
return left.name < right.name
end)
local names = {}
for index, entry in ipairs(scored) do names[index] = entry.name end
return names
end)
return panel {
id = "launcher", layer = "Overlay", keyboard_interactivity = "OnDemand",
child = column {
width = 320, padding = 12, spacing = 6, background = "#1E1E2E",
children = {
textfield { autofocus = true, placeholder = "Search", on_change = function(text) query:set(text) end },
list { source = results, itemfn = function(name) return text { content = name } end },
},
},
}
| 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
Median-cut dominant colours of an image, computed on a background thread.
local swatches = state("swatches", {})
palette.quantize("/usr/share/backgrounds/default.png", { depth = 3 }, function(found)
swatches:set(found or {})
end)
return panel {
id = "palette", layer = "Top", anchor = { bottom = true },
child = row {
children = swatches:map(function(list)
local chips = {}
for index, swatch in ipairs(list) do
chips[index] = rect { width = math.max(4, 200 * swatch.share), height = 16, background = swatch.color }
end
return chips
end),
},
}
| Part | Contract |
|---|---|
| Signature | palette.quantize(path, opts?, cb) → 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 | Only depth 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 |
cb(swatches) | { color = "#RRGGBB", share = 0..1 } entries, most common first. share counts only non-transparent pixels. nil on failure, with a logged warning. Runs outside the CPU budget; a raise is logged as a warning |
| Handle | handle:cancel() drops the callback; the work still finishes |
fonts
fonts { family, ... } sets the fallback chain every text node uses. Each glyph takes the
first family that covers it.
fonts { "Inter", "Symbols Nerd Font", "Noto Color Emoji" }
| 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) |
| Uncovered glyph | fontconfig is asked for any installed face that covers it |
| 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 |
| 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 | Every key reads nil until mantle.storage pushes, even with defaults. Handle nil in every map |
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, fonts.
Capabilities
mantle.<name> reads one slice of the system (audio, network, battery, workspaces and the rest) as
a read-only signal, and its action methods ask its backend to act. This page holds the rules every
capability shares; each capability’s page holds its state, actions and backend.
button {
on_click = function() mantle.audio:toggle_mute() end,
on_wheel = function(_, steps)
local audio = mantle.audio:get()
if audio and audio.volume then
mantle.audio:set_volume(audio.volume + steps * 0.05) -- clamped to [0, 1.5]
end
end,
children = {
text {
content = mantle.audio:map(function(audio)
if audio == nil or audio.volume == nil then
return "--" -- nil before the first push; no volume without a default sink
end
return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 0.5))
end),
},
},
}
Reading and acting
A capability is a signal the backend writes: pass it, or a :map of it, to
a property and the property stays live. Each push replaces the whole snapshot.
| Member | Contract |
|---|---|
:get() | The last pushed snapshot; nil before the first |
:map(fn) | Derived signal; fn must handle nil. A capability also works as a computed dependency |
:on_change(fn) | fn(current, previous) once per push, after it lands; previous is nil on the first. Runs under the 5 ms callback budget and may call actions, process.run or write state. A raise logs a warning and the next handler still runs. Every evaluation clears them before shell.lua registers its own |
:<action>(...) | One method per action on the capability’s page, e.g. mantle.audio:set_volume(0.5). Queues one command and returns nothing. Read the state it changes for the outcome. Call it with :; a . call raises |
There is no :set on the state; mantle.brightness:set and mantle.storage:set are actions. mantle.idle has no actions; it takes methods instead.
Lifecycle
| 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 and privacy share one PipeWire thread; workspaces and windows share one niri/Hyprland reader. Whichever is read first starts it |
| Buses | One system-bus connection for all. tray, notifications, mpris and idle each open their own session bus. Every D-Bus method call times out after 25 s |
| Pushes | On backend events. system, 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 files 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 action name and marshalling; the Supervisor checks types and count.
| Mistake | Result |
|---|---|
A state field read off the capability (mantle.audio.volume) | Raises at the read: did you mean mantle.audio:get().volume? |
A misspelled action (mantle.audio:set_volum(1)) | Raises at the read: did you mean mantle.audio:set_volume(...)? |
| Any other unknown name | Raises at the read, listing the actions the capability takes |
Any other method but get, map and on_change on battery, privacy or system | Raises: they have no actions |
| A function or userdata argument | Raises at the call, naming its slot |
| Wrong type or argument count | Logged (mantle log) and dropped |
A float where an integer goes | Dropped: 5.0 is refused, 5 works. math.floor(x + 0.5) returns an integer |
| Arguments to an action that takes none | Dropped: mantle.network:scan(1) is refused |
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 |
| Volume | 1.0 is 100% |
| Percentages | Integers 0 to 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 |
|---|---|---|
applications | Desktop entries, window app_id index, launching | |
audio | Output and input volume and mute, devices, per-app streams, Bluetooth codecs | |
battery | Charge, state, time estimates | Check present first |
bluetooth | Adapter power, devices, discovery, pairing prompts | |
brightness | Screen backlight percentage | nil without a backlight |
files | 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 |
storage | Each persistent_table file | Use persistent_table, not its actions |
sysinfo | CPU, memory, swap, temperatures | nil until configure |
system | 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 or Hyprland 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 surface’s monitor 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.system.time: system |
| 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.class through applications.by_app_id: applications |
| Play or pause whatever is playing | control on players[1].id: 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 (audio.volume with no default sink) |
local ok = mantle.audio:set_volume(...) is always nil | Bind the state the action changes; read mantle log for dropped commands |
| An action silently does nothing | Wrong argument type or count, often a float where an integer goes (brightness:set(50.0)). Check mantle log |
on_change fires at startup with previous == 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, payload and action types under
supervisor/src/capabilities/.
applications
Installed desktop entries, indexed by window app_id.
text {
content = computed({ mantle.workspaces, mantle.applications }, function(workspaces, applications)
local client = workspaces and workspaces.active_client
if client == nil or applications == nil then
return ""
end
local index = applications.by_app_id[client.class]
return index and applications.entries[index].name or client.class
end),
}
State
mantle.applications:get() returns ApplicationsState, nil before the first push. A field marked ? may be absent.
mantle.applications payload.
| 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). A change under an applications directory rescans 250 ms after the last event. |
AppSummary
One visible Type=Application desktop entry; display data only, argv stays private.
| 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. |
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 | Launches entries[].id, detached; Terminal=true entries run in $TERMINAL. |
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. |
Backend
Reads applications/ under $XDG_DATA_HOME, then each $XDG_DATA_DIRS entry (default
/usr/local/share:/usr/share), subdirectories included. The first file for a desktop id wins, so a
copy under ~/.local/share/applications overrides the system one. Type=Application entries with
Name and Exec are listed; NoDisplay=true and Hidden=true ones are not. inotify watches every
directory, including ones created later.
How do I…
| Task | Answer |
|---|---|
| Hide an app from a launcher | Copy its .desktop file to ~/.local/share/applications and add NoDisplay=true; the rescan drops it |
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 {
source = mantle.audio:map(function(audio)
return audio and audio.sinks or {}
end),
key = function(sink) return tostring(sink.id) end,
itemfn = function(sink)
return button {
padding = 6,
background = sink.active and "#45475A" or "#1E1E2E",
on_click = function() mantle.audio:set_default_sink(sink.id) end,
children = { text { content = sink.name } },
}
end,
}
State
mantle.audio:get() returns AudioState, nil before the first push. A field marked ? may be absent.
mantle.audio’s payload.
| Field | Type | Description |
|---|---|---|
apps | AppStream[] | Apps playing or recording audio, excluding pid-less streams, notification sounds, meters and monitor captures. |
balance? | number | Default output balance, -1.0 (left) to 1.0 (right); nil for mono or an unknown channel map. |
bluetooth | BluetoothCodecs[] | BlueZ audio devices PipeWire knows, with their codecs, ordered by device. |
muted | boolean | Default output mute; false with no default sink. |
sinks | AudioDevice[] | Every output device. |
source_muted | boolean | Default input (microphone) mute; false with no default source. |
source_volume? | number | Default input volume, 1.0 is 100%; set_source_volume caps at 1.0, another client may not. nil with no source or before its first volume report. |
sources | AudioDevice[] | Every input device. |
volume? | number | Default output volume, 0.0 to 1.5 (1.0 is 100%), loudest channel; louder writes by other clients are pulled back to 1.5. nil with no sink or before its first volume report. |
AppStream
One app’s playback or recording stream. Streams without a pid are left out.
| 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 and set_app_muted. |
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. |
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. |
volume? | number | Stream volume, 1.0 is 100%; nil until PipeWire reports the stream’s Props. |
AudioDevice
One sinks or sources entry.
| Field | Type | Description |
|---|---|---|
active | boolean | This is the default output or input; with no default known, the lowest id is. |
bus? | string | device.bus, e.g. "pci", "usb", "bluetooth". |
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 argument of set_default_sink/set_default_source; not reboot-stable. |
name | string | node.description, e.g. "Built-in Audio Analog Stereo", else node.nick, else node.name. |
port? | string | The active card route’s port.type, e.g. "headphones", "hdmi", "mic". |
BluetoothCodecs
One BlueZ audio device’s codec choices, joined to mantle.bluetooth by MAC.
| Field | Type | Description |
|---|---|---|
active? | integer | index of the active profile; nil before PipeWire reports it or when it is not in codecs. |
codecs | CodecProfile[] | Available profiles that name a codec, ordered by index. |
device | integer | PipeWire device id, the first argument of set_bluetooth_profile. |
mac | string | MAC address from the bluez_card.* name, _ turned to :. |
CodecProfile
One entry of BluetoothCodecs::codecs.
| Field | Type | Description |
|---|---|---|
codec | string | Codec from the profile name, else its English description, e.g. "AAC", "LDAC", "mSBC". |
description | string | PipeWire’s description, e.g. "High Fidelity Playback (A2DP Sink, codec AAC)". |
index | integer | Profile index, the second argument of set_bluetooth_profile. |
Actions
Call each as mantle.audio:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
set_volume | volume: number | Sets master output volume, clamped to [0.0, 1.5]. |
set_muted | muted: boolean | Sets master output mute. |
toggle_mute | Toggles master output mute. | |
set_balance | balance: number | Sets default output 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. |
set_default_source | id: integer | Makes this sources[].id the default input. |
set_source_volume | volume: number | Sets default input volume, clamped to [0.0, 1.0]. |
set_source_muted | muted: boolean | Sets default input mute. |
toggle_source_mute | Toggles default input mute. | |
set_app_volume | id: integer, volume: number | Sets an apps[].id stream’s volume, clamped to [0.0, 1.0]. |
set_app_muted | id: integer, muted: boolean | Sets an apps[].id stream’s mute. |
set_bluetooth_profile | device: integer, index: integer | Switches a bluetooth[].device to one of its codecs[].index. |
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, volume, muted, balance, source_volume, source_muted |
Stream/Output/Audio, Stream/Input/Audio nodes | apps, minus the streams its field lists |
bluez_card.* devices and their profiles | bluetooth |
The first push waits until PipeWire has reported every object and its volume, so a machine with no
audio hardware still gets one push of empty lists. An unreachable PipeWire is logged and audio
stays nil. Nothing reconnects: a PipeWire restart freezes audio at its last push until the
Supervisor restarts.
How do I…
Change volume on the scroll wheel, mute on middle click
A handler reads with :get(): it needs the value now, not a binding (input).
button {
on_wheel = function(_, steps)
local audio = mantle.audio:get()
if audio == nil or audio.volume == nil then
return
end
mantle.audio:set_volume(math.max(0, math.min(1, audio.volume + steps * 0.05)))
end,
on_click = function(_, which)
if which == "middle" then
mantle.audio:toggle_mute()
end
end,
children = {
text {
content = mantle.audio:map(function(audio)
if audio == nil or audio.volume == nil then
return "--"
end
return audio.muted and "muted" or string.format("%d%%", math.floor(audio.volume * 100 + 0.5))
end),
},
},
}
Show an OSD when volume changes
on_change writes named state that a panel binds, and a
timer hides the panel again:
local osd_text = state("osd_text", "")
local osd_visible = state("osd_visible", false)
local hide_timer
mantle.audio:on_change(function(audio, previous)
if previous == nil or audio.volume == nil then
return -- the first push is learned state, not a change
end
if audio.volume == previous.volume and audio.muted == previous.muted then
return
end
osd_text:set(audio.muted and "Muted" or string.format("Volume %d%%", math.floor(audio.volume * 100 + 0.5)))
osd_visible:set(true)
if hide_timer then
hide_timer:cancel()
end
hide_timer = timer(2000, function() osd_visible:set(false) end)
end)
local osd = panel {
id = "osd",
layer = "Overlay",
anchor = { bottom = true },
margin = { bottom = 80 },
visible = osd_visible,
padding = 12,
radius = 12,
background = "#1E1E2ECC",
child = text { content = osd_text, font_size = 16 },
}
See also: Volume OSD recipe.
Source: supervisor/src/capabilities/audio/
battery
UPower’s display device: charge, state and time estimates.
text {
content = mantle.battery:map(function(battery)
local seconds = battery and battery.present and battery.time_to_empty
if not seconds then
return ""
end
return string.format("%d:%02d left", seconds // 3600, seconds % 3600 // 60)
end),
}
State
mantle.battery:get() returns BatteryState, nil before the first push. A field marked ? may be absent.
mantle.battery’s payload. No battery, or no UPower, reads present = false and defaults.
| Field | Type | Description |
|---|---|---|
percent | integer | UPower’s Percentage, rounded to 0 to 100; a spurious 0 while not draining keeps the last value. |
present | boolean | UPower’s display device is a present battery. Check it before drawing the other fields. |
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 == "PendingCharge".
| Value | Description |
|---|---|
"Unknown" | No answer: UPower unreachable, an unknown state number, or a display device that is not a battery. |
"Charging" | Taking current from an adapter. |
"Discharging" | Draining. |
"Empty" | Flat. |
"FullyCharged" | Charged and holding. |
"PendingCharge" | On mains, neither draining nor taking current: a charge limit, weak charger or thermal pause. |
"PendingDischarge" | Waiting to discharge. |
Actions
None: read-only, so any method but get, map and on_change raises.
Backend
| Contract | Behavior |
|---|---|
| Source | UPower’s DisplayDevice, the composite of every battery |
| Updates | Re-reads every field on each PropertiesChanged; no timer, since UPower already polls the hardware |
| No battery or no UPower | present = false, percent = 0, state = "Unknown", no time estimates |
How do I…
Show a battery label with every nil state handled
text {
foreground = mantle.battery:map(function(battery)
return (battery and battery.present and battery.percent <= 15) and "#F38BA8" or "#CDD6F4"
end),
content = mantle.battery:map(function(battery)
if battery == nil then
return "…"
elseif not battery.present then
return ""
end
return string.format("%d%%%s", battery.percent, battery.state == "Charging" and " +" or "")
end),
}
See also: Battery indicator recipe; power for on-battery and power draw.
Source: supervisor/src/capabilities/battery/
bluetooth
BlueZ: adapter power, discovery, connected, paired and discovered devices, and pairing prompts.
list {
source = mantle.bluetooth:map(function(bluetooth)
return bluetooth and bluetooth.connected_devices or {}
end),
key = function(device) return device.mac end,
itemfn = function(device)
local battery = device.battery >= 0 and string.format(" %d%%", device.battery) or ""
return button {
on_click = function() mantle.bluetooth:disconnect(device.mac) end,
children = { text { content = device.name .. battery } },
}
end,
}
State
mantle.bluetooth:get() returns BluetoothState, nil before the first push. A field marked ? may be absent.
| 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. Answer with answer_pairing. |
ConnectedDevice
| Field | Type | Description |
|---|---|---|
battery | integer | Battery percentage, or -1 when the device reports none. |
busy? | DeviceAction | Same as DiscoveredDevice::busy. |
category | string | From the class of device: "keyboard", "mouse", "headphones", "headset", "phone", "computer" or "generic". |
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".
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. |
paired | boolean | Always false. |
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 | string | Same set as ConnectedDevice.category. |
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".
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. |
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. |
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 the pairing_request for mac; a yes within 750 ms of it appearing is ignored. |
Backend
BlueZ on the system bus.
| Contract | Behavior |
|---|---|
| State | org.bluez’s ObjectManager plus property changes. An adapter added later is picked up; a bluetoothd started after the Supervisor is not. Without BlueZ, available is false, the lists stay empty and every action does nothing |
| Agent | Mantle registers the default DisplayYesNo agent at /org/mantle/Bluez/Agent1. A confirmation, authorization or displayed code becomes pairing_request only while the adapter is discoverable or Mantle is pairing that device. A "service" request asks only for a paired device. One request shows at a time: a second is rejected, unless the first only displays a code and the second needs an answer. PIN and passkey entry are rejected |
| Discovery | start_discovery clears discovered_devices; stop_discovery keeps it |
| Battery, category | Battery1 gives battery; the Class major and minor bits give category |
| Codecs | PipeWire owns them: audio.bluetooth lists each device’s profiles and set_bluetooth_profile switches one |
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 |
| A device that needs a PIN typed on the computer fails to pair | The agent rejects PIN and passkey entry. Pair it with bluetoothctl, which brings its own agent |
Source: supervisor/src/capabilities/bluetooth/
brightness
The screen backlight percentage; nil without a backlight.
Scroll to change the brightness by 5% a notch:
button {
on_wheel = function(_, steps)
local brightness = mantle.brightness:get()
if brightness then
local percent = brightness.percent + math.floor(steps * 5) -- math.floor returns an integer
mantle.brightness:set(math.max(1, math.min(100, percent)))
end
end,
children = {
text {
content = mantle.brightness:map(function(brightness)
return brightness and string.format("☀ %d%%", brightness.percent) or ""
end),
},
},
}
State
mantle.brightness:get() returns BrightnessState, nil before the first push. A field marked ? may be absent.
mantle.brightness’s payload; the capability stays nil on a machine with no backlight.
| 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: integer | Sets the screen backlight, 0 to 100; higher clamps to 100. |
Backend
| Contract | Behavior |
|---|---|
| Device | One /sys/class/backlight device with max_brightness > 0, chosen on the first read: firmware, then platform, then raw, then by name. External monitors are not covered |
| No device | Stays nil for good; set is logged and ignored |
| 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/
files
Live file listings of watched folders.
local folder = (os.getenv("HOME") or "") .. "/Pictures/Wallpapers"
mantle.files:watch(folder, { "jpg", "png" })
list {
source = mantle.files:map(function(files)
local listing = files and files.folders[folder]
return listing and listing.entries or {}
end),
key = function(entry) return entry.path end,
itemfn = function(entry)
return text { content = entry.name }
end,
}
State
mantle.files:get() returns FilesState, nil before the first push. A field marked ? may be absent.
mantle.files payload.
| Field | Type | Description |
|---|---|---|
folders | table<string, Folder> | One entry per "watch", keyed by its path 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. |
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 is not watched for reappearing. |
ready | boolean | false until the first listing lands, then true even when empty or failed. |
Actions
Call each as mantle.files:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
watch | path: string, extensions?: string[] | Keeps folders[path] listing an absolute folder. extensions match case-insensitively, dot optional; omitted means every file. |
unwatch | path: string | Stops watching path and removes it from folders. |
Backend
One inotify watch per folder, on the folder only: subfolders are neither listed nor watched. A new
generation drops every watch, so call watch at top level and each evaluation asks again; repeating
a watch with the same extensions only re-pushes the listing.
Gotchas
| Trap | Fix |
|---|---|
watch("~/Pictures") does nothing | Only absolute paths pass; ~ is not expanded. Build the path from os.getenv("HOME") |
folders[path] is nil after a watch | The key drops trailing slashes: watch("/walls/") lands at folders["/walls"] |
A folder created after watch never lists | A missing folder records error and stays unwatched. unwatch, then watch again once it exists |
See also: process.run to read a file’s contents.
Source: supervisor/src/capabilities/files/
idle
Idle inhibitors, plus threshold and inhibit methods.
mantle.idle reads like the others (:get, :map, :on_change) but has no actions. Its
thresholds take Lua callbacks, which cannot cross to the Supervisor, so it has methods
instead. Any method, :get included, starts it.
local dimmed = state("dimmed", false)
mantle.idle:register_threshold(300, function()
dimmed:set(true)
end, function()
dimmed:set(false)
end)
State
mantle.idle:get() returns IdleState, nil before the first push. A field marked ? may be absent.
mantle.idle’s payload.
| Field | Type | Description |
|---|---|---|
inhibited | boolean | Something holds the session awake: a logind inhibitor (this shell’s included), a ScreenSaver client or a Wayland inhibitor. No threshold fires while true. |
inhibitors | IdleInhibitor[] | Holders other than this shell, ScreenSaver clients included. The compositor’s hold has an empty who; draw why then. |
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 when input returns. Returns an integer handle. If this idle period already passed seconds for another registration, on_idle runs at once |
:cancel_threshold(handle) | Drops one registration. An unknown or cancelled handle is a no-op |
:inhibit(reason) | Takes one hold on a logind idle block inhibitor. Counted: two calls need two releases |
:release_inhibit() | Releases one hold; with none held, a no-op |
| 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 and none fire; 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, so it needs a registered threshold and an idle seat. It sets inhibited and adds one holder with an empty who |
How do I…
Keep the screen awake (caffeine)
The hold survives reloads, so named state records it:
local caffeine = state("caffeine", false)
button {
on_click = function()
if caffeine:get() then
mantle.idle:release_inhibit()
else
mantle.idle:inhibit("caffeine")
end
caffeine:set(not caffeine:get())
end,
children = {
text {
content = computed({ caffeine, mantle.idle }, function(held, idle)
if held then
return "awake (held)"
elseif idle and idle.inhibited then
return "awake (another app)"
end
return "idle allowed"
end),
},
},
}
Gotchas
| 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.
button {
on_click = function()
local keyboard = mantle.keyboard:get()
if keyboard and keyboard.layout_count > 1 then
mantle.keyboard:switch_layout((keyboard.active_layout_index + 1) % keyboard.layout_count)
end
end,
children = {
text {
content = mantle.keyboard:map(function(keyboard)
if keyboard == nil then
return ""
end
return keyboard.active_layout .. (keyboard.caps_lock and " ⇪" or "")
end),
},
},
}
State
mantle.keyboard:get() returns KeyboardState, nil before the first push. A field marked ? may be absent.
mantle.keyboard’s payload. Lock keys read false when no source resolves.
| 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_pct | integer | Keyboard backlight, 0 to 100, or -1 without a backlight device or readable level. Refreshes on hardware hotkeys and set_backlight only, not on other software writes. |
caps_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: integer | Sets the keyboard backlight, 0 to 100; higher clamps to 100. |
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 read once, then frozen; with none of those, false |
| Backlight | Reads sysfs *::kbd_backlight, writes through logind’s SetBrightness | backlight_pct = -1; set_backlight is logged and ignored |
| Layout | The event stream workspaces reads: niri’s layout events, or Hyprland’s devices for the keyboard marked main (the one typed on last) | active_layout = "", layout_count = 0; switch_layout is logged and ignored |
The first push comes when keyboard starts, with whatever layout the compositor has reported.
Gotchas
| Trap | Fix |
|---|---|
caps_lock never changes | The Supervisor cannot read /dev/input, so the sysfs fallback was read once. Add the user to the input group |
backlight_pct misses a change another program 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 reports the password attempt.
button {
on_click = function() mantle.lock:lock() end,
children = { text { content = "Lock" } },
}
State
mantle.lock:get() returns LockState, nil before the first push. A field marked ? may be absent.
mantle.lock’s payload.
| 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 | A password is with PAM. 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. |
unlocking | boolean | PAM said yes and the lock is still up: the window for an out-animation. |
Actions
Call each as mantle.lock:<action>(arguments...); ? marks an argument you may omit.
mantle.lock actions. There is no unlock; only a correct password unlocks.
| 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. An exchange that takes over 30 s fails |
| 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 |
| 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 |
See also: Lock screen recipe; idle to lock after inactivity.
Source: supervisor/src/capabilities/lock/
mpris
MPRIS: media players with track metadata, playback state and position.
local player = mantle.mpris:map(function(mpris)
return mpris and mpris.players[1]
end)
button {
on_click = function()
local current = player:get()
if current then
mantle.mpris:control(current.id, "play_pause")
end
end,
children = {
text {
max_width = 240,
elide = "End",
content = player:map(function(current)
return current and (current.play_state .. ": " .. current.title) or ""
end),
},
},
}
State
mantle.mpris:get() returns MprisState, nil before the first push. A field marked ? may be absent.
| Field | Type | Description |
|---|---|---|
players | PlayerState[] | Every controllable MPRIS player except playerctld, longest-running first, so players[1] stays put; empty when none runs. |
PlayerState
| Field | Type | Description |
|---|---|---|
album_art_path | string | Cover art as an existing absolute path, or empty; a remote artUrl is not fetched. |
artist | string | Artists joined with ", "; empty when unset. |
desktop_entry | string | The player’s .desktop basename, e.g. "firefox", for app matching; 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 -1 when unknown, as for a live stream. |
play_state | string | "Playing", "Paused" or "Stopped"; keeps the last value when a read fails, empty if none. |
position | integer | Playback offset in microseconds as of position_updated_at, not polled while playing: add elapsed time. -1 when unknown. |
position_updated_at | integer | CLOCK_MONOTONIC microseconds when position was read. No Lua clock shares this epoch (not mantle.system.monotonic); only compare it with itself. |
title | string | Track title; empty when unset, normal between tracks. |
url | string | xesam:url as sent, e.g. a file:// path or an https:// page; empty when unset. |
Actions
Call each as mantle.mpris:<action>(arguments...); ? marks an argument you may omit.
| 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 -1). |
seek_relative | id: string, offset_us: integer | Seeks by a signed offset in microseconds, unclamped; past the end may skip to the next track. |
PlayerCommand
One of "play", "pause", "play_pause", "next", "previous".
Backend
| Contract | Behavior |
|---|---|
| Discovery | Session bus ListNames once, then NameOwnerChanged for org.mpris.MediaPlayer2.*. Skips playerctld and any player reporting CanControl = false |
| Pushes | On a PlaybackStatus or Metadata change 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 |
How do I…
| Task | Answer |
|---|---|
| Show a live progress bar | Stamp each new position_updated_at with mantle.system.monotonic in on_change, then 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.system.monotonic gives nonsense | Different clocks and units: CLOCK_MONOTONIC microseconds against seconds since system started. Only compare it with itself |
See also: Media player recipe.
Source: supervisor/src/capabilities/mpris/
network
NetworkManager: connectivity, Wi-Fi and wired state, scanned access points and join progress.
text {
content = mantle.network:map(function(network)
if network == nil then
return ""
elseif not network.connected then
return "offline"
elseif network.ssid == "Ethernet" then
return "wired"
end
return string.format("%s %d%%", network.ssid or "", network.strength)
end),
}
State
mantle.network:get() returns NetworkState, nil before the first push. A field marked ? may be absent.
mantle.network’s payload.
| Field | Type | Description |
|---|---|---|
available_networks | AccessPointInfo[] | NetworkManager’s visible networks, re-read on every change: one per SSID, at most 20, ordered associated, then saved, then strongest. {} without Wi-Fi hardware. |
connect_error? | JoinError | The last failed connect, or nil before any or after a success. Kept until the next connect, cancel_connect or abort_connect; check its ssid before showing it. |
connected | boolean | A connection carries the default route; false means offline. |
connecting_ssid? | string | The SSID connect is joining, 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; 0 when unknown or none is activated. |
networking_enabled | boolean | NetworkManager networking is on (NetworkingEnabled). |
password_ssid? | string | The SSID whose connect waits for a password from a network/connect secure field, or nil. Also set after a rejected key; cleared when a join starts or by cancel_connect. |
scanning | boolean | A scan is in flight, from the moment scan is accepted. |
ssid? | string | "Ethernet" when the default route is wired, else the associated SSID, else nil. An association still getting an address has an ssid while connected is false. |
strength | integer | The associated network’s strength, 0 to 100; 0 without a Wi-Fi association. |
wifi_enabled | boolean | Wi-Fi radio power (WirelessEnabled); can be true with no Wi-Fi hardware, see wifi_present. |
wifi_ip? | string | The Wi-Fi device’s IPv4 address without prefix, or nil. |
wifi_present | boolean | A 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 names this SSID, so connect asks for no password. |
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. |
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. | |
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. |
cancel_connect | Drops the password request password_ssid names; a join already running continues. | |
abort_connect | Stops the join connecting_ssid names, 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. |
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 | Only the first Wi-Fi device is tracked. Wired fields describe the first activated wired device |
| Toggles | Networking through Enable, Wi-Fi through WirelessEnabled |
| Scan | RequestScan. 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 |
| Missing | Stays nil. The next generation’s first read retries |
How do I…
Ask for a Wi-Fi password
Show the field while password_ssid is set. Escape clears a secure field and keeps it armed, then
calls its on_cancel, the place to call cancel_connect:
local asking = mantle.network:map(function(network)
return network ~= nil and network.password_ssid ~= nil
end)
return column {
visible = asking,
spacing = 6,
children = {
text {
content = mantle.network:map(function(network)
return network and network.password_ssid and ("Password for " .. network.password_ssid) or ""
end),
},
textfield {
width = 240,
height = 24,
placeholder = "Password",
secure_submit = { capability = "network", action = "connect" },
on_cancel = function() mantle.network:cancel_connect() end,
},
},
}
Know whether a join worked
A failed connect lands in connect_error:
text {
foreground = "#F38BA8",
content = mantle.network:map(function(network)
local failure = network and network.connect_error
return failure and (failure.ssid .. ": " .. failure.message) or ""
end),
}
Gotchas
| Trap | Fix |
|---|---|
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.
button {
on_click = function()
local notifications = mantle.notifications:get()
if notifications then
mantle.notifications:set_dnd(not notifications.dnd)
end
end,
children = {
text {
content = mantle.notifications:map(function(notifications)
return (notifications and notifications.dnd) and "DND on" or "DND off"
end),
},
},
}
State
mantle.notifications:get() returns NotificationsState, nil before the first push. A field marked ? may be absent.
mantle.notifications’s payload.
| 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.system.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. 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,
source = mantle.notifications:map(function(notifications)
return notifications and notifications.feed or {}
end),
key = function(item) return tostring(item.id) end,
itemfn = function(item)
return button {
width = 320,
padding = 8,
radius = 8,
background = "#1E1E2E",
on_click = function() mantle.notifications:dismiss(item.id) end,
children = {
column {
children = {
text { content = item.summary, font_size = 13, elide = "End", width = "Fill" },
text { content = item.app_name, font_size = 11, foreground = "#A6ADC8" },
},
},
},
}
end,
}
See also: Notification popups recipe.
Source: supervisor/src/capabilities/notifications/
polkit
The pending polkit authentication request, its progress and the last failure.
The password never reaches Lua: a secure field with
secure_submit = { capability = "polkit", action = "authenticate" } sends it straight to the
Supervisor.
column {
visible = mantle.polkit:map(function(polkit)
return polkit ~= nil and polkit.active
end),
spacing = 8,
children = {
text {
content = mantle.polkit:map(function(polkit)
return polkit and polkit.message or ""
end),
},
textfield {
width = 280,
height = 24,
placeholder = "Password",
secure_submit = { capability = "polkit", action = "authenticate" },
},
text {
foreground = "#F38BA8",
content = mantle.polkit:map(function(polkit)
return polkit and polkit.error or ""
end),
},
button {
on_click = function() mantle.polkit:cancel() end,
children = { text { content = "Cancel" } },
},
},
}
State
mantle.polkit:get() returns PolkitState, nil before the first push. A field marked ? may be absent.
mantle.polkit’s payload. Every other field is empty while active is false.
| 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 | A password is with PAM. 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. |
message | string | The action’s prompt, e.g. "Authentication is required to ...", in en_US: the locale the agent registers with. |
Actions
Call each as mantle.polkit:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
cancel | Dismisses the prompt; the requesting program sees the request cancelled. |
Backend
On first read, registers as the authentication agent for $XDG_SESSION_ID’s session, at
/org/mantle/PolicyKit1/AuthenticationAgent with locale en_US.UTF-8. If another agent already
answers, it stays off for the run. polkitd accepts an answer only from uid 0, so the
PAM worker hands the password to polkit’s root helper at
/run/polkit/agent-helper.socket. The agent is polkit.rs.
Gotchas
| 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 retries |
See also: secure fields; FAQ for prompts that never appear.
Source: supervisor/src/capabilities/polkit.rs
power
Power profiles, mains or battery, and battery power draw.
list {
direction = "Horizontal",
source = mantle.power:map(function(power)
return power and power.profiles or {}
end),
itemfn = function(name)
return button {
padding = 6,
background = mantle.power:map(function(power)
return (power and power.active_profile == name) and "#89B4FA" or "#313244"
end),
on_click = function() mantle.power:set_profile(name) end,
children = { text { content = name } },
}
end,
}
State
mantle.power:get() returns PowerState, nil before the first push. A field marked ? may be absent.
mantle.power’s payload. Profile fields are nil without power-profiles-daemon, the rest without UPower;
a failed read is also nil. With neither service the payload is an empty table.
| 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. Not validated here; a rejected name 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. 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 ~= nil and (#privacy.microphone_users > 0 or #privacy.camera_users > 0)
end),
}
State
mantle.privacy:get() returns PrivacyState, nil before the first push. A field marked ? may be absent.
| Field | Type | Description |
|---|---|---|
camera_users | PrivacyUser[] | One entry per process holding a /dev/videoN open; empty when none is. Only devices present when privacy started are watched. |
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 PipeWire nodes | screencast_users |
/proc/*/fd links to a /dev/videoN, rescanned on each inotify open or close of the device | camera_users. A PipeWire Video/Source from the same pid only supplies the name |
The PipeWire half shares audio’s thread. privacy pushes its first /proc
scan as soon as it starts. Without PipeWire the microphone and screencast lists
stay empty and camera_users keeps its first scan: the update loop ends with the PipeWire thread.
Gotchas
| Trap | Fix |
|---|---|
A webcam plugged in after privacy started never shows | The device list is read once at start. Restart the Supervisor |
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 and processes.sessions.recorder
return (recorder and recorder.running) and "● REC" or ""
end),
}
State
mantle.processes:get() returns ProcessesState, nil before the first push. A field marked ? may be absent.
mantle.processes payload.
| 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/
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 |
|---|---|---|
files | 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 an absolute JSON file into files[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, nil deleting it; saved 1 s after the last write. |
Backend
Plain JSON files at absolute paths, watched with inotify. A set pushes at once; the save follows
1 s later. Another writer’s change, another shell’s included, gets missing defaults refilled and
pushes.
Source: supervisor/src/capabilities/storage/
sysinfo
CPU, memory and swap use, CPU and GPU temperatures. nil until configure sets intervals.
mantle.sysinfo:configure({ cpu_interval = 2, ram_interval = 5 })
text {
content = mantle.sysinfo:map(function(sysinfo)
if sysinfo == nil then
return ""
end
return string.format("CPU %d%% RAM %d%%", sysinfo.cpu_percent, sysinfo.ram_percent)
end),
}
State
mantle.sysinfo:get() returns SysinfoState, nil before the first push. A field marked ? may be absent.
mantle.sysinfo’s payload; nil until configure sets an interval and a reading changes a field.
Pushes only on a change.
| Field | Type | Description |
|---|---|---|
cpu_percent | integer | CPU utilization across all cores, 0 to 100, rounded down; 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 or nvidia hwmon temperature in whole Celsius, or -1 without a readable one. |
Actions
Call each as mantle.sysinfo:<action>(arguments...); ? marks an argument you may omit.
| 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 drops the call.
| Field | Type | Description |
|---|---|---|
cpu_interval? | integer | Seconds between CPU 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 or nvidia | temp_interval |
The chips are picked once, when sysinfo starts; a driver loaded later needs a Supervisor
restart. A reading pushes only when it changed a field. Intervals live in the Supervisor, so they
outlast reloads until the next configure. Each read runs on the wall-clock second, first at the
next one after configure, so it lands with mantle.system.time’s tick and the two can share one
layout pass.
Gotchas
| 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 |
Source: supervisor/src/capabilities/sysinfo/
system
Wall and monotonic clocks, pushed once a second until configure sets the interval.
mantle.system:configure({ interval = 60 }) -- the text below shows no seconds
text {
content = mantle.system:map(function(system)
return system and os.date("%a %H:%M", system.time) or ""
end),
}
State
mantle.system:get() returns SystemState, nil before the first push. A field marked ? may be absent.
mantle.system’s payload, pushed on each tick of interval.
| Field | Type | Description |
|---|---|---|
monotonic | integer | Seconds since system was first used, as of the last push; excludes suspend. Take durations from it, since NTP moves time. |
time | integer | Unix epoch seconds, as os.date takes them. |
Actions
Call each as mantle.system:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
configure | settings: SystemConfigure | Sets the push interval, 1 second until this. Each push lands on a wall-clock multiple of it, and one lands at once. |
SystemConfigure
system:configure’s table. An absent interval keeps the current one.
| 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. |
Backend
The Supervisor’s own clocks; nothing external. Pushes land on wall-clock multiples of interval
since the epoch (default 1): 60 lands on each minute’s :00; 3600 on UTC hours, not local
ones in a half-hour timezone. A push due during suspend lands on resume, and a clock step (NTP,
settimeofday) pushes at once and re-aligns. 0 stops pushes; time and monotonic keep their
last values. The interval lives in the Supervisor, so it outlasts reloads until the next
configure, and every reader shares it.
See also: Clock bar recipe.
Source: supervisor/src/capabilities/system/
tray
StatusNotifierItem: registered tray items with artwork, status and menus.
list {
direction = "Horizontal",
spacing = 4,
source = mantle.tray:map(function(tray)
return tray and tray.items or {}
end),
key = function(item) return item.id end,
itemfn = function(item)
return button {
on_click = function(_, which)
if which == "left" and not item.item_is_menu then
mantle.tray:activate(item.id, 0, 0) -- screen x, y; most apps ignore them
end
end,
children = { icon { name = item.icon_name or item.icon_path or "", size = 16 } },
}
end,
}
State
mantle.tray:get() returns TrayState, nil before the first push. A field marked ? may be absent.
| 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, or nil. Icon pixmaps are not carried. |
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 | string | "standard" (the default) or "separator", as the application sent it. |
toggle_state? | integer | 0 off, 1 on, -1 indeterminate or unreported; nil exactly when toggle_type is. |
toggle_type? | string | "checkmark", "radio", or nil for an entry that is not a toggle. |
TrayItem
| Field | Type | Description |
|---|---|---|
attention_icon_name? | string | Artwork to draw while status == "NeedsAttention", 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 | string | "Active", "Passive" (the item asks to be hidden) or "NeedsAttention", as the item sent it. |
tooltip? | string | Tooltip title and text joined by a newline, or nil when both are empty. |
Actions
Call each as mantle.tray:<action>(arguments...); ? marks an argument you may omit.
| 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. |
secondary_activate | id: string, x: integer, y: integer | Middle-click activation at screen coordinates x, y. |
scroll | id: string, delta: integer, orientation: string | Scrolls the icon by delta; orientation is "vertical" or "horizontal", passed verbatim. |
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. |
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. 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 submenu is empty | Some apps fill submenus only after AboutToShow. Send menu_will_show with the submenu’s id before drawing it |
See also: System tray with menu recipe.
Source: supervisor/src/capabilities/tray/
updates
Pending package upgrades (pacman, optionally AUR), install progress and whether a reboot is due.
mantle.updates:configure({ interval = 3600 })
button {
on_click = function() mantle.updates:check() end,
children = {
text {
content = mantle.updates:map(function(updates)
if updates == nil or updates.checking then
return "…"
end
return updates.count > 0 and (updates.count .. " updates") or "up to date"
end),
},
},
}
State
mantle.updates:get() returns UpdatesState, nil before the first push. A field marked ? may be absent.
mantle.updates’s payload.
| 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. |
count | integer | Always #packages. |
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 drops the whole 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 | curl fetches each repo database pacman-conf lists into $XDG_RUNTIME_DIR/mantle/pacman, skipping one the mirror reports unchanged. pacman -Qu, -Sp and -Si read it against /var/lib/pacman/local | dnf repoquery --refresh --upgrades, then --installed for old_version; metadata goes to your cache (~/.cache/libdnf5) | apt-get update into $XDG_RUNTIME_DIR/mantle/apt with its hooks cleared, then apt-get -s upgrade --with-new-pkgs and apt-cache show for sizes |
| 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 dir = (os.getenv("HOME") or "") .. "/.local/state/myshell"
local cache = persistent_table { path = dir, name = "updates.json" }
local file = dir .. "/updates.json"
mantle.storage:on_change(function(storage, previous)
local saved = storage.files[file]
if saved and not (previous and previous.files[file]) then
mantle.updates:configure({
interval = 3600,
checked_at = saved.checked_at,
packages = saved.packages,
})
end
end)
mantle.updates:on_change(function(updates, previous)
local at = updates.last_successful_check
if at and at ~= (previous and previous.last_successful_check) then
cache:set("checked_at", at)
cache:set("packages", updates.packages)
end
end)
return text {
content = mantle.updates:map(function(updates)
return updates and tostring(updates.count) or ""
end),
}
Gotchas
| 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 {
source = mantle.windows:map(function(windows)
return windows and windows.windows or {}
end),
key = function(window) return window.id end,
itemfn = function(window)
return button {
on_click = function() mantle.windows:focus(window.id) end,
children = {
text { content = window.title, foreground = window.focused and "#89B4FA" or "#CDD6F4" },
},
}
end,
}
State
mantle.windows:get() returns WindowsState, nil before the first push. A field marked ? may be absent.
mantle.windows payload; nil with no niri, Hyprland or wlr-foreign-toplevel backend.
| Field | Type | Description |
|---|---|---|
source | string | "niri", "hyprland", or "wlr_foreign_toplevel". |
windows | WindowEntry[] | Sorted by workspace_id, then backend order; windows without one last. |
WindowEntry
One toplevel window. nil optional fields are ones the backend does not report.
| 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. |
workspace_id? | integer | 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. |
Backend
niri and Hyprland share the workspaces reader; any other compositor needs
zwlr_foreign_toplevel_manager_v1 (backend table).
| Backend | Reports | Writes |
|---|---|---|
| niri | floating | focus, close |
| Hyprland | floating, fullscreen, maximized | focus, close, set_fullscreen, set_maximized |
| wlr foreign-toplevel | fullscreen, maximized, minimized | Every action |
A flag a backend does not report is nil; an action it lacks is logged at debug level and dropped.
Gotchas
| Trap | Fix |
|---|---|
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 and workspaces.outputs or {}) do
if entry.name == output then
for _, workspace in ipairs(entry.workspaces) do
if workspace.id == entry.active_workspace then
return "workspace " .. workspace.idx
end
end
end
end
return ""
end),
}
end,
}
State
mantle.workspaces:get() returns WorkspacesState, nil before the first push. A field marked ? may be absent.
mantle.workspaces payload; nil without niri or Hyprland.
| Field | Type | Description |
|---|---|---|
active_client? | ActiveClient | The focused window, or nil when none has focus. One per session, not per output. |
compositor | string | "niri" or "hyprland". |
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 |
|---|---|---|
class | string | Wayland app_id, e.g. "firefox"; the key of applications.by_app_id. Empty when unset. |
is_floating | boolean | Whether the window floats rather than tiles. |
is_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 | integer | WorkspaceEntry::id shown on this output. |
focused_workspace? | integer | WorkspaceEntry::id with focus, present only on the focused output. |
name | string | Connector name, e.g. "eDP-1", as in mantle.screens and a surface’s monitor. |
workspaces | WorkspaceEntry[] | Workspaces on this output, sorted by WorkspaceEntry::idx. |
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. |
WorkspaceEntry
One workspace. Draw idx, 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 | integer | Stable id, the argument of "focus". Hyprland’s workspace number; opaque on niri. |
idx | integer | Label number: niri’s 1-based position on the output, renumbered on reorder; Hyprland’s workspace number, equal to id up to 255, where it saturates. |
name? | string | Workspace name; nil when unnamed, or on Hyprland when the name is just the number. |
populated | boolean | Whether a window sits here. |
Actions
Call each as mantle.workspaces:<action>(arguments...); ? marks an argument you may omit.
| Action | Arguments | Description |
|---|---|---|
focus | id: integer | Focuses a WorkspaceEntry.id. Hyprland creates a missing number; niri ignores it. |
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
(compositor.rs). One reader feeds both workspaces and
windows.
| Capability | niri | Hyprland | Neither |
|---|---|---|---|
workspaces | IPC event stream | .socket2.sock events, then one re-read per burst over .socket.sock; a title change alone patches in place | nil for the run |
windows | Same event stream | Same re-read | zwlr_foreign_toplevel_manager_v1 on its own Wayland connection; nil if the protocol is missing or setup takes over 5 s |
Hyprland’s refusal of a write logs at debug level only (MANTLE_LOG=debug); niri’s is not logged.
How do I…
Draw workspace buttons
Draw idx, send id (list builds one button per entry):
list {
direction = "Horizontal",
spacing = 4,
source = mantle.workspaces:map(function(workspaces)
local output = workspaces and workspaces.outputs[1]
return output and output.workspaces or {}
end),
key = function(workspace) return tostring(workspace.id) end,
itemfn = function(workspace)
local active = mantle.workspaces:map(function(workspaces)
local output = workspaces and workspaces.outputs[1]
return output ~= nil and output.active_workspace == workspace.id
end)
return button {
padding = { left = 8, right = 8 },
radius = 6,
background = active:map(function(is_active) return is_active and "#89B4FA" or "#313244" end),
on_click = function() mantle.workspaces:focus(workspace.id) end,
children = { text { content = tostring(workspace.idx) } },
}
end,
}
Gotchas
| Trap | Fix |
|---|---|
| Labels show large or odd numbers on niri | Draw idx, send id. niri’s id is opaque |
| The strip differs between compositors | Hyprland lists no empty workspace but the active one, and focus on a missing number creates it; niri keeps its own empty workspace and ignores an unknown id. Branch on compositor |
| Actions do nothing on Hyprland older than 0.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/
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, monitor = "Active" |
| Workspaces | Per-monitor workspace pills for Hyprland and niri, 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.system, show_date }, function(system, date)
if system == nil then
return "--:--" -- nil until the first push
end
return os.date(date and "%A %d %B" or "%H:%M", system.time)
end)
local tooltip_text = mantle.system:map(function(system)
return system and os.date("%A, %d %B %Y", system.time) or ""
end)
local clock = button {
align_v = "Center",
padding = { left = 10, right = 10, top = 4, bottom = 4 },
radius = 6,
hover = clock_hover,
background = clock_hover:map(function(on) return on and "#313244" or "#00000000" end),
animate = { background = 120 },
on_click = function() show_date:set(not show_date:get()) end,
children = { text { content = label, font_size = 14, foreground = "#cdd6f4" } },
}
local bar = panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = { rect { width = "Fill" }, clock, rect { width = "Fill" } },
},
}
local tooltip = popup {
id = "clock_tooltip",
parent = "bar",
anchor_rect = hover_rect("clock"),
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 6 },
grab = false,
visible = clock_hover,
padding = 8,
radius = 8,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
child = text { content = tooltip_text, foreground = "#cdd6f4" },
}
return { bar, tooltip }
How it works
mantle.systempushes the time once a second, andos.dateformats it (system).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 | monitor = "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",
FullyCharged = "Fully charged",
PendingCharge = "Plugged in, not charging",
Empty = "Empty",
}
local function duration(seconds)
return string.format("%d h %02d min", seconds // 3600, seconds % 3600 // 60)
end
-- Adwaita-style names: battery-level-0 to battery-level-100 in steps of 10.
local glyph = mantle.battery:map(function(battery)
if battery == nil or not battery.present then
return "battery-missing-symbolic"
end
local step = math.floor(battery.percent / 10 + 0.5) * 10
if battery.state == "FullyCharged" or (step == 100 and battery.state == "Charging") then
return "battery-level-100-charged-symbolic"
end
return string.format("battery-level-%d%s-symbolic", step, battery.state == "Charging" and "-charging" or "")
end)
local colour = mantle.battery:map(function(battery)
local low = battery and battery.present and battery.percent <= LOW and battery.state == "Discharging"
return low and "#f38ba8" or "#cdd6f4"
end)
local details = mantle.battery:map(function(battery)
if battery == nil or not battery.present then
return "No battery"
end
local line = string.format("%d%% · %s", battery.percent, STATES[battery.state] or battery.state)
if battery.state == "Discharging" and battery.time_to_empty then
line = line .. "\n" .. duration(battery.time_to_empty) .. " left"
elseif battery.state == "Charging" and battery.time_to_full then
line = line .. "\n" .. duration(battery.time_to_full) .. " to full"
end
return line
end)
-- Warn once each time the charge falls past LOW while draining.
mantle.battery:on_change(function(battery, previous)
if previous == nil or not battery.present then
return
end
if battery.state == "Discharging" and battery.percent <= LOW and previous.percent > LOW then
process.detach("notify-send", { "-u", "critical", "Battery low", battery.percent .. "% remaining" })
end
end)
local pill = row {
align_v = "Center",
spacing = 4,
padding = { left = 8, right = 10, top = 3, bottom = 3 },
radius = 12,
background = pill_hover:map(function(on) return on and "#45475a" or "#313244" end),
hover = pill_hover,
visible = mantle.battery:map(function(battery) return battery ~= nil and battery.present end),
children = {
icon { name = glyph, size = 16, foreground = colour, align_v = "Center" },
text {
content = mantle.battery:map(function(battery)
return battery and battery.present and battery.percent .. "%" or ""
end),
foreground = colour,
align_v = "Center",
},
},
}
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = { rect { width = "Fill" }, pill },
},
},
popup {
id = "battery_tooltip",
parent = "bar",
anchor_rect = hover_rect("battery"),
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 6 },
grab = false,
visible = pill_hover,
padding = 8,
radius = 8,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
child = text { content = details, foreground = "#cdd6f4", wrap = "Word" },
},
}
How it works
mantle.batteryisnilbefore its first push and readspresent = falseon a desktop; every map checks both (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 button whose on_click 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 and 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 ~= nil and battery.present and battery.state ~= "FullyCharged" |
Volume OSD
A card near the bottom of the focused monitor that shows the volume for a moment whenever it
changes, whether from a media key, wpctl or a mixer. It fades and slides in, then out.
-- The last change worth showing. A fresh table on every set, so each change counts as new.
local osd = state("volume_osd", { volume = 0, muted = false })
mantle.audio:on_change(function(audio, previous)
if previous == nil or audio.volume == nil then
return -- the first push is learned state, not a change
end
if audio.volume ~= previous.volume or audio.muted ~= previous.muted then
osd:set({ volume = audio.volume, muted = audio.muted })
end
end)
local shown = pulse(osd, 1500) -- true for 1.5 s after each change
local mapped = pulse(osd, 1700) -- keeps the surface up while the card fades out
local function percent(entry)
return math.floor(math.min(entry.volume, 1) * 100 + 0.5)
end
local glyph = osd:map(function(entry)
if entry.muted or entry.volume == 0 then
return "audio-volume-muted-symbolic"
end
local level = percent(entry)
return level < 34 and "audio-volume-low-symbolic"
or level < 67 and "audio-volume-medium-symbolic"
or "audio-volume-high-symbolic"
end)
return {
panel {
id = "volume_osd",
layer = "Overlay",
monitor = "Active",
anchor = { bottom = true }, -- no left/right: centred, width measured
margin = { bottom = 80 },
visible = mapped,
child = row {
width = 280,
height = 48,
spacing = 12,
padding = { left = 16, right = 16 },
align_v = "Center",
radius = 24,
background = "#1e1e2ee6",
border_width = 1,
border_color = "#45475a",
opacity = shown:map(function(on) return on and 1 or 0 end),
translate = shown:map(function(on) return { y = on and 0 or 12 } end),
animate = {
opacity = { duration = 150, from = 0 },
translate = { duration = 200, easing = "OutCubic", from = { y = 12 } },
},
children = {
icon { name = glyph, size = 20, foreground = "#cdd6f4", align_v = "Center" },
rect {
width = "Fill",
height = 6,
radius = 3,
align_v = "Center",
background = "#45475a",
children = {
rect {
height = "Fill",
radius = 3,
background = osd:map(function(entry) return entry.muted and "#6c7086" or "#89b4fa" end),
width = osd:map(function(entry) return percent(entry) .. "%" end),
animate = { width = { duration = 120 } },
},
},
},
text {
content = osd:map(function(entry) return entry.muted and "Muted" or percent(entry) .. "%" end),
width = 44,
text_align = "End",
align_v = "Center",
font_size = 13,
foreground = "#cdd6f4",
},
},
},
},
}
Bind the volume keys to anything that changes the default sink, for example
wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+; the OSD follows the push.
How it works
on_changereacts to each audio push and skips the first one, which is learned state (on_change, audio).- It writes a fresh table into a named state;
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). monitor = "Active"shows it on the output the compositor picks, usually the focused one, and a bottom-only anchor centres it (panel monitor, OSD).- The fill is a
"NN%"width inside a fixed track (sizes);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 / 100, muted = false } into the same state |
| Show above 100% | Drop math.min from percent, and size the fill math.floor(entry.volume / 1.5 * 100) .. "%": the track is then 150% |
| Top of the screen | anchor = { top = true }, margin = { top = 80 } and from = { y = -12 } |
| Every monitor | Remove monitor = "Active" |
| Longer on screen | pulse(osd, 3000) and pulse(osd, 3200) |
Workspaces
A bar on every monitor listing that monitor’s workspaces, on Hyprland and niri alike. The active workspace is a wide pill, occupied ones are brighter, a click focuses one and the wheel steps through them. On Hyprland, special workspaces get their own toggles.
-- The workspaces of one output, `nil` until the compositor answers.
local function output_of(workspaces, name)
for _, output in ipairs(workspaces and workspaces.outputs or {}) do
if output.name == name then
return output
end
end
end
local function focus_step(name, step)
local output = output_of(mantle.workspaces:get(), name)
if output == nil then
return
end
for index, workspace in ipairs(output.workspaces) do
if workspace.id == output.active_workspace then
local target = output.workspaces[index + step]
if target then
mantle.workspaces:focus(target.id)
end
return
end
end
end
local function strip(name)
return button {
align_v = "Center",
-- Wheel up is positive; step towards lower numbers.
on_wheel = function(_, steps) focus_step(name, steps > 0 and -1 or 1) end,
children = {
list {
direction = "Horizontal",
spacing = 4,
align_v = "Center",
source = mantle.workspaces:map(function(workspaces)
local output = output_of(workspaces, name)
local items = {}
for index, workspace in ipairs(output and output.workspaces or {}) do
items[index] = {
id = workspace.id,
label = tostring(workspace.idx), -- draw idx, send id
active = workspace.id == output.active_workspace,
populated = workspace.populated,
}
end
return items
end),
key = function(item) return tostring(item.id) end,
itemfn = function(item)
return button {
width = item.active and 36 or 22,
height = 20,
radius = 10,
background = item.active and "#89b4fa" or (item.populated and "#45475a" or "#313244"),
animate = { width = { duration = 180, easing = "OutCubic" }, background = 180 },
on_click = function() mantle.workspaces:focus(item.id) end,
children = {
text {
content = item.label,
align_h = "Center",
align_v = "Center",
font_size = 11,
foreground = item.active and "#1e1e2e" or "#cdd6f4",
},
},
}
end,
},
},
}
end
-- Hyprland's scratchpads; `special` is nil on niri, so this list is empty there.
local specials = list {
direction = "Horizontal",
spacing = 4,
align_v = "Center",
source = mantle.workspaces:map(function(workspaces)
return workspaces and workspaces.special or {}
end),
key = function(special) return special.name end,
itemfn = function(special)
local short = special.name:gsub("^special:?", "") -- "special:scratch" -> "scratch"
return button {
padding = { left = 8, right = 8, top = 2, bottom = 2 },
radius = 10,
background = special.shown_on and "#f9e2af" or "#313244",
on_click = function() mantle.workspaces:toggle_special(special.name) end,
children = {
text {
content = short ~= "" and short or "special",
font_size = 11,
foreground = special.shown_on and "#1e1e2e" or "#cdd6f4",
},
},
}
end,
}
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = function(output) -- one instance per monitor, named by connector
return row {
width = "Fill",
height = "Fill",
spacing = 12,
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = { strip(output), specials },
}
end,
},
}
How it works
child = function(output)builds one bar per monitor and passes its connector name (per-output child).outputs[].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
idxand clicks sendid: niri’s ids are opaque (workspaces gotchas). on_wheelon the outer button reads 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 = 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.idx), 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 and 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 ~= nil and lock.active and not lock.unlocking end)
local time = mantle.system:map(function(system) return system and os.date("%H:%M", system.time) or "" end)
local date = mantle.system:map(function(system) return system and os.date("%A, %d %B", system.time) or "" end)
local hint = mantle.lock:map(function(lock)
if lock == nil then
return ""
elseif lock.authenticating then
return "Checking…"
elseif lock.error ~= "" then
return lock.attempts > 1 and string.format("Wrong password (%d tries)", lock.attempts) or "Wrong password"
end
return "Type your password and press Enter"
end)
local failed = mantle.lock:map(function(lock) return lock ~= nil and lock.error ~= "" and not lock.authenticating end)
local lock_screen = lock {
id = "lock",
background = "#11111b",
child = function(output)
return column {
width = "Fill",
height = "Fill",
align_h = "Center",
align_v = "Center",
spacing = 16,
background = { gradient = "Linear", angle = 160, stops = { { 0, "#1e1e2e" }, { 1, "#11111b" } } },
opacity = up:map(function(on) return on and 1 or 0 end),
animate = { opacity = { duration = FADE_MS, from = 0 } },
children = {
text { content = time, font_size = 96, foreground = "#cdd6f4", align_h = "Center" },
text { content = date, font_size = 20, foreground = "#a6adc8", align_h = "Center" },
rect { height = 32 },
text { content = os.getenv("USER") or "", font_size = 16, foreground = "#cdd6f4", align_h = "Center" },
rect {
width = 320,
align_h = "Center",
padding = { left = 16, right = 16 },
radius = 22,
background = "#1e1e2e",
border_width = 2,
border_color = failed:map(function(bad) return bad and "#f38ba8" or "#45475a" end),
animate = { border_color = 150 },
children = {
textfield {
width = "Fill",
height = 44,
font_size = 16,
foreground = "#cdd6f4",
text_align = "Center",
placeholder = "Password",
secure_submit = { capability = "lock", action = "authenticate" },
},
},
},
text {
content = hint,
foreground = failed:map(function(bad) return bad and "#f38ba8" or "#6c7086" end),
align_h = "Center",
},
},
}
end,
}
return { lock_screen }
How it works
- Declaring a
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).error,attemptsandauthenticatingdrive the hint and the red border.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 button { submit = true, ... } beside the field sends it like Enter (pointer) |
| 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)
pending:set("")
end
local function log_out()
local workspaces = mantle.workspaces:get()
local compositor = workspaces and workspaces.compositor
if compositor == "niri" then
process.detach("niri", { "msg", "action", "quit", "--skip-confirmation" })
elseif compositor == "hyprland" then
process.detach("hyprctl", { "dispatch", "exit" })
end
end
local ACTIONS = {
{ key = "lock", label = "Lock", glyph = "system-lock-screen-symbolic",
run = function() mantle.lock:lock() end },
{ key = "suspend", label = "Suspend", glyph = "weather-clear-night-symbolic",
run = function() process.detach("systemctl", { "suspend" }) end },
{ key = "logout", label = "Log out", glyph = "system-log-out-symbolic", confirm = true, run = log_out },
{ key = "reboot", label = "Restart", glyph = "system-reboot-symbolic", confirm = true,
run = function() process.detach("systemctl", { "reboot" }) end },
{ key = "poweroff", label = "Power off", glyph = "system-shutdown-symbolic", confirm = true,
run = function() process.detach("systemctl", { "poweroff" }) end },
}
local function action_button(action)
local over = hover("power_" .. action.key)
local armed = pending:map(function(key) return key == action.key end)
return button {
width = 120,
height = 120,
radius = 20,
hover = over,
background = computed({ over, armed }, function(hovered, is_armed)
if is_armed then return "#f38ba8" end
return hovered and "#45475a" or "#313244"
end),
animate = { background = 120, scale = { duration = 120, easing = "OutCubic" } },
scale = over:map(function(hovered) return hovered and 1.05 or 1 end),
on_click = function()
if action.confirm and pending:get() ~= action.key then
pending:set(action.key)
return
end
close()
action.run()
end,
children = {
column {
align_h = "Center",
align_v = "Center",
spacing = 10,
children = {
icon {
name = action.glyph,
size = 36,
align_h = "Center",
foreground = armed:map(function(is_armed) return is_armed and "#1e1e2e" or "#cdd6f4" end),
},
text {
content = armed:map(function(is_armed) return is_armed and "Click again" or action.label end),
align_h = "Center",
foreground = armed:map(function(is_armed) return is_armed and "#1e1e2e" or "#cdd6f4" end),
},
},
},
},
}
end
local buttons = {}
for index, action in ipairs(ACTIONS) do
buttons[index] = action_button(action)
end
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = {
rect { width = "Fill" },
button {
align_v = "Center",
padding = 6,
radius = 6,
on_click = function() open:set(true) end,
children = { icon { name = "system-shutdown-symbolic", size = 16, foreground = "#f38ba8" } },
},
},
},
},
panel {
id = "power_menu",
layer = "Overlay",
monitor = "Active",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
exclusive = "Ignore",
visible = open,
child = rect {
width = "Fill",
height = "Fill",
background = "#11111bcc",
opacity = 1, -- `from` needs the property set
animate = { opacity = { duration = 150, from = 0 } },
children = {
button { width = "Fill", height = "Fill", on_click = close }, -- outside click
row { align_h = "Center", align_v = "Center", spacing = 16, children = buttons },
},
},
},
}
How it works
process.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).
- One named state,
pending, holds the armed action; a second click on the same button runs it (named state). - A full-size
buttonunder 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 and notifications.feed or {}) do
local quiet = notifications.dnd and item.urgency ~= "critical"
if not item.expired and not quiet and #out < MAX_CARDS then
out[#out + 1] = item
end
end
return out
end)
-- A text span is a text run as it is; links also get a colour. Image spans are skipped.
local function body_runs(spans)
local runs = {}
for _, span in ipairs(spans) do
if span.kind == "text" then
-- A copy: the span belongs to the pushed snapshot every other reader shares.
local run = {}
for field, value in pairs(span) do run[field] = value end
if run.href then
run.underline, run.color = true, "#89b4fa"
end
runs[#runs + 1] = run
end
end
return runs
end
local function artwork(item)
if item.image_path then
return rect {
width = 40,
height = 40,
radius = 8,
clip = "Rounded",
children = { image { source = item.image_path, fit = "cover", width = "Fill", height = "Fill" } },
}
end
return icon { name = item.app_icon or "dialog-information-symbolic", size = 32 }
end
local function action_buttons(item)
local buttons = {}
for index, action in ipairs(item.actions) do
buttons[index] = button {
width = "Fill",
padding = 6,
radius = 6,
background = "#313244",
on_click = function() mantle.notifications:invoke_action(item.id, action.key) end,
children = {
text { content = action.label, align_h = "Center", elide = "End", foreground = "#cdd6f4" },
},
}
end
return row { width = "Fill", spacing = 6, visible = #buttons > 0, children = buttons }
end
local function card(item)
local critical = item.urgency == "critical"
local runs = body_runs(item.body)
return button {
width = "Fill",
padding = 12,
radius = 12,
background = "#1e1e2ef2",
border_width = 1,
border_color = critical and "#f38ba8" or "#45475a",
opacity = 1, -- `from` needs the property set
translate = { x = 0 },
animate = {
opacity = { duration = 150, from = 0 },
translate = { duration = 200, easing = "OutCubic", from = { x = 40 } },
exit = { duration = 150, opacity = 0 },
},
on_click = function()
if item.has_default_action then
mantle.notifications:invoke_action(item.id, "default")
else
mantle.notifications:dismiss(item.id)
end
end,
children = {
row {
width = "Fill",
spacing = 10,
children = {
artwork(item),
column {
width = "Fill",
spacing = 4,
children = {
row {
width = "Fill",
spacing = 6,
children = {
text { content = item.app_name, width = "Fill", elide = "End", font_size = 11, foreground = "#a6adc8" },
button {
padding = { left = 4, right = 4 },
radius = 4,
on_click = function() mantle.notifications:dismiss(item.id) end,
children = { text { content = "×", font_size = 14, foreground = "#a6adc8" } },
},
},
},
text {
content = { { text = item.summary, bold = true } },
width = "Fill",
elide = "End",
font_size = 13,
foreground = "#cdd6f4",
},
text {
content = runs,
visible = #runs > 0,
width = "Fill",
wrap = "Word",
max_lines = 4,
elide = "End",
foreground = "#bac2de",
on_link = function(href) mantle.applications:open_url(href) end,
},
action_buttons(item),
},
},
},
},
},
}
end
return {
panel {
id = "notifications",
layer = "Overlay",
anchor = { top = true, right = true },
margin = { top = 8, right = 8 },
width = 380,
visible = cards:map(function(shown) return #shown > 0 end),
child = column {
width = "Fill",
hover = stack_hover,
-- Pause every countdown while the pointer is over the stack.
on_hover = function(inside) mantle.notifications:hold_expiry(inside and 300 or 0) end,
children = {
list {
width = "Fill",
spacing = 8,
source = cards,
key = function(item) return tostring(item.id) end,
itemfn = card,
},
},
},
},
}
How it works
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; a dismissed card fades out throughanimate.exit(identity, exit).- A body’s text spans pass to
textas runs unchanged;on_linkhands a clickedhreftoopen_url(text runs, applications). - The × is a
buttoninside the card’sbutton: the innermost one with a handler takes the click (pointer). on_hoveron the stack callshold_expiry, so a card cannot expire while being read (hover).- The panel is anchored to two edges, so it measures its content and grows with the stack (corner stack).
Variations
| 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.system:map(function(system) return system and math.floor((system.time - item.timestamp) / 60) .. " min ago" or "" 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, and Escape or a click outside closes it.
Open it from a compositor keybind with mantle toggle launcher_open.
local MAX_RESULTS = 50
local open = state("launcher_open", false)
local query = state("launcher_query", "")
local selected = state("launcher_selected", 1)
local results_scroll = scroll("launcher_results")
local function close()
open:set(false)
query:set("")
selected:set(1)
end
-- Best score of the name, generic name and keywords; nil when none match.
local function score_of(entry, needle)
local best, best_start = fuzzy(entry.name, needle)
for _, field in ipairs({ entry.generic_name or "", table.concat(entry.keywords, " ") }) do
local score, start = fuzzy(field, needle)
if score and (best == nil or score > best) then
best, best_start = score, start
end
end
return best, best_start
end
local matches = computed({ mantle.applications, query }, function(applications, needle)
local scored = {}
for _, entry in ipairs(applications and applications.entries or {}) do
local score, start = score_of(entry, needle)
if score then
scored[#scored + 1] = { entry = entry, score = score, start = start }
end
end
table.sort(scored, function(left, right)
if left.score ~= right.score then return left.score > right.score end
if left.start ~= right.start then return left.start < right.start end
return left.entry.id < right.entry.id -- unique, so the order is stable
end)
local out = {}
for index = 1, math.min(#scored, MAX_RESULTS) do
out[index] = scored[index].entry
end
return out
end)
local rows = computed({ matches, selected }, function(entries, current)
local out = {}
for index, entry in ipairs(entries) do
out[index] = { entry = entry, selected = index == current }
end
return out
end)
local function launch(entry)
if entry then
mantle.applications:launch(entry.id)
end
close()
end
local function move(step)
local count = #matches:get()
if count == 0 then
return
end
selected:set((selected:get() - 1 + step) % count + 1) -- wraps at both ends
results_scroll:reveal(selected:get())
end
local search = rect {
width = "Fill",
padding = { left = 12, right = 12 },
radius = 10,
background = "#313244",
children = {
textfield {
id = "search",
width = "Fill",
height = 40,
font_size = 16,
foreground = "#cdd6f4",
placeholder = "Search apps",
autofocus = true,
on_change = function(text)
query:set(text)
selected:set(1)
results_scroll:reveal(1)
end,
on_navigate = function(key)
if key == "down" or key == "tab" then move(1) end
if key == "up" or key == "backtab" then move(-1) end
end,
on_submit = function() launch(matches:get()[selected:get()]) end,
on_cancel = function(cleared)
if not cleared then close() end -- first Escape clears, second closes
end,
},
},
}
local results = list {
width = "Fill",
max_height = 400,
spacing = 2,
scroll = results_scroll,
source = rows,
key = function(row_data) return row_data.entry.id end,
itemfn = function(row_data)
local entry = row_data.entry
return button {
width = "Fill",
padding = 8,
radius = 8,
background = row_data.selected and "#45475a" or "#00000000",
on_click = function() launch(entry) end,
children = {
row {
width = "Fill",
spacing = 12,
children = {
icon { name = entry.icon or "application-x-executable", size = 32, align_v = "Center" },
column {
width = "Fill",
align_v = "Center",
children = {
text { content = entry.name, width = "Fill", elide = "End", font_size = 14, foreground = "#cdd6f4" },
text {
content = entry.comment or entry.generic_name or "",
visible = (entry.comment or entry.generic_name) ~= nil,
width = "Fill",
elide = "End",
font_size = 11,
foreground = "#a6adc8",
},
},
},
},
},
},
}
end,
}
return {
panel {
id = "launcher",
layer = "Overlay",
monitor = "Active",
anchor = { top = true, bottom = true, left = true, right = true },
width = "Fill",
height = "Fill",
exclusive = "Ignore",
visible = open,
keyboard_interactivity = "Exclusive", -- hiding destroys the surface, so no binding needed
child = rect {
width = "Fill",
height = "Fill",
background = "#11111b80",
children = {
-- Catches clicks outside the card.
button { width = "Fill", height = "Fill", on_click = close },
column {
width = 560,
align_h = "Center",
margin = { top = 160 },
padding = 12,
spacing = 8,
radius = 16,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
children = {
search,
results,
text {
content = "No matches",
visible = matches:map(function(entries) return #entries == 0 end),
align_h = "Center",
padding = 12,
foreground = "#6c7086",
},
},
},
},
},
},
}
Bind a key to mantle toggle launcher_open, for example bind = SUPER, Space, exec, mantle toggle launcher_open on Hyprland or Mod+Space { spawn "mantle" "toggle" "launcher_open"; } on niri.
How it works
applications.entriesholds every visible desktop entry and follows installs and removals;launchtakes itsidand runs it detached (applications).fuzzyscores one candidate; ranking, the tiebreak and the cap stay in Lua (fuzzy).computedjoins the capability with the query, and a second one marks the selected row (derived signals).- The
textfieldowns the typed text and reports it throughon_change;on_navigategets the arrow and Tab keys (textfield, text fields). scroll(name):reveal(index)keeps the selected row in view inside themax_heightlist (scroll, list)."Exclusive"hands the panel the keyboard when it maps, andautofocusgives it to the field (keyboard focus).- A full-size transparent
buttonunder the card closes it on an outside click (close an overlay).
Variations
| Change | Edit |
|---|---|
| Pointer focus on Hyprland | "OnDemand" 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, a middle click sends its secondary action, the wheel scrolls it and a right click opens its menu in a dropdown. Submenus expand in place, check marks and radio dots follow the app, and disabled entries are drawn greyed out.
local menu_open = state("tray_menu_open", false)
local menu_anchor = state("tray_menu_anchor", { x = 0, y = 0, width = 1, height = 1 })
local menu_item = state("tray_menu_item", "") -- the tray item whose menu is open
local expanded = state("tray_menu_expanded", {}) -- open submenu ids, as strings
local function close_menu()
menu_open:set(false)
end
local function open_menu(item, rect)
menu_item:set(item.id)
expanded:set({})
menu_anchor:set(rect)
menu_open:set(true)
end
local function artwork(item)
local name = item.icon_name
local path = item.icon_path
if item.status == "NeedsAttention" and (item.attention_icon_name or item.attention_icon_path) then
name, path = item.attention_icon_name, item.attention_icon_path
end
if path then
return image { source = path, width = 16, height = 16, fit = "contain", align_v = "Center" }
end
-- `foreground` tints symbolic icons, which are dark by default; colour icons ignore it.
return icon { name = name or "application-x-executable", size = 16, foreground = "#cdd6f4", align_v = "Center" }
end
local tray_items = list {
direction = "Horizontal",
spacing = 2,
align_v = "Center",
source = mantle.tray:map(function(tray)
local shown = {}
for _, item in ipairs(tray and tray.items or {}) do
if item.status ~= "Passive" then -- Passive asks to be hidden
shown[#shown + 1] = item
end
end
return shown
end),
key = function(item) return item.id end,
itemfn = function(item)
return button {
padding = 4,
radius = 6,
on_click = function(rect, which)
if item.menu and (which == "right" or item.item_is_menu) then
open_menu(item, rect)
elseif which == "left" then
mantle.tray:activate(item.id, 0, 0)
elseif which == "middle" then
mantle.tray:secondary_activate(item.id, 0, 0)
end
end,
on_wheel = function(_, steps)
mantle.tray:scroll(item.id, steps > 0 and 1 or -1, "vertical")
end,
children = { artwork(item) },
}
end,
}
-- The open item's menu as flat rows, depth-first, with open submenus inlined.
local function flatten(entries, depth, open, out)
for _, entry in ipairs(entries or {}) do
out[#out + 1] = { entry = entry, depth = depth }
if #entry.children > 0 and open[tostring(entry.id)] then
flatten(entry.children, depth + 1, open, out)
end
end
return out
end
local menu_rows = computed({ mantle.tray, menu_item, expanded }, function(tray, id, open)
for _, item in ipairs(tray and tray.items or {}) do
if item.id == id then
return flatten(item.menu, 0, open, {})
end
end
return {}
end)
-- "_Quit" -> "Quit"; "__" is a literal underscore.
local function strip_mnemonic(label)
return ((label or ""):gsub("__", "\0"):gsub("_", ""):gsub("%z", "_"))
end
local function marker(entry)
if #entry.children > 0 then
return "›"
elseif entry.toggle_state == 1 then
return entry.toggle_type == "radio" and "●" or "✓"
end
return ""
end
local function menu_row(row_data)
local entry = row_data.entry
local indent = 8 + row_data.depth * 12
if entry.menu_type == "separator" then
return rect {
width = "Fill",
padding = { top = 4, bottom = 4, left = indent, right = 8 },
children = { rect { width = "Fill", height = 1, background = "#45475a" } },
}
end
local mark = marker(entry)
local row_hover = hover("tray_menu_row_" .. entry.id .. "_" .. row_data.depth)
return button {
width = "Fill",
padding = { top = 6, bottom = 6, left = indent, right = 8 },
radius = 6,
hover = row_hover,
background = row_hover:map(function(on) return on and entry.enabled and "#313244" or "#00000000" end),
opacity = entry.enabled and 1 or 0.4,
on_click = function()
if #entry.children > 0 then
local key = tostring(entry.id)
local open = {}
for id, value in pairs(expanded:get()) do open[id] = value end
open[key] = not open[key] or nil
expanded:set(open)
if open[key] then
mantle.tray:menu_will_show(menu_item:get(), entry.id)
end
elseif entry.enabled then
mantle.tray:activate_menu_item(menu_item:get(), entry.id)
close_menu()
end
end,
children = {
row {
width = "Fill",
spacing = 8,
children = {
icon { name = entry.icon_name or "", size = 14, foreground = "#cdd6f4", visible = entry.icon_name ~= nil, align_v = "Center" },
text { content = strip_mnemonic(entry.label), width = "Fill", elide = "End", foreground = "#cdd6f4" },
text { content = mark, visible = mark ~= "", foreground = "#a6adc8" },
},
},
},
}
end
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = { rect { width = "Fill" }, tray_items },
},
},
popup {
id = "tray_menu",
parent = "bar",
anchor_rect = menu_anchor,
anchor = "Bottom",
gravity = "BottomLeft",
offset = { y = 4 },
constraint_adjustment = { "SlideX", "FlipY" },
visible = menu_open,
on_dismiss = close_menu,
width = 260,
padding = 6,
radius = 10,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
child = list {
width = "Fill",
max_height = 480,
scroll = scroll("tray_menu"),
source = menu_rows,
-- The same entry at two depths is two rows.
key = function(row_data) return row_data.entry.id .. ":" .. row_data.depth end,
itemfn = menu_row,
},
},
}
How it works
tray.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). - The menu tree is flattened into one
listwith an indent per depth, so a submenu opens in place rather than in a second popup (list, nested menus for the other way). menu_will_showlets apps that fill submenus lazily send them before the row expands.
Variations
| Change | Edit |
|---|---|
| Show hidden (Passive) items | Return tray and tray.items or {} 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 = "BottomRight" |
| 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.system.monotonic` seconds.
local position_mark = state("media_position_mark", { key = "", at = 0 })
-- The playing player, else the longest-running one.
local function pick(mpris)
local players = mpris and mpris.players or {}
for _, candidate in ipairs(players) do
if candidate.play_state == "Playing" then
return candidate
end
end
return players[1]
end
local player = mantle.mpris:map(pick)
local function report_key(current)
return current.id .. ":" .. current.position_updated_at
end
-- Positions are not polled: stamp each new report so the bar can add the time since.
mantle.mpris:on_change(function(mpris)
local current = pick(mpris)
local system = mantle.system:get()
if current and system then
local key = report_key(current)
if key ~= position_mark:get().key then
position_mark:set({ key = key, at = system.monotonic })
end
end
end)
-- Live position in microseconds, or -1 when unknown.
local position = computed({ player, position_mark, mantle.system }, function(current, mark, system)
if current == nil or current.position < 0 then
return -1
end
local elapsed = 0
-- An unstamped report, such as one before the first clock push, adds nothing.
if current.play_state == "Playing" and system and mark.key == report_key(current) then
elapsed = (system.monotonic - mark.at) * 1000000
end
local now = current.position + elapsed
return current.length > 0 and math.min(now, current.length) or now
end)
local function clock(microseconds)
local seconds = math.max(0, microseconds // 1000000)
return string.format("%d:%02d", seconds // 60, seconds % 60)
end
local function control(command)
local current = player:get()
if current then
mantle.mpris:control(current.id, command)
end
end
local function control_button(glyph, command, size)
return button {
align_v = "Center",
padding = 8,
radius = 20,
background = command == "play_pause" and "#89b4fa" or "#313244",
on_click = function() control(command) end,
children = { icon { name = glyph, size = size, foreground = command == "play_pause" and "#1e1e2e" or "#cdd6f4" } },
}
end
local play_glyph = player:map(function(current)
return current and current.play_state == "Playing" and "media-playback-pause-symbolic" or "media-playback-start-symbolic"
end)
local pill = button {
align_v = "Center",
padding = { left = 10, right = 10, top = 4, bottom = 4 },
radius = 12,
background = "#313244",
visible = player:map(function(current) return current ~= nil end),
on_click = function(rect, which)
if which == "middle" then
control("play_pause")
else
card_anchor:set(rect)
card_open:set(not card_open:get())
end
end,
on_wheel = function(_, steps) control(steps > 0 and "previous" or "next") end,
children = {
row {
spacing = 6,
children = {
icon { name = play_glyph, size = 14, foreground = "#cdd6f4", align_v = "Center" },
text {
max_width = 240,
elide = "End",
foreground = "#cdd6f4",
content = player:map(function(current)
if current == nil or current.title == "" then return "" end
return current.artist ~= "" and current.artist .. " — " .. current.title or current.title
end),
},
},
},
},
}
local progress = button {
width = "Fill",
height = 6,
radius = 3,
clip = "Rounded",
background = "#45475a",
-- Seek on release, to where the pointer let go.
on_drag = function(rect, pointer, phase)
local current = player:get()
if phase == "end" and current and current.length > 0 then
local fraction = math.max(0, math.min(1, pointer.x / rect.width))
mantle.mpris:seek(current.id, math.floor(fraction * current.length))
end
end,
children = {
rect {
height = "Fill",
background = "#89b4fa",
width = computed({ player, position }, function(current, now)
if current == nil or current.length <= 0 or now < 0 then return "0%" end
return string.format("%.1f%%", now / current.length * 100)
end),
},
},
}
local card = column {
width = 320,
padding = 16,
spacing = 12,
children = {
row {
width = "Fill",
spacing = 12,
children = {
rect {
width = 64,
height = 64,
radius = 8,
clip = "Rounded",
background = "#313244",
children = {
image {
width = "Fill",
height = "Fill",
fit = "cover",
source = player:map(function(current) return current and current.album_art_path or "" end),
},
},
},
column {
width = "Fill",
align_v = "Center",
spacing = 2,
children = {
text {
content = player:map(function(current) return current and current.title or "" end),
width = "Fill", elide = "End", font_size = 14, foreground = "#cdd6f4",
},
text {
content = player:map(function(current) return current and current.artist or "" end),
width = "Fill", elide = "End", foreground = "#a6adc8",
},
text {
content = player:map(function(current) return current and current.identity or "" end),
width = "Fill", elide = "End", font_size = 11, foreground = "#6c7086",
},
},
},
},
},
progress,
row {
width = "Fill",
children = {
text { content = position:map(function(now) return now < 0 and "" or clock(now) end), font_size = 11, foreground = "#a6adc8" },
rect { width = "Fill" },
text {
content = player:map(function(current) return current and current.length > 0 and clock(current.length) or "" end),
font_size = 11,
foreground = "#a6adc8",
},
},
},
row {
align_h = "Center",
spacing = 12,
width = "Fill",
children = {
control_button("media-skip-backward-symbolic", "previous", 16),
control_button(play_glyph, "play_pause", 20),
control_button("media-skip-forward-symbolic", "next", 16),
},
},
},
}
return {
panel {
id = "bar",
layer = "Top",
anchor = { top = true, left = true, right = true },
width = "Fill",
height = 32,
exclusive = true,
child = row {
width = "Fill",
height = "Fill",
padding = { left = 8, right = 8 },
background = "#1e1e2e",
children = { rect { width = "Fill" }, pill, rect { width = "Fill" } },
},
},
popup {
id = "media_card",
parent = "bar",
anchor_rect = card_anchor,
anchor = "Bottom",
gravity = "Bottom",
offset = { y = 6 },
visible = computed({ card_open, player }, function(open, current) return open and current ~= nil end),
on_dismiss = function() card_open:set(false) end,
radius = 14,
background = "#1e1e2e",
border_width = 1,
border_color = "#45475a",
child = card,
},
}
How it works
playersis longest-running first; the map prefers one that is playing (mpris).positionis a snapshot, not polled.on_changestamps each new report withmantle.system.monotonic, and acomputedadds the seconds since (system, 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 a local file or"", and 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 |
| No popup, controls in the bar | Put the three control_buttons in the bar row and drop the popup |
FAQ
Symptoms whose cause lives on another page: find the symptom, then follow the link. A trap that stays within one page is in that page’s Gotchas table.
First steps when something is wrong
| 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 | cli gotchas |
| 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 | A failed reload drops every action, timer, handler and idle threshold the last evaluation registered | Evaluation, reload and generations |
An on_change, timer, process.run, palette or idle callback does nothing | Its error, a blown CPU budget included, is a warning. Read mantle log | Output and logging |
mantle.<cap>:<action>(...) returns nil and nothing changes | Actions are fire and forget; a wrong argument type or count is dropped with a log line | actions |
A keybind running mantle set or mantle toggle does nothing | It was refused (an undeclared name, a bare toggle on a non-boolean). The compositor discards the error; mantle log keeps it | cli gotchas |
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 map 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 | 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 5ms CPU budget for one evaluation | A map, computed, handler or timer did 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 nested in a property table does not resolve | 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 exit_cb 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, then restart the shell; 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 the app so it registers again |
| 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 | Not niri or Hyprland; only one layout configured (layout_count 1); on Hyprland it sends switchxkblayout main <i> to the keyboard marked main, which likely fails on Hyprland 0.56+, whose socket parses Lua | Configure several layouts in the compositor; on Hyprland 0.56+, switch through a compositor keybind |
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, or the device asks for a PIN or passkey entry (rejected) | Make the adapter visible or pair from the shell |
See also: runtime, cli, signals, processes, capabilities, glossary (rescue, hydration, generation).
Source: reload and rescue, apply, check, callback logging, log subsystems.
Glossary
The terms these pages use, each defined once. Engine-internal vocabulary (retained scene, dirty
scope, capability roster) lives in CONTEXT.md.
Processes
| 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 and once with a sample push each. 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 after a saved .lua or .frag file or an output change, then one apply: the scene is reconciled, and surfaces whose fingerprint changed are destroyed or created. See runtime. |
| Surface fingerprint | A declaration’s creation-time fields (panel: id, layer, anchor, monitor, namespace; other roles: id). A change rebuilds that surface; other edits update it live. |
| Evaluation-scoped 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 apply keeps the previous scene and surfaces. A failed evaluation drops the timers, actions and change handlers it registered; a failed apply drops only its timers. |
| Rescue | mantle.rescue, { is_rescue, error_log }: set by a failed evaluation, apply or live update, or a refused or lost session lock; cleared by the next reload that applies. A failed startup apply or live update also clears when a later pass applies. |
Surfaces
| 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 monitor = "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, monitor, 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, button, 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. |
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, 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. |
| 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(0.5), 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 and config_dir. |
| 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. Mantle is pre-release and unversioned,
so everything since the rename from Obelisk sits under Unreleased.
Unreleased
Added
- x86_64 release builds: a
.deb, an.rpmand a tarball, built in Ubuntu 26.04 by thereleaseworkflow on av*tag (install). - This site: guide, one page per node, surface and capability, cookbook, glossary. Every Lua
example on it runs as a test, and the
lua-metastubs link each entry to its page. - Nodes:
shader(a config fragment shader) andcapture(a live output preview, withlivecapped at a frame rate andregionto crop). - Paint: gradient backgrounds,
mask,shadow_*withshadow_mode,content_blur,backdrop_blur,image.source_blur,clip = "None"andz(paint). palette.quantizefor an image’s dominant colours.mantle.screensentries gain position,model,description,fractional_scaleandorientation;mantle.screensandmantle.rescuegainon_change.monitor = "Active"lets the compositor pick a panel’s output.- A plain
textfieldhas a caret, a selection, grapheme-correct deletion, key repeat and Ctrl bindings; Left/Right reachon_navigatewhen the caret cannot move; Escape in asecure_submitfield callson_cancel. - Numbered verbosity (
-v,-vv,-vvv), quiet by default;--profileprints its own reports (CLI). --profilereports each capability’s snapshot pushes sent and deduped beside its size (CLI).- A bare
mantle calllists the running config’s actions, and a baremantle setormantle toggleits states with their values (CLI). mantle.system:configure({ interval = 60 })pushes the clock every minute on the minute instead of every second, or never with0; default1(system).mantle stop [--pid | -c]stops a running shell and waits for it to exit;mantle.pidis the shell’s own pid, so a config can stop itself (CLI).mantle.updatesruns on Fedora throughdnf(dnf5 or dnf4) and on Debian and Ubuntu throughapt-get;package_manageris"dnf"or"apt"(updates).
Changed
mantle.updateschecks throughpacman,pacman-conf,curlandvercmpinstead of linking libalpm, so building no longer needs libalpm and the binary starts off Arch.packagesleaves outIgnorePkgentries, andinstalled_sizerounds to the two decimals pacman prints (updates).- A
geometryrect an animation moved schedules one pass when the animation settles, so a property bound to it catches up instead of waiting for an unrelated write. - A
state:setof the value the state already holds re-resolves nothing, and a:maporcomputedre-resolves its readers only when its result changes. Scalars and plain-data tables compare by value (signals). mantle.systempushes land on the wall-clock second after a resume or clock step too, rather than mid-second until a restart.mantle.keyboardpushes once when it starts, so it is no longernilwithout niri or Hyprland until a lock key or the backlight changes.mantle.sysinforeads on the wall-clock second, first at the next one afterconfigurerather than one interval later, so its pushes can sharemantle.system’s layout pass.mantle.applicationswatches its directories and rescans after a change, so installs and removals appear withoutrefresh(applications).- A reload kills every
process.runchild and calls itsexit_cb(nil)before the new evaluation runs, so a top-level follower restarts instead of doubling (processes). - Lua’s
warnnow logs at warn level likelog.warn, on by default; before, it printed nothing. - A
text.contentrun’skindmust be"text", as a notification text span’s is; any other value fails the pass instead of being ignored. lua-metanode, surface and global types come from the Rust types the engine parses with: a surface’schilddeclares theBoundit always took, a panel’swidth/heightthe[0, 8192]it always enforced. A signal-bound popupparentfails every pass, not only the first.- A callback (
on_*) that is not a function (writecond and fn or nil), asubmitorautofocusthat is not a boolean, and anilorfalseentry inchildren, which dropped every child after it, fail the pass instead of being ignored. - A table property (
padding,anchor,shadow_offset,min_size,anchor_rect, ananimateentry, a text run,secure_submit, …) and thesession_process,persistent_tableandpalette.quantizeoption tables refuse a key they do not take. secure_submittakes onlylock/authenticate,polkit/authenticateandnetwork/connect.- Two surfaces with one
id, or two different scalar seeds for onestatename in one evaluation, fail the evaluation. - A
listwithoutsourcebuilds no items instead of raising. - A
listkeeps its items while nothing its last build read has changed, instead of callingitemfnfor every item on every pass: a write elsewhere on its surface costs about half as much.itemfn,keyand the maps inside items must read time and mutable data through signals, or they show what they read at the last build (when items rebuild). - A node reads each
childrenorchildtable once and keeps it while it holds that table, so a pass’s property reads outsidelistbuilds cost about 40% less. A node table orchildrenarray changed in place is no longer seen; bind a signal or:seta new table (gotchas). - A node keeps the properties it resolved until a signal they read is written, instead of running
every getter on its surface on every write: a clock tick re-reads the clock’s node, not the bar.
A map,
computedor functionchildthat readsos.date()with no time,os.time(), a mutable variable or a file keeps its last answer until a signal it read changes. Derive time frommantle.system:mantle.system:map(function(s) return s and os.date("%H:%M", s.time) or "" end)(what a node reads again). - A write one
listitem read, such as itshover, builds that item alone instead of every item: 4.5 ms instead of 16 ms a pass on 500 rows (when items rebuild). - A wheel over a container whose
scrollsignal no getter,maporlistbuild reads moves its children without a layout pass: 0.06 ms instead of 2.4 ms on 500 rows. - Breaking: each capability action is a method, and
:invokeis gone:mantle.audio:set_volume(0.5)replacesmantle.audio:invoke("set_volume", 0.5). The editor stubs type each action’s own arguments, somantle.audio:set_muted(0.5)is flagged. An unknown name raises, listing the actions the capability has, as does any action on a capability with none; a.call in place of:raises instead of sending a wrong argument (actions). - A state field read off a capability raises
did you mean mantle.audio:get().volume?, and a misspelled action names the one it is close to. - A misspelled node or surface property raises “did you mean
content?” instead of listing every property the kind takes; the list stays for a key close to none. mantle setandmantle togglewait for the shell and exit 1 with its reason when it refuses the write (CLI).- A tween advances on its own surface’s frame callbacks, so a surface animates at its output’s refresh rate instead of the fastest animating output’s (animation).
mantle checklays the config out on stand-in outputs and fails on a layout error, and says when the stubsmantle initwrote are out of date.- A pass that fails names every broken node, one per line, in
mantle check, the log andmantle.rescue, instead of stopping at the first. It lists 20, then counts the rest (what check covers). Achildrenentry that is not a node readschildren[1]: expected a node table, counted from 0 like the rest of the path. mantle checklays out a second time after one sample push per capability, every list one entry long, so an error in a listitemfnor a data-only branch fails the check; each error names its pass (what check covers).- Errors name files relative to the config directory (
widgets/bar.lua:4, not a path Lua cut to...2b41-2949-.../bar.lua:4), includingrequired modules. A layout error’s path names the line that built each node (row[0] (shell.lua:7) > ...), a failing:maporcomputedthe line that created it (signal created at shell.lua:3), and tracebacks drop the engine’s own frames.mantle checkprints the config directory once instead ofshell.lua: shell.lua failed to evaluate(CLI). - The
.luarc.jsonfrommantle initwarns on unused locals and on thetype-check,unbalanced,strictandglobaldiagnostic groups in every file. mantle.rescueis set when a reload, or a live update, fails to apply, and clears only when a scene applies. Errors raised in callbacks, failed spawns and failedmantle calls are warnings, and a missing icon or undecodable image warns once per name.- The editor stubs flag a misspelled property or table key and a percent that is not a whole
"0%"to"100%", and typechildrenas taking a signal, as the engine does. - The editor stubs type a capability’s
:get()asT?, so an unguarded read of a field warns;mantle.screensandmantle.rescuestay non-nil. Ananimatekey the node does not take (orz), and an unknown key in ananimateentry,{ steps = n },session_process,persistent_tableorpalette.quantizeoptions, are flagged. Each node’sanimateis typed by its own alias (RectAnimations,TextAnimations, …); a wrapper that passesanimatethrough types it with that alias. A capability orscroll(...)handle passes where aSignalor ascrollproperty is declared. translate,scale,rotateandorigintweens repaint without relayout.- Hover callbacks fire on pointer entry.
- A
nilor non-signalcomputeddependency raises naming its index,computed() dependency 2 is nil; ...; before, a hole dropped every dependency after it. A named key in the list raises too. timer’smsis anumber:timer(1.5, fn)runs, andtimer(-1, fn)or a NaN raisestimer(-1) is outside 1..=86400000 milliseconds, not mlua’serror converting Lua integer to u64.mantle.idle:register_thresholdoutside1..=4294967seconds raises naming the range; before, a negative one got mlua’s conversion error and a huge one was clamped.cancel_threshold(-1)is a no-op like any unknown handle.- A
nilin the returned surface list raisessurface 2 is nil: ...; before, it dropped every surface after it. A named key in the list (return { bar, cfg = x }) raises instead of being ignored. - An equal capability snapshot is not pushed again, except
trayandnotifications. - A bad
layer,corner_shape,keyboard_interactivity, popupanchororgravity,constraint_adjustmententry or easing name fails with one wording that lists every choice:expected one of `Background`, `Bottom`, `Top`, `Overlay`, got ….
Roadmap
Ordering is intent, not a schedule. The docs hold what exists,
DECISIONS.md why. Rust owns platform connections, validation, secrets,
resource lifetimes, input and rendering; Lua owns composition, appearance and orchestration. A
feature one config lacks is not an engine gap.
Next
Defects or missing pieces a config cannot work around.
| Item | Why / what’s left | ADR |
|---|---|---|
expected_revision is unchecked | The socket drops a frame from another generation, but nothing reads the revision it claims, so it is no authorization guarantee. Settle stale-revision semantics before anything relies on it | — |
| Keyboard focus and accessibility | Only textfield holds focus; Tab reaches the config as on_navigate("tab"). Needs focusable controls, keyboard activation and an accessibility tree | — |
Blocking dofile / loadfile | The base library keeps both, and they read files on the Wayland thread outside the CPU budget, against ADR-0048’s intent. Remove them or route them through require’s resolver | 0048 |
| HiDPI | Paint scale is fixed at 1.0, so every surface on a scaled output is upscaled and soft. Needs set_buffer_scale (or fractional-scale plus viewporter) with a matching EGL resize and glyph raster scale | — |
| Silent failures | An invalid stop_signal is dropped with a log line; mantle set on a state a reload removed still passes. Each should reach the author | — |
| Hyprland layout switch | keyboard/layout.rs sends switchxkblayout, which Hyprland 0.56’s Lua socket likely rejects; the other Hyprland writes already use hl.dsp.* | — |
| Multi-prompt PAM | The worker relays every masked prompt, but LockState and secure_submit carry one password, answered to every prompt. Fingerprint, 2FA and expired passwords fail. Echo-on prompts stay refused | 0241 |
Later
Wanted, but each needs a consumer or a decision first.
| Item | Why / what’s left | ADR |
|---|---|---|
| Greeter | Mantle as a greetd client under cage or sway. Needs multi-prompt PAM and a session-launch command | — |
| Drawing | Gradients, mask, shadows and blurs exist; no config-facing paths, and no node masks another. Add the smallest set a real component needs; SVG covers static artwork, but its <text> draws nothing | 0254–0256 |
| Large lists | Every item up to limit is laid out on every pass and built again whenever anything it read changes, visible or not. Virtualization would need key to be mandatory, which cannot be enforced | 0191, 0219 |
| Output actions | windows has five actions; screens are read-only. Pick the actions, then settle niri/Hyprland differences and revert | 0119, 0247 |
| Service depth | MPRIS lacks stop, shuffle, repeat, rate and volume; audio has no per-channel levels or peak metering; UPower reads only DisplayDevice; network tracks only the first Wi-Fi device; Bluetooth pairing refuses PIN and passkey entry. Extend for concrete controls | — |
| External IPC | set/toggle answer only applied or refused; call returns only what the action returns. No generic state read or subscription | 0197 |
| Process control | Start, stream and signal exist. No child stdin, cwd or env | 0175, 0188 |
| Panel root sizing | A panel spanning an axis sizes the surface but not its root node, while window and lock roots fill theirs (forced_root_size). Decide whether panel roots fill too | — |
| Move transitions | A sibling closing a gap snaps. Needs the solver’s old and new rects per sibling | — |
| Text field editing | No undo, paste or IME; the secure field edits only at its end. On RTL or mixed lines a click lands one cluster off and the caret does not move inside a ligature | 0236 |
| Animated WebP and APNG | Only GIF animates; the others draw their first frame. AnimationDecoder covers both | 0233 |
| Localization | No translation API; desktop entry Name, GenericName and Keywords are read unlocalized | 0112 |
| Wayland and input extras | No shortcut inhibition, per-surface idle inhibition, touch gestures, cross-app drag and drop, pointer buttons past left, right and middle, or a click position inside a button. logind and ScreenSaver inhibition work | — |
| Window capture | capture takes an output. A window source would take windows ids | 0247, 0248 |
| Native I/O | No HTTP, sockets, watched file contents or json.encode; JSON storage and folder watching exist. Native only for a measured latency or volume need | — |
| KDE Connect | No device or plugin model. A capability or a streaming helper, not unrestricted D-Bus | — |
| Derived nested stub shapes | Gradient, GradientStop, Mask, Easing, Animation, Animations and Exit are still hand-written in nodes.lua’s header, since no struct holds their keys, so a parser change can leave them stale. Derive them with lua_shape! like Edges and Transition once their parsers fill one | — |
| Dynamic topology | A reload rebuilds only what changed. Revisit only if dynamic windows need a different lifetime | 0216 |
Won’t do
| Item | Instead | ADR |
|---|---|---|
| Weather, currency or geolocation capabilities | process.run with an HTTP CLI, then json.decode | — |
| Native FFT | Stream Cava output into state and draw it | — |
| Clipboard capability | process.detach("wl-copy", { text }): a selection needs a process that stays alive to serve it | 0188 |
| Video encoding | A recorder under session_process, driven from config | 0175 |
| Global input capture | An external input backend, streamed in | — |
| Wallpaper capability | A Background panel, an image with async/retain/transition, files 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 | — |
| Per-panel IPC commands | mantle set, toggle and call | — |
| Deferred surface loader | Wayland objects are created when shown; the 5 ms cap guards one signal resolve, not a whole evaluation | 0157 |
| Shaders over a subtree or as a persistent filter | image.transition and the input-less shader node keep a stable contract. Fixed blur or shadow is content_blur and shadow_* | 0184, 0253, 0254 |
| Display manager (PAM as root, sessions, seats) | greetd; see Greeter | — |
| X11 or 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 doc_examples | 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 both layout passes as mantle check: no Wayland, no subprocesses,
every capability nil and then one sample push each, one 704x396 output named DP-1. The test
fails on a Lua error, a check error or a layout error, and names the block by file:line. Blocks
inside > quotes count too.
| Info 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:
button {
padding = 6,
on_click = function() print("clicked") end,
children = { text { content = "Click" } },
}
Evaluation accepts this width, but layout refuses it:
rect { width = "Wide", height = 10 }
A module file that only makes sense beside another one:
local clock = require("widgets.clock")
return panel { id = "bar", layer = "Top", child = clock }
return { require("bar") } -- fails
local bar = require("bar")
return { bar } -- works
Screenshots
A lua,shot block also renders, headless over EGL, and must match its committed image,
docs/images/<section>/<page>-<n>.png, the page’s n-th shot. tools/book_links.py puts the image
under the block on the site, so a page never links it. Tag the example a reader wants to see; a
block has to return a node or surfaces.
| Part | Rule |
|---|---|
| Image | Every visible surface stacked top to bottom 8 px apart, each popup where its anchor_rect, anchor, gravity, offset and SlideX put it on its parent, at scale 1 over Catppuccin Mocha crust #11111b. Cropped to the painted pixels plus 16 px; a shot that paints nothing fails |
| 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. frames=0,50,120 lists them |
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 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 Rust payload types.
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/lua/nodes/properties.rs, the same fields lua-meta/nodes.lua
and surfaces.lua come from and the engine parses through. just stubs rewrites what sits between
two markers inside the page and leaves the rest alone:
<!-- Generated from renderer/src/lua/nodes/properties.rs by `just stubs`: edit the table there. -->
<!-- End of the generated table. -->
| 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
One or two sentences on what the page is for, a small complete example, reference tables, a
How do I… table (Task | Answer), a Gotchas table (Trap | Fix), See also, then a Source:
line of code links. Each fact lives on one page; link to it elsewhere. Links to code go out of
docs/ as relative paths (../../renderer/src/... from docs/<dir>/x.md); the book turns them
into GitHub links and fails the build on a missing target.