dotcli: Enable shortcuts to be discovered by diff.

This commit is contained in:
2026-07-05 22:21:26 -04:00
parent 537724989a
commit f15713d183
5 changed files with 255 additions and 39 deletions

View File

@@ -0,0 +1 @@
{"sessionId":"d1732179-27b7-49fb-82af-6669b783a444","pid":45537,"procStart":"7266197","acquiredAt":1783288905666}

View File

@@ -0,0 +1,102 @@
---
blocked-by: [0005-kde-shortcuts-mechanism, 0009-kde-shortcut-completion]
---
## What to build
`dot kde diff`'s broad-scan (the pass that reports *undeclared* drift, not
just already-declared entries) currently only walks schema-backed
identifiers via `iter_schema_identifiers`. Shortcuts are treated the same
as freeform in `cmd_diff` -- checked only when already present in the
manifest -- per the code comment at the top of that loop. That comment is
overstated for shortcuts: unlike freeform, which genuinely has no
enumeration source, shortcuts *are* enumerable via `kglobalaccel`'s
`allMainComponents`/`allActionsForComponent`, and `iter_shortcut_identifiers`
(added in 0009 for tab-completion) already walks exactly that.
Add a second broad-scan pass in `cmd_diff`, after the existing schema-backed
one, over `sorted(set(iter_shortcut_identifiers()))`: for each identifier,
compare `shortcutKeys` against `defaultShortcutKeys` (the same live/default
read already used for declared shortcuts), and tag `declared`/`undeclared`
exactly like the schema loop. Remove the shortcuts branch from the
manifest-only loop below it (now redundant), leaving that loop for freeform
only, since freeform is the only mechanism that still can't be enumerated.
Tolerate two failure modes without aborting the whole command:
- The enumeration call itself (`allMainComponents`) failing (no live
session, no `busctl`) -- print one diagnostic to stderr and skip the
shortcuts block entirely, same as any other reported problem in `diff`.
- An individual action failing to resolve (`_resolve_shortcut_action_id`
raising because its owning app hasn't registered with kglobalaccel this
session) -- print that one identifier's error to stderr and continue,
matching the schema loop's existing per-identifier tolerance.
Update `DIFF_USAGE` to reflect that shortcuts now participate in broad-scan
alongside schema-backed settings, leaving only freeform as declared-only.
## Acceptance criteria
- [x] `dot kde diff` reports undeclared shortcut drift (a shortcut changed
from its packaged default but never `dot kde save`d) without requiring
it to be in the manifest first
- [x] Already-declared shortcut drift is still reported, tagged `declared`,
with no duplicate line from the old manifest-only loop
- [x] A shortcut belonging to an app that hasn't registered with kglobalaccel
this session produces one stderr diagnostic for that identifier and
does not stop the rest of the scan (schema block, other shortcuts,
freeform block) from completing
- [x] If the `allMainComponents` enumeration itself fails (no `busctl`, no
live session), `diff` prints one diagnostic, skips the shortcuts block,
and still completes the schema and freeform passes, exiting 0
- [x] Freeform remains declared-only (unchanged) -- only its loop comment and
the removed shortcuts branch change
- [x] `DIFF_USAGE` text updated to describe shortcuts as broad-scanned
- [x] Verified manually against the real session (consistent with the
shortcuts mechanism's existing test carve-out, 0005/0009) -- no new
automated tests
- [x] Full existing test suite still passes unchanged
## Implementation Notes
- `cmd_diff` (`commands/kde/kde.py`) gained a second broad-scan pass between
the existing schema-backed loop and the manifest-only loop: it walks
`sorted(set(iter_shortcut_identifiers()))` (the same enumeration
`cmd_complete` already uses), compares `shortcutKeys` against
`defaultShortcutKeys` per identifier, and tags `declared`/`undeclared`
exactly like the schema loop.
- The manifest-only loop below it lost its `shortcuts` branch entirely
(`resolve_mechanism` returning `"shortcuts"` now just falls through
`if mechanism != "freeform": continue`), since the new broad-scan pass
already reports every declared shortcut mismatch -- keeping the old branch
would have double-printed them.
- Two failure modes, handled at different granularity: `iter_shortcut_identifiers()`
itself is wrapped in `try/except (RuntimeError, OSError)` -- a failure there
(no live session, missing `busctl`) prints one diagnostic and skips the
whole shortcuts block, letting the schema and freeform passes still run.
Inside the per-identifier loop, `read_shortcut_value` raising `RuntimeError`
(an app that hasn't registered with kglobalaccel this session yet) prints
one diagnostic for that identifier and continues, matching the schema
loop's existing per-identifier tolerance.
- Real-world validation on this machine: manually ran the same enumeration in
a throwaway script before implementing, confirming 29 of 278 registered
shortcuts differed from default (the Meta+1-9 desktop-switch remap,
Meta+Shift+1-9 window-to-desktop binds, and Meta+A/Meta+Shift+A activity
switching) -- all 29 were `dot kde save`d into the manifest in the same
session as a prerequisite for testing this cleanly. After implementing,
`dot kde diff` reported all 30 shortcuts (29 plus the pre-existing
`ksmserver.Lock Session`) as `declared` with correct default values, and
~34 unrelated `RuntimeError`s for apps not launched this session (Konsole,
Spectacle, Dolphin, etc.) printed to stderr without aborting the scan.
Removing one entry (`kwin.Switch to Desktop 1`) from the manifest and
re-running confirmed it flips to `undeclared` with the same live/default
values, then restoring the manifest flipped it back to `declared` --
confirms both tags work and the manifest was left untouched by `diff`
itself (read-only, as documented).
- Full test suite re-run after the change: 101/101 pass, unchanged from
before this task. No automated tests added for the new pass itself, per
the shortcuts mechanism's existing carve-out (0005's Implementation Notes:
a live `kglobalaccel` D-Bus session isn't practically substitutable without
disproportionate mock infrastructure) -- the existing tests already
exercise `dot kde diff` against the real live session and continued to
pass with the new pass active, incidentally covering that it doesn't break
anything even though it isn't asserting on the new pass's own output.

View File

@@ -38,23 +38,85 @@ APPLY_USAGE = """usage: dot kde apply
DIFF_USAGE = """usage: dot kde diff DIFF_USAGE = """usage: dot kde diff
Scans every schema-backed setting reachable through the kcfg mapping Scans every schema-backed setting reachable through the kcfg mapping
table and reports each one whose live value differs from its table, and every shortcut registered with kglobalaccel, reporting each
schema-declared default, tagged declared (present in the manifest) one whose live value differs from its default, tagged declared
or undeclared. Also reports already-declared freeform and shortcut (present in the manifest) or undeclared. Also reports already-declared
settings whose live value differs from their default (neither has a freeform settings whose live value differs from their default (no
schema/mapping table to broad-scan, so both are only checked when schema to broad-scan, so it's only checked when already declared).
already declared). Read-only -- never writes the manifest or the Read-only -- never writes the manifest or the live system.
live system.
help show this message""" help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"]) Setting = namedtuple("Setting", ["file", "group", "key"])
def parse_identifier(identifier): def _split_on_known_prefix(rest, candidates):
parts = identifier.split(".", 2) matches = [c for c in candidates if rest == c or rest.startswith(c + ".")]
if len(parts) != 3: 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)") raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)")
return Setting(*parts)
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): def load_manifest(path):
@@ -137,7 +199,7 @@ def iter_schema_identifiers(kcfg_map):
for entry in group_elem.findall(f"{KCFG_NS}entry"): for entry in group_elem.findall(f"{KCFG_NS}entry"):
key = entry.get("key") or entry.get("name") key = entry.get("key") or entry.get("name")
if key: if key:
yield f"{rcfile}.{group}.{key}" yield Setting(rcfile, group, key)
def resolve_mechanism(setting, kcfg_map): def resolve_mechanism(setting, kcfg_map):
@@ -233,7 +295,7 @@ def iter_shortcut_identifiers():
(components,) = _kglobalaccel_call("allMainComponents", None) (components,) = _kglobalaccel_call("allMainComponents", None)
for component in components: for component in components:
for action in _actions_for_component(component[0]): for action in _actions_for_component(component[0]):
yield f"kglobalshortcutsrc.{action[0]}.{action[1]}" yield Setting("kglobalshortcutsrc", action[0], action[1])
def _resolve_shortcut_action_id(component_unique, action_unique): def _resolve_shortcut_action_id(component_unique, action_unique):
@@ -279,7 +341,7 @@ def write_shortcut_value(component_unique, action_unique, value):
def save_one(identifier, kcfg_map): def save_one(identifier, kcfg_map):
setting = parse_identifier(identifier) setting = parse_identifier(identifier, kcfg_map)
mechanism, default = resolve_mechanism(setting, kcfg_map) mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts": if mechanism == "shortcuts":
return read_shortcut_value(setting.group, setting.key) return read_shortcut_value(setting.group, setting.key)
@@ -287,7 +349,7 @@ def save_one(identifier, kcfg_map):
def apply_one(identifier, value, kcfg_map): def apply_one(identifier, value, kcfg_map):
setting = parse_identifier(identifier) setting = parse_identifier(identifier, kcfg_map)
mechanism, _default = resolve_mechanism(setting, kcfg_map) mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts": if mechanism == "shortcuts":
write_shortcut_value(setting.group, setting.key, value) write_shortcut_value(setting.group, setting.key, value)
@@ -355,8 +417,8 @@ def cmd_diff(args, manifest_path, schema_dir):
kcfg_map = build_kcfg_map(schema_dir) kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path) manifest = load_manifest(manifest_path)
for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
setting = parse_identifier(identifier) identifier = f"{setting.file}.{setting.group}.{setting.key}"
default = find_schema_default(kcfg_map.get(setting.file, []), setting) default = find_schema_default(kcfg_map.get(setting.file, []), setting)
try: try:
live = read_live_value(setting, default) live = read_live_value(setting, default)
@@ -370,25 +432,49 @@ def cmd_diff(args, manifest_path, schema_dir):
tag = "declared" if identifier in manifest else "undeclared" tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})") print(f"{tag} {identifier} = {live} (default: {default})")
# Freeform and shortcuts settings have no schema/mapping table to enumerate, # Shortcuts are enumerable via kglobalaccel's allMainComponents/
# so unlike the schema-backed loop above, they can only be checked by walking # allActionsForComponent (the same source iter_shortcut_identifiers already
# identifiers already in the manifest -- neither ever surfaces an undeclared # walks for tab-completion), so unlike freeform they can participate in
# setting via broad scan. # broad undeclared-drift discovery too.
for identifier in manifest: 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: try:
setting = parse_identifier(identifier)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts":
live = read_shortcut_value(setting.group, setting.key) live = read_shortcut_value(setting.group, setting.key)
default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys") 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: if live == default:
continue continue
elif mechanism == "freeform":
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) live = read_live_value(setting, default)
if live == "": if live == "":
continue continue
else:
continue
except (ValueError, RuntimeError) as e: except (ValueError, RuntimeError) as e:
print(f"dot kde diff: {e}", file=sys.stderr) print(f"dot kde diff: {e}", file=sys.stderr)
continue continue
@@ -400,19 +486,19 @@ def cmd_diff(args, manifest_path, schema_dir):
def cmd_complete(schema_dir): def cmd_complete(schema_dir):
kcfg_map = build_kcfg_map(schema_dir) kcfg_map = build_kcfg_map(schema_dir)
for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
print(identifier) print(f"{setting.file}.{setting.group}.{setting.key}")
try: try:
# Fish's completion runs this on every TAB press, in shells that may have no # 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 # live KDE session (or no busctl at all) -- a broken shortcuts source must
# never cost the schema-backed candidates already printed above. # never cost the schema-backed candidates already printed above.
shortcut_identifiers = sorted(set(iter_shortcut_identifiers())) shortcut_settings = sorted(set(iter_shortcut_identifiers()))
except (RuntimeError, OSError): except (RuntimeError, OSError):
shortcut_identifiers = [] shortcut_settings = []
for identifier in shortcut_identifiers: for setting in shortcut_settings:
print(identifier) print(f"{setting.file}.{setting.group}.{setting.key}")
return 0 return 0

