Compare commits

...

3 Commits

Author SHA1 Message Date
8d6ec10b74 dotcli: Add shortcuts mechanism to dot kde save/apply/diff
Dispatches kglobalshortcutsrc identifiers through the kglobalaccel D-Bus
service instead of the schema-backed or freeform rc-file paths. Verified
manually against the live session (per the spec's testing decision, this
mechanism is excluded from the automated suite); switched to the
shortcutKeys/defaultShortcutKeys/setShortcutKeys D-Bus methods after the
spec's originally-named shortcut/defaultShortcut/setShortcut proved to
return stale data on the live system. Moves Lock Session off Meta+L to
Meta+X as the real-world validation, tracked in the manifest and recorded
in keybindings.md.
2026-07-05 21:07:30 -04:00
00da6f1466 dotcli: add freeform mechanism to dot kde save/apply/diff
Read/write schema-less KDE settings (e.g. kxkbrc's Options= line)
directly via kreadconfig6/kwriteconfig6, with default defined as the
key being absent. diff only checks freeform entries already declared
in the manifest, never via undeclared broad scan.

Brings the machine's caps-lock/Escape swap under tracking as the
real-world validation case.
2026-07-05 20:41:15 -04:00
8570826927 dotcli: Add dot kde diff for schema-backed settings, fix apply/diff completions
Implements the broad, read-only schema-backed scan for dot kde diff:
walks every (rcfile, group, key) reachable through the kcfg mapping
table, compares live values against their schema-declared defaults,
and reports each mismatch tagged declared or undeclared. Never writes.

Also fixes dot kde completions, which only ever offered save/help —
apply (added in a prior task) and diff were both missing.
2026-07-05 20:24:15 -04:00
10 changed files with 348 additions and 39 deletions

View File

@@ -19,9 +19,17 @@ Add a README row for `dot kde diff`.
## Acceptance criteria
- [ ] `dot kde diff` reports every schema-backed setting whose live value differs from its schema-declared default
- [ ] Each reported mismatch is tagged declared or undeclared based on manifest presence
- [ ] `dot kde diff` makes no writes under any circumstances
- [ ] `dot kde diff help` prints usage without scanning
- [ ] Tests run against a scratch `$HOME` and fixture `.kcfg` schema directory, covering: a declared mismatch, an undeclared mismatch, and a setting matching its default (not reported)
- [ ] README has a row for `dot kde diff`
- [x] `dot kde diff` reports every schema-backed setting whose live value differs from its schema-declared default
- [x] Each reported mismatch is tagged declared or undeclared based on manifest presence
- [x] `dot kde diff` makes no writes under any circumstances
- [x] `dot kde diff help` prints usage without scanning
- [x] Tests run against a scratch `$HOME` and fixture `.kcfg` schema directory, covering: a declared mismatch, an undeclared mismatch, and a setting matching its default (not reported)
- [x] README has a row for `dot kde diff`
## Implementation Notes
- `cmd_diff` (in `commands/kde/kde.py`) reuses `build_kcfg_map`/`iter_schema_identifiers` (already built for `kde.py complete`) to walk every schema-backed `(rcfile, group, key)`, then `find_schema_default`/`read_live_value` (already built for `save`) to compare live vs. default. No new scanning machinery was needed — this task's whole job was wiring existing pieces together into a read-only report.
- Output format: one line per mismatch, `<declared|undeclared> <identifier> = <live> (default: <default>)`. Not specified by the task, so chosen to read clearly and stay unambiguous under substring matching in tests (avoided bracketed tags like `[declared]`, since fish's `string match` glob treats `[...]` as a character class).
- `/review-uncommitted`'s Spec pass caught that `cmd_diff` had no error handling around `read_live_value`, unlike `cmd_apply`/`cmd_save`'s `try/except (ValueError, RuntimeError)` — a single `kreadconfig6` failure would have aborted the entire broad scan with an uncaught traceback, contradicting `diff`'s "report every mismatch" framing. Fixed: `cmd_diff` now catches `RuntimeError` per-identifier, prints a warning to stderr, and continues scanning the rest.
- The Standards pass flagged the "build map → iterate `sorted(set(iter_schema_identifiers(...)))`" shape as now duplicated between `cmd_diff` and `cmd_complete`, and the new test scenarios' fixture boilerplate as repeating the `apply` tests' shape almost verbatim. Left both as-is: the loop duplication is two call sites doing genuinely different things with the result, and the test boilerplate matches this file's already-established per-scenario convention (each scenario resets `$HOME` independently) rather than introducing a new pattern.
- Post-closeout fix (user-reported): `~/.config/fish/completions/dot.fish`'s `dot kde` completion block only ever listed `save`/`help` as verbs — `apply` was never added when task 0002 built it, and this task initially repeated the same omission for `diff`. Fixed both by adding `apply` and `diff` to the top-level verb-offering line and to the post-subcommand `help` gating; verified manually via `complete -C"dot kde "` and `complete -C"dot kde apply "`/`complete -C"dot kde diff "`.

View File

@@ -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 <identifier>` 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 <identifier>` 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`).

View File

@@ -30,9 +30,28 @@ project's cross-cutting keybindings convention.
## Acceptance criteria
- [ ] An identifier whose rc file is `kglobalshortcutsrc` dispatches to the `kglobalaccel` D-Bus mechanism rather than the schema-backed or freeform paths
- [ ] `dot kde save <identifier>` and `dot kde save` (refresh) read a shortcut's current value via `shortcut(actionId)`, resolving the friendly-name fields dynamically
- [ ] `dot kde apply` writes a declared shortcut via `setShortcut(actionId, keys, NoAutoloading)`, verified manually to take effect immediately in the running session
- [ ] `dot kde diff` reports a declared shortcut mismatch by comparing against `defaultShortcut(actionId)`, verified manually
- [ ] The Spectacle and Lock-Session (`Meta+X`) keybind changes are applied through `dot kde save`/`apply` and tracked in the manifest
- [ ] `keybindings.md` is updated to reflect the new bindings in the same change
- [x] An identifier whose rc file is `kglobalshortcutsrc` dispatches to the `kglobalaccel` D-Bus mechanism rather than the schema-backed or freeform paths
- [x] `dot kde save <identifier>` and `dot kde save` (refresh) read a shortcut's current value via `shortcut(actionId)`, resolving the friendly-name fields dynamically
- [x] `dot kde apply` writes a declared shortcut via `setShortcut(actionId, keys, NoAutoloading)`, verified manually to take effect immediately in the running session
- [x] `dot kde diff` reports a declared shortcut mismatch by comparing against `defaultShortcut(actionId)`, verified manually
- [-] The Spectacle and Lock-Session (`Meta+X`) keybind changes are applied through `dot kde save`/`apply` and tracked in the manifest
- [x] `keybindings.md` is updated to reflect the new bindings in the same change
## Implementation Notes
- **Deviation from the task's named D-Bus methods**: manually verifying against the real, live `kglobalaccel` session (both on the just-applied `Lock Session` action and on an untouched, pre-existing action with a genuinely different current/default in `kglobalshortcutsrc`) showed that `defaultShortcut(actionId)` — the flat `ai`-signature method the task names — does not return the true packaged default on this KF6 build.
It just mirrors `shortcut(actionId)`.
Using it would have made `diff` permanently blind to shortcut drift after the very first `apply`.
The newer plural `shortcutKeys`/`defaultShortcutKeys`/`setShortcutKeys` methods (signature `a(ai)`, one 4-int `QKeyCombination` chord slot per bound key sequence) were empirically confirmed correct instead — `defaultShortcutKeys` kept reporting `Meta+L` for `Lock Session` even after `setShortcutKeys` changed its current value to `Meta+X` — and are what `read_shortcut_value`/`write_shortcut_value` in `commands/kde/kde.py` actually call.
`NoAutoloading`'s value (`0x4`, from `KF6/KGlobalAccel/kglobalaccel.h`) is unchanged by this swap.
- Only single, non-chorded key combinations are supported (`_string_to_keys` rejects a `QKeySequence` whose `count()` isn't exactly 1) — chord sequences like "Ctrl+K, Ctrl+S" were out of scope for the two real bindings this task needed and add ambiguity to the tab-separated multi-binding format below.
- **Value format**: a shortcut's manifest value is its bound key sequences joined with `\t` (matching `kglobalshortcutsrc`'s own convention for an action with more than one simultaneous binding, e.g. `Lock Session`'s `Screensaver` + `Meta+L`), converted to/from KDE's integer key encoding via `QKeySequence` (PyQt6).
PyQt6 import is lazy (`_key_sequence_class`) and raises a clear `RuntimeError` if missing, so `save`/`apply`/`diff` on non-shortcut identifiers never pay for or depend on it.
- **Spectacle bindings dropped** from this change's real-world validation.
Investigating turned up that Spectacle has never registered any shortcuts with the live `kglobalaccel` at all (`allActionsForComponent` returns empty even after launching it), and no "planned" Spectacle keybindings were recorded anywhere in the repo (spec, task file, or `keybindings.md`) for me to apply — this task's own text names Lock Session's target (`Meta+X`) explicitly but only gestures at "Spectacle bindings" with no specifics.
Asked the user directly; they chose to skip Spectacle for this change and handle it separately.
Only the Lock Session move is applied here.
The parent spec's aside about "renaming Spectacle's save folder" is also left untouched for the same reason — no recorded target folder name to apply, and out of scope once Spectacle itself was descoped.
- **Lock Session validation**: `dot kde save "kglobalshortcutsrc.ksmserver.Lock Session"` seeded the manifest from the live value (`Meta+L\tScreensaver`); the manifest was then hand-edited to `Meta+X\tScreensaver` (preserving the existing `Screensaver` multimedia-key binding, changing only the `Meta+L` half); `dot kde apply` pushed it live (confirmed via a direct `kglobalaccel` D-Bus read afterward, and idempotent on a second run); `dot kde diff` correctly reports `declared kglobalshortcutsrc.ksmserver.Lock Session = Meta+X\tScreensaver (default: Meta+L\tScreensaver)`.
`Meta+X` is now live and tracked; `keybindings.md` has a row for it.
- Per the spec's testing decision, no automated tests were added for the shortcuts mechanism; the two pre-existing "not yet supported" rejection tests for shortcuts (in `save` and `apply`) were removed from `tests/dot.fish` and replaced with a short comment pointing to this exclusion, rather than left in place asserting behavior that's no longer true.

View File

@@ -3,6 +3,7 @@ function _dot_kde_usage
Commands:
apply push manifest entries onto the live system
diff scan for settings whose live value differs from its default
save write live KDE settings into the manifest
help show this message
@@ -21,6 +22,9 @@ function _dot_kde
case apply
python3 $helper_dir/kde.py apply $argv[2..-1]
return $status
case diff
python3 $helper_dir/kde.py diff $argv[2..-1]
return $status
case save
python3 $helper_dir/kde.py save $argv[2..-1]
return $status

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
@@ -9,6 +10,14 @@ 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 = {
@@ -26,6 +35,18 @@ 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 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.
help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"])
@@ -159,19 +180,107 @@ def write_live_value(setting, value):
)
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 _resolve_shortcut_action_id(component_unique, action_unique):
(actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique)
for action in actions:
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)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism != "schema":
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
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)
mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism != "schema":
raise RuntimeError(f"{identifier}: {mechanism} settings are not yet supported")
if mechanism == "shortcuts":
write_shortcut_value(setting.group, setting.key, value)
return
write_live_value(setting, value)
@@ -223,6 +332,61 @@ def cmd_apply(args, manifest_path, schema_dir):
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 identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
setting = parse_identifier(identifier)
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})")
# 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:
try:
setting = parse_identifier(identifier)
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:
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 identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
@@ -245,6 +409,9 @@ def main(argv):
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.

2
.config/dot/kde-manifest Normal file
View File

@@ -0,0 +1,2 @@
kxkbrc.Layout.Options=caps:escape_shifted_capslock
kglobalshortcutsrc.ksmserver.Lock Session=Meta+X Screensaver

View File

@@ -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
# the shortcuts mechanism (kglobalshortcutsrc -> kglobalaccel D-Bus calls) is
# deliberately excluded from this suite -- it depends on a live, already-running
# session service not practically substitutable without disproportionate mock
# infrastructure. Verified manually against the real session instead.
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,22 @@ 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"
# the shortcuts mechanism is deliberately excluded from this suite -- see the
# note by the `dot kde save` shortcuts exclusion above.
# misuse: apply takes no arguments
printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest
@@ -457,6 +481,76 @@ dot kde apply extra-arg >/dev/null 2>&1
set -l apply_extra_arg_status $status
@test "dot kde apply rejects an unexpected argument" $apply_extra_arg_status -ne 0
# --- dot kde diff help touches neither the manifest nor kreadconfig6 ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
set -l fake_bin_kde_diff (mktemp -d)
set -gx KREADCONFIG_LOG (mktemp)
echo '#!/bin/sh
echo "$@" >>"$KREADCONFIG_LOG"
exit 1' >$fake_bin_kde_diff/kreadconfig6
chmod +x $fake_bin_kde_diff/kreadconfig6
set -gx PATH $fake_bin_kde_diff $path_before_fake_kreadconfig
set -l kde_diff_help_output (dot kde diff help)
set -l kde_diff_help_status $status
set -l kreadconfig_called_for_diff_help (test -s $KREADCONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_diff_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde diff help succeeds" $kde_diff_help_status -eq 0
@test "dot kde diff help mentions undeclared" (string match -q '*undeclared*' -- $kde_diff_help_output; echo $status) -eq 0
@test "dot kde diff help never invokes kreadconfig6" $kreadconfig_called_for_diff_help = no
@test "dot kde diff help does not create a manifest" $manifest_exists_after_diff_help = no
set -gx PATH $path_before_fake_kreadconfig
# --- dot kde diff: broad read-only scan over every schema-backed identifier,
# tagging each mismatch declared/undeclared, and skipping settings that
# already match their schema default ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
mkdir -p $HOME/.config/dot
# Greeting differs from its default and is already declared in the manifest;
# 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. 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 '[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)
set -l diff_status $status
set -l manifest_after_diff (cat $HOME/.config/dot/kde-manifest | string collect)
@test "dot kde diff succeeds" $diff_status -eq 0
@test "dot kde diff tags an already-declared mismatch as declared" (string match -q '*declared testrc.General.Greeting = Bonjour (default: Hello)*' -- $diff_output; echo $status) -eq 0
@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
set -l diff_extra_arg_status $status
@test "dot kde diff rejects an unexpected argument" $diff_extra_arg_status -ne 0
# --- kde.py complete: tab-completion candidates, sourced from the live
# schema mapping table rather than a hardcoded list. This is the
# underlying data completions/dot.fish shells out to; the fish

View File

@@ -19,9 +19,11 @@ complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_arg
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l restore" -f -a "(__fish_print_pacman_packages)"
# --- dot kde ---
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from save help" -f -a save -d "write live KDE settings into the manifest"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from save help" -f -a help -d "show usage"
complete -c dot -n "__fish_seen_subcommand_from kde; and __fish_seen_subcommand_from save" -f -a help -d "show usage"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a apply -d "push manifest entries onto the live system"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a diff -d "scan for settings whose live value differs from its default"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a save -d "write live KDE settings into the manifest"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a help -d "show usage"
complete -c dot -n "__fish_seen_subcommand_from kde; and __fish_seen_subcommand_from apply diff save" -f -a help -d "show usage"
# Sourced live from the schema mapping table (real .kcfg files), not a
# hardcoded list -- same helper kde.py's own save/refresh logic builds from.
complete -c dot -n "__fish_seen_subcommand_from kde; and __fish_seen_subcommand_from save" -f -a "(python3 $HOME/.config/dot/commands/kde/kde.py complete 2>/dev/null)"

1
.github/README.md vendored
View File

@@ -21,6 +21,7 @@ fish -c 'dot init'
| `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). |
| `dot install --restore` | Reinstalls every package from the tracked list. |
| `dot kde apply` | Pushes every manifest entry's declared value onto the live system. |
| `dot kde diff` | Reports every schema-backed setting whose live value differs from its default, tagged declared or undeclared. |
| `dot kde help` | Lists `dot kde`'s subcommands. |
| `dot kde save <identifier>` | Reads a KDE setting's current live value and declares it in the manifest (`~/.config/dot/kde-manifest`). |
| `dot kde save` | Refreshes every already-declared manifest entry's value from the live system. |

View File

@@ -35,3 +35,6 @@ 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 |
| `Meta` + `X` | KDE | Lock Session (moved off `Meta+L`, tracked via `dot kde`) |