diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ccc8d..bef772e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,25 @@ on the `main` branch via `.github/workflows/release.yml`. ## [Unreleased] +## [2.5.0] - 2026-07-06 + +### Changed +- **⌘ taps now switch the input source even while other modifiers are held.** + The chord-cancel behavior introduced in 2.4.11 (#122) treated any second held + modifier as a shortcut chord, so Shift+⌘ — e.g. committing a kana conversion + and dropping to eisu with left ⌘ — stopped working. Each modifier's tap is now + tracked independently: only a real key press, mouse event, or media key during + the hold cancels it, regardless of press/release order. + +### Fixed +- **The synthesized 英数/かな key inherited still-held modifier flags** (e.g. a + held Shift), so the IME saw Shift+英数 and ignored the switch. Residual flags + are now stripped; the mapping's own output flags are preserved. +- **Releasing the last typed character key after pressing ⌘ cancelled the tap.** + A character key's up-stroke no longer cancels a pending ⌘ tap, so committing a + conversion with ⌘ works mid-typing when the previous key is still physically + depressed; only a key going *down* during the hold cancels. + ## [2.4.11] - 2026-07-05 ### Fixed diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift index d54991e..de82ead 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift @@ -19,17 +19,24 @@ private enum EventConversion { } class KeyEvent: NSObject { - var keyCode: CGKeyCode? + // Modifier keyCodes whose press is still a live "tap" candidate: nothing + // but modifier activity has happened since their keyDown. A release fires + // its mapping iff its keyCode is still in this set. Other modifiers held + // alongside (any press/release order) do NOT cancel a pending tap — so + // tapping ⌘ with Shift held still switches the IME, e.g. when committing + // a kana conversion. Regular keyDowns, mouse events, and media keys clear + // the whole set. Regular keyUps deliberately don't: fast typists still + // hold the last character key when they tap ⌘ to commit. + var pendingModifierTaps: Set = [] + + // Test seam: modifierKeyUp posts the synthesized tap through this. + // Overridden in unit tests to capture the tap instead of emitting a + // system-wide event. + var postModifierTap: (KeyboardShortcut) -> Void = { $0.postEvent() } + var isExclusionApp = false let bundleId = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "com.kazuki.cmdime" - // Modifier keyCodes currently physically held down, per this tap's view of - // the world. Used to detect chords (more than one modifier held) so a - // second key going down while another is already down doesn't get - // mistaken for a fresh lone-press gesture once the first key's slot was - // cancelled and released. - private var downModifierKeyCodes: Set = [] - private var eventTap: CFMachPort? private var eventTapRunLoopSource: CFRunLoopSource? private var tapRetryAttempts = 0 @@ -111,7 +118,7 @@ class KeyEvent: NSObject { func setupEventMonitoring() { // Pair NSEvent + CGEvent monitors to work around a mouse-drag bug - // where keyCode tracking would otherwise stick. + // where pending-tap tracking would otherwise stick. // NSEvent monitors must be set up on main thread. let nsEventMaskList: NSEvent.EventTypeMask = [ .leftMouseDown, @@ -124,11 +131,11 @@ class KeyEvent: NSObject { ] if let m = NSEvent.addGlobalMonitorForEvents(matching: nsEventMaskList, handler: { [weak self] _ in - self?.keyCode = nil + self?.pendingModifierTaps.removeAll() }) { nsEventMonitors.append(m) } if let m = NSEvent.addLocalMonitorForEvents(matching: nsEventMaskList, handler: { [weak self] event in - self?.keyCode = nil + self?.pendingModifierTaps.removeAll() return event }) { nsEventMonitors.append(m) } @@ -320,7 +327,7 @@ class KeyEvent: NSObject { return keyUp(event) default: - self.keyCode = nil + pendingModifierTaps.removeAll() return Unmanaged.passRetained(event) } @@ -331,7 +338,7 @@ class KeyEvent: NSObject { print(KeyboardShortcut(event).toString()) #endif - self.keyCode = nil + pendingModifierTaps.removeAll() switch convertedEvent(for: event) { case .passThrough: return Unmanaged.passRetained(event) @@ -341,8 +348,11 @@ class KeyEvent: NSObject { } func keyUp(_ event: CGEvent) -> Unmanaged? { - self.keyCode = nil - + // Deliberately does NOT cancel pending modifier taps: only a key going + // DOWN during the hold means the modifier was used as a shortcut. The + // up-stroke of a character typed just before the ⌘ press must not + // cancel the tap, or committing a conversion with ⌘ misfires whenever + // the last character key is still physically held. switch convertedEvent(for: event) { case .passThrough: return Unmanaged.passRetained(event) case .disable: return nil @@ -355,45 +365,27 @@ class KeyEvent: NSObject { print(KeyboardShortcut(event).toString()) #endif - let code = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - downModifierKeyCodes.insert(code) - - if downModifierKeyCodes.count == 1 { - // This is the only modifier currently held — arm the lone-press - // gesture for it. - self.keyCode = code - } else { - // A chord (e.g. both Commands held) — cancel the lone-press - // gesture instead of overwriting the tracked keyCode. Otherwise a - // later release of the newly-arrived key would fire the - // lone-press mapping while another modifier is still physically - // held. Re-pressing a key while a sibling is still held (slot - // already nil) must stay cancelled, not re-arm — that's exactly - // the case `downModifierKeyCodes.count` catches. - self.keyCode = nil - } + pendingModifierTaps.insert(CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode))) return Unmanaged.passRetained(event) } func modifierKeyUp(_ event: CGEvent) -> Unmanaged? { let code = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - // Remove defensively even if the tap missed the matching keyDown - // (e.g. the key was already held when the tap started). - downModifierKeyCodes.remove(code) - if self.keyCode == code { - if case .remap(let converted) = convertedEvent(for: event) { - KeyboardShortcut(converted).postEvent() - } + if pendingModifierTaps.remove(code) != nil, + let mapping = findMapping(for: event), + mapping.output.keyCode != 999 { + // Post the bare output shortcut. Residual held modifiers (e.g. a + // still-held Shift) must not leak into the synthesized tap, or + // the IME sees Shift+英数 instead of 英数 and ignores it. + postModifierTap(KeyboardShortcut(keyCode: mapping.output.keyCode, flags: mapping.output.flags)) } - self.keyCode = nil - return Unmanaged.passRetained(event) } func mediaKeyDown(_ mediaKeyEvent: MediaKeyEvent) -> Unmanaged? { - self.keyCode = nil + pendingModifierTaps.removeAll() let mediaKeyCodeValue = CGKeyCode(1000 + mediaKeyEvent.keyCode) switch convertedEvent(for: mediaKeyEvent.event, keyCode: mediaKeyCodeValue) { diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift index 6258ba2..c91258d 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift @@ -4,10 +4,14 @@ import XCTest final class KeyEventTests: XCTestCase { var keyEvent: KeyEvent! + var postedTaps: [KeyboardShortcut] = [] override func setUp() { super.setUp() keyEvent = KeyEvent() + // Capture synthesized modifier taps instead of posting system-wide events. + postedTaps = [] + keyEvent.postModifierTap = { [weak self] in self?.postedTaps.append($0) } // Reset global state keyMappingList = [] shortcutList = [:] @@ -156,129 +160,140 @@ final class KeyEventTests: XCTestCase { XCTAssertFalse(keyEvent.shouldRebuildTap(for: .mirrorFlag)) } - // MARK: - Lone-modifier gesture tracking (chord false-fire bug) + // MARK: - Modifier tap gesture tracking // - // modifierKeyDown/modifierKeyUp track a single "lone press in flight" - // keyCode in `keyEvent.keyCode`, backed by `downModifierKeyCodes` (the - // set of modifier keyCodes currently physically held). modifierKeyDown - // arms the lone-press gesture only when the incoming key is the sole - // held modifier; otherwise (a chord, or a re-press while a sibling is - // still held) it cancels the gesture instead of overwriting the slot. - // modifierKeyUp only ever fires its remap when `keyEvent.keyCode` still - // equals the released key, so asserting on that tracked value is the - // observable proxy for "will fire on release" — the actual remap is - // posted to the system as a side effect (KeyboardShortcut.postEvent), - // which isn't something a unit test can intercept. - - func testModifierKeyDown_TracksLoneModifierPress() { - let downEvent = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - downEvent.flags = .maskCommand - - _ = keyEvent.modifierKeyDown(downEvent) - - XCTAssertEqual(keyEvent.keyCode, 55) + // modifierKeyDown/modifierKeyUp track per-modifier pending taps in + // `pendingModifierTaps`; a release fires its mapping iff its keyCode is + // still pending. Other modifiers held alongside (any press/release + // order) must NOT cancel a tap — tapping ⌘ with Shift held still has to + // switch the IME. A regular keyDown, mouse event, or media key cancels + // all pending taps; a regular keyUp does not. Fired taps are captured + // through the `postModifierTap` seam instead of hitting the system. + + private func modifierEvent(_ keyCode: CGKeyCode, flags: CGEventFlags = []) -> CGEvent { + let event = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true)! + event.flags = flags + return event } - func testModifierKeyDownThenUp_FiresRemap_OnLonePressAndRelease() { - // Command_L (55) alone -> Kana (104) + func testModifierTap_Fires_OnLonePressAndRelease() { + // Command_L (55) -> Kana (104) let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) let output = KeyboardShortcut(keyCode: 104) keyMappingList = [KeyMapping(input: input, output: output)] keyMappingListToShortcutList() - let downEvent = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - downEvent.flags = .maskCommand - _ = keyEvent.modifierKeyDown(downEvent) - XCTAssertEqual(keyEvent.keyCode, 55, "lone press must be tracked so the matching release can fire") + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + XCTAssertEqual(keyEvent.pendingModifierTaps, [55], "press must be pending so the release can fire") - let upEvent = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - let result = keyEvent.modifierKeyUp(upEvent) + _ = keyEvent.modifierKeyUp(modifierEvent(55)) - XCTAssertNotNil(result) - XCTAssertNil(keyEvent.keyCode, "tracking slot is cleared after release") + XCTAssertEqual(postedTaps.count, 1) + XCTAssertEqual(postedTaps.first?.keyCode, 104) + XCTAssertTrue(keyEvent.pendingModifierTaps.isEmpty, "pending tap is consumed by the release") } - func testModifierChord_CancelsGesture_SoNeitherReleaseFires() { - // Hold Left Command (55), then Right Command (54) while it's still held. - let leftDown = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - leftDown.flags = .maskCommand - _ = keyEvent.modifierKeyDown(leftDown) - XCTAssertEqual(keyEvent.keyCode, 55) - - let rightDown = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - rightDown.flags = [.maskCommand] - _ = keyEvent.modifierKeyDown(rightDown) - - // The chord must cancel tracking for BOTH keys, not overwrite the slot - // with 54 (which would make Right Command's release fire alone). - XCTAssertNil(keyEvent.keyCode, "second modifier down while one is tracked must cancel, not overwrite") - - let rightUp = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - _ = keyEvent.modifierKeyUp(rightUp) - XCTAssertNil(keyEvent.keyCode, "cancelled gesture must not fire on Right Command's release") - - // Left Command is still physically held in this scenario, but its - // eventual release must not retroactively fire either. - let leftUp = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - _ = keyEvent.modifierKeyUp(leftUp) - XCTAssertNil(keyEvent.keyCode, "cancelled gesture must not fire on Left Command's release either") + func testModifierTap_Fires_WithShiftHeld_AndStripsResidualFlags() { + // Shift held the whole time: ⇧ down -> ⌘ down -> ⌘ up. The tap must + // still fire, and the synthesized Eisu must NOT carry the Shift flag + // (the IME ignores Shift+英数). + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let output = KeyboardShortcut(keyCode: 102) + keyMappingList = [KeyMapping(input: input, output: output)] + keyMappingListToShortcutList() + + _ = keyEvent.modifierKeyDown(modifierEvent(56, flags: .maskShift)) + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: [.maskCommand, .maskShift])) + _ = keyEvent.modifierKeyUp(modifierEvent(55, flags: .maskShift)) + + XCTAssertEqual(postedTaps.count, 1, "a held sibling modifier must not cancel the ⌘ tap") + XCTAssertEqual(postedTaps.first?.keyCode, 102) + XCTAssertEqual(postedTaps.first?.flags, CGEventFlags(), + "residual held modifiers must be stripped from the synthesized tap") } - func testModifierChord_RepressAfterReleaseWhileOtherStillHeld_DoesNotFire() { - // Regression for the hole in the first chord fix: the tracked-keyCode - // slot alone can't tell "nothing else is held" from "something else - // is still held but its slot got cancelled". downModifierKeyCodes - // fixes that by counting actual held keys instead. - // - // Hold Left Command (55) -> press Right Command (54, cancels slot) -> - // release Right Command (no fire) -> press Right Command again while - // Left Command is STILL held -> release Right Command again. Before - // the fix, the slot was nil after the first Right Command release, so - // the second Right Command press would re-arm it with 54 and its - // release would falsely fire the lone-press mapping. - let leftDown = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - leftDown.flags = .maskCommand - _ = keyEvent.modifierKeyDown(leftDown) - XCTAssertEqual(keyEvent.keyCode, 55) - - let rightDown = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - rightDown.flags = [.maskCommand] - _ = keyEvent.modifierKeyDown(rightDown) - XCTAssertNil(keyEvent.keyCode) - - let rightUp = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - _ = keyEvent.modifierKeyUp(rightUp) - XCTAssertNil(keyEvent.keyCode) - - let rightDownAgain = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - rightDownAgain.flags = [.maskCommand] - _ = keyEvent.modifierKeyDown(rightDownAgain) - XCTAssertNil(keyEvent.keyCode, - "re-pressing a modifier while a sibling is still held must not re-arm the lone-press gesture") - - let rightUpAgain = CGEvent(keyboardEventSource: nil, virtualKey: 54, keyDown: true)! - let result = keyEvent.modifierKeyUp(rightUpAgain) - XCTAssertNotNil(result) - XCTAssertNil(keyEvent.keyCode, "must not fire — Left Command is still physically held") + func testModifierTap_Fires_WhenOtherModifierReleasedFirst() { + // ⌘ down -> ⇧ down -> ⇧ up -> ⌘ up: the unrelated Shift release must + // not consume or cancel the still-pending ⌘ tap. + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let output = KeyboardShortcut(keyCode: 102) + keyMappingList = [KeyMapping(input: input, output: output)] + keyMappingListToShortcutList() + + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.modifierKeyDown(modifierEvent(56, flags: [.maskCommand, .maskShift])) + _ = keyEvent.modifierKeyUp(modifierEvent(56, flags: .maskCommand)) + XCTAssertEqual(postedTaps.count, 0, "Shift has no mapping, so its release posts nothing") + + _ = keyEvent.modifierKeyUp(modifierEvent(55)) + + XCTAssertEqual(postedTaps.count, 1) + XCTAssertEqual(postedTaps.first?.keyCode, 102) + } + + func testModifierTap_EachModifierFiresIndependently() { + // Hold Command_L, tap Command_R, release Command_L: both taps fire + // their own mapping. Held siblings no longer cancel (that chord-cancel + // behavior broke "switch even with other modifiers held"). + keyMappingList = [ + KeyMapping(input: KeyboardShortcut(keyCode: 55, flags: .maskCommand), + output: KeyboardShortcut(keyCode: 102)), + KeyMapping(input: KeyboardShortcut(keyCode: 54, flags: .maskCommand), + output: KeyboardShortcut(keyCode: 104)) + ] + keyMappingListToShortcutList() - // Left Command's eventual release must not retroactively fire either. - let leftUp = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - _ = keyEvent.modifierKeyUp(leftUp) - XCTAssertNil(keyEvent.keyCode, "cancelled gesture must not fire on Left Command's release either") + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.modifierKeyDown(modifierEvent(54, flags: .maskCommand)) + _ = keyEvent.modifierKeyUp(modifierEvent(54, flags: .maskCommand)) + _ = keyEvent.modifierKeyUp(modifierEvent(55)) + + XCTAssertEqual(postedTaps.map(\.keyCode), [104, 102]) } - func testRegularKeyDown_CancelsInFlightModifierGesture() { - // Existing behavior: keyDown()/keyUp() unconditionally reset keyCode, - // so a regular keystroke mid-hold cancels the lone-press gesture. - let modifierDown = CGEvent(keyboardEventSource: nil, virtualKey: 55, keyDown: true)! - modifierDown.flags = .maskCommand - _ = keyEvent.modifierKeyDown(modifierDown) - XCTAssertEqual(keyEvent.keyCode, 55) + func testRegularKeyDown_CancelsAllPendingTaps() { + // ⌘ down -> 'A' down -> ⌘ up is a shortcut, not a tap. + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let output = KeyboardShortcut(keyCode: 102) + keyMappingList = [KeyMapping(input: input, output: output)] + keyMappingListToShortcutList() + + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.keyDown(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)!) + XCTAssertTrue(keyEvent.pendingModifierTaps.isEmpty, + "a regular keystroke mid-hold must cancel pending modifier taps") + + _ = keyEvent.modifierKeyUp(modifierEvent(55)) + XCTAssertTrue(postedTaps.isEmpty) + } + + func testRegularKeyUp_DoesNotCancelPendingTap() { + // Fast-typing commit: 'A' down -> ⌘ down -> 'A' up -> ⌘ up. The + // up-stroke of the character typed just before the ⌘ press must not + // cancel the tap, or committing a conversion with ⌘ misfires. + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let output = KeyboardShortcut(keyCode: 102) + keyMappingList = [KeyMapping(input: input, output: output)] + keyMappingListToShortcutList() + + _ = keyEvent.keyDown(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)!) + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.keyUp(CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false)!) + _ = keyEvent.modifierKeyUp(modifierEvent(55)) + + XCTAssertEqual(postedTaps.map(\.keyCode), [102]) + } + + func testModifierTap_DoesNotPost_WhenMappedToDisable() { + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let output = KeyboardShortcut(keyCode: 999) + keyMappingList = [KeyMapping(input: input, output: output)] + keyMappingListToShortcutList() - let regularKey = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true)! - _ = keyEvent.keyDown(regularKey) + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.modifierKeyUp(modifierEvent(55)) - XCTAssertNil(keyEvent.keyCode, "a regular keystroke mid-hold must cancel the lone-modifier gesture") + XCTAssertTrue(postedTaps.isEmpty) } // MARK: - Media-key remap keyUp (stuck-key bug) diff --git a/manifest.toml b/manifest.toml index 7a94c57..e08f6be 100644 --- a/manifest.toml +++ b/manifest.toml @@ -3,7 +3,7 @@ [project] id = "cmd-ime" name = "⌘IME" -version = "2.4.11" +version = "2.5.0" description = "Lightweight macOS app for switching between alphanumeric and kana input using Command keys" authors = ["Kazuki "] license = "MIT"