View File

@@ -1,2 +1,29 @@
kxkbrc.Layout.Options=caps:escape_shifted_capslock kxkbrc.Layout.Options=caps:escape_shifted_capslock
kglobalshortcutsrc.ksmserver.Lock Session=Meta+X Screensaver kglobalshortcutsrc.ksmserver.Lock Session=Meta+X Screensaver
kglobalshortcutsrc.kwin.Window to Desktop 1=Meta+!
kglobalshortcutsrc.kwin.Window to Desktop 2=Meta+@
kglobalshortcutsrc.kwin.Window to Desktop 3=Meta+#
kglobalshortcutsrc.kwin.Window to Desktop 4=Meta+$
kglobalshortcutsrc.kwin.Window to Desktop 5=Meta+%
kglobalshortcutsrc.kwin.Window to Desktop 6=Meta+^
kglobalshortcutsrc.kwin.Window to Desktop 7=Meta+&
kglobalshortcutsrc.kwin.Window to Desktop 8=Meta+*
kglobalshortcutsrc.kwin.Window to Desktop 9=Meta+(
kglobalshortcutsrc.kwin.Switch to Desktop 1=Meta+1
kglobalshortcutsrc.kwin.Switch to Desktop 2=Meta+2
kglobalshortcutsrc.kwin.Switch to Desktop 3=Meta+3
kglobalshortcutsrc.kwin.Switch to Desktop 4=Meta+4
kglobalshortcutsrc.kwin.Switch to Desktop 5=Meta+5
kglobalshortcutsrc.kwin.Switch to Desktop 6=Meta+6
kglobalshortcutsrc.kwin.Switch to Desktop 7=Meta+7
kglobalshortcutsrc.kwin.Switch to Desktop 8=Meta+8
kglobalshortcutsrc.kwin.Switch to Desktop 9=Meta+9
kglobalshortcutsrc.plasmashell.activate task manager entry 1=
kglobalshortcutsrc.plasmashell.activate task manager entry 2=
kglobalshortcutsrc.plasmashell.activate task manager entry 3=
kglobalshortcutsrc.plasmashell.activate task manager entry 4=
kglobalshortcutsrc.plasmashell.activate task manager entry 5=
kglobalshortcutsrc.plasmashell.activate task manager entry 6=
kglobalshortcutsrc.plasmashell.activate task manager entry 7=
kglobalshortcutsrc.plasmashell.activate task manager entry 8=
kglobalshortcutsrc.plasmashell.activate task manager entry 9=