dankMenu

September 1, 2026 · View on GitHub

One key to every command on the machine — a hierarchical, searchable root menu for DankMaterialShell, in the shape of Omarchy's Super+Space menu.

dankMenu root

Type instead of navigating, and it searches every command below the level you're on — with a breadcrumb telling you where each result lives:

searching from the root

Credits

The design is Omarchy's, by Basecamp and its contributors (MIT). Omarchy binds SUPER+SPACE to omarchy-menu, a hierarchical menu covering apps, settings, toggles and power actions — the single entry point to the whole desktop.

This plugin reimplements that idea for DMS. It shares no code with Omarchy, but it deliberately keeps their menu file schema field-for-field, so subtrees of omarchy-menu.jsonc paste in and parse unchanged, and their when / checked / disabled semantics behave the same way. The IPC verbs mirror omarchy-menu's too, so routes and muscle memory carry over.

The schema is a superset, not a copy: labelCmd is an addition of ours. Omarchy menus still paste in unchanged — the extra field is simply absent from them — but a menu using labelCmd will not work in Omarchy.

Go try the real thing if you're on Arch — it's very good.

Why a window instead of a spotlight plugin

DMS already supports type: launcher plugins that add rows to its spotlight, so that looks like the obvious way to build this. It cannot work: DMS's launcher controller emits itemExecuted after every selection and the spotlight modal hides itself on that signal unconditionally, with no hook for a plugin to prevent it. "Enter descends into a submenu" is therefore impossible there — selecting a row always closes the launcher.

So dankMenu is a daemon plugin owning its own wlr-layershell window, and its search, ranking and app list are its own code. Two things are borrowed from the shell rather than rebuilt: SessionService.launchDesktopEntry, so apps started from the menu land in the right systemd scope instead of being reparented to the shell process, and the launcher plugins themselves — see below.

Install

Requires DMS ≥ 1.5.0. Nothing else — the plugin is QML and JavaScript, there is no build step, and it references no path above its own root.

Any distro, from the DMS plugin registry:

dms plugins install dankMenu

Then enable it in DMS: Mod+, → Plugins → enable Dank Menu. dms plugins list confirms the shell has picked it up, and dms plugins update dankMenu pulls a newer version later.

From a clone instead — for hacking on it, or pinning it to the repo

The directory name must match plugin.json's id, dankMenu. Symlinking the clone means the plugin updates with a git pull:

git clone https://github.com/sitolam/dms-plugins ~/src/dms-plugins
mkdir -p ~/.config/DankMaterialShell/plugins
ln -s ~/src/dms-plugins/plugins/dankmenu ~/.config/DankMaterialShell/plugins/dankMenu

Or copy the directory, if you don't want the clone lying around:

git clone --depth 1 https://github.com/sitolam/dms-plugins /tmp/dms-plugins
cp -r /tmp/dms-plugins/plugins/dankmenu ~/.config/DankMaterialShell/plugins/dankMenu

On NixOS, via home-manager. dms plugins install writes into ~/.config, which a declarative setup usually doesn't want, so take the plugin as a flake package instead:

programs.dank-material-shell.plugins.dankMenu = {
  enable = true;
  src = inputs.dms-plugins.packages.${pkgs.system}.dankmenu;
};

Note that with managePluginSettings = true, plugin_settings.json becomes a read-only store symlink — the plugin must be enabled declaratively as above, because the DMS settings GUI cannot write to it.

Finally, bind it. For niri:

binds {
    Mod+Space { spawn "dms" "ipc" "call" "dankMenu" "toggle" "root"; }
}

For Hyprland:

bind = SUPER, SPACE, exec, dms ipc call dankMenu toggle root

For Sway:

bindsym $mod+space exec dms ipc call dankMenu toggle root

For anything else — river, Wayfire, a keybind daemon, a script: bind that same shell command. It is an ordinary one-shot process with no compositor-specific parts, so whatever can run a command can open the menu.

Usage

