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