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") } }