refactor(fws-keys): make keyboard shortcuts tool universal for hyprland and i3

Rename fws-hypr-keys to fws-keys and extend it to support both Hyprland and i3 desktop environments. The tool now detects the session type and uses appropriate methods:

- Hyprland: queries hyprctl binds for real loaded bindings (wofi dmenu)
- i3: parses ~/.config/i3/config with variable resolution (rofi dmenu)

Update all references in configs and templates. Add VM detection warning in fws-vr-setup to prevent VR attempts in virtual machines where GPU/USB access is unavailable.

Add ttf-liberation font dependency for Steam UI stability on minimal installations and improve error guidance for Steam crashes.
This commit is contained in:
2026-07-04 03:59:09 +02:00
parent a63021cde7
commit 2c5f08330a
12 changed files with 350 additions and 125 deletions
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
# FWS — affiche les raccourcis clavier RÉELS de Hyprland.
#
# Source de vérité : « hyprctl binds -j » = les binds effectivement CHARGÉS
# par le compositeur (ceux de ~/.config/hypr/hyprland.conf, y compris ceux
# ajoutés par l'utilisateur après l'installation). Les descriptions viennent
# des « bindd » de la config ; un bind sans description affiche son action
# brute (dispatcher + argument). Affichage : wofi en mode dmenu (recherche
# incrémentale intégrée) — déclenché par le bouton ⌨ de la waybar ou SUPER+K.
#
# python3 et wofi sont déjà sur le système (image FWS + paquets Hyprland) :
# aucune dépendance supplémentaire.
import json
import os
import subprocess
import sys
_STYLE = "/usr/local/share/fws/wofi-keys.css"
# Bits de modmask (masques X11) dans l'ordre d'affichage voulu.
_MODS = [
(64, "SUPER"),
(4, "CTRL"),
(8, "ALT"),
(1, "SHIFT"),
]
# Libellés humains pour les « touches » spéciales.
_KEY_LABELS = {
"mouse:272": "clic gauche",
"mouse:273": "clic droit",
"mouse:274": "clic molette",
"mouse_down": "molette bas",
"mouse_up": "molette haut",
"return": "Entrée",
"space": "Espace",
"escape": "Échap",
"tab": "Tab",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓",
}
def _mods(mask):
return [name for bit, name in _MODS if mask & bit]
def _combo(bind):
parts = _mods(bind.get("modmask", 0))
key = bind.get("key") or ""
if not key and bind.get("keycode"):
key = f"code {bind['keycode']}"
parts.append(_KEY_LABELS.get(key.lower(), key))
return " + ".join(p for p in parts if p)
def _action(bind):
desc = (bind.get("description") or "").strip()
if desc:
return desc
arg = (bind.get("arg") or "").strip()
return f"{bind.get('dispatcher', '?')} {arg}".strip()
def main():
try:
raw = subprocess.check_output(["hyprctl", "binds", "-j"], text=True)
binds = json.loads(raw)
except (OSError, subprocess.CalledProcessError, ValueError) as exc:
subprocess.run(["notify-send", "Raccourcis FWS",
f"hyprctl binds a échoué : {exc}"], check=False)
return 1
lines = []
seen = set()
for bind in binds:
combo = _combo(bind)
if not combo or combo in seen: # binde répétés (resize) : une ligne
continue
seen.add(combo)
lines.append(f"{combo:<24} {_action(bind)}")
if not lines:
lines = ["(aucun raccourci déclaré)"]
# dmenu = liste + filtre incrémental ; la sélection n'a pas d'effet.
cmd = ["wofi", "--dmenu", "--prompt", "Raccourcis clavier — taper pour filtrer",
"--width", "820", "--height", "600", "--insensitive",
"--cache-file", "/dev/null"]
if os.path.exists(_STYLE):
# Style dédié : monospace (colonnes alignées) + palette FWS.
cmd += ["--style", _STYLE]
subprocess.run(cmd, input="\n".join(lines), text=True, check=False)
return 0
if __name__ == "__main__":
sys.exit(main())
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
# FWS — affiche les raccourcis clavier RÉELS de la session (bouton ⌨ de la
# barre ou Super+K). Universel :
# • Hyprland : « hyprctl binds -j » = les binds effectivement chargés
# (descriptions des bindd) — affichage wofi (Wayland).
# • i3 : parse ~/.config/i3/config (bindsym, variables résolues)
# — affichage rofi (X11).
# Aucune dépendance hors python3 + wofi/rofi (déjà installés avec le bureau).
import json
import os
import re
import subprocess
import sys
_WOFI_STYLE = "/usr/local/share/fws/wofi-keys.css"
# Bits de modmask Hyprland (masques X11) dans l'ordre d'affichage voulu.
_MODS = [(64, "SUPER"), (4, "CTRL"), (8, "ALT"), (1, "SHIFT")]
_KEY_LABELS = {
"mouse:272": "clic gauche",
"mouse:273": "clic droit",
"mouse:274": "clic molette",
"mouse_down": "molette bas",
"mouse_up": "molette haut",
"button4": "molette haut",
"button5": "molette bas",
"return": "Entrée",
"space": "Espace",
"escape": "Échap",
"tab": "Tab",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓",
"print": "Impr écran",
}
def _label(key):
return _KEY_LABELS.get(key.lower(), key)
# --- Hyprland : binds réels via hyprctl ------------------------------------
def hyprland_lines():
raw = subprocess.check_output(["hyprctl", "binds", "-j"], text=True)
lines, seen = [], set()
for bind in json.loads(raw):
parts = [name for bit, name in _MODS if bind.get("modmask", 0) & bit]
key = bind.get("key") or (f"code {bind['keycode']}" if bind.get("keycode") else "")
parts.append(_label(key))
combo = " + ".join(p for p in parts if p)
if not combo or combo in seen:
continue
seen.add(combo)
desc = (bind.get("description") or "").strip() \
or f"{bind.get('dispatcher', '?')} {(bind.get('arg') or '').strip()}".strip()
lines.append(f"{combo:<24} {desc}")
return lines
# --- i3 : parse de la config (variables résolues) ----------------------------
def i3_lines():
path = os.path.expanduser("~/.config/i3/config")
variables = {}
lines, seen = [], set()
with open(path, encoding="utf-8", errors="replace") as fh:
content = fh.readlines()
for line in content:
m = re.match(r"\s*set\s+(\$\S+)\s+(.+)$", line)
if m:
variables[m.group(1)] = m.group(2).strip()
def resolve(text):
# $var les plus longs d'abord ($filemanager avant $file…).
for var in sorted(variables, key=len, reverse=True):
text = text.replace(var, variables[var])
return text
for line in content:
m = re.match(r"\s*bindsym\s+(?:--\S+\s+)*(\S+)\s+(.+)$", line)
if not m:
continue
combo_raw, action = m.groups()
combo_raw = resolve(combo_raw).replace("Mod4", "Super").replace("Mod1", "Alt")
combo = " + ".join(_label(k) for k in combo_raw.split("+"))
if combo in seen:
continue
seen.add(combo)
action = resolve(action)
action = re.sub(r"^exec\s+(--no-startup-id\s+)?", "", action).strip().strip('"')
lines.append(f"{combo:<24} {action}")
return lines
def show(lines):
if not lines:
lines = ["(aucun raccourci trouvé)"]
text = "\n".join(lines)
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
cmd = ["wofi", "--dmenu", "--prompt", "Raccourcis clavier — taper pour filtrer",
"--width", "820", "--height", "600", "--insensitive",
"--cache-file", "/dev/null"]
if os.path.exists(_WOFI_STYLE):
cmd += ["--style", _WOFI_STYLE]
else:
# X11/i3 : rofi en dmenu, style assorti (mono pour l'alignement).
cmd = ["rofi", "-dmenu", "-i", "-p", "Raccourcis clavier",
"-no-custom",
"-theme-str", 'window { width: 880px; } listview { lines: 18; }',
"-theme-str", '* { font: "Noto Sans Mono 11"; }']
subprocess.run(cmd, input=text, text=True, check=False)
def main():
try:
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
lines = hyprland_lines()
else:
lines = i3_lines()
except Exception as exc: # noqa: BLE001 — best effort, jamais de crash muet
subprocess.run(["notify-send", "Raccourcis FWS", f"Erreur : {exc}"],
check=False)
return 1
show(lines)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -24,6 +24,18 @@ if [ "$(id -u)" -ne 0 ]; then
exec sudo "$0" "$@"
fi
# VM = cul-de-sac VR : pas d'accès direct au GPU ni à l'USB/DisplayPort du
# casque, et Steam (steamwebhelper) est INSTABLE en rendu logiciel (llvmpipe).
# On prévient au lieu de laisser l'utilisateur croire à une panne de FWS.
if systemd-detect-virt --quiet 2>/dev/null; then
warn "Machine VIRTUELLE détectée ($(systemd-detect-virt 2>/dev/null))."
warn "La VR ne fonctionnera PAS ici : casque et GPU inaccessibles depuis une VM."
warn "Steam lui-même peut planter en rendu logiciel (steamwebhelper)."
echo " → Fais cette installation sur la machine RÉELLE avec le casque branché."
read -rp " Continuer quand même (tests uniquement) ? [o/N] " _ok
case "$_ok" in o|O|oui) ;; *) exit 0 ;; esac
fi
# lib32 Vulkan selon le GPU (indispensable à SteamVR, qui est 32/64 bits).
GPU="$(lspci 2>/dev/null | grep -iE 'VGA compatible controller|3D controller|Display controller')"
LIB32="lib32-mesa"
@@ -48,12 +60,20 @@ install_pkgs() {
steamvr_base() {
say "Installation de la base SteamVR (Steam + Vulkan 32 bits : $LIB32)…"
# ttf-liberation : polices attendues par l'interface Steam (évite des
# rendus cassés/plantages cosmétiques sur install minimale).
# shellcheck disable=SC2086
install_pkgs steam $LIB32 || return 1
install_pkgs steam $LIB32 ttf-liberation || return 1
say "Base installée. Étapes suivantes :"
echo " 1. Lance Steam, connecte-toi, puis installe « SteamVR » (Bibliothèque → Outils)."
echo " 2. Branche le casque et les stations, puis lance SteamVR."
echo " 3. Premier lancement : SteamVR peut demander à ajuster les règles udev — accepte."
say "Si Steam PLANTE (fenêtre qui se ferme, souvent steamwebhelper) :"
echo " • Vérifie l'espace disque (SteamVR ≈ 5 Go) : df -h ~"
echo " • GPU faible / VM / rendu logiciel — lance :"
echo " steam -cef-disable-gpu -cef-disable-gpu-compositing"
echo " • Pour voir l'erreur exacte : lance « steam » depuis un terminal,"
echo " ou regarde ~/.steam/steam/logs/ (console_log.txt, bootstrap_log.txt)."
}
case "${1:-menu}" in