#!/usr/bin/bash # # Show every keyboard shortcut in the session, and what each one runs. # # WHY THIS EXISTS # --------------- # COSMIC has no keyboard reference. cosmic-settings lists shortcuts in a # scrolling pane behind three clicks, which is where you go to *change* one, not # where you go to remember one mid-task. Hyprland desktops all ship a cheatsheet # on a key and a bar click for exactly that reason, and this is that. # # WHERE THE BINDINGS COME FROM, AND WHY NOT cosmic.conf # ----------------------------------------------------- # The obvious implementation reads cosmic.conf, and it would be wrong. This # machine's cosmic.conf declares six bindings. The session answers to 122. The # other 116 are COSMIC's own defaults, which the fork does not restate because # it has no reason to -- Super+Q closes a window whether or not anybody wrote it # down. A cheatsheet showing six entries would not look broken; it would look # complete, and it would be missing every window, workspace and media key on the # machine. That is a worse failure than not having the feature. # # So the sources are the three RON files the compositor actually reads: # # /usr/share/cosmic/.../Shortcuts/v1/defaults 116 COSMIC built-ins # ~/.config/cosmic/.../Shortcuts/v1/custom 6 ours, projected from # cosmic.conf by cosmic-conf # /usr/share/cosmic/.../Shortcuts/v1/system_actions # what System(X) actually runs # # custom wins over defaults on a collision, matching the order the compositor # merges them, so a rebound key is listed once with the binding in force. # # system_actions is what makes this answer the question that was asked. A # default reads `System(Screenshot)`, which names an action and not a program; # system_actions maps that to `cosmic-screenshot`. Without it a third of the # list would name internal actions rather than the applications they launch. # # THE PARSER # ---------- # These are RON files, and this reads them with a regex, which deserves a # defence. The alternative is a `cosmic-conf binds` subcommand, which would be # the principled home for this -- cosmic-conf already has the schema and the # bind grammar. That is a compiled change and a full package build, and this # feature does not need one: the files are one binding per line, and the shape # has not moved. The cost of being wrong here is a mangled row in a reference # window, not a broken keybinding, because nothing downstream consumes this. # # If the format does move, the failure is visible immediately and loudly: the # count check below refuses to show a list it could not parse. set -uo pipefail SYS_DIR=/usr/share/cosmic/com.system76.CosmicSettings.Shortcuts/v1 USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/cosmic/com.system76.CosmicSettings.Shortcuts/v1" command -v rofi >/dev/null 2>&1 || { printf 'hyprcosmic-keybinds: rofi is not installed\n' >&2 exit 1 } # Errors go to a rofi dialog as well as stderr: the two ways in are a keybinding # and a bar click, and neither has a terminal to read stderr from. Same reasoning # as hyprcosmic-powermenu. fail() { printf 'hyprcosmic-keybinds: %s\n' "$*" >&2 rofi -e "$*" >/dev/null 2>&1 || : exit 1 } [[ -r "$SYS_DIR/defaults" ]] || fail "cannot read $SYS_DIR/defaults, so the built-in shortcuts are unknown" # Resolve System(X) to the command it runs, as "Xcommand" pairs. # # system_actions carries /// doc comments and commented-out entries for actions # that exist in the enum but are not wired up (KeyboardBrightnessUp is one), so # this matches only lines that are a bare name followed by a quoted string. system_actions() { [[ -r "$SYS_DIR/system_actions" ]] || return 0 sed -nE 's/^[[:space:]]*([A-Za-z]+):[[:space:]]*"(.*)",?[[:space:]]*$/\1\t\2/p' \ "$SYS_DIR/system_actions" } # One binding per line, as "modifierskeyaction". # # Two shapes appear. The usual one carries a key: # (modifiers: [Super, Shift], key: "Escape"): System(LogOut), # and a modifier-only binding has none at all: # (modifiers: [Super]): Spawn("rofi -show drun"), # The second is how Super alone opens the launcher, so dropping it would lose # the single most used binding on the machine. bindings() { sed -nE \ -e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\],[[:space:]]*key:[[:space:]]*"([^"]*)"\):[[:space:]]*(.*),[[:space:]]*$/\1\t\2\t\3/p' \ -e 's/^[[:space:]]*\(modifiers:[[:space:]]*\[([^]]*)\]\):[[:space:]]*(.*),[[:space:]]*$/\1\t\t\2/p' \ "$1" } actions_tsv="$(system_actions)" # custom is read second so that its rows overwrite defaults in the map below. # Missing is normal, not an error: a session with no custom bindings has no such # file, and every default still applies. raw="$(bindings "$SYS_DIR/defaults")" if [[ -r "$USER_DIR/custom" ]]; then raw+=$'\n'"$(bindings "$USER_DIR/custom")" fi [[ -n "${raw//[[:space:]]/}" ]] || fail "parsed no shortcuts at all from $SYS_DIR/defaults; the file format has changed" rendered="$( LC_ALL=C awk -F'\t' -v actions="$actions_tsv" ' BEGIN { # System(X) -> command, from system_actions. n = split(actions, lines, "\n") for (i = 1; i <= n; i++) { if (split(lines[i], kv, "\t") == 2) cmd[kv[1]] = kv[2] } } # Turn CamelCase into words so an action reads as a description rather than # an identifier: MoveToWorkspace -> "move to workspace". Applied only to the # action name, never to a command, which must stay verbatim to be typed. function words(s, out) { out = s gsub(/([a-z0-9])([A-Z])/, "\\1 \\2", out) return tolower(out) } # Super is written first and Shift last, so that chords sort and read the # way they are pressed rather than the way the RON happened to list them. function order(m) { if (m == "Super") return 1 if (m == "Ctrl") return 2 if (m == "Alt") return 3 if (m == "Shift") return 4 return 5 } { mods = $1; key = $2; action = $3 if (action == "") next # "Super, Alt" -> "Super + Alt", in a fixed order. cnt = split(mods, m, /,[[:space:]]*/) for (i = 1; i <= cnt; i++) gsub(/^[[:space:]]+|[[:space:]]+$/, "", m[i]) for (i = 1; i < cnt; i++) for (j = i + 1; j <= cnt; j++) if (order(m[j]) < order(m[i])) { t = m[i]; m[i] = m[j]; m[j] = t } chord = "" for (i = 1; i <= cnt; i++) chord = chord (chord == "" ? "" : " + ") m[i] if (key != "") chord = chord (chord == "" ? "" : " + ") key # Spawn("cmd") is a command as written in cosmic.conf. # System(X) is an action name that system_actions turns into a command. # Everything else is internal to the compositor and has no command; the # action itself is the honest answer, and inventing a program name for # Focus(Left) would be a lie. what = "" if (action ~ /^Spawn\(".*"\)$/) { what = substr(action, 8, length(action) - 9) } else if (action ~ /^System\(/) { name = action sub(/^System\(/, "", name); sub(/\)$/, "", name) what = (name in cmd) ? cmd[name] : words(name) } else if (action ~ /\(/) { name = action; arg = action sub(/\(.*$/, "", name) sub(/^[^(]*\(/, "", arg); sub(/\)$/, "", arg) what = words(name) " " arg } else { what = words(action) } # Last write wins, which is why custom is appended after defaults. seen[chord] = what if (!(chord in orderkey)) orderkey[chord] = ++seq } END { for (c in seen) printf "%d\t%s\t%s\n", orderkey[c], c, seen[c] } ' <<<"$raw" | sort -n | cut -f2- )" count="$(grep -c . <<<"$rendered")" ((count > 0)) || fail "every shortcut line failed to parse; the RON format has changed" # Column-aligned so the chords form a readable left edge. Done here rather than # in awk because the width has to be measured across the whole set first. width="$(cut -f1 <<<"$rendered" | LC_ALL=C awk '{ if (length($0) > w) w = length($0) } END { print w }')" # -dmenu rather than a static window, so the list is filterable: 122 bindings is # more than fits on a screen, and typing "workspace" is how you actually use # this. No theme arguments, so ~/.config/rofi/config.rasi applies and it matches # the launcher and the power menu. # # The selection is discarded. This is a reference, not a menu -- there is no # sensible action for "you picked Super + Q", and running the bound command on # Return would make an accidental keypress close a window from a help screen. LC_ALL=C awk -F'\t' -v w="$width" '{ printf "%-*s %s\n", w, $1, $2 }' <<<"$rendered" | rofi -dmenu -i -no-custom -no-show-icons -p "keys" \ -mesg "$count shortcuts" >/dev/null 2>&1 || :