Sway typeahead command palette for the memory constrained

be_erik1 pts0 comments

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

sway command section keys bindings mode

Related Articles