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
$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
enddef 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.