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:
@@ -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"
|
||||||
@@ -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")),
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user