From c8a947e5b8879ba9177e83b2254b7dce56c1d5fb Mon Sep 17 00:00:00 2001 From: kazuki nakai Date: Thu, 6 Aug 2026 23:38:13 +0900 Subject: [PATCH 1/2] test: spike TISSelectInputSource across separate keyboard input sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirms TIS-based switching works for Pinyin/Korean once the parent input method (e.g. com.apple.inputmethod.SCIM) is enabled alongside the child mode — unlike the known-broken Japanese Eisu/Kana internal mode toggle, this is a real cross-source switch and TIS handles it. --- .../InputSourceSwitchSpikeTests.swift | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift new file mode 100644 index 0000000..0f2d5d5 --- /dev/null +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift @@ -0,0 +1,164 @@ +import XCTest +import Carbon.HIToolbox +@testable import CmdIMESwift + +/// Spike for the multilingual input-source generalization design (see PR brief): +/// does `TISSelectInputSource` reliably switch *between separate keyboard input +/// sources* (e.g. ABC <-> Pinyin, ABC <-> 2-Set Korean), as opposed to the +/// internal Japanese IME mode toggle (ABC <-> Kana within one IME), which is +/// known to only move the menu-bar indicator without posting Eisu/Kana keys +/// (see `KeyboardShortcut.postEvent()`). +/// +/// This test mutates system input-source state: it enables Pinyin / 2-Set +/// Korean if not already present, and disables anything it enabled when done. +/// Skippable so headless CI stays clean. +final class InputSourceSwitchSpikeTests: XCTestCase { + + private let pinyinID = "com.apple.inputmethod.SCIM.ITABC" + private let koreanID = "com.apple.inputmethod.Korean.2SetKorean" + private let abcID = "com.apple.keylayout.ABC" + + func testTISSelectSwitchesBetweenSeparateInputSources() throws { + guard ProcessInfo.processInfo.environment["CMDIME_SPIKE_INPUT_SOURCE_SWITCH"] != nil else { + throw XCTSkip("set CMDIME_SPIKE_INPUT_SOURCE_SWITCH=1 to run the live TIS switch spike") + } + + let startID = currentSourceID() + addTeardownBlock { [self] in + _ = select(id: abcID) + } + + dumpAllInstalled(matching: "SCIM") + dumpAllInstalled(matching: "Korean") + + // Modes (e.g. SCIM.ITABC) belong to a parent input method + // (TISTypeKeyboardInputMethodModeEnabled, e.g. SCIM) which must itself + // be enabled before any of its child modes become selectable — the + // same parent/mode relationship as com.google.inputmethod.Japanese and + // its .base mode. + try assertRoundTrip(parentID: "com.apple.inputmethod.SCIM", sourceID: pinyinID, label: "Pinyin (Simplified)") + try assertRoundTrip(parentID: "com.apple.inputmethod.Korean", sourceID: koreanID, label: "2-Set Korean") + + print("=== spike summary === started at \(startID)") + } + + /// Enables `sourceID` if needed, selects it, reads back the current source, + /// switches to ABC, reads back again, and asserts both transitions actually + /// took effect (not just that `TISSelectInputSource` returned success). + private func assertRoundTrip(parentID: String, sourceID: String, label: String) throws { + guard let source = findSource(id: sourceID) else { + throw XCTSkip("\(label) (\(sourceID)) is not installed on this machine") + } + + print("[\(label)] category=\(prop(source, kTISPropertyInputSourceCategory) ?? "nil") " + + "type=\(prop(source, kTISPropertyInputSourceType) ?? "nil") " + + "isEnableCapable=\(boolProp(source, kTISPropertyInputSourceIsEnableCapable))") + + let parentWasEnabled = findSource(id: parentID).map(isEnabled) ?? true + if !parentWasEnabled, let parent = findSource(id: parentID) { + let status = TISEnableInputSource(parent) + print("[\(label)] TISEnableInputSource(parent \(parentID)) status=\(status)") + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + } + addTeardownBlock { [self] in + if !parentWasEnabled, let p = findSource(id: parentID) { + TISDisableInputSource(p) + } + } + + let wasEnabled = isEnabled(source) + if !wasEnabled { + let enableStatus = TISEnableInputSource(source) + print("[\(label)] TISEnableInputSource status=\(enableStatus)") + // Enabling broadcasts kTISNotifyEnabledKeyboardInputSourcesChanged + // asynchronously; give the run loop a chance to process it before + // re-reading the property or attempting to select. + RunLoop.current.run(until: Date().addingTimeInterval(1.0)) + } + addTeardownBlock { [self] in + if !wasEnabled, let s = findSource(id: sourceID) { + TISDisableInputSource(s) + } + } + + if let refreshed = findSource(id: sourceID) { + print("[\(label)] after enable: enabled=\(isEnabled(refreshed)) selectable=\(selectCapable(refreshed))") + } + + // ABC -> target + XCTAssertTrue(select(id: abcID), "failed to select ABC as baseline") + Thread.sleep(forTimeInterval: 0.3) + let selectResult = select(id: sourceID) + Thread.sleep(forTimeInterval: 0.3) + let afterSelect = currentSourceID() + print("[\(label)] TISSelectInputSource(\(sourceID)) returned success=\(selectResult); read-back id=\(afterSelect)") + XCTAssertTrue(selectResult, "\(label): TISSelectInputSource reported failure") + XCTAssertEqual(afterSelect, sourceID, "\(label): read-back did not move to the target source (stale-select suspected)") + + // target -> ABC + let backResult = select(id: abcID) + Thread.sleep(forTimeInterval: 0.3) + let afterBack = currentSourceID() + print("[\(label)] TISSelectInputSource(ABC) returned success=\(backResult); read-back id=\(afterBack)") + XCTAssertTrue(backResult) + XCTAssertEqual(afterBack, abcID, "\(label): failed to switch back to ABC") + } + + /// Dumps every installed (enabled or not) source whose id contains + /// `substring`, to find the parent input method entry for a given mode. + private func dumpAllInstalled(matching substring: String) { + guard let list = TISCreateInputSourceList(nil, true)?.takeRetainedValue() else { return } + let sources = (list as NSArray) as? [TISInputSource] ?? [] + for s in sources { + guard let id = prop(s, kTISPropertyInputSourceID), id.contains(substring) else { continue } + print("[installed:\(substring)] id=\(id) category=\(prop(s, kTISPropertyInputSourceCategory) ?? "nil") " + + "type=\(prop(s, kTISPropertyInputSourceType) ?? "nil") modeID=\(prop(s, kTISPropertyInputModeID) ?? "nil") " + + "enabled=\(boolProp(s, kTISPropertyInputSourceIsEnabled)) enableCapable=\(boolProp(s, kTISPropertyInputSourceIsEnableCapable))") + } + } + + // MARK: - TIS helpers + + private func findSource(id: String) -> TISInputSource? { + let conditions = [kTISPropertyInputSourceID as String: id] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, true)?.takeRetainedValue() else { return nil } + let sources = (list as NSArray) as? [TISInputSource] ?? [] + return sources.first + } + + private func isEnabled(_ source: TISInputSource) -> Bool { + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsEnabled) else { return false } + return CFBooleanGetValue(Unmanaged.fromOpaque(ptr).takeUnretainedValue()) + } + + private func selectCapable(_ source: TISInputSource) -> Bool { + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsSelectCapable) else { return false } + return CFBooleanGetValue(Unmanaged.fromOpaque(ptr).takeUnretainedValue()) + } + + private func prop(_ s: TISInputSource, _ key: CFString) -> String? { + guard let p = TISGetInputSourceProperty(s, key) else { return nil } + return Unmanaged.fromOpaque(p).takeUnretainedValue() as String + } + + private func boolProp(_ s: TISInputSource, _ key: CFString) -> Bool { + guard let p = TISGetInputSourceProperty(s, key) else { return false } + return CFBooleanGetValue(Unmanaged.fromOpaque(p).takeUnretainedValue()) + } + + @discardableResult + private func select(id: String) -> Bool { + guard let source = findSource(id: id) else { return false } + let status = TISSelectInputSource(source) + if status != noErr { + print("TISSelectInputSource(\(id)) OSStatus=\(status)") + } + return status == noErr + } + + private func currentSourceID() -> String { + guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return "" } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String + } +} From 0e1bf5c50febbe725ff32a9c01936ba84187a054 Mon Sep 17 00:00:00 2001 From: kazuki nakai Date: Thu, 6 Aug 2026 23:51:34 +0900 Subject: [PATCH 2/2] feat(ime): generalize the key-mapping action to any installed input source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds InputSourceCatalog, which enumerates selectable keyboard input sources (TIS category=Keyboard + selectCapable, so palette sources like Character Viewer/emoji are excluded) and selects one via TISSelectInputSource, auto-enabling its parent input method (e.g. com.apple.inputmethod.SCIM) and itself if needed — confirmed necessary and sufficient by InputSourceSwitchSpikeTests. KeyMapping gains outputInputSourceID, an optional field mutually exclusive with the existing key-post output. KeyEvent.modifierKeyUp dispatches on it: input-source mappings call TISSelectInputSource through InputSourceCatalog.select(id:), everything else (Eisu/Kana/ Disable/plain remaps) keeps posting a key exactly as before. Default mappings (left CMD -> Eisu key post, right CMD -> Kana key post) are unchanged. Settings > Shortcuts' action picker gained a "Switch to Input Source" submenu listing every selectable source on the machine. New tests: InputSourceCatalogTests (parentCandidateID pure logic, live enumeration filters out palette sources and dedupes), KeyMapping outputInputSourceID persistence round-trip (incl. legacy dictionaries without the field), AppSettings.updateKeyMappingOutputSource behavior, KeyEvent modifier-tap mechanism dispatch. --- .../CmdIMESwift/InputSourceCatalog.swift | 97 +++++++++++++++++++ .../Sources/CmdIMESwift/KeyEvent.swift | 22 +++-- .../Sources/CmdIMESwift/KeyMapping.swift | 15 ++- .../Resources/Localizable.xcstrings | 41 ++++++++ .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + .../CmdIMESwift/Settings/AppSettings.swift | 18 +++- .../Settings/ShortcutsSettingsView.swift | 44 +++++++-- .../CmdIMESwiftTests/AppSettingsTests.swift | 28 ++++++ .../InputSourceCatalogTests.swift | 57 +++++++++++ .../InputSourceSwitchSpikeTests.swift | 16 ++- .../CmdIMESwiftTests/KeyEventTests.swift | 36 +++++++ .../CmdIMESwiftTests/KeyMappingTests.swift | 29 ++++++ 17 files changed, 393 insertions(+), 22 deletions(-) create mode 100644 apps/cmd-ime-swift/Sources/CmdIMESwift/InputSourceCatalog.swift create mode 100644 apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceCatalogTests.swift diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/InputSourceCatalog.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/InputSourceCatalog.swift new file mode 100644 index 0000000..cfdccb9 --- /dev/null +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/InputSourceCatalog.swift @@ -0,0 +1,97 @@ +// +// InputSourceCatalog.swift +// ⌘IME +// +// Enumerates and selects installed keyboard input sources for the +// multilingual key-mapping action (Settings > Shortcuts). This is a +// distinct mechanism from the Eisu/Kana key-post in +// `KeyboardShortcut.postEvent()`: that one drives the Japanese IME's +// internal mode by synthesizing keystrokes, because bare +// `TISSelectInputSource` only moves the menu-bar indicator for it. Every +// other language's mapping (ABC <-> Pinyin, ABC <-> 2-Set Korean, ...) is a +// switch between *separate* input sources, where `TISSelectInputSource` +// works directly — confirmed live by InputSourceSwitchSpikeTests. +// + +import Carbon.HIToolbox +import Foundation + +enum InputSourceCatalog { + struct Entry: Identifiable, Equatable { + let id: String + let localizedName: String + } + + /// Installed (enabled or not) selectable keyboard input sources — + /// standalone layouts (e.g. ABC) and IME modes (e.g. Google Japanese's + /// `.base`, SCIM's `.ITABC`). Filtering by category + select-capable + /// naturally excludes both the parent input-method entries (not + /// select-capable themselves, only their modes are) and palette sources + /// (Character Viewer, emoji, etc. sit in a different category — a known + /// contamination risk when enumerating "enabled sources" naively). + static func selectableKeyboardSources() -> [Entry] { + let conditions = [ + kTISPropertyInputSourceCategory as String: kTISCategoryKeyboardInputSource as Any, + kTISPropertyInputSourceIsSelectCapable as String: true + ] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, true)?.takeRetainedValue() else { return [] } + let sources = (list as NSArray) as? [TISInputSource] ?? [] + return sources.compactMap { source -> Entry? in + guard let id = stringProperty(source, kTISPropertyInputSourceID) else { return nil } + let name = stringProperty(source, kTISPropertyLocalizedName) ?? id + return Entry(id: id, localizedName: name) + } + .sorted { $0.localizedName < $1.localizedName } + } + + /// Enables `id`'s parent input method (if it has one) and `id` itself + /// when needed, then selects it. A mode's parent is not enabled + /// automatically by enabling the mode — see InputSourceSwitchSpikeTests, + /// which found this the hard way for SCIM.ITABC / Korean.2SetKorean. + /// Returns false if `id` isn't installed or the select call fails. + @discardableResult + static func select(id: String) -> Bool { + guard let source = find(id: id) else { return false } + + if let parentID = parentCandidateID(for: id), + let parent = find(id: parentID), !isEnabled(parent) { + TISEnableInputSource(parent) + } + if !isEnabled(source) { + TISEnableInputSource(source) + } + return TISSelectInputSource(source) == noErr + } + + /// A mode id's parent input method id is conventionally its id with the + /// last dot-separated component dropped (`com.apple.inputmethod.SCIM.ITABC` + /// -> `com.apple.inputmethod.SCIM`, `com.google.inputmethod.Japanese.base` + /// -> `com.google.inputmethod.Japanese`). `kTISPropertyInputModeID` isn't + /// reliable for this: it can equal the mode's own id (SCIM.ITABC) instead + /// of the parent's. Standalone layouts (`com.apple.keylayout.ABC`) yield a + /// candidate that resolves to no installed source, which is correct — they + /// have no parent to enable. + static func parentCandidateID(for id: String) -> String? { + guard let lastDot = id.lastIndex(of: ".") else { return nil } + return String(id[.. TISInputSource? { + let conditions = [kTISPropertyInputSourceID as String: id] as CFDictionary + guard let list = TISCreateInputSourceList(conditions, true)?.takeRetainedValue() else { return nil } + let sources = (list as NSArray) as? [TISInputSource] ?? [] + return sources.first + } + + private static func isEnabled(_ source: TISInputSource) -> Bool { + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsEnabled) else { return false } + return CFBooleanGetValue(Unmanaged.fromOpaque(ptr).takeUnretainedValue()) + } + + private static func stringProperty(_ source: TISInputSource, _ key: CFString) -> String? { + guard let ptr = TISGetInputSourceProperty(source, key) else { return nil } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String + } +} diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift index 4ec3431..2406961 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift @@ -34,6 +34,12 @@ class KeyEvent: NSObject { // system-wide event. var postModifierTap: (KeyboardShortcut) -> Void = { $0.postEvent() } + // Test seam: modifierKeyUp selects a TIS input source through this when + // the mapping carries `outputInputSourceID` instead of a key-post output. + // Overridden in unit tests to capture the selection instead of mutating + // real system input-source state. + var selectInputSourceAction: (String) -> Void = { InputSourceCatalog.select(id: $0) } + var isExclusionApp = false let bundleId = Bundle.main.infoDictionary?["CFBundleIdentifier"] as? String ?? "com.kazuki.cmdime" @@ -370,13 +376,15 @@ class KeyEvent: NSObject { func modifierKeyUp(_ event: CGEvent) -> Unmanaged? { let code = CGKeyCode(event.getIntegerValueField(.keyboardEventKeycode)) - 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)) + if pendingModifierTaps.remove(code) != nil, let mapping = findMapping(for: event) { + if let sourceID = mapping.outputInputSourceID { + selectInputSourceAction(sourceID) + } else if 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)) + } } return Unmanaged.passRetained(event) diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyMapping.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyMapping.swift index 44e11f8..243abf6 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyMapping.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/KeyMapping.swift @@ -15,11 +15,17 @@ class KeyMapping: NSObject, Identifiable { var input: KeyboardShortcut var output: KeyboardShortcut var enable: Bool + // Non-nil selects this TIS input source id via InputSourceCatalog.select(id:) + // instead of posting `output` as a key (see KeyEvent.modifierKeyUp). Mutually + // exclusive with a key-post output — set by updateKeyMappingOutputSource(at:) + // and cleared whenever a key-based output is chosen again. + var outputInputSourceID: String? - init(input: KeyboardShortcut, output: KeyboardShortcut, enable: Bool = true) { + init(input: KeyboardShortcut, output: KeyboardShortcut, enable: Bool = true, outputInputSourceID: String? = nil) { self.input = input self.output = output self.enable = enable + self.outputInputSourceID = outputInputSourceID super.init() } @@ -28,6 +34,7 @@ class KeyMapping: NSObject, Identifiable { input = KeyboardShortcut() output = KeyboardShortcut() self.enable = true + self.outputInputSourceID = nil super.init() } @@ -41,6 +48,8 @@ class KeyMapping: NSObject, Identifiable { self.input = inputKey self.output = outputKey self.enable = enable + // Absent in dictionaries persisted before this field existed. + self.outputInputSourceID = dictionary["outputInputSourceID"] as? String super.init() } else { @@ -49,10 +58,12 @@ class KeyMapping: NSObject, Identifiable { } func toDictionary() -> [AnyHashable: Any] { - return [ + var dictionary: [AnyHashable: Any] = [ "input": input.toDictionary(), "output": output.toDictionary(), "enable": enable ] + dictionary["outputInputSourceID"] = outputInputSourceID + return dictionary } } diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings index 1b214fd..b88965e 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/Localizable.xcstrings @@ -2871,6 +2871,47 @@ } } }, + "shortcuts.actionSwitchToInputSource": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Switch to Input Source" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "入力ソースに切り替え" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "切换到输入法" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "切換到輸入方式" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "입력 소스로 전환" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Chuyển sang nguồn nhập" + } + } + } + }, "shortcuts.actionDisableKey": { "extractionState": "manual", "localizations": { diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings index f706f04..b115b33 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/en.lproj/Localizable.strings @@ -118,6 +118,8 @@ Choose what happens when this key is pressed shortcuts.actionSwitchToAlphanumeric Switch to Alphanumeric + shortcuts.actionSwitchToInputSource + Switch to Input Source shortcuts.actionSwitchToKana Switch to Kana shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings index 276dc7c..8532cbe 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ja.lproj/Localizable.strings @@ -118,6 +118,8 @@ このキーを押したときの動作を選択します shortcuts.actionSwitchToAlphanumeric 英数に切り替え + shortcuts.actionSwitchToInputSource + 入力ソースに切り替え shortcuts.actionSwitchToKana かなに切り替え shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings index 98cde3a..c3a896f 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/ko.lproj/Localizable.strings @@ -118,6 +118,8 @@ 이 키를 눌렀을 때 실행할 작업을 선택하세요 shortcuts.actionSwitchToAlphanumeric 영숫자로 전환 + shortcuts.actionSwitchToInputSource + 입력 소스로 전환 shortcuts.actionSwitchToKana 가나로 전환 shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings index 70a253b..3cfc9a6 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/vi.lproj/Localizable.strings @@ -118,6 +118,8 @@ Chọn điều xảy ra khi phím này được nhấn shortcuts.actionSwitchToAlphanumeric Chuyển sang chữ và số + shortcuts.actionSwitchToInputSource + Chuyển sang nguồn nhập shortcuts.actionSwitchToKana Chuyển sang Kana shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings index 87c8cd9..335a916 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hans.lproj/Localizable.strings @@ -118,6 +118,8 @@ 选择按下此键时执行的操作 shortcuts.actionSwitchToAlphanumeric 切换到英数 + shortcuts.actionSwitchToInputSource + 切换到输入法 shortcuts.actionSwitchToKana 切换到假名 shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings index 753ca4e..5dbcf7c 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Resources/zh-Hant.lproj/Localizable.strings @@ -118,6 +118,8 @@ 選擇按下此按鍵時執行的操作 shortcuts.actionSwitchToAlphanumeric 切換到英數 + shortcuts.actionSwitchToInputSource + 切換到輸入方式 shortcuts.actionSwitchToKana 切換到假名 shortcuts.addButton diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift index 3ffb97f..054d3e3 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/AppSettings.swift @@ -138,7 +138,12 @@ final class AppSettings: ObservableObject { func updateKeyMapping(at index: Int, input: KeyboardShortcut? = nil, output: KeyboardShortcut? = nil) { guard keyMappings.indices.contains(index) else { return } if let input = input { keyMappings[index].input = input } - if let output = output { keyMappings[index].output = output } + if let output = output { + keyMappings[index].output = output + // A key-post output supersedes any input-source output previously + // chosen on this row — the two mechanisms are mutually exclusive. + keyMappings[index].outputInputSourceID = nil + } // A freshly-added row stays disabled until both sides have been // explicitly chosen through the preset menus (see addKeyMapping()). if !keyMappings[index].input.isUnset && !keyMappings[index].output.isUnset { @@ -147,6 +152,17 @@ final class AppSettings: ObservableObject { keyMappings = keyMappings // trigger didSet } + /// Sets this row's action to "select this input source" (TIS mechanism) + /// instead of posting a key. See KeyMapping.outputInputSourceID. + func updateKeyMappingOutputSource(at index: Int, sourceID: String) { + guard keyMappings.indices.contains(index) else { return } + keyMappings[index].outputInputSourceID = sourceID + if !keyMappings[index].input.isUnset { + keyMappings[index].enable = true + } + keyMappings = keyMappings // trigger didSet + } + func addExclusion(_ app: AppData) { guard !exclusionApps.contains(where: { $0.id == app.id }) else { return } exclusionApps.append(app) diff --git a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift index b1a8dd9..bdfd652 100644 --- a/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift +++ b/apps/cmd-ime-swift/Sources/CmdIMESwift/Settings/ShortcutsSettingsView.swift @@ -29,6 +29,14 @@ struct ShortcutsSettingsView: View { (L("shortcuts.actionDisableKey"), KeyboardShortcut(keyCode: 999)), ] + // Installed non-Japanese input sources (Pinyin, Korean, ...) the user can + // assign a key to switch to via TIS instead of a key post — see + // InputSourceCatalog. Read once per view lifetime; a live picker would + // need to react to input sources being added/removed in System Settings + // while this view is open, which this app doesn't currently support for + // any other list either (e.g. exclusion apps). + private let inputSourceOptions = InputSourceCatalog.selectableKeyboardSources() + var body: some View { VStack(spacing: 8) { Text(L("shortcuts.description")) @@ -58,7 +66,7 @@ struct ShortcutsSettingsView: View { HStack(spacing: 12) { inputCell(label: mapping.input.toString(), index: index) Image(systemName: "arrow.right").foregroundStyle(.secondary) - actionCell(shortcut: mapping.output, index: index) + actionCell(mapping: mapping, index: index) if Self.isShadowed(settings.keyMappings, at: index) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.orange) @@ -120,24 +128,42 @@ struct ShortcutsSettingsView: View { } @ViewBuilder - private func actionCell(shortcut: KeyboardShortcut, index: Int) -> some View { + private func actionCell(mapping: KeyMapping, index: Int) -> some View { + let shortcut = mapping.output + let selectedSourceID = mapping.outputInputSourceID + Menu { ForEach(Self.actionPresets, id: \.label) { preset in Button { settings.updateKeyMapping(at: index, output: preset.shortcut) } label: { - if shortcut.keyCode == preset.shortcut.keyCode { + if selectedSourceID == nil && shortcut.keyCode == preset.shortcut.keyCode { Label(preset.label, systemImage: "checkmark") } else { Text(preset.label) } } } + if !inputSourceOptions.isEmpty { + Menu(L("shortcuts.actionSwitchToInputSource")) { + ForEach(inputSourceOptions) { option in + Button { + settings.updateKeyMappingOutputSource(at: index, sourceID: option.id) + } label: { + if selectedSourceID == option.id { + Label(option.localizedName, systemImage: "checkmark") + } else { + Text(option.localizedName) + } + } + } + } + } } label: { HStack(spacing: 6) { - Text(actionLabel(for: shortcut)) + Text(actionLabel(for: mapping)) .frame(maxWidth: .infinity, alignment: .leading) - .foregroundStyle(shortcut.keyCode == 0 ? Color.secondary : Color.primary) + .foregroundStyle(selectedSourceID == nil && shortcut.keyCode == 0 ? Color.secondary : Color.primary) Image(systemName: "chevron.up.chevron.down") .font(.caption2) .foregroundStyle(.tertiary) @@ -148,8 +174,12 @@ struct ShortcutsSettingsView: View { .help(Text(L("shortcuts.actionHelp"))) } - private func actionLabel(for shortcut: KeyboardShortcut) -> String { - Self.actionPresets.first(where: { $0.shortcut.keyCode == shortcut.keyCode })?.label + private func actionLabel(for mapping: KeyMapping) -> String { + if let sourceID = mapping.outputInputSourceID { + return inputSourceOptions.first(where: { $0.id == sourceID })?.localizedName ?? sourceID + } + let shortcut = mapping.output + return Self.actionPresets.first(where: { $0.shortcut.keyCode == shortcut.keyCode })?.label ?? (shortcut.toString().isEmpty ? L("shortcuts.actionColumnHeader") : shortcut.toString()) } diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift index fdbf2dc..a73e34d 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/AppSettingsTests.swift @@ -113,6 +113,34 @@ final class AppSettingsTests: XCTestCase { XCTAssertTrue(settings.keyMappings[index].enable, "both sides are now set") } + func testUpdateKeyMappingOutputSourcePersistsAndEnablesRow() { + let settings = AppSettings(defaults: defaults) + settings.addKeyMapping() + let index = settings.keyMappings.count - 1 + settings.updateKeyMapping(at: index, input: KeyboardShortcut(keyCode: 54)) + + settings.updateKeyMappingOutputSource(at: index, sourceID: "com.apple.inputmethod.SCIM.ITABC") + + XCTAssertEqual(settings.keyMappings[index].outputInputSourceID, "com.apple.inputmethod.SCIM.ITABC") + XCTAssertTrue(settings.keyMappings[index].enable, "input is set, so the row goes live") + + let stored = defaults.object(forKey: "mappings") as? [[AnyHashable: Any]] + XCTAssertEqual(stored?.last?["outputInputSourceID"] as? String, "com.apple.inputmethod.SCIM.ITABC") + } + + func testUpdateKeyMappingOutputClearsPreviouslySetInputSource() { + let settings = AppSettings(defaults: defaults) + settings.addKeyMapping() + let index = settings.keyMappings.count - 1 + settings.updateKeyMapping(at: index, input: KeyboardShortcut(keyCode: 54)) + settings.updateKeyMappingOutputSource(at: index, sourceID: "com.apple.inputmethod.SCIM.ITABC") + + settings.updateKeyMapping(at: index, output: KeyboardShortcut(keyCode: 104)) + + XCTAssertNil(settings.keyMappings[index].outputInputSourceID, + "choosing a key-post action must clear the mutually exclusive input-source action") + } + func testRemoveKeyMappingPersistsRemoval() { let settings = AppSettings(defaults: defaults) settings.removeKeyMapping(at: 0) diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceCatalogTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceCatalogTests.swift new file mode 100644 index 0000000..667913a --- /dev/null +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceCatalogTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import CmdIMESwift + +final class InputSourceCatalogTests: XCTestCase { + + // MARK: - parentCandidateID (pure string logic, no TIS involved) + + func testParentCandidateID_DropsLastDotComponent_ForAppleModeID() { + XCTAssertEqual( + InputSourceCatalog.parentCandidateID(for: "com.apple.inputmethod.SCIM.ITABC"), + "com.apple.inputmethod.SCIM" + ) + } + + func testParentCandidateID_DropsLastDotComponent_ForVendorModeID() { + // Confirmed against the real TIS dump: com.google.inputmethod.Japanese.base's + // parent entry is com.google.inputmethod.Japanese, not the modeID property + // value (com.apple.inputmethod.Japanese), which is unreliable for this. + XCTAssertEqual( + InputSourceCatalog.parentCandidateID(for: "com.google.inputmethod.Japanese.base"), + "com.google.inputmethod.Japanese" + ) + } + + func testParentCandidateID_NilForIDWithoutADot() { + XCTAssertNil(InputSourceCatalog.parentCandidateID(for: "noDotsHere")) + } + + // MARK: - selectableKeyboardSources (live, read-only enumeration — + // TISCreateInputSourceList needs no special entitlement to read, unlike + // TISEnableInputSource/TISSelectInputSource which mutate system state and + // are only exercised by the gated InputSourceSwitchSpikeTests). + + func testSelectableKeyboardSources_IncludesABC() { + let sources = InputSourceCatalog.selectableKeyboardSources() + + XCTAssertTrue(sources.contains { $0.id == "com.apple.keylayout.ABC" }, + "com.apple.keylayout.ABC ships on every macOS install") + } + + func testSelectableKeyboardSources_ExcludesPaletteSources() { + // Known contamination risk (see CLAUDE.md): Character Viewer / emoji + // palettes share the "enabled sources" list but must not appear as + // switchable input-source actions. + let sources = InputSourceCatalog.selectableKeyboardSources() + + XCTAssertFalse(sources.contains { $0.id.contains("CharacterPalette") }) + XCTAssertFalse(sources.contains { $0.id == "com.apple.PressAndHold" }) + } + + func testSelectableKeyboardSources_HasNoDuplicateIDs() { + let sources = InputSourceCatalog.selectableKeyboardSources() + let ids = sources.map(\.id) + + XCTAssertEqual(ids.count, Set(ids).count) + } +} diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift index 0f2d5d5..9737264 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/InputSourceSwitchSpikeTests.swift @@ -91,9 +91,11 @@ final class InputSourceSwitchSpikeTests: XCTestCase { let selectResult = select(id: sourceID) Thread.sleep(forTimeInterval: 0.3) let afterSelect = currentSourceID() - print("[\(label)] TISSelectInputSource(\(sourceID)) returned success=\(selectResult); read-back id=\(afterSelect)") + print("[\(label)] TISSelectInputSource(\(sourceID)) returned success=\(selectResult); " + + "read-back id=\(afterSelect)") XCTAssertTrue(selectResult, "\(label): TISSelectInputSource reported failure") - XCTAssertEqual(afterSelect, sourceID, "\(label): read-back did not move to the target source (stale-select suspected)") + XCTAssertEqual(afterSelect, sourceID, + "\(label): read-back did not move to the target source (stale-select suspected)") // target -> ABC let backResult = select(id: abcID) @@ -111,9 +113,13 @@ final class InputSourceSwitchSpikeTests: XCTestCase { let sources = (list as NSArray) as? [TISInputSource] ?? [] for s in sources { guard let id = prop(s, kTISPropertyInputSourceID), id.contains(substring) else { continue } - print("[installed:\(substring)] id=\(id) category=\(prop(s, kTISPropertyInputSourceCategory) ?? "nil") " + - "type=\(prop(s, kTISPropertyInputSourceType) ?? "nil") modeID=\(prop(s, kTISPropertyInputModeID) ?? "nil") " + - "enabled=\(boolProp(s, kTISPropertyInputSourceIsEnabled)) enableCapable=\(boolProp(s, kTISPropertyInputSourceIsEnableCapable))") + let category = prop(s, kTISPropertyInputSourceCategory) ?? "nil" + let type = prop(s, kTISPropertyInputSourceType) ?? "nil" + let modeID = prop(s, kTISPropertyInputModeID) ?? "nil" + let enabled = boolProp(s, kTISPropertyInputSourceIsEnabled) + let enableCapable = boolProp(s, kTISPropertyInputSourceIsEnableCapable) + print("[installed:\(substring)] id=\(id) category=\(category) type=\(type) modeID=\(modeID) " + + "enabled=\(enabled) enableCapable=\(enableCapable)") } } diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift index c91258d..5dc9eb5 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyEventTests.swift @@ -5,6 +5,7 @@ final class KeyEventTests: XCTestCase { var keyEvent: KeyEvent! var postedTaps: [KeyboardShortcut] = [] + var selectedInputSources: [String] = [] override func setUp() { super.setUp() @@ -12,6 +13,9 @@ final class KeyEventTests: XCTestCase { // Capture synthesized modifier taps instead of posting system-wide events. postedTaps = [] keyEvent.postModifierTap = { [weak self] in self?.postedTaps.append($0) } + // Capture TIS selections instead of mutating real input-source state. + selectedInputSources = [] + keyEvent.selectInputSourceAction = { [weak self] in self?.selectedInputSources.append($0) } // Reset global state keyMappingList = [] shortcutList = [:] @@ -284,6 +288,38 @@ final class KeyEventTests: XCTestCase { XCTAssertEqual(postedTaps.map(\.keyCode), [102]) } + // MARK: - Output mechanism selection (#multilang): a mapping with + // outputInputSourceID set selects that TIS source instead of posting a + // key; a plain key-code output (Eisu/Kana/remap) keeps posting as before. + + func testModifierTap_SelectsInputSource_WhenMappingHasOutputInputSourceID() { + let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) + let mapping = KeyMapping( + input: input, output: KeyboardShortcut(), outputInputSourceID: "com.apple.inputmethod.SCIM.ITABC" + ) + keyMappingList = [mapping] + keyMappingListToShortcutList() + + _ = keyEvent.modifierKeyDown(modifierEvent(55, flags: .maskCommand)) + _ = keyEvent.modifierKeyUp(modifierEvent(55)) + + XCTAssertEqual(selectedInputSources, ["com.apple.inputmethod.SCIM.ITABC"]) + XCTAssertTrue(postedTaps.isEmpty, "an input-source mapping must not also post a key") + } + + func testModifierTap_PostsKey_WhenMappingHasNoOutputInputSourceID() { + 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.modifierKeyUp(modifierEvent(55)) + + XCTAssertEqual(postedTaps.map(\.keyCode), [102]) + XCTAssertTrue(selectedInputSources.isEmpty, "a key-post mapping must not also select an input source") + } + func testModifierTap_DoesNotPost_WhenMappedToDisable() { let input = KeyboardShortcut(keyCode: 55, flags: .maskCommand) let output = KeyboardShortcut(keyCode: 999) diff --git a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyMappingTests.swift b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyMappingTests.swift index 1d0a7cd..1f2ece7 100644 --- a/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyMappingTests.swift +++ b/apps/cmd-ime-swift/Tests/CmdIMESwiftTests/KeyMappingTests.swift @@ -20,6 +20,35 @@ final class KeyMappingTests: XCTestCase { XCTAssertEqual(restored?.enable, true) } + func testOutputInputSourceIDRoundTrips() { + let mapping = KeyMapping( + input: KeyboardShortcut(keyCode: 54, flags: .maskCommand), + output: KeyboardShortcut(), + enable: true, + outputInputSourceID: "com.apple.inputmethod.Korean.2SetKorean" + ) + + let restored = KeyMapping(dictionary: mapping.toDictionary()) + + XCTAssertEqual(restored?.outputInputSourceID, "com.apple.inputmethod.Korean.2SetKorean") + } + + func testOutputInputSourceIDAbsentByDefaultAndOnLegacyDictionaries() { + // Fresh mapping: nil until explicitly set. + XCTAssertNil(KeyMapping().outputInputSourceID) + + // A dictionary persisted before this field existed has no such key. + let legacyDictionary: [AnyHashable: Any] = [ + "input": KeyboardShortcut(keyCode: 55).toDictionary(), + "output": KeyboardShortcut(keyCode: 102).toDictionary(), + "enable": true + ] + let restored = KeyMapping(dictionary: legacyDictionary) + + XCTAssertNotNil(restored) + XCTAssertNil(restored?.outputInputSourceID) + } + // SwiftUI ForEach must key on a stable per-instance id, never the array // index — distinct mappings must never collide, and an id must not change // across mutation/persistence round-trips.