Custom Walker Menus with Lua

Posted on · 5 minute read

When I switched to Wayland early this year, I had to wave goodbye to my trusty Rofi and find a new application launcher. Enter Walker - a fast, customizable launcher that comes with a bunch of useful menus out of the box. The important bits - Desktop applications, file browsing, calculator - are all there.

But what about the less-important-but-still-kind-of-nice-for-my-workflow parts? What about custom menus? Of course you can build those!

Walker & Elephants

Walker is the pretty face you see on screen - the interface that displays menus and handles your input. But the interesting stuff happens behind the scenes with Elephant, Walker’s data provider backend.

Elephant is a service that runs in the background and provides all the data Walker displays - applications, files, clipboard entries, and so on. Each menu you see in Walker corresponds to an Elephant provider.

Now, if you wanted to create a completely custom data source, you could build your own Elephant provider. That’s what all the built-in menus are - small Go applications that implement the provider interface. But don’t you worry, you don’t need to know Go or mess with Protocol Buffers to create simple custom menus. You can build those using small Lua scripts instead.

The documentation for custom menus is a bit sparse and scattered between the official GitBook and the provider source code on GitHub (and probably other places I forgot about). Let me save you some digging and show you what you actually need to know.

Setting Up Custom Menus

First, you need the elephant-menus provider installed. Without it, none of your custom Lua scripts will be picked up by Elephant.

yay -S elephant-menus
systemctl --user restart elephant.service
systemctl --user status elephant.service

Next, update your Walker configuration to include the menus provider:

# ~/.config/walker/config.toml
[providers]
default = [
  "desktopapplications",
  "websearch",
  "menus", # Add the menu provider
] 

Your custom menus live in ~/.config/elephant/menus/. Elephant auto-discovers them, but you’ll need to restart the service whenever you add a new menu.

systemctl --user restart elephant.service

Building Your First Custom Menu

Let me show you a practical example. I use Tmuxinator to manage my Tmux sessions, and I wanted a quick way to launch them from Walker. Here’s what that looks like:

-- ~/.config/elephant/menus/tmuxinator.lua
Name = "tmuxinator"
NamePretty = "Tmuxinator"
Icon = "utilities-terminal"
Terminal = true
Cache = false

Action = "tmuxinator start '%VALUE%'"

function GetEntries()
	local entries = {}

  -- Path to your Tmuxinator executable
	local handle = io.popen("/usr/local/bin/tmuxinator list")
	local output = handle:read("*a")
	handle:close()

	local first_line = true
	for line in output:gmatch("[^\r\n]+") do
		if first_line then
			first_line = false
		else
			for project in line:gmatch("%S+") do
				table.insert(entries, {
					Text = project,
					Subtext = "Tmuxinator Session",
					Value = project,
					Icon = "utilities-terminal",
				})
			end
		end
	end

	return entries
end

You can probably tell, custom menus aren’t too complicated. You just need to adhere to the interface elephant expects:

  • Name/NamePretty: The identifier and display name for your menu
  • Icon: An icon name. Per default, Walker uses your system icon theme.
  • Terminal: Set to true if the action should run in a terminal
  • Cache: Whether to cache the results - useful for static data.
  • Action: The command to execute when an entry is selected. %VALUE% gets replaced with the selected entry’s Value field
  • GetEntries(): A Lua function that returns a table of menu entries

Also, each entry needs:

  • Text: The main display text
  • Subtext: Additional information shown below
  • Value: The value passed to the Action command
  • Icon: An optional icon for the entry

A More Complex Example

Here’s another menu I built to help me remember Hyprland keybindings. This one parses JSON output from hyprctl and even executes the shortcuts themselves when selected:

-- ~/.config/elephant/menus/keybinds.lua
local json = dofile(os.getenv("HOME") .. "/.config/elephant/utils/json.lua")

Name = "keybinds"
NamePretty = "Keybinds"
Icon = "preferences-desktop-keyboard-shortcuts"
Cache = false
Action = "hyprctl dispatch %VALUE%"

local function get_mods(modmask)
	local mods = {}
	if (modmask / 64) % 2 >= 1 then
		table.insert(mods, "󰘳")
	end -- Super
	if (modmask / 8) % 2 >= 1 then
		table.insert(mods, "󰘵")
	end -- Alt
	if (modmask / 4) % 2 >= 1 then
		table.insert(mods, "󰘴")
	end -- Ctrl
	if (modmask / 1) % 2 >= 1 then
		table.insert(mods, "󰘶")
	end -- Shift
	return mods
end

function GetEntries()
	local handle = io.popen("hyprctl binds -j")
	local output = handle:read("*a")
	handle:close()

	if not output or output == "" then
		return {}
	end

	local status, data = pcall(json.decode, output)
	if not status then
		return { { Text = "JSON Error", Subtext = tostring(data) } }
	end

	local entries = {}
	for _, bind in ipairs(data) do
		if bind.description and bind.description ~= "" then
			local mods = get_mods(bind.modmask)
			local key_icons = table.concat(mods, " ")
			local key_name = bind.key or ""

			local display_keys = key_icons
			if #mods > 0 then
				display_keys = key_icons .. " + " .. key_name
			else
				display_keys = key_name
			end

			table.insert(entries, {
				Text = bind.description .. " (" .. display_keys .. ")",
				Subtext = "Shortcut: " .. display_keys,
				Value = (bind.dispatcher or "") .. " " .. (bind.arg or ""),
				Icon = "preferences-desktop-keyboard-shortcuts",
			})
		end
	end

	if #entries == 0 then
		table.insert(entries, {
			Text = "No labeled binds found",
			Subtext = "Add descriptions to binds in hyprland.conf using 'bindd'",
			Value = "exec notify-send 'Hint' 'Use bindd in your config!'",
		})
	end

	return entries
end

A couple of things worth noting: You can’t require Lua modules the usual way. Instead, you need to use dofile() with an absolute path. I keep helper libraries like json.lua in ~/.config/elephant/utils/ and load them with dofile(os.getenv("HOME") .. "/.config/elephant/utils/json.lua").

Testing Your Menus

You can verify that Elephant picked up your custom menu without opening Walker:

elephant query "menus:tmuxinator;;10;false"
elephant query "menus:keybinds;;10;false"

This is useful for debugging. If the menu doesn’t show up, check the elephant service logs:

journalctl --user -u elephant.service -f

Once your menus are working, you probably want quick access to them. You can bind them to prefix triggers in Walker’s config:

# ~/.config/walker/config.toml
[[providers.prefixes]]
prefix = "!"
provider = "menus:tmuxinator"

[[providers.prefixes]]
prefix = "?"
provider = "menus:keybinds"

A walker menu for Hyprland Keybinds

The possibilities are pretty broad here. You could build menus for anything. Someone built a Iconify browse. Look here, someone built a session menu for Opencode. Let your imagination run wild.

If you can write a shell command that outputs data, you can turn it into a Walker menu.