dms ipc call dankMenu toggle          # open at the root, or close if open
dms ipc call dankMenu open system     # open straight into a submenu
dms ipc call dankMenu open power-menu # aliases work too
dms ipc call dankMenu close
dms ipc call dankMenu refresh         # re-read the menu file

Because open takes a route, any submenu can have its own keybind — a power menu on Mod+Escape is just dms ipc call dankMenu open system.

keyeffect
Enterenter a submenu, or run a row and close
Rightenter a submenu, when the cursor is at the end of the query
Escapeup one level; at the root, close
Left / Backspaceup one level, when the query is empty
Up / Downmove the selection
any textsearch this level's whole subtree

vim bindings

Every one is Ctrl-prefixed. Bare hjkl cannot navigate here: the search field is always focused and always accepting a query, so plain letters have to reach it as text.

keyeffect
Ctrl+J / Ctrl+Kdown / up (Ctrl+N / Ctrl+P also work)
Ctrl+L / Ctrl+Hin / out — enter a submenu, or go up a level
Ctrl+D / Ctrl+Uhalf a page down / up
Ctrl+Gclose the menu outright, from any depth

Going back out of a submenu returns the highlight to the row you entered through, not to the top of the list.

Configuring the menu

The whole menu is one JSONC file. The plugin ships menu.jsonc as a starting point; point the menuPath setting at your own file to replace it entirely. Whichever file is live is watched, so saving it updates the menu immediately — no shell restart, no refresh call.

Set menuPath in DMS under Mod+, → Plugins → Dank Menu, or declaratively:

programs.dank-material-shell.plugins.dankMenu.settings.menuPath = "/home/you/.config/dankmenu.jsonc";

The file format

JSONC — JSON with // comments and trailing commas, the same dialect Omarchy uses. Object keys are dotted ids, and the dots are the hierarchy: setup.network.dns is a child of setup.network, which is a child of setup. Declaration order is display order.

{
  // A submenu: no action, no target, no provider.
  "system": {"icon":"power_settings_new","label":"System","aliases":["power-menu"]},

  // An action: runs a shell command, then closes the menu.
  "system.lock": {"icon":"lock","label":"Lock","action":"loginctl lock-session"},

  // A link: opens in your browser.
  "learn.niri": {"icon":"grid_view","label":"Niri","target":"https://github.com/YaLTeR/niri/wiki"},

  // A provider: contents generated at open time. "apps" is the only one so far.
  "apps": {"icon":"apps","label":"Apps","provider":"apps"},
}

The kind of a row is inferred, never declared: action makes it an action, target a link, provider a provider-backed submenu, and anything else a plain submenu.

Fields

fieldmeaning
labelthe row's text. Defaults to its id.
icona Material Symbols name (wifi, school). App rows use the icon theme instead.
iconFontfont family for the glyph, when it isn't the menu font
titleheader text when the submenu is open. Defaults to label.
aliasesextra names for open <route>, and extra search terms
actionshell command, run through bash -lc
targetURL, opened externally
providergenerated contents — currently only apps
whenhide the row unless this succeeds
checkedappend a ✓ when this succeeds
disableddim the row and block selection when this succeeds
labelCmdreplace the row's label with this snippet's output

Actions go through bash -lc, so pipes, $(…), && and quoting all work — menu actions are shell text, not argv.

Conditions

when, checked and disabled are shell snippets, judged by exit status. They make the menu reflect the machine rather than just describe it:

// Ticked while night mode is actually on.
"trigger.toggle.night": {
  "icon":"nightlight","label":"Night Mode",
  "checked":"dms ipc call night status | grep -q enabled",
  "action":"dms ipc call night toggle"
},

// Absent entirely on a desktop.
"trigger.toggle.battery": {
  "icon":"battery_std","label":"Battery Percentage",
  "when":"test -d /sys/class/power_supply/BAT0",
  "action":"dms ipc call bar toggle"
},

conditions in action

Every snippet for one menu level runs in a single shell, not one process per row, so a level with a dozen conditions costs one spawn. Rows stay visible while results are pending, so a slow condition delays a tick rather than making rows flicker in and out of the list.

