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.