Files
dotfiles/.config/dot/commands/kde/kde.py

536 lines
18 KiB
Python

#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict, namedtuple
from pathlib import Path
KCFG_NS = "{http://www.kde.org/standards/kcfg/1.0}"
DEFAULT_SCHEMA_DIR = "/usr/share/config.kcfg"
KGLOBALACCEL_SERVICE = "org.kde.kglobalaccel"
KGLOBALACCEL_PATH = "/kglobalaccel"
KGLOBALACCEL_IFACE = "org.kde.KGlobalAccel"
# KGlobalAccel::GlobalShortcutLoading::NoAutoloading, per KF6/KGlobalAccel/kglobalaccel.h --
# makes a write always win over whatever shortcut was previously saved, rather than being
# ignored in favor of it (the Autoloading=0x0 default).
SHORTCUT_NO_AUTOLOADING = 0x4
# .kcfg files that only declare their target rc file at runtime
# (<kcfgfile arg="true">), so it can't be discovered by scanning.
ARG_TRUE_RCFILES = {
"kwin.kcfg": "kwinrc",
}
SAVE_USAGE = """usage: dot kde save [identifier]
identifier declare a new manifest entry, seeded from its current live value
(no args) refresh every already-declared manifest entry from the live system
help show this message"""
APPLY_USAGE = """usage: dot kde apply
Pushes every manifest entry's declared value onto the live system.
help show this message"""
DIFF_USAGE = """usage: dot kde diff
Scans every schema-backed setting reachable through the kcfg mapping
table, and every shortcut registered with kglobalaccel, reporting each
one whose live value differs from its default, tagged declared
(present in the manifest) or undeclared. Also reports already-declared
freeform settings whose live value differs from their default (no
schema to broad-scan, so it's only checked when already declared).
Read-only -- never writes the manifest or the live system.
help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"])
def _split_on_known_prefix(rest, candidates):
matches = [c for c in candidates if rest == c or rest.startswith(c + ".")]
if not matches:
return None
prefix = max(matches, key=len)
remainder = rest[len(prefix):].lstrip(".")
if not remainder:
return None
return prefix, remainder
def _known_schema_groups(file, kcfg_map):
groups = set()
for path in kcfg_map.get(file, []):
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
name = group_elem.get("name")
if name:
groups.add(name)
return groups
def _split_schema_group_key(file, rest, kcfg_map):
match = _split_on_known_prefix(rest, _known_schema_groups(file, kcfg_map))
if match is not None:
return match
# No schema group matches -- freeform. Its group is never known to contain
# a dot (there's no schema to have told us otherwise), so the boundary is
# just the first remaining dot.
group, _, key = rest.partition(".")
if not key:
raise ValueError(f"invalid identifier {file}.{rest!r} (expected file.group.key)")
return group, key
def _split_shortcut_group_key(rest):
(components,) = _kglobalaccel_call("allMainComponents", None)
match = _split_on_known_prefix(rest, [component[0] for component in components])
if match is None:
raise RuntimeError(
f"no live kglobalaccel component matches {rest!r} "
"(the owning application may need to run once to register its shortcuts with kglobalaccel)"
)
return match
# Only the file segment is unambiguous (rc file names never contain a dot).
# The group/key boundary can't be found by counting dots -- both KConfig group
# names (e.g. "org.kde.kdecoration2") and kglobalaccel componentUnique names
# (e.g. "org.kde.dolphin.desktop") routinely contain their own dots -- so it's
# resolved against known-good data instead: the live kglobalaccel component
# list for shortcuts, the kcfg schema's declared group names for everything
# else (falling back to freeform's first-dot split when no schema matches).
def parse_identifier(identifier, kcfg_map):
file, sep, rest = identifier.partition(".")
if not sep or not rest:
raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)")
if file == "kglobalshortcutsrc":
group, key = _split_shortcut_group_key(rest)
else:
group, key = _split_schema_group_key(file, rest, kcfg_map)
return Setting(file, group, key)
def load_manifest(path):
entries = {}
if not path.exists():
return entries
for line in path.read_text().splitlines():
if not line.strip():
continue
identifier, _, value = line.partition("=")
entries[identifier] = value
return entries
def write_manifest(path, entries):
lines = [f"{identifier}={value}" for identifier, value in entries.items()]
path.write_text("".join(f"{line}\n" for line in lines))
def _parse_kcfg(path):
try:
return ET.parse(path).getroot()
except ET.ParseError:
return None
def _kcfgfile_name(root):
elem = root.find(f"{KCFG_NS}kcfgfile")
if elem is None:
return None
return elem.get("name")
def build_kcfg_map(schema_dir):
mapping = defaultdict(list)
if not schema_dir.is_dir():
return mapping
for path in sorted(schema_dir.glob("*.kcfg")):
root = _parse_kcfg(path)
if root is None:
continue
rcfile = _kcfgfile_name(root) or ARG_TRUE_RCFILES.get(path.name)
if rcfile:
mapping[rcfile].append(path)
return mapping
def find_schema_default(kcfg_paths, setting):
for path in kcfg_paths:
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
if group_elem.get("name") != setting.group:
continue
for entry in group_elem.findall(f"{KCFG_NS}entry"):
if (entry.get("key") or entry.get("name")) != setting.key:
continue
default_elem = entry.find(f"{KCFG_NS}default")
return default_elem.text if default_elem is not None and default_elem.text else ""
return None
def iter_schema_identifiers(kcfg_map):
for rcfile, paths in kcfg_map.items():
for path in paths:
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
group = group_elem.get("name")
if not group:
continue
for entry in group_elem.findall(f"{KCFG_NS}entry"):
key = entry.get("key") or entry.get("name")
if key:
yield Setting(rcfile, group, key)
def resolve_mechanism(setting, kcfg_map):
if setting.file == "kglobalshortcutsrc":
return "shortcuts", None
default = find_schema_default(kcfg_map.get(setting.file, []), setting)
if default is not None:
return "schema", default
return "freeform", None
def read_live_value(setting, default):
cmd = ["kreadconfig6", "--file", setting.file, "--group", setting.group, "--key", setting.key]
if default is not None:
cmd += ["--default", default]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"kreadconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
)
return result.stdout.rstrip("\n")
def write_live_value(setting, value):
cmd = [
"kwriteconfig6",
"--file", setting.file,
"--group", setting.group,
"--key", setting.key,
"--",
value,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"kwriteconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
)
def _key_sequence_class():
try:
from PyQt6.QtGui import QKeySequence
except ImportError as e:
raise RuntimeError(
"the shortcuts mechanism requires PyQt6 (install python-pyqt6) to translate key names"
) from e
return QKeySequence
def _keys_to_string(key_ints):
QKeySequence = _key_sequence_class()
return "\t".join(QKeySequence(key).toString() for key in key_ints)
def _string_to_keys(value):
if not value:
return []
QKeySequence = _key_sequence_class()
keys = []
for part in value.split("\t"):
part = part.strip()
if not part or part.lower() == "none":
continue
sequence = QKeySequence(part)
if sequence.count() != 1:
raise RuntimeError(f"invalid key sequence {part!r} (expected exactly one key combination)")
keys.append(int(sequence[0].toCombined()))
return keys
def _kglobalaccel_call(method, signature, *tokens):
cmd = ["busctl", "--user", "--json=short", "call",
KGLOBALACCEL_SERVICE, KGLOBALACCEL_PATH, KGLOBALACCEL_IFACE, method]
if signature:
cmd += [signature, *(str(token) for token in tokens)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"kglobalaccel {method} failed: {result.stderr.strip()}")
return json.loads(result.stdout)["data"]
def _actions_for_component(component_unique):
(actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique)
return actions
def iter_shortcut_identifiers():
(components,) = _kglobalaccel_call("allMainComponents", None)
for component in components:
for action in _actions_for_component(component[0]):
yield Setting("kglobalshortcutsrc", action[0], action[1])
def _resolve_shortcut_action_id(component_unique, action_unique):
for action in _actions_for_component(component_unique):
if action[0] == component_unique and action[1] == action_unique:
return action
raise RuntimeError(
f"no shortcut action {action_unique!r} in component {component_unique!r} "
"(the owning application may need to run once to register its shortcuts with kglobalaccel)"
)
# The plural *Keys methods (a(ai), one 4-int QKeyCombination chord slot per bound
# key sequence) are used instead of the singular shortcut()/defaultShortcut()/
# setShortcut() methods the flat ai signature suggests: on this KF6 build,
# defaultShortcut() was empirically found to just mirror shortcut() -- returning
# whatever the *current* value is rather than the true packaged default -- while
# defaultShortcutKeys() correctly returns the untouched default even after
# setShortcutKeys() has changed the current value. Only single, non-chorded key
# combinations are supported (see _string_to_keys), so only the first of each
# chord's 4 int slots is ever meaningful here; the rest are always 0.
def _keys_from_chords(chords):
return [chord[0][0] for chord in chords]
def read_shortcut_value(component_unique, action_unique, method="shortcutKeys"):
action_id = _resolve_shortcut_action_id(component_unique, action_unique)
(chords,) = _kglobalaccel_call(method, "as", len(action_id), *action_id)
return _keys_to_string(_keys_from_chords(chords))
def write_shortcut_value(component_unique, action_unique, value):
action_id = _resolve_shortcut_action_id(component_unique, action_unique)
keys = _string_to_keys(value)
tokens = [len(action_id), *action_id, len(keys)]
for key in keys:
tokens += [4, key, 0, 0, 0]
tokens.append(SHORTCUT_NO_AUTOLOADING)
_kglobalaccel_call("setShortcutKeys", "asa(ai)u", *tokens)
def save_one(identifier, kcfg_map):
setting = parse_identifier(identifier, kcfg_map)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts":
return read_shortcut_value(setting.group, setting.key)
return read_live_value(setting, default)
def apply_one(identifier, value, kcfg_map):
setting = parse_identifier(identifier, kcfg_map)
mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts":
write_shortcut_value(setting.group, setting.key, value)
return
write_live_value(setting, value)
def cmd_save(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(SAVE_USAGE)
return 0
if len(args) > 1:
print("dot kde save: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
try:
if args:
manifest[args[0]] = save_one(args[0], kcfg_map)
else:
for identifier in manifest:
manifest[identifier] = save_one(identifier, kcfg_map)
except (ValueError, RuntimeError) as e:
print(f"dot kde save: {e}", file=sys.stderr)
return 1
write_manifest(manifest_path, manifest)
return 0
def cmd_apply(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(APPLY_USAGE)
return 0
if args:
print("dot kde apply: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
try:
for identifier, value in manifest.items():
apply_one(identifier, value, kcfg_map)
except (ValueError, RuntimeError) as e:
print(f"dot kde apply: {e}", file=sys.stderr)
return 1
return 0
def cmd_diff(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(DIFF_USAGE)
return 0
if args:
print("dot kde diff: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
identifier = f"{setting.file}.{setting.group}.{setting.key}"
default = find_schema_default(kcfg_map.get(setting.file, []), setting)
try:
live = read_live_value(setting, default)
except RuntimeError as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
if live == default:
continue
tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})")
# Shortcuts are enumerable via kglobalaccel's allMainComponents/
# allActionsForComponent (the same source iter_shortcut_identifiers already
# walks for tab-completion), so unlike freeform they can participate in
# broad undeclared-drift discovery too.
try:
shortcut_settings = sorted(set(iter_shortcut_identifiers()))
except (RuntimeError, OSError) as e:
print(f"dot kde diff: shortcuts scan unavailable: {e}", file=sys.stderr)
shortcut_settings = []
for setting in shortcut_settings:
identifier = f"{setting.file}.{setting.group}.{setting.key}"
try:
live = read_shortcut_value(setting.group, setting.key)
default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys")
except RuntimeError as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
if live == default:
continue
tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})")
# Freeform settings have no schema to enumerate from, so unlike the
# schema-backed and shortcuts scans above, they can only be checked by
# walking identifiers already in the manifest -- they never surface an
# undeclared setting via broad scan. Shortcuts entries are skipped here
# (rather than re-parsed) since the broad-scan pass above already reports
# every declared shortcut mismatch; parsing one here would also mean an
# extra live kglobalaccel round-trip per entry for no benefit.
for identifier in manifest:
if identifier.split(".", 1)[0] == "kglobalshortcutsrc":
continue
try:
setting = parse_identifier(identifier, kcfg_map)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism != "freeform":
continue
live = read_live_value(setting, default)
if live == "":
continue
except (ValueError, RuntimeError) as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
print(f"declared {identifier} = {live} (default: {default or ''})")
return 0
def cmd_complete(schema_dir):
kcfg_map = build_kcfg_map(schema_dir)
for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
print(f"{setting.file}.{setting.group}.{setting.key}")
try:
# Fish's completion runs this on every TAB press, in shells that may have no
# live KDE session (or no busctl at all) -- a broken shortcuts source must
# never cost the schema-backed candidates already printed above.
shortcut_settings = sorted(set(iter_shortcut_identifiers()))
except (RuntimeError, OSError):
shortcut_settings = []
for setting in shortcut_settings:
print(f"{setting.file}.{setting.group}.{setting.key}")
return 0
def main(argv):
if not argv:
print("dot kde: no command given", file=sys.stderr)
return 1
command, rest = argv[0], argv[1:]
schema_dir = Path(os.environ.get("DOT_KDE_KCFG_DIR", DEFAULT_SCHEMA_DIR))
manifest_path = Path(os.environ["HOME"]) / ".config" / "dot" / "kde-manifest"
if command == "save":
return cmd_save(rest, manifest_path, schema_dir)
if command == "apply":
return cmd_apply(rest, manifest_path, schema_dir)
if command == "diff":
return cmd_diff(rest, manifest_path, schema_dir)
# Internal, not a user-facing `dot kde` subcommand -- called directly by
# completions/dot.fish to source candidates from the live schema, never
# dispatched to via kde.fish.
if command == "complete":
return cmd_complete(schema_dir)
print(f"dot kde: unknown command {command!r}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))