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.