From 369b4583ef7ab0674d253ee341ddcc165c393c0b Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:22:08 +0200 Subject: [PATCH] feat(settings): niri keybindings page Corruption-safe rebinding: parses only the binds{} block, validates via `niri validate` on a temp copy before writing, backs up to .kdl.bak. Review fixes applied: StatusPage icon_name kwarg, capture dialog copies tn-dark from the parent window. Co-Authored-By: Claude Fable 5 --- src/taninux/core/keybindings.py | 200 +++++++++++++++++++++++++++++ src/taninux/gui/pages/__init__.py | 5 + src/taninux/gui/pages/keyboard.py | 202 ++++++++++++++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 src/taninux/core/keybindings.py create mode 100644 src/taninux/gui/pages/keyboard.py diff --git a/src/taninux/core/keybindings.py b/src/taninux/core/keybindings.py new file mode 100644 index 0000000..787bd9e --- /dev/null +++ b/src/taninux/core/keybindings.py @@ -0,0 +1,200 @@ +"""Niri keybindings — read & rebind, from ~/.config/niri/config.kdl. UI-free. + +Line-based on purpose: each bind sits on ONE line inside the top-level +`binds { … }` block: + + [attr=…]… { ; } + +We remember each bind's source line index, so a rebind is a surgical one-line +edit (only the leading KEY token is swapped) — comments, ordering and the rest +of the file are preserved byte-for-byte. Every write is validated with +`niri validate -c ` BEFORE it touches the real config; on failure nothing +changes and a `.kdl.bak` backup is left from the last good write. niri watches +config.kdl and live-reloads on save, so no explicit apply hook is needed. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +def _config_path() -> Path: + env = os.environ.get("NIRI_CONFIG") + if env: + return Path(env) + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base) / "niri" / "config.kdl" + + +# one bind line: KEY [attr=…]… { action } (all on one line) +_BIND_RE = re.compile( + r"^(?P\s*)" + r"(?P[A-Za-z0-9_]+(?:\+[A-Za-z0-9_]+)*)" # Mod+Shift+H, XF86AudioPlay … + r"(?P(?:\s+[a-z][a-z-]*=(?:\"[^\"]*\"|\S+))*)" # hotkey-overlay-title="…" allow-when-locked=true + r"\s*\{\s*(?P.*?)\s*\}\s*$" +) +_SECTION_RE = re.compile(r"^\s*//\s*-+\s*(?P.*?)\s*-*\s*$") +_TITLE_RE = re.compile(r'hotkey-overlay-title="([^"]*)"') + + +# Friendly names for hardware/media keys that carry no hotkey-overlay-title. +_KEY_LABELS = { + "XF86AudioRaiseVolume": "Volume up", + "XF86AudioLowerVolume": "Volume down", + "XF86AudioMute": "Mute", + "XF86AudioMicMute": "Microphone mute", + "XF86AudioPlay": "Play / Pause", + "XF86AudioPause": "Pause", + "XF86AudioNext": "Next track", + "XF86AudioPrev": "Previous track", + "XF86MonBrightnessUp": "Brightness up", + "XF86MonBrightnessDown": "Brightness down", + "Print": "Screenshot", +} + + +@dataclass +class Keybind: + key: str # "Mod+H" + action: str # 'focus-column-left' · 'spawn "kitty"' + title: str | None # hotkey-overlay-title, if present + section: str # cleaned label of the preceding `// --- … ---` comment + line: int # 0-based index into the config's lines (for in-place edit) + + def label(self) -> str: + """Human-readable description for the UI.""" + if self.title: + return self.title + if self.key in _KEY_LABELS: + return _KEY_LABELS[self.key] + a = self.action.strip().rstrip(";").strip() + head, _, rest = a.partition(" ") + if head in ("spawn", "spawn-sh"): + # no descriptive title — name it after the launched command + cmd = rest.strip().strip('"').split() + prog = Path(cmd[0]).name if cmd else "command" + return f"Run: {prog}" + text = head.replace("-", " ").strip().capitalize() + rest = rest.replace('"', "").strip() + return f"{text} {rest}".strip() + + +def _clean_section(name: str) -> str: + name = re.sub(r"\s*\([^)]*\)", "", name).strip() # drop "(…)" asides + if not name: + return "General" + # capitalize each word, including across "/" (e.g. "columns/windows") + return re.sub(r"[A-Za-z]+", + lambda m: m.group() if m.group().isupper() else m.group().capitalize(), + name) + + +def load() -> list[Keybind]: + """Parse the `binds { … }` block. Empty list if the file is unreadable.""" + try: + lines = _config_path().read_text().splitlines() + except OSError: + return [] + binds: list[Keybind] = [] + in_binds = False + section = "General" + for i, line in enumerate(lines): + stripped = line.strip() + if not in_binds: + if re.match(r"^binds\s*\{", stripped): + in_binds = True + continue + if stripped == "}": # end of the top-level binds block + break + sec = _SECTION_RE.match(line) + if sec: + section = _clean_section(sec.group("name")) + continue + m = _BIND_RE.match(line) + if not m: + continue + title_m = _TITLE_RE.search(m.group("attrs") or "") + binds.append(Keybind( + key=m.group("key"), + action=m.group("action").strip().rstrip(";").strip(), + title=title_m.group(1) if title_m else None, + section=section, + line=i, + )) + return binds + + +def conflict(binds: list[Keybind], new_key: str, exclude: Keybind) -> Keybind | None: + """Return an existing bind already using new_key (case-insensitive), if any.""" + nk = new_key.lower() + for b in binds: + if b is not exclude and b.key.lower() == nk: + return b + return None + + +def _validate(text: str) -> tuple[bool, str]: + """niri validate on a temp copy. (True, '') if niri is missing (best-effort).""" + tmp = "" + try: + with tempfile.NamedTemporaryFile("w", suffix=".kdl", delete=False) as tf: + tf.write(text) + tmp = tf.name + r = subprocess.run(["niri", "validate", "-c", tmp], + capture_output=True, text=True, timeout=5) + if r.returncode == 0: + return True, "" + # niri's error is a multi-line pretty box; we only ever change one key + # token, so a failure means the key name itself is invalid. + return False, "niri rejected this shortcut — try a different key" + except (FileNotFoundError, subprocess.TimeoutExpired): + return True, "" # niri not available here — don't block the edit + finally: + if tmp: + Path(tmp).unlink(missing_ok=True) + + +def rebind(bind: Keybind, new_key: str) -> tuple[bool, str]: + """Swap bind's key for new_key via a one-line edit. Returns (ok, message). + + Validates the result before writing; on any problem the config is untouched. + """ + new_key = new_key.strip() + if not new_key: + return False, "empty shortcut" + if new_key == bind.key: + return True, "unchanged" + path = _config_path() + try: + lines = path.read_text().splitlines(keepends=True) + except OSError as e: + return False, f"cannot read config: {e}" + + if bind.line >= len(lines): + return False, "config changed on disk — reopen the page" + orig = lines[bind.line] + m = re.match(r"^(\s*)" + re.escape(bind.key) + r"(?![A-Za-z0-9_+])", orig) + if not m: + return False, "config changed on disk — reopen the page" + indent = m.group(1) + rest = orig[len(indent) + len(bind.key):] # attrs + action block + newline + lines[bind.line] = f"{indent}{new_key}{rest}" + new_text = "".join(lines) + + ok, err = _validate(new_text) + if not ok: + return False, err + + try: + if path.exists(): + shutil.copy2(path, path.with_suffix(".kdl.bak")) + path.write_text(new_text) + except OSError as e: + return False, f"cannot write config: {e}" + bind.key = new_key + return True, "ok" diff --git a/src/taninux/gui/pages/__init__.py b/src/taninux/gui/pages/__init__.py index 3de18b2..7245783 100644 --- a/src/taninux/gui/pages/__init__.py +++ b/src/taninux/gui/pages/__init__.py @@ -24,6 +24,7 @@ from taninux.gui.pages import ( firewall, hub_home, kernel, + keyboard, locale, lock, maintain, @@ -119,6 +120,10 @@ SETTINGS_SECTIONS: list[Section] = [ Page("locale", "Region & Time", "input-keyboard-symbolic", locale.build, ("region", "time", "timezone", "date", "language", "locale", "keyboard", "layout", "ntp")), + Page("keyboard", "Keyboard Shortcuts", + "preferences-desktop-keyboard-shortcuts-symbolic", keyboard.build, + ("keyboard", "shortcuts", "keybinds", "keybindings", "hotkeys", + "bindings", "niri", "keys")), Page("settings", "Appearance", "emblem-system-symbolic", settings.build, ("appearance", "theme", "accent", "color scheme", "dark", "light", "icons", "font", "cursor", "wallpaper", "window buttons", "gtk")), diff --git a/src/taninux/gui/pages/keyboard.py b/src/taninux/gui/pages/keyboard.py new file mode 100644 index 0000000..b4518ee --- /dev/null +++ b/src/taninux/gui/pages/keyboard.py @@ -0,0 +1,202 @@ +"""Keyboard Shortcuts — view niri keybindings and rebind them to other keys. + +Reads the binds via core.keybindings, lists them grouped by the config's own +`// --- section ---` headers, and lets the user press a new combination +(capture dialog). Rebinds are surgical, validated one-line edits (see +core.keybindings); niri live-reloads on save. v1: rebind existing keys only — +the action stays fixed; adding/removing comes later. +""" +from __future__ import annotations + +from collections import OrderedDict + +import gi + +gi.require_version("Gtk", "4.0") +from gi.repository import Gdk, Gtk # noqa: E402 + +from taninux.core import keybindings as kb +from taninux.gui import ui + +# --- key capture → niri syntax ------------------------------------------------- + +_MODS = (("SUPER_MASK", "Mod"), ("CONTROL_MASK", "Ctrl"), + ("ALT_MASK", "Alt"), ("SHIFT_MASK", "Shift")) +# gdk keysym name → the spelling niri's config uses +_KEY_FIX = {"BackSpace": "Backspace", "space": "Space"} +_MOD_KEYVALS = { + Gdk.KEY_Super_L, Gdk.KEY_Super_R, Gdk.KEY_Control_L, Gdk.KEY_Control_R, + Gdk.KEY_Shift_L, Gdk.KEY_Shift_R, Gdk.KEY_Alt_L, Gdk.KEY_Alt_R, + Gdk.KEY_Meta_L, Gdk.KEY_Meta_R, Gdk.KEY_Hyper_L, Gdk.KEY_Hyper_R, + Gdk.KEY_ISO_Level3_Shift, +} + + +def _mods(state: Gdk.ModifierType) -> list[str]: + out = [] + for attr, name in _MODS: + mask = getattr(Gdk.ModifierType, attr, None) + if mask and (state & mask): + out.append(name) + return out + + +def _capture_to_key(keyval: int, state: Gdk.ModifierType) -> str | None: + """Convert a key-press to niri syntax, or None for a bare modifier.""" + if keyval in _MOD_KEYVALS: + return None + name = Gdk.keyval_name(keyval) + if not name: + return None + lower = Gdk.keyval_name(Gdk.keyval_to_lower(keyval)) or name + if len(lower) == 1 and lower.isalpha(): + name = lower.upper() # 'h'/'H' → 'H' (Shift carried as a modifier) + else: + name = _KEY_FIX.get(name, name) + return "+".join(_mods(state) + [name]) + + +# --- capture dialog ------------------------------------------------------------ + +def _capture_dialog(parent: Gtk.Widget, bind: kb.Keybind, + binds: list[kb.Keybind], on_done) -> None: + root = parent.get_root() + win = Gtk.Window(modal=True, resizable=False, decorated=False) + win.add_css_class("tn-alert-dialog") + # Inherit the parent window's dark mode, else the modal renders light. + if isinstance(root, Gtk.Window) and root.has_css_class("tn-dark"): + win.add_css_class("tn") + win.add_css_class("tn-dark") + if isinstance(root, Gtk.Window): + win.set_transient_for(root) + win.set_default_size(400, -1) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + box.add_css_class("tn-alert-box") + win.set_child(box) + + head = Gtk.Label(label=f"Rebind — {bind.label()}") + head.add_css_class("tn-alert-heading") + head.set_wrap(True) + head.set_xalign(0.5) + box.append(head) + + combo = Gtk.Label(label="Press the new shortcut…") + combo.add_css_class("tn-alert-body") + combo.add_css_class("tn-capture-combo") + combo.set_xalign(0.5) + box.append(combo) + + hint = Gtk.Label(label=f"Current: {bind.key} · Esc to cancel") + hint.add_css_class("dim-label") + hint.set_wrap(True) + hint.set_xalign(0.5) + box.append(hint) + + btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + btns.add_css_class("tn-alert-buttons") + btns.set_halign(Gtk.Align.CENTER) + box.append(btns) + cancel = Gtk.Button(label="Cancel") + cancel.add_css_class("pill") + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("pill") + apply_btn.add_css_class("suggested-action") + apply_btn.set_sensitive(False) + btns.append(cancel) + btns.append(apply_btn) + + captured = {"key": None} + + def on_key(_c, keyval, _code, state) -> bool: + if keyval == Gdk.KEY_Escape and not _mods(state): + win.destroy() + return True + key = _capture_to_key(keyval, state) + if key is None: + return True # bare modifier — keep waiting + captured["key"] = key + combo.set_label(key) + clash = kb.conflict(binds, key, bind) + if clash: + hint.set_label(f"⚠ already used by “{clash.label()}”") + apply_btn.set_sensitive(False) + else: + hint.set_label("Press Apply to save") + apply_btn.set_sensitive(True) + return True + + kc = Gtk.EventControllerKey() + kc.connect("key-pressed", on_key) + win.add_controller(kc) + + cancel.connect("clicked", lambda *_: win.destroy()) + + def do_apply(*_a) -> None: + key = captured["key"] + if not key: + return + ok, msg = kb.rebind(bind, key) + if ok: + on_done(key) + win.destroy() + else: + hint.set_label(f"⚠ {msg}") + apply_btn.set_sensitive(False) + + apply_btn.connect("clicked", do_apply) + win.present() + + +# --- page ---------------------------------------------------------------------- + +def build() -> Gtk.Widget: + page = ui.Page() + binds = kb.load() + + if not binds: + page.add(ui.StatusPage( + icon_name="input-keyboard-symbolic", + title="No keybindings found", + description="Could not read ~/.config/niri/config.kdl.", + )) + return page + + page.add(_intro()) + + groups: "OrderedDict[str, list[kb.Keybind]]" = OrderedDict() + for b in binds: + groups.setdefault(b.section, []).append(b) + + for section, items in groups.items(): + page.add(_section_group(section, items, binds)) + + return page + + +def _intro() -> ui.Group: + g = ui.Group( + title="Keyboard Shortcuts", + description=("Click a shortcut and press a new key combination to rebind " + "it. Changes are validated and applied live (niri)."), + ) + return g + + +def _section_group(section: str, items: list[kb.Keybind], + all_binds: list[kb.Keybind]) -> ui.Group: + g = ui.Group(title=section) + for bind in items: + row = ui.Row(title=bind.label()) + key_btn = Gtk.Button(label=bind.key) + key_btn.add_css_class("tn-keycap") + key_btn.set_valign(Gtk.Align.CENTER) + key_btn.set_tooltip_text("Click, then press a new shortcut") + + def on_click(_b, b=bind, btn=key_btn) -> None: + _capture_dialog(btn, b, all_binds, lambda new: btn.set_label(new)) + + key_btn.connect("clicked", on_click) + row.add_suffix(key_btn) + g.add(row) + return g