Compare commits

..

3 Commits

Author SHA1 Message Date
f15713d183 dotcli: Enable shortcuts to be discovered by diff. 2026-07-05 22:33:45 -04:00
537724989a dotcli: add shortcut identifiers to dot kde save completion
dot kde save's tab-completion only ever enumerated schema-backed
identifiers; kglobalshortcutsrc identifiers never showed up as
candidates despite being a fully supported mechanism. Enumerate them
live via kglobalaccel (allMainComponents/allActionsForComponent),
printed as a separate block after the schema-backed one, degrading
silently if the D-Bus session is unavailable.
2026-07-05 22:02:40 -04:00
ec43fb2e14 docs: KDE shortcut completion fix. 2026-07-05 21:55:27 -04:00
6 changed files with 326 additions and 36 deletions

View File

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

View File

@@ -0,0 +1,51 @@
---
blocked-by: 0005-kde-shortcuts-mechanism
---
## What to build
`dot kde save`'s tab-completion (`cmd_complete` in `commands/kde/kde.py`)
currently only enumerates schema-backed identifiers via
`iter_schema_identifiers` — it was built as a side effect of the `diff`
task (0003) and never revisited when the shortcuts mechanism (0005)
landed. Extend `cmd_complete` to also enumerate shortcut identifiers.
Source the shortcut identifiers live via `kglobalaccel`, mirroring how
schema identifiers are freshly parsed from `.kcfg` files on every call:
call `allMainComponents()` to get every registered component's
`componentUnique`, then `allActionsForComponent()` per component
(already used by `_resolve_shortcut_action_id`) to get every
`actionUnique`, yielding `kglobalshortcutsrc.<componentUnique>.<actionUnique>`
candidates. No caching — walk fresh on every invocation.
Print shortcut identifiers as their own block, after the existing
schema-backed block — not merged into one interleaved sorted list.
Keep them plain, with no friendly-name description text, matching the
existing schema-identifier output style.
If the D-Bus walk fails for any reason — a non-zero `busctl` exit
(`RuntimeError`, already raised by `_kglobalaccel_call`) or `busctl`
itself being missing (`OSError` from `subprocess.run`) — swallow it
silently: omit the shortcuts block, still print the schema block, and
emit no stderr diagnostic.
Freeform identifiers (e.g. `kxkbrc.Layout.Options`) are explicitly out
of scope for this task: there is no schema to enumerate them from, so
this stays a permanent, accepted completion gap, not something to fix
here.
## Acceptance criteria
- [x] `python3 kde.py complete` includes every currently-registered `kglobalshortcutsrc.<componentUnique>.<actionUnique>` identifier, sourced live via `allMainComponents`/`allActionsForComponent`
- [x] Schema-backed identifiers print first, followed by shortcut identifiers, as two distinct blocks — not interleaved into one merged sorted list
- [x] Shortcut identifiers print plain, with no friendly-name description text
- [x] If the D-Bus walk raises `RuntimeError` or `OSError`, the shortcuts block is omitted, the schema block still prints normally, and nothing is written to stderr
- [x] Freeform identifiers remain unlisted by `cmd_complete` (unchanged, confirmed not a regression)
- [x] Verified manually against a live session — no new automated tests, consistent with the existing shortcuts-mechanism test carve-out (spec's testing decisions, 0005's Implementation Notes)
## Implementation Notes
- `iter_shortcut_identifiers` (new, `commands/kde/kde.py`) walks `allMainComponents()` then `allActionsForComponent()` per component, yielding `kglobalshortcutsrc.<componentUnique>.<actionUnique>`. `cmd_complete` wraps that walk in `sorted(set(...))` and appends it as a second print loop after the existing schema-backed one, inside a `try/except (RuntimeError, OSError)` that falls back to an empty list on any failure — so a missing `busctl` or an unreachable D-Bus session degrades completion instead of breaking it.
- Manually verified both paths: live run on this machine prints 278 shortcut identifiers after 322 schema-backed ones; with `busctl` removed from `PATH` (simulating a non-KDE/minimal shell), `cmd_complete` still exits 0, prints only the 322 schema identifiers, and writes nothing to stderr.
- `/review-uncommitted`'s Standards pass flagged two judgement-call smells: (1) the D-Bus call/unpack idiom for `allActionsForComponent` was duplicated between the new function and `_resolve_shortcut_action_id`; (2) the silent `except` swallow had no comment explaining why. Fixed both: extracted a shared `_actions_for_component(component_unique)` helper used by both call sites, and added a comment on the `try` explaining that fish invokes this on every TAB press in shells that may lack a live KDE session, so a broken shortcuts source must never cost the already-printed schema candidates. Re-ran the full test suite (101/101 pass) and both manual checks after the fix.
- No automated tests added, per the task's own acceptance criterion and the shortcuts mechanism's existing test carve-out (0005's Implementation Notes: a live D-Bus session isn't practically substitutable without disproportionate mock infrastructure).

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):
@@ -224,9 +286,20 @@ def _kglobalaccel_call(method, signature, *tokens):
return json.loads(result.stdout)["data"] return json.loads(result.stdout)["data"]
def _resolve_shortcut_action_id(component_unique, action_unique): def _actions_for_component(component_unique):
(actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique) (actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique)
for action in actions: 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: if action[0] == component_unique and action[1] == action_unique:
return action return action
@@ -268,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)
@@ -276,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)
@@ -344,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)
@@ -359,24 +432,48 @@ 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) 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) mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts": if mechanism != "freeform":
live = read_shortcut_value(setting.group, setting.key) continue
default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys") live = read_live_value(setting, default)
if live == default: if live == "":
continue
elif mechanism == "freeform":
live = read_live_value(setting, default)
if live == "":
continue
else:
continue 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)
@@ -389,8 +486,20 @@ 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:
# 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 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=