A script multiplexer
I had a bunch of dmenu scripts with a keybinding for each one, and I could never remember which key was which.
- A menu is just an executable script which prints lines.
- cmenu runs the scripts, they don't run cmenu.
- One keybinding for all of them - scripts are picked by prefix,
bfor bluetooth, oron-startfor none at all. Or by name with#<name>, or by cycling with Shift+Left / Shift+Right. - Several scripts can be shown at once, in one list, each with its own colour.
- Because cmenu runs them, scripts can be re-run - on an interval, after a selection, or with Ctrl+r. One script can even trigger another.
- Optional preview pane, filled by the same script - called again with the selected line, printing text or an image.
- Nothing is bundled - write your own, or copy someone else's and change it.
A script is more than a one-shot pipe:
- It can be re-run while the menu is open, so the lines stay live.
- It can be triggered by another - selecting a device in the audio menu reloads the bluetooth menu.
- It gets the selected line back as
$1, instead of you parsing dmenu's stdout.
Install from source with Go and $ go install go.senan.xyz/cmenu@latest.
cmenu is a terminal program, so it's launched in a terminal of its own, and the compositor floats that window. Give the terminal an app ID so it can be matched, then bind it to something nice.
Sway:
bindsym Mod4+space exec foot --app-id cmenu cmenu
for_window [app_id="cmenu"] floating enable, resize set 1000 600, border none
Hyprland:
bind = SUPER, space, exec, foot --app-id cmenu cmenu
windowrule = float, class:cmenu
windowrule = size 1000 600, class:cmenu
Other terminals use a different flag for the same thing - kitty --class cmenu, alacritty --class cmenu, wezterm start --class cmenu. On X11 window managers, match on the class instead of the app ID.
It also runs nice in a normal terminal or a tmux pane, which is handy while writing a script.
Config lives in $XDG_CONFIG_HOME/cmenu/config.toml, and is a list of scripts.
[[scripts]]
triggers = ["on-start", "pre b", "script audio", "interval 750ms"]
name = "bluetooth"
path = "menu-bluetooth"
colour = 3
stay_open = true
preview = true
[[scripts]]
triggers = ["pre pw", "pre pass"]
name = "pass"
path = "menu-pass"
colour = 12
columns = [2, 3, 4]
preview = true| Key | Description |
|---|---|
triggers |
When to show and load this script, see triggers |
name |
Name shown in the gutter, and referenced by script triggers |
path |
Path to the script, looked up in $PATH if not absolute |
colour |
Terminal colour (0-15) for the script's lines |
columns |
Which tab-separated columns of each line to display, e.g. [2, 3] |
stay_open |
Keep cmenu open after running a selection, and reload the script |
preview |
Run the script in preview mode for the selected line |
debounce |
How long to wait for typing to settle before reloading, e.g. "300ms" |
| Trigger | Description |
|---|---|
on-start |
Show when cmenu opens with no prefix typed |
pre <prefix> |
Show when the input starts with <prefix>, e.g. pre b |
script <name> |
Show and reload when script <name> runs a selection |
interval <dur> |
Reload every <dur> while visible, e.g. interval 750ms |
| Key | Description |
|---|---|
| Enter | Run the selected line |
| Shift+Enter | Run it, but keep cmenu open |
| Ctrl+r | Reload the selected script |
| Up / Down | Move |
| Shift+Up / Shift+Down | Jump between scripts |
| Shift+Left / Shift+Right | Cycle which script is shown |
| Escape / Ctrl+c / Ctrl+d | Quit |
The whole thing is two calls:
$ menu-radio # no args, print the lines
$ menu-radio "<line>" # one arg, act on the selected lineThat's a working menu. Everything below is extra, and there are complete examples below.
With preview = true, the script is called with the selected line again, but with CMENU_MODE=preview, and whatever it prints goes in the side pane. $CMENU_PREVIEW_COLS and $CMENU_PREVIEW_LINES give the size of the pane.
if [[ "$CMENU_MODE" = "preview" ]]; then
...
fiMost scripts need no input at all - they just print their lines, and what you type filters them.
But some scripts can't print anything until you've told them what you want - a calculator, a search against a server, a chat assistant. For those, text typed inside [ ] is passed to the script as $CMENU_INPUT, and re-runs it:
c [1+34]-cpicks the calculator menu, which is called withCMENU_INPUT=1+34and prints the result.m [deepchord] album-mpicks the subsonic menu, which searches the server fordeepchord, andalbumfilters those results down to the album lines.
Text outside the brackets filters the lines cmenu already has. Text inside them runs the script again, so give bracket scripts a debounce to keep that off every keystroke.
A script doesn't need a prefix. If the input starts with #, the next word is a script name, and only that script is shown:
#bluetooth- show the bluetooth script, whatever its triggers are.#radio jazz- show the radio script, filtered byjazz.
Shift+Left / Shift+Right cycle through every script in config order, rewriting the input as #<name>, so the footer is walkable without remembering any prefix.
Lines are plain text, tab-separated if you want columns. A few markers are available as subcommands, printed as part of a line:
| Command | Description |
|---|---|
cmenu highlight |
Mark this line as current, e.g. the connected device |
cmenu label |
Mark this line as a non-selectable label |
cmenu stay |
Keep cmenu open after running this line |
cmenu image <path> |
In preview mode, render an image instead of text |
cmenu image - |
Same, reading the image from stdin |
menu-radio - highlights the playing station, previews it with ffprobe
#!/usr/bin/env bash
pidfile="$XDG_RUNTIME_DIR/menu-radio.pid"
if [[ "$CMENU_MODE" = "preview" ]]; then
ffprobe -hide_banner "$RADIO_DIR/$1" 2>&1
exit 0
fi
[[ -f "$pidfile" ]] && IFS=$'\t' read -r current_pid current_station <"$pidfile"
if [[ "$#" -gt 0 ]]; then
kill "$current_pid" 2>/dev/null
[[ "$1" = "$current_station" ]] && exit
mpv --no-video --quiet "$RADIO_DIR/$1" &
echo -e "$!\t$1" >"$pidfile"
exit
fi
highlight="$(cmenu highlight)"
find "$RADIO_DIR" -maxdepth 1 -type f -printf "%f\n" | sort | while read -r station; do
pre=""
[[ "$station" = "$current_station" ]] && pre="$highlight"
printf '%s%s\n' "$pre" "$station"
donemenu-calc - needs $CMENU_INPUT, has nothing to print without it
#!/usr/bin/env bash
if [[ "$#" -gt 0 ]]; then
wl-copy "$1"
exit
fi
[[ -z "$CMENU_INPUT" ]] && { echo "$(cmenu label)type an expression in [ ]"; exit; }
result="$(awk "BEGIN { print $CMENU_INPUT }" 2>/dev/null)"
[[ -z "$result" ]] && exit
echo "$result"With debounce = "300ms" so it isn't run on every keystroke.
More examples from the author, to copy and change:
https://github.com/sentriz/dotfiles/tree/master/conf_desktop/.local/bin/desktop/menus
Why a terminal program and not a GUI?
The terminal already handles fonts, colours, images, and window rules, and scripts already speak stdout.
Is this like Raycast, Alfred, rofi?
It's similar, but there are no extensions or integrations - cmenu only renders lines from your scripts, and re-runs them.
How do I hide the ID column I use for lookups?
Print it as a tab-separated column and use columns to display only the ones you want, e.g. columns = [2, 3]. Your script still gets the full line back in $1.
