diff --git a/.config/dot/.claude/tasks/0004-kde-freeform-mechanism.md b/.config/dot/.claude/tasks/0004-kde-freeform-mechanism.md index 30e04d9..1fb5b57 100644 --- a/.config/dot/.claude/tasks/0004-kde-freeform-mechanism.md +++ b/.config/dot/.claude/tasks/0004-kde-freeform-mechanism.md @@ -24,9 +24,18 @@ correctly against it. ## Acceptance criteria -- [ ] An identifier whose `(rcfile, group, key)` has no schema match is treated as freeform rather than erroring -- [ ] `dot kde save ` and `dot kde save` (refresh) work for freeform entries -- [ ] `dot kde apply` writes freeform entries via `kwriteconfig6`, idempotently -- [ ] `dot kde diff` reports a freeform mismatch when its identifier is already declared in the manifest, and never surfaces an undeclared freeform setting via broad scan -- [ ] Tests run against a scratch `$HOME`, covering freeform save/apply/diff using a fixture rc file with no corresponding schema -- [ ] The live `kxkbrc` caps-lock/Escape swap is tracked via `dot kde save` and the manifest committed to the dotfiles repo +- [x] An identifier whose `(rcfile, group, key)` has no schema match is treated as freeform rather than erroring +- [x] `dot kde save ` and `dot kde save` (refresh) work for freeform entries +- [x] `dot kde apply` writes freeform entries via `kwriteconfig6`, idempotently +- [x] `dot kde diff` reports a freeform mismatch when its identifier is already declared in the manifest, and never surfaces an undeclared freeform setting via broad scan +- [x] Tests run against a scratch `$HOME`, covering freeform save/apply/diff using a fixture rc file with no corresponding schema +- [x] The live `kxkbrc` caps-lock/Escape swap is tracked via `dot kde save` and the manifest committed to the dotfiles repo + +## Implementation Notes + +- `save_one`/`apply_one`'s gate changed from `mechanism != "schema"` (reject everything but schema) to `mechanism == "shortcuts"` (reject only shortcuts) — freeform now flows through the same `read_live_value`/`write_live_value` calls schema-backed settings already use, since both mechanisms only differ in what "default" means, not in how the read/write itself happens. +- `cmd_diff` gained a second pass after the existing schema broad-scan: it walks the manifest (not the kcfg mapping table, which freeform settings are absent from by definition), resolves each identifier's mechanism, and reports only those that resolve to `freeform` and whose live value is non-empty — structurally guaranteeing freeform can never surface via undeclared broad scan, since the loop never sees anything outside the manifest. +- **Real-world validation surfaced a stale premise**: the task assumed the caps-lock/Escape swap was "already hand-set" and live, but the machine had no `kxkbrc` file and no active XKB option at all. Confirmed with the user before proceeding; with their approval, wrote the option live via `kwriteconfig6 --file kxkbrc --group Layout --key Options -- caps:escape_shifted_capslock` and applied it immediately via a live KWin reconfigure (`busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure`), then ran `dot kde save kxkbrc.Layout.Options` to bring it under tracking. `dot kde apply`/`dot kde diff` were both verified against the real entry (idempotent apply; diff reports `declared kxkbrc.Layout.Options = caps:escape_shifted_capslock (default: )`). +- Added a `.github/keybindings.md` row for the swap (`CapsLock` → `Esc`, `Shift`+`CapsLock` → real Caps Lock toggle), per the project's cross-cutting keybindings convention. +- Existing tests that previously asserted freeform saves/applies were *rejected* (written when freeform was still unimplemented, per task 0001/0002's "not yet supported" stopgap) were updated to assert success instead, using a new `somefreeform` fixture rc file with no corresponding `.kcfg` schema. Coverage for the still-unimplemented shortcuts mechanism (task 0005) was added in the same spots to keep the "not yet supported" rejection path tested now that freeform no longer exercises it. +- `/review-uncommitted`'s Spec pass caught that `cmd_diff`'s new freeform loop called `parse_identifier` on raw manifest keys with no exception guard, unlike the rest of the function — a hand-edited manifest with a malformed identifier would have crashed the whole scan instead of reporting a clean per-identifier error. Fixed: the loop body is now wrapped in `try/except (ValueError, RuntimeError)`, matching the file's established per-identifier-failure-tolerant convention. The Standards pass also flagged threading a hardcoded `None`/blank literal through the freeform loop instead of the real `default` value returned by `resolve_mechanism`; fixed by reusing that variable directly (`default or ''` for display, since freeform's default is always `None`). diff --git a/.config/dot/commands/kde/kde.py b/.config/dot/commands/kde/kde.py index 552fe65..6d3375e 100644 --- a/.config/dot/commands/kde/kde.py +++ b/.config/dot/commands/kde/kde.py @@ -31,8 +31,10 @@ 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. Read-only -- never writes the manifest or the live - system. + or undeclared. Also reports already-declared freeform settings whose + live value is present (freeform has no schema to scan, so it is 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"]) @@ -171,7 +173,7 @@ def write_live_value(setting, value): def save_one(identifier, kcfg_map): setting = parse_identifier(identifier) mechanism, default = resolve_mechanism(setting, kcfg_map) - if mechanism != "schema": + if mechanism == "shortcuts": raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported") return read_live_value(setting, default) @@ -179,7 +181,7 @@ def save_one(identifier, kcfg_map): def apply_one(identifier, value, kcfg_map): setting = parse_identifier(identifier) mechanism, _default = resolve_mechanism(setting, kcfg_map) - if mechanism != "schema": + if mechanism == "shortcuts": raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported") write_live_value(setting, value) @@ -259,6 +261,25 @@ def cmd_diff(args, manifest_path, schema_dir): tag = "declared" if identifier in manifest else "undeclared" print(f"{tag} {identifier} = {live} (default: {default})") + # Freeform settings have no schema to enumerate, so unlike the schema-backed + # loop above, this can only walk identifiers already in the manifest -- it + # never surfaces an undeclared freeform setting via broad scan. + for identifier in manifest: + try: + setting = parse_identifier(identifier) + mechanism, default = resolve_mechanism(setting, kcfg_map) + if mechanism != "freeform": + continue + live = read_live_value(setting, default) + except (ValueError, RuntimeError) as e: + print(f"dot kde diff: {e}", file=sys.stderr) + continue + + if live == "": + continue + + print(f"declared {identifier} = {live} (default: {default or ''})") + return 0 diff --git a/.config/dot/kde-manifest b/.config/dot/kde-manifest new file mode 100644 index 0000000..7741ad1 --- /dev/null +++ b/.config/dot/kde-manifest @@ -0,0 +1 @@ +kxkbrc.Layout.Options=caps:escape_shifted_capslock diff --git a/.config/dot/tests/dot.fish b/.config/dot/tests/dot.fish index cc8f773..0d26b15 100644 --- a/.config/dot/tests/dot.fish +++ b/.config/dot/tests/dot.fish @@ -353,35 +353,49 @@ dot kde save kwinrc.Windows.BorderSize >/dev/null 2>&1 set -l declared_count_before_freeform (cat $manifest | count) # a setting whose rc file never appears in the mapping table falls to the -# freeform branch, which the dispatch structure accounts for but does not -# implement yet +# freeform branch: read/write directly via kreadconfig6/kwriteconfig6, with +# "default" meaning "the key is absent" rather than any schema value +printf '[Group]\nKey=FreeformValue\n' >$HOME/.config/somefreeform dot kde save somefreeform.Group.Key >/dev/null 2>&1 -set -l unmapped_status $status +set -l freeform_save_status $status set -l declared_count_after_freeform (cat $manifest | count) -@test "an unmapped rc file is not silently treated as schema-backed" $unmapped_status -ne 0 -@test "a rejected freeform save adds no manifest entry" $declared_count_after_freeform -eq $declared_count_before_freeform +@test "dot kde save succeeds for a freeform (unmapped rc file) identifier" $freeform_save_status -eq 0 +@test "declares the freeform identifier with its real live value" (string match -q '*somefreeform.Group.Key=FreeformValue*' -- (cat $manifest); echo $status) -eq 0 +@test "a freeform save adds exactly one manifest entry" $declared_count_after_freeform -eq (math $declared_count_before_freeform + 1) # an arg="true" schema *absent* from the exceptions list (unmapped.kcfg) # must not be guessed at (e.g. from its own filename) -- it contributes -# nothing to the mapping table, so its settings fall to freeform too +# nothing to the mapping table, so its settings fall to freeform too. Proven +# here by reading with the key absent: a schema-backed read would fall back +# to the schema's declared default ("Unreachable"); freeform's "default" is +# instead "the key is absent", so it reads empty. dot kde save unmapped.Whatever.Setting >/dev/null 2>&1 set -l unlisted_arg_true_status $status -set -l declared_count_after_unlisted (cat $manifest | count) -@test "an arg=true schema missing from the exceptions list resolves to freeform, not schema" $unlisted_arg_true_status -ne 0 -@test "a rejected unlisted-arg=true save adds no manifest entry" $declared_count_after_unlisted -eq $declared_count_before_freeform +@test "an arg=true schema missing from the exceptions list resolves to freeform, not schema" $unlisted_arg_true_status -eq 0 +@test "a freeform read never falls back to another schema's default" (string match -q '*Unreachable*' -- (cat $manifest); echo $status) -eq 1 +@test "a freeform read of an absent key stores an empty value" (string match -q '*unmapped.Whatever.Setting=*' -- (cat $manifest); echo $status) -eq 0 + +# misuse: shortcuts settings remain unsupported until a later task +dot kde save kglobalshortcutsrc.someComponent.someAction >/dev/null 2>&1 +set -l shortcuts_save_status $status +@test "dot kde save rejects a shortcuts identifier (not yet supported)" $shortcuts_save_status -ne 0 + +set -l declared_count_before_refresh (cat $manifest | count) # --- dot kde save with no arguments refreshes every already-declared entry --- printf '[General]\nGreeting=Changed\n' >$HOME/.config/testrc +printf '[Group]\nKey=RefreshedFreeform\n' >$HOME/.config/somefreeform dot kde save >/dev/null 2>&1 set -l refresh_status $status set -l declared_count_after_refresh (cat $manifest | count) @test "dot kde save with no arguments succeeds" $refresh_status -eq 0 -@test "refreshes an already-declared entry's value from the live system" (string match -q '*testrc.General.Greeting=Changed*' -- (cat $manifest); echo $status) -eq 0 +@test "refreshes an already-declared schema-backed entry's value from the live system" (string match -q '*testrc.General.Greeting=Changed*' -- (cat $manifest); echo $status) -eq 0 +@test "refreshes an already-declared freeform entry's value from the live system" (string match -q '*somefreeform.Group.Key=RefreshedFreeform*' -- (cat $manifest); echo $status) -eq 0 @test "refresh leaves other already-declared entries untouched" (string match -q '*testrc.General.RealKey=AliasDefault*' -- (cat $manifest); echo $status) -eq 0 -@test "refresh adds no new undeclared entries" $declared_count_after_refresh -eq $declared_count_before_freeform +@test "refresh adds no new undeclared entries" $declared_count_after_refresh -eq $declared_count_before_refresh # --- misuse: too many arguments / a malformed identifier --- dot kde save one two >/dev/null 2>&1 @@ -444,12 +458,26 @@ set -l testrc_after_reapply (cat $HOME/.config/testrc) @test "re-running dot kde apply succeeds" $reapply_status -eq 0 @test "re-running dot kde apply against an already-applied system is idempotent" "$testrc_after_reapply" = "$testrc_after_apply" -# a manifest entry whose rc file isn't schema-backed (freeform, not yet -# implemented) is rejected rather than silently mis-applied +# a manifest entry whose rc file has no schema (freeform) is written +# directly via kwriteconfig6, idempotently, just like a schema-backed entry printf 'testrc.General.Greeting=Applied Greeting\nsomefreeform.Group.Key=Value\n' >$HOME/.config/dot/kde-manifest dot kde apply >/dev/null 2>&1 set -l apply_freeform_status $status -@test "dot kde apply rejects a manifest entry whose mechanism isn't schema-backed yet" $apply_freeform_status -ne 0 +set -l freeformrc_after_apply (cat $HOME/.config/somefreeform) + +@test "dot kde apply succeeds for a manifest with a freeform entry" $apply_freeform_status -eq 0 +@test "dot kde apply writes a freeform entry via kwriteconfig6" (string match -q '*Key=Value*' -- $freeformrc_after_apply; echo $status) -eq 0 + +dot kde apply >/dev/null 2>&1 +set -l freeformrc_after_reapply (cat $HOME/.config/somefreeform) +@test "re-running dot kde apply against an already-applied freeform entry is idempotent" "$freeformrc_after_reapply" = "$freeformrc_after_apply" + +# a manifest entry whose mechanism is shortcuts (not yet implemented) is +# rejected rather than silently mis-applied +printf 'testrc.General.Greeting=Applied Greeting\nkglobalshortcutsrc.someComponent.someAction=Value\n' >$HOME/.config/dot/kde-manifest +dot kde apply >/dev/null 2>&1 +set -l apply_shortcuts_status $status +@test "dot kde apply rejects a manifest entry whose mechanism isn't implemented yet (shortcuts)" $apply_shortcuts_status -ne 0 # misuse: apply takes no arguments printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest @@ -498,9 +526,15 @@ mkdir -p $HOME/.config/dot # RealKey differs from its default but has never been declared; Some.Key With # Spaces is left unset, so it falls back to (and matches) its schema default, # and kwinrc.Windows.BorderSize likewise matches its default via the -# arg=true/exceptions-list mapping -- neither should be reported. +# arg=true/exceptions-list mapping -- neither should be reported. On the +# freeform side: Group.Key is declared and present live (a mismatch against +# freeform's "absent" default); Group.AbsentKey is declared but never applied +# live, so it matches the absent default and isn't reported; Other.Undeclared +# is present live but never declared, and must never surface via broad scan +# since freeform has no schema to enumerate from. printf '[General]\nGreeting=Bonjour\nRealKey=ChangedAlias\n' >$HOME/.config/testrc -printf 'testrc.General.Greeting=Bonjour\n' >$HOME/.config/dot/kde-manifest +printf '[Group]\nKey=CustomValue\n\n[Other]\nUndeclared=ShouldNeverAppear\n' >$HOME/.config/somefreeform +printf 'testrc.General.Greeting=Bonjour\nsomefreeform.Group.Key=CustomValue\nsomefreeform.Group.AbsentKey=NeverApplied\n' >$HOME/.config/dot/kde-manifest set -l manifest_before_diff (cat $HOME/.config/dot/kde-manifest | string collect) set -l diff_output (dot kde diff) @@ -512,6 +546,9 @@ set -l manifest_after_diff (cat $HOME/.config/dot/kde-manifest | string collect) @test "dot kde diff tags a never-declared mismatch as undeclared" (string match -q '*undeclared testrc.General.RealKey = ChangedAlias (default: AliasDefault)*' -- $diff_output; echo $status) -eq 0 @test "dot kde diff does not report a setting matching its default (unset key)" (string match -q '*Some.Key With Spaces*' -- $diff_output; echo $status) -eq 1 @test "dot kde diff does not report a setting matching its default (arg=true mapping)" (string match -q '*BorderSize*' -- $diff_output; echo $status) -eq 1 +@test "dot kde diff reports an already-declared freeform mismatch (default is absent)" (string match -q '*declared somefreeform.Group.Key = CustomValue (default: )*' -- $diff_output; echo $status) -eq 0 +@test "dot kde diff does not report a declared freeform entry matching its absent default" (string match -q '*AbsentKey*' -- $diff_output; echo $status) -eq 1 +@test "dot kde diff never surfaces an undeclared freeform setting via broad scan" (string match -q '*Undeclared*' -- $diff_output; echo $status) -eq 1 @test "dot kde diff makes no writes to the manifest" "$manifest_after_diff" = "$manifest_before_diff" dot kde diff extra-arg >/dev/null 2>&1 diff --git a/.github/keybindings.md b/.github/keybindings.md index 6b3b698..a0d7646 100644 --- a/.github/keybindings.md +++ b/.github/keybindings.md @@ -35,3 +35,5 @@ Comma-separated keys are pressed in sequence, not together. | `Ctrl` + `h` / `j` / `k` / `l` | neovim | Move focus between splits left / down / up / right | | `Esc` | neovim | Clear search highlight | | `Space`, `e` | neovim | Toggle file explorer (netrw) | +| `CapsLock` | KDE | Acts as `Esc` (`kxkbrc` `Options=caps:escape_shifted_capslock`) | +| `Shift` + `CapsLock` | KDE | Toggles Caps Lock |