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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:22:08 +02:00
parent 30899de5a3
commit 369b4583ef
3 changed files with 407 additions and 0 deletions
+200
View File
@@ -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:
<KEY> [attr=…]… { <action>; }
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 <tmp>` 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: <indent> KEY [attr=…]… { action } (all on one line)
_BIND_RE = re.compile(
r"^(?P<indent>\s*)"
r"(?P<key>[A-Za-z0-9_]+(?:\+[A-Za-z0-9_]+)*)" # Mod+Shift+H, XF86AudioPlay …
r"(?P<attrs>(?:\s+[a-z][a-z-]*=(?:\"[^\"]*\"|\S+))*)" # hotkey-overlay-title="…" allow-when-locked=true
r"\s*\{\s*(?P<action>.*?)\s*\}\s*$"
)
_SECTION_RE = re.compile(r"^\s*//\s*-+\s*(?P<name>.*?)\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"
+5
View File
@@ -24,6 +24,7 @@ from taninux.gui.pages import (
firewall, firewall,
hub_home, hub_home,
kernel, kernel,
keyboard,
locale, locale,
lock, lock,
maintain, maintain,
@@ -119,6 +120,10 @@ SETTINGS_SECTIONS: list[Section] = [
Page("locale", "Region & Time", "input-keyboard-symbolic", locale.build, Page("locale", "Region & Time", "input-keyboard-symbolic", locale.build,
("region", "time", "timezone", "date", "language", "locale", ("region", "time", "timezone", "date", "language", "locale",
"keyboard", "layout", "ntp")), "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, Page("settings", "Appearance", "emblem-system-symbolic", settings.build,
("appearance", "theme", "accent", "color scheme", "dark", "light", ("appearance", "theme", "accent", "color scheme", "dark", "light",
"icons", "font", "cursor", "wallpaper", "window buttons", "gtk")), "icons", "font", "cursor", "wallpaper", "window buttons", "gtk")),
+202
View File
@@ -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