From 45ac7ecb158287d402193736b9d22fc0962bd34c Mon Sep 17 00:00:00 2001 From: Micah Alpern Date: Tue, 20 Oct 2026 07:11:56 -0700 Subject: [PATCH] Mapper: a save that declines says so; captured media keys map to Kanata names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay Mapper saves on capture and has no Save button, yet it never rendered the view model's status message and the save path's early returns did not log — so a declined save looked like a mapping that vanished (seen on the Air, 2026-10-20: a ` → mute rule disappeared with nothing in the log). The overlay now shows the status under the keycaps; autosave logs why it skipped and reports a missing runtime coordinator as an error; save logs each early return and the rule it is about to write. KeyMappingFormatter also mapped no media names, so a captured volume or brightness key reached the generator as "volumeup" and would have been typed as letters. The names KeyboardCapture assigns now map to the Kanata names the session posts. Co-Authored-By: Claude Fable 5.1 --- .../UI/Mapper/KeyMappingFormatter.swift | 14 ++++- .../MapperViewModel+CaptureManagement.swift | 53 +++++++++++-------- .../MapperViewModel+RuleManagement.swift | 3 ++ .../UI/Overlay/OverlayMapperSection.swift | 15 ++++++ .../MapperAutosaveVisibilityTests.swift | 41 ++++++++++++++ .../MapperKanataFormatTests.swift | 12 +++++ .../AdmissionEnvironmentTests.swift | 8 +-- 7 files changed, 120 insertions(+), 26 deletions(-) create mode 100644 Tests/KeyPathTests/MapperAutosaveVisibilityTests.swift diff --git a/Sources/KeyPathAppKit/UI/Mapper/KeyMappingFormatter.swift b/Sources/KeyPathAppKit/UI/Mapper/KeyMappingFormatter.swift index b142b7f85..b4a14bdd6 100644 --- a/Sources/KeyPathAppKit/UI/Mapper/KeyMappingFormatter.swift +++ b/Sources/KeyPathAppKit/UI/Mapper/KeyMappingFormatter.swift @@ -18,7 +18,19 @@ public enum KeyMappingFormatter { "enter": "ret", "escape": "esc", "backspace": "bspc", - "delete": "del" + "delete": "del", + // Media keys as `KeyboardCapture.mediaKeyCodeToString` names them, to + // the Kanata names the session can post (phase 6). Fast-forward, + // rewind and keyboard backlight have no Kanata name and stay as is. + "volumeup": "volu", + "volumedown": "vold", + "mute": "mute", + "brightnessup": "brup", + "brightnessdown": "brdn", + "playpause": "pp", + "next": "next", + "previous": "prev", + "eject": "eject" ] /// Convert KeySequence to kanata format string diff --git a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift index 028b41861..cf8604e69 100644 --- a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift +++ b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+CaptureManagement.swift @@ -439,28 +439,39 @@ extension MapperViewModel { autosaveCapturedMapping() } - /// Auto-save when input is captured and we have either output or app/system action/URL/folder/script + /// Auto-save when input is captured and we have either output or app/system action/URL/folder/script. + /// Every way this can decline to save says so: the overlay has no Save button, + /// so a silent return here looks to the user like a mapping that vanished. private func autosaveCapturedMapping() { - if canSave, let manager = kanataManager { - Task { - if selectedURL != nil { - AppLogger.shared.log("🎯 [MapperViewModel] Calling saveURLMapping") - await saveURLMapping(kanataManager: manager) - } else if selectedApp != nil { - AppLogger.shared.log("🎯 [MapperViewModel] Calling saveAppLaunchMapping") - await saveAppLaunchMapping(kanataManager: manager) - } else if selectedSystemAction != nil { - AppLogger.shared.log("🎯 [MapperViewModel] Calling saveSystemActionMapping") - await saveSystemActionMapping(kanataManager: manager) - } else if selectedFolder != nil { - AppLogger.shared.log("🎯 [MapperViewModel] Calling saveFolderMapping") - await saveFolderMapping(kanataManager: manager) - } else if selectedScript != nil { - AppLogger.shared.log("🎯 [MapperViewModel] Calling saveScriptMapping") - await saveScriptMapping(kanataManager: manager) - } else { - await save(kanataManager: manager) - } + guard canSave else { + AppLogger.shared.log("🎯 [MapperViewModel] Autosave skipped: nothing saveable yet (canSave=false)") + return + } + guard let manager = kanataManager else { + AppLogger.shared.errorUnlessQuietTest("❌ [MapperViewModel] Autosave skipped: no runtime coordinator configured") + statusMessage = "Couldn't save: the Mapper isn't connected to the runtime. Reopen the overlay." + statusIsError = true + return + } + Task { + if selectedURL != nil { + AppLogger.shared.log("🎯 [MapperViewModel] Calling saveURLMapping") + await saveURLMapping(kanataManager: manager) + } else if selectedApp != nil { + AppLogger.shared.log("🎯 [MapperViewModel] Calling saveAppLaunchMapping") + await saveAppLaunchMapping(kanataManager: manager) + } else if selectedSystemAction != nil { + AppLogger.shared.log("🎯 [MapperViewModel] Calling saveSystemActionMapping") + await saveSystemActionMapping(kanataManager: manager) + } else if selectedFolder != nil { + AppLogger.shared.log("🎯 [MapperViewModel] Calling saveFolderMapping") + await saveFolderMapping(kanataManager: manager) + } else if selectedScript != nil { + AppLogger.shared.log("🎯 [MapperViewModel] Calling saveScriptMapping") + await saveScriptMapping(kanataManager: manager) + } else { + AppLogger.shared.log("🎯 [MapperViewModel] Calling save (keystroke mapping)") + await save(kanataManager: manager) } } } diff --git a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+RuleManagement.swift b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+RuleManagement.swift index 8f8522443..17c867b2d 100644 --- a/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+RuleManagement.swift +++ b/Sources/KeyPathAppKit/UI/Mapper/MapperViewModel+RuleManagement.swift @@ -12,6 +12,7 @@ extension MapperViewModel { !inputSeq.isEmpty, !outputSeq.isEmpty else { + AppLogger.shared.log("🚫 [MapperViewModel] Save declined: input or output not captured") statusMessage = "Capture both input and output first" statusIsError = true return @@ -33,6 +34,7 @@ extension MapperViewModel { !hasShiftedOutput, !hasDeviceCondition { + AppLogger.shared.log("🚫 [MapperViewModel] Save declined: '\(inputKey)' maps to itself") statusMessage = "Nothing to save - input and output are the same" statusIsError = true return @@ -150,6 +152,7 @@ extension MapperViewModel { return } + AppLogger.shared.log("💾 [MapperViewModel] Saving '\(inputKanata)' → '\(outputKanata)' [layer: \(currentLayer)]") let customRuleSaved = await kanataManager.saveCustomRule(customRule, skipReload: false, autoResolveConflicts: true) AppLogger.shared.log("💾 [MapperViewModel] saveCustomRule returned: \(customRuleSaved)") diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection.swift index 95560e30e..d4465d92d 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection.swift @@ -234,6 +234,21 @@ struct OverlayMapperSection: View { } .frame(height: 132) + // The overlay saves on capture, so a refusal or a declined save + // must be said here; otherwise the mapping just seems to vanish. + if let status = viewModel.statusMessage { + Text(status) + .font(.caption) + .foregroundStyle(viewModel.statusIsError ? Color.red : Color.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 12) + .padding(.top, 2) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("overlay-mapper-status") + .transition(.opacity) + } + appMappingIndicators .saturation(Double(1 - fadeAmount)) .opacity(Double(1 - fadeAmount * 0.5)) diff --git a/Tests/KeyPathTests/MapperAutosaveVisibilityTests.swift b/Tests/KeyPathTests/MapperAutosaveVisibilityTests.swift new file mode 100644 index 000000000..ca917c299 --- /dev/null +++ b/Tests/KeyPathTests/MapperAutosaveVisibilityTests.swift @@ -0,0 +1,41 @@ +@testable import KeyPathAppKit +import KeyPathCore +import XCTest + +/// The overlay Mapper saves on capture and has no Save button, so a save that +/// declines must say so, in the status the overlay shows and in the log. +@MainActor +final class MapperAutosaveVisibilityTests: KeyPathTestCase { + private func sequence(_ key: String, keyCode: Int64) -> KeySequence { + KeySequence(keys: [KeyPress(baseKey: key, modifiers: [], keyCode: keyCode)], captureMode: .single) + } + + func testCaptureWithoutARuntimeCoordinatorReportsInsteadOfVanishing() { + let vm = MapperViewModel() + vm.kanataManager = nil + vm.inputSequence = sequence("grave", keyCode: 50) + vm.outputSequence = sequence("mute", keyCode: 1007) + XCTAssertTrue(vm.canSave) + vm.finalizeCapture() + XCTAssertTrue(vm.statusIsError) + XCTAssertEqual(vm.statusMessage, "Couldn't save: the Mapper isn't connected to the runtime. Reopen the overlay.") + } + + func testIdentityMappingDeclinesWithAMessage() async { + let vm = MapperViewModel() + vm.inputSequence = sequence("a", keyCode: 0) + vm.outputSequence = sequence("a", keyCode: 0) + await vm.save(kanataManager: RuntimeCoordinator()) + XCTAssertTrue(vm.statusIsError) + XCTAssertEqual(vm.statusMessage, "Nothing to save - input and output are the same") + } + + func testMissingCaptureDeclinesWithAMessage() async { + let vm = MapperViewModel() + vm.inputSequence = sequence("a", keyCode: 0) + vm.outputSequence = nil + await vm.save(kanataManager: RuntimeCoordinator()) + XCTAssertTrue(vm.statusIsError) + XCTAssertEqual(vm.statusMessage, "Capture both input and output first") + } +} diff --git a/Tests/KeyPathTests/MapperKanataFormatTests.swift b/Tests/KeyPathTests/MapperKanataFormatTests.swift index b03dd147e..d45fcf4e9 100644 --- a/Tests/KeyPathTests/MapperKanataFormatTests.swift +++ b/Tests/KeyPathTests/MapperKanataFormatTests.swift @@ -167,4 +167,16 @@ final class MapperKanataFormatTests: XCTestCase { private func makeSequence(keys: [KeyPress], mode: CaptureMode = .single) -> KeySequence { KeySequence(keys: keys, captureMode: mode) } + + // MARK: - Media keys (captured from system-defined events) + + func testCapturedMediaKeysBecomeKanataMediaNames() { + for (captured, kanata) in [("volumeup", "volu"), ("volumedown", "vold"), ("mute", "mute"), + ("brightnessup", "brup"), ("brightnessdown", "brdn"), ("playpause", "pp"), + ("next", "next"), ("previous", "prev")] + { + let seq = makeSequence(keys: [KeyPress(baseKey: captured, modifiers: [], keyCode: 1007)]) + XCTAssertEqual(KeyMappingFormatter.toKanataFormat(seq), kanata, captured) + } + } } diff --git a/Tests/KeyPathTests/RuleCollections/AdmissionEnvironmentTests.swift b/Tests/KeyPathTests/RuleCollections/AdmissionEnvironmentTests.swift index 260f5d459..5a673cd16 100644 --- a/Tests/KeyPathTests/RuleCollections/AdmissionEnvironmentTests.swift +++ b/Tests/KeyPathTests/RuleCollections/AdmissionEnvironmentTests.swift @@ -13,7 +13,7 @@ final class AdmissionEnvironmentTests: KeyPathTestCase { private var directory: URL! private var manager: RuleCollectionsManager! private let allowAll = BackendCapabilityProfile(backend: .session) { _ in true } - private let noVolume = BackendCapabilityProfile(backend: .session) { $0 != "volumeup" } + private let noVolume = BackendCapabilityProfile(backend: .session) { $0 != "volu" } /// The driverless backend's actual limit: no consumer-page (media) keys. private let noMedia = BackendCapabilityProfile(backend: .session) { !BackendCapabilityProfile.fallbackConsumerKeys.contains($0) } @@ -109,9 +109,9 @@ final class AdmissionEnvironmentTests: KeyPathTestCase { } func testOutputTheBackendCannotSendIsRefusedNamingTheKey() { - let rule = CustomRule(title: "Louder", input: "f12", action: .keystroke(key: "volumeup")) + let rule = CustomRule(title: "Louder", input: "f12", action: .keystroke(key: "volu")) let review = manager.reviewAdmission(forProposed: rule, profile: noVolume) - XCTAssertEqual(review.refusal, "Louder sends volumeup, which the current backend can't send.") + XCTAssertEqual(review.refusal, "Louder sends Volume Up, which the current backend can't send.") XCTAssertNil(manager.reviewAdmission(forProposed: rule, profile: allowAll).refusal) } @@ -142,7 +142,7 @@ final class AdmissionEnvironmentTests: KeyPathTestCase { let before = coordinator.ruleCollectionsManager.customRules.count await vm.save(kanataManager: coordinator) XCTAssertTrue(vm.statusIsError) - XCTAssertEqual(vm.statusMessage?.hasSuffix("sends volumeup, which the current backend can't send."), true, vm.statusMessage ?? "nil") + XCTAssertEqual(vm.statusMessage?.hasSuffix("sends Volume Up, which the current backend can't send."), true, vm.statusMessage ?? "nil") XCTAssertEqual(coordinator.ruleCollectionsManager.customRules.count, before, "nothing was written") } }