A Command Palette for Sway - Nilcoast 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<br>#!/usr/bin/env ruby<br># frozen_string_literal: true<br># sway-palette - searchable command palette / chord cheatsheet for sway<br># always up to date<br># Usage:<br># sway-palette fuzzel overlay (bind $mod+slash or ctrl-p or ...)<br># sway-palette --list plain aligned table to stdout (this is the parsing)
require "json"
MODIFIERS = {<br>"Mod4" => "Super",<br>"Mod1" => "Alt",<br>"Mod5" => "AltGr"<br>}.freeze
Chord = Struct.new(<br>:section,<br>:keys,<br>:command, :executable, keyword_init: true)
def live_config<br>raw = `swaymsg -t get_config`<br>abort "sway-palette: no sway IPC (is sway running?)" unless $?.success?
JSON.parse(raw).fetch("config")<br>end
# Walk the config top to bottom, tracking the current `### Section` header and<br># any `mode "x" {` block, collecting bindsym lines as we go.<br>def parse(config)<br>vars = {}<br>section = "General"<br>mode = nil<br>bindings = []
config.each_line do |raw|<br>line = raw.rstrip
# in: "set $mod Mod4" ex: "bindsym $mod+q kill"<br>if (m = line.match(/^\s*set\s+(\$\S+)\s+(.+)$/))<br>vars[m[1]] = unquote(m[2])<br>next<br>end
# in: "### Focus" ex: "# a plain comment"<br>if (m = line.match(/^###\s+(.+)$/))<br>section = strip_note(m[1])<br>next<br>end
# in: 'mode "resize" {' ex: 'bindsym $mod+r mode "resize"'<br>if (m = line.match(/^\s*mode\s+"([^"]+)"\s*\{/))<br>mode = m[1]<br>next<br>end<br>if mode && line =~ /^\s*\}/<br>mode = nil<br>next<br>end
chord = parse_binding(line, vars:, section: mode ? "#{mode} (mode)" : section)<br>bindings chord if chord<br>end
collapse_numeric_runs(bindings)<br>end
def parse_binding(line, vars:, section:)<br># in: "bindsym $mod+q kill" ex: "set $mod Mod4"<br>m = line.match(/^\s*bindsym\s+((?:--\S+\s+)*)(\S+)\s+(.+)$/)<br>return nil unless m
flags, keys, command = m.captures<br>return nil if flags.include?("--release") # release pairs are duplicate no-ops; noise here
Chord.new(<br>section:,<br>keys: pretty_keys(expand(keys, vars:)),<br>command: expand(command, vars:).strip,<br>executable: true,<br>end
def unquote(value)<br>value.gsub(/\A"|"\z/, "") # in: '"3CLD984"' -> 3CLD984 ex: Mod4 (left alone)<br>end
def strip_note(title)<br>title.sub(/\s*\(.*\)\s*$/, "") # in: "resize (mode)" -> "resize" ex: "Focus"<br>end
def expand(text, vars:)<br>text.gsub(/\$\w+/) { |var| vars[var] || var } # in: "$mod+q" -> "Mod4+q" ex: "Return"<br>end
def pretty_keys(keys)<br>keys.split("+").map { |part| MODIFIERS[part] || part }.join("+")<br>end
# let's include all workspaces<br>def collapse_numeric_runs(bindings)<br>bindings.chunk_while { |a, b| same_numeric_run?(a, b) }.flat_map do |group|<br>group.size >= 3 ? [fold_run(group)] : group<br>end<br>end
def same_numeric_run?(a, b)<br>a.section == b.section &&<br>masked(a.keys) == masked(b.keys) &&<br>masked(a.command) == masked(b.command)<br>end
def masked(text)<br>text.gsub(/\d+/, "#") # in: "$mod+1" -> "$mod+#" ex: "$mod+Return"<br>end
def fold_run(group)<br>first = group.first<br>Chord.new(<br>section: first.section,<br>keys: first.keys.gsub(/\d+/, "N"),<br>command: first.command.gsub(/\d+/, "N"),<br>executable: false, # which number would we pick?<br>end
def rows(bindings)<br>section_width = bindings.map { |b| b.section.length }.max<br>keys_width = bindings.map { |b| b.keys.length }.max<br>bindings.map do |b|<br>[format("%-#{section_width}s %-#{keys_width}s %s", b.section, b.keys, b.command), b]<br>end<br>end
def run_fuzzel(bindings)<br>table = rows(bindings) # fzf for wayland<br>chosen = IO.popen(<br>["fuzzel", "--dmenu", "--font=monospace:size=11", "--width=90",<br>"--prompt=chord ", "--lines=20"],<br>"r+",<br>) do |io|<br>io.write(table.map(&:first).join("\n"))<br>io.close_write<br>io.read<br>end.to_s.strip<br>return if chosen.empty?
chord = table.find { |line, _| line == chosen }&.last<br>system("swaymsg", chord.command) if...