Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Sources/KeyPathAppKit/UI/Mapper/KeyMappingFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +22 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle the media names emitted by real F7/F9 presses

On Apple keyboards, F7/F9 media-mode presses arrive as rewind and fastforward (KeyboardCapture.mediaKeyCodeToString handles codes 20/19), not previous and next. Leaving those names unchanged means an output captured from these common keys reaches ConfigurationService.convertToKanataSequence, which treats the unknown multi-character token as a text macro, so the saved mapping types “rewind” or “fastforward” instead of controlling playback. Translate them to prev/next, or reject them explicitly rather than allowing a wrong rule to save.

Useful? React with 👍 / 👎.

"volumeup": "volu",
"volumedown": "vold",
"mute": "mute",
"brightnessup": "brup",
"brightnessdown": "brdn",
"playpause": "pp",
"next": "next",
"previous": "prev",
"eject": "eject"
]

/// Convert KeySequence to kanata format string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)")

Expand Down
15 changes: 15 additions & 0 deletions Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
41 changes: 41 additions & 0 deletions Tests/KeyPathTests/MapperAutosaveVisibilityTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
12 changes: 12 additions & 0 deletions Tests/KeyPathTests/MapperKanataFormatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
let seq = makeSequence(
keys: [
KeyPress(baseKey: "g", modifiers: [], keyCode: 5),
KeyPress(baseKey: "g", modifiers: [], keyCode: 5),

Check warning on line 89 in Tests/KeyPathTests/MapperKanataFormatTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
],
mode: .sequence
)
Expand All @@ -97,7 +97,7 @@
let seq = makeSequence(
keys: [
KeyPress(baseKey: "k", modifiers: .command, keyCode: 40),
KeyPress(baseKey: "c", modifiers: .command, keyCode: 8),

Check warning on line 100 in Tests/KeyPathTests/MapperKanataFormatTests.swift

View workflow job for this annotation

GitHub Actions / code-quality

Collection literals should not have trailing commas (trailing_comma)
],
mode: .sequence
)
Expand Down Expand Up @@ -167,4 +167,16 @@
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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")
}
}
Loading