A Command Palette for Sway

www.nilcoast.com · 2026-06-29 · by Long Nguyen ·generic
Erik Benoist - Software engineer building platforms for 20+ years. Chicago-based. Focusing on product and DevOps.

A Command Palette for Sway

tmux never stuck for me. I wanted to like it, but could never get it under my fingers. Zellij is a new(er) multiplexer that did ultimately find its way into my daily workflow. I wouldn’t want to work without it.

Zellij has a live command cheat sheet at the bottom of the screen by default. It’s like vim with arrow keys (why wouldn’t you?!).

Sway is a logical cousin for the UI. It does for my windows what zellij does for my shell sessions. Sway doesn’t come with a cheat sheet. I could print one and hang it next to the laminated Perl “cheat sheet” I actually owned— Borders, somewhere around 1997.

One of the reasons I told my self that using Sway would be a great idea was its ability to be controlled by a set of composable cli driven tools. Let’s see if we can use fuzzel (fzf for the WM) to create a command pallet that rivals Zellij’s.

Sway Palette

The sway-palette overlay open over a zellij session, listing every sway keybinding grouped by section, with zellij's own keybinding bar along the bottom of the screen.

$mod+/ over a zellij session— every chord, grouped, filterable. Zellij's palette is the bar along the bottom.

Live Config

Sway’s endless customization is exactly why reading the live config matters, and why it’s a pain. `swaymsg -t get_config` hands back JSON, but the payload is the whole config as one string. There’s no structured list of bindings to ask for, and it’s sway’s own grammar, not INI, so `jc` and friends can’t parse it. You walk the text yourself. Luckily LLMs are better at regexp than I am.

swaymsg -t get_config
#!/usr/bin/env ruby

frozen_string_literal: true

#

sway-palette - searchable command palette / chord cheatsheet for sway

#

always up to date

#

Usage:

sway-palette fuzzel overlay (bind $mod+slash or ctrl-p or ...)

sway-palette --list plain aligned table to stdout (this is the parsing)

require "json"

MODIFIERS = { "Mod4" => "Super", "Mod1" => "Alt", "Mod5" => "AltGr" }.freeze

Chord = Struct.new( :section, :keys, :command, :executable, keyword_init: true)

def live_config raw = `swaymsg -t get_config` abort "sway-palette: no sway IPC (is sway running?)" unless $?.success?

JSON.parse(raw).fetch("config") end

Walk the config top to bottom, tracking the current `### Section` header and

any `mode "x" {` block, collecting bindsym lines as we go.

def parse(config) vars = {} section = "General" mode = nil bindings = []

config.each_line do |raw| line = raw.rstrip

# in: "set $mod Mod4" ex: "bindsym $mod+q kill" if (m = line.match(/^\s*set\s+(\$\S+)\s+(.+)$/)) vars[m[1]] = unquote(m[2]) next end

# in: "### Focus" ex: "# a plain comment" if (m = line.match(/^###\s+(.+)$/)) section = strip_note(m[1]) next end

# in: 'mode "resize" {' ex: 'bindsym $mod+r mode "resize"' if (m = line.match(/^\s*mode\s+"([^"]+)"\s*\{/)) mode = m[1] next end if mode && line =~ /^\s*\}/ mode = nil next end

chord = parse_binding(line, vars:, section: mode ? "#{mode} (mode)" : section) bindings << chord if chord end

collapse_numeric_runs(bindings) end

def parse_binding(line, vars:, section:) # in: "bindsym $mod+q kill" ex: "set $mod Mod4" m = line.match(/^\s*bindsym\s+((?:--\S+\s+)*)(\S+)\s+(.+)$/) return nil unless m

flags, keys, command = m.captures return nil if flags.include?("--release") # release pairs are duplicate no-ops; noise here

Chord.new( section:, keys: pretty_keys(expand(keys, vars:)), command: expand(command, vars:).strip, executable: true, ) end

def unquote(value) value.gsub(/\A"|"\z/, "") # in: '"3CLD984"' -> 3CLD984 ex: Mod4 (left alone) end

def strip_note(title) title.sub(/\s*\(.\)\s$/, "") # in: "resize (mode)" -> "resize" ex: "Focus" end

def expand(text, vars:) text.gsub(/\$\w+/) { |var| vars[var] || var } # in: "$mod+q" -> "Mod4+q" ex: "Return" end

def pretty_keys(keys) keys.split("+").map { |part| MODIFIERS[part] || part }.join("+") end

let's include all workspaces

def collapse_numeric_runs(bindings) bindings.chunk_while { |a, b| same_numeric_run?(a, b) }.flat_map do |group| group.size >= 3 ? [fold_run(group)] : group end end

def same_numeric_run?(a, b) a.section == b.section && masked(a.keys) == masked(b.keys) && masked(a.command) == masked(b.command) end

def masked(text) text.gsub(/\d+/, "#") # in: "$mod+1" -> "$mod+#" ex: "$mod+Return" end

def fold_run(group) first = group.first Chord.new( section: first.section, keys: first.keys.gsub(/\d+/, "N"), command: first.command.gsub(/\d+/, "N"), executable: false, # which number would we pick? ) end

def rows(bindings) section_width = bindings.map { |b| b.section.length }.max keys_width = bindings.map { |b| b.keys.length }.max bindings.map do |b| [format("%-#{section_width}s %-#{keys_width}s %s", b.section, b.keys, b.command), b] end end

def run_fuzzel(bindings) table = rows(bindings) # fzf for wayland chosen = IO.popen( ["fuzzel", "--dmenu", "--font=monospace:size=11", "--width=90", "--prompt=chord ", "--lines=20"], "r+", ) do |io| io.write(table.map(&:first).join("\n")) io.close_write io.read end.to_s.strip return if chosen.empty?

chord = table.find { |line, _| line == chosen }&.last system("swaymsg", chord.command) if chord&.executable end

def print_list(bindings) rows(bindings).each { |line, _| puts line } end

bindings = parse(live_config) if ARGV.include?("--list") print_list(bindings) else run_fuzzel(bindings) end

Now `$mod+/` drops a searchable list of every chord I have, read straight from the config that defines them. Maybe now I’ll remember how to do a split?

Note: Prose is my own. An LLM was used to write the gnarly parts of this parser.

← Back to Blog

www.nilcoast.com · 1 images · 3 links

Comments (0)

No comments yet