diff --git a/.config/dot/.claude/scheduled_tasks.lock b/.config/dot/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..0a93074 --- /dev/null +++ b/.config/dot/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"d1732179-27b7-49fb-82af-6669b783a444","pid":45537,"procStart":"7266197","acquiredAt":1783288905666} \ No newline at end of file diff --git a/.config/dot/.claude/tasks/0010-kde-shortcuts-diff-broadscan.md b/.config/dot/.claude/tasks/0010-kde-shortcuts-diff-broadscan.md new file mode 100644 index 0000000..0d1ca7d --- /dev/null +++ b/.config/dot/.claude/tasks/0010-kde-shortcuts-diff-broadscan.md @@ -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. diff --git a/.config/dot/commands/kde/__pycache__/kde.cpython-314.pyc b/.config/dot/commands/kde/__pycache__/kde.cpython-314.pyc new file mode 100644 index 0000000..b6f99b5 Binary files /dev/null and b/.config/dot/commands/kde/__pycache__/kde.cpython-314.pyc differ diff --git a/.config/dot/commands/kde/kde.py b/.config/dot/commands/kde/kde.py index 98174c5..4b0effc 100644 --- a/.config/dot/commands/kde/kde.py +++ b/.config/dot/commands/kde/kde.py @@ -38,23 +38,85 @@ APPLY_USAGE = """usage: dot kde apply DIFF_USAGE = """usage: dot kde diff Scans every schema-backed setting reachable through the kcfg mapping - table and reports each one whose live value differs from its - schema-declared default, tagged declared (present in the manifest) - or undeclared. Also reports already-declared freeform and shortcut - settings whose live value differs from their default (neither has a - schema/mapping table to broad-scan, so both are only checked when - already declared). Read-only -- never writes the manifest or the - live system. + 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 parse_identifier(identifier): - parts = identifier.split(".", 2) - if len(parts) != 3: +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)") - 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): @@ -137,7 +199,7 @@ def iter_schema_identifiers(kcfg_map): for entry in group_elem.findall(f"{KCFG_NS}entry"): key = entry.get("key") or entry.get("name") if key: - yield f"{rcfile}.{group}.{key}" + yield Setting(rcfile, group, key) def resolve_mechanism(setting, kcfg_map): @@ -233,7 +295,7 @@ def iter_shortcut_identifiers(): (components,) = _kglobalaccel_call("allMainComponents", None) for component in components: 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): @@ -279,7 +341,7 @@ def write_shortcut_value(component_unique, action_unique, value): def save_one(identifier, kcfg_map): - setting = parse_identifier(identifier) + setting = parse_identifier(identifier, kcfg_map) mechanism, default = resolve_mechanism(setting, kcfg_map) if mechanism == "shortcuts": 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): - setting = parse_identifier(identifier) + setting = parse_identifier(identifier, kcfg_map) mechanism, _default = resolve_mechanism(setting, kcfg_map) if mechanism == "shortcuts": 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) manifest = load_manifest(manifest_path) - for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): - setting = parse_identifier(identifier) + 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) @@ -370,24 +432,48 @@ def cmd_diff(args, manifest_path, schema_dir): tag = "declared" if identifier in manifest else "undeclared" print(f"{tag} {identifier} = {live} (default: {default})") - # Freeform and shortcuts settings have no schema/mapping table to enumerate, - # so unlike the schema-backed loop above, they can only be checked by walking - # identifiers already in the manifest -- neither ever surfaces an undeclared - # setting via broad scan. - for identifier in manifest: + # 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: - 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) - if mechanism == "shortcuts": - live = read_shortcut_value(setting.group, setting.key) - default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys") - if live == default: - continue - elif mechanism == "freeform": - live = read_live_value(setting, default) - if live == "": - continue - else: + 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) @@ -400,19 +486,19 @@ def cmd_diff(args, manifest_path, schema_dir): def cmd_complete(schema_dir): kcfg_map = build_kcfg_map(schema_dir) - for identifier in sorted(set(iter_schema_identifiers(kcfg_map))): - print(identifier) + 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_identifiers = sorted(set(iter_shortcut_identifiers())) + shortcut_settings = sorted(set(iter_shortcut_identifiers())) except (RuntimeError, OSError): - shortcut_identifiers = [] + shortcut_settings = [] - for identifier in shortcut_identifiers: - print(identifier) + for setting in shortcut_settings: + print(f"{setting.file}.{setting.group}.{setting.key}") return 0 diff --git a/.config/dot/kde-manifest b/.config/dot/kde-manifest index 95e0e70..56996ee 100644 --- a/.config/dot/kde-manifest +++ b/.config/dot/kde-manifest @@ -1,2 +1,29 @@ kxkbrc.Layout.Options=caps:escape_shifted_capslock 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=