Live labels

labelCmd is the odd one out: it is judged by its output, not its exit status. Its first line of stdout becomes the row's label, which is the only way to put a live value in front of someone — the menu tree is a static file, so label alone can never show a number that changes.

// Reads "Memory  2.1GiB / 4GiB" rather than "Memory".
"windows.memory": {
  "icon":"memory",
  "label":"Memory",
  "labelCmd":"docker stats --no-stream --format '{{.MemUsage}}' windows | sed 's/^/Memory  /'",
  "disabled":"true"
},

Details worth knowing:

  • Output is clamped to one line and stripped of tabs, because it travels as one field of a tab-separated record.
  • stderr is discarded. A warning from the snippet would otherwise be rendered as the label.
  • Empty output falls back to the static label, so a snippet that fails leaves a sensible row rather than a blank one. Give every labelCmd row a label worth falling back to.
  • It is a snapshot, taken when the level opens — like the other three conditions. It is not a running meter; reopen the level to refresh.
  • Keep it fast. Every snippet for a level runs in one shell, and the level waits on all of them.

Conditions are re-evaluated every time you enter a level, so a tick is never stale. While a search is active they cover the whole subtree being searched.

Apps

The apps provider lists your desktop entries, ranked by the plugin's own fuzzy scorer plus a frecency boost. At the root, a search covers apps too — so one keystroke sequence finds either a command or a program.

the apps provider

Launcher plugins

Your type: launcher plugins — calculator, emoji, whatever else you have installed — work here too, under the same triggers they use in spotlight:

=12*30      calculator
;shrug      emoji

a calculator answer in the menu

The trigger is honoured at any level, not just the root: the prefix is explicit intent, and the level you are standing in has nothing to say about an arithmetic expression. A plugin's rows come back in the plugin's own order, unranked — this menu's scorer would throw away a calculator result whose label (360) shares no letters with the query that produced it.

A plugin set to always active in DMS (its trigger cleared) needs no prefix. It answers every root search, and its rows sit above the apps and commands.

Nothing here is configured in dankMenu: the list of plugins, their triggers and their settings are DMS's, under Mod+, → Plugins, and a change there takes effect in the menu immediately. Selecting a plugin row runs it through DMS, which is what puts a calculator result on the clipboard.

Plugins are asked for rows only while the menu is open and only for the query as typed, so an unused plugin costs nothing.

Generating the tree

Because menuPath is just a path, the file can be generated. On NixOS that means menu rows can reference store paths and your own flake:

{ id = "update.rebuild"; icon = "build"; label = "Rebuild";
  action = "ghostty --working-directory=${flakeDir} -e just rebuild"; }

Emit it as ordered JSONC text rather than via pkgs.formats.json — Nix serialises attrsets alphabetically, and menu rows have a meaningful order. A power menu reading "lock, logout, reboot, shutdown, suspend" is not the one anybody wants. See sitolamix's plugins.nix for a full worked example.

Development

nix develop ../..
qmltestrunner -input tests/tst_menumodel.qml
qmltestrunner -input tests/tst_search.qml
qmltestrunner -input tests/tst_conditions.qml
qmltestrunner -input tests/tst_plugins.qml

qmltestrunner takes one -input per run and exits with the failure count. nix flake check runs all four headless.

MenuModel.js (JSONC parsing, tree building, route resolution), Search.js (scoring and ranking), Conditions.js (script generation and output parsing) and Plugins.js (trigger detection, plugin items to menu rows) import no QML types at all, so the tests exercise the exact files the plugin loads rather than copies of them. The QML files are thin shells over those four.

fileresponsibility
DankMenuDaemon.qmlIPC handler, menu file loading, window lifecycle
MenuWindow.qmlthe layershell window, keys, row building
MenuList.qmllist and row delegate
Conditions.qmlruns the generated condition script
AppSource.qmldesktop entries in, menu rows out
PluginSource.qmlDMS launcher plugins in, menu rows out

License

GPL-3.0-only — see ../../LICENSE.