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
97 changes: 97 additions & 0 deletions apps/cmd-ime-swift/Sources/CmdIMESwift/InputSourceCatalog.swift
Original file line number Diff line number Diff line change
@@ -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[..<lastDot])
}

// MARK: - TIS helpers

private static func find(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 static func isEnabled(_ source: TISInputSource) -> Bool {
guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceIsEnabled) else { return false }
return CFBooleanGetValue(Unmanaged<CFBoolean>.fromOpaque(ptr).takeUnretainedValue())
}

private static func stringProperty(_ source: TISInputSource, _ key: CFString) -> String? {
guard let ptr = TISGetInputSourceProperty(source, key) else { return nil }
return Unmanaged<CFString>.fromOpaque(ptr).takeUnretainedValue() as String
}
}
22 changes: 15 additions & 7 deletions apps/cmd-ime-swift/Sources/CmdIMESwift/KeyEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -370,13 +376,15 @@ class KeyEvent: NSObject {
func modifierKeyUp(_ event: CGEvent) -> Unmanaged<CGEvent>? {
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)
Expand Down
15 changes: 13 additions & 2 deletions apps/cmd-ime-swift/Sources/CmdIMESwift/KeyMapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -28,6 +34,7 @@ class KeyMapping: NSObject, Identifiable {
input = KeyboardShortcut()
output = KeyboardShortcut()
self.enable = true
self.outputInputSourceID = nil
super.init()
}

Expand All @@ -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 {
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>Choose what happens when this key is pressed</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>Switch to Alphanumeric</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>Switch to Input Source</string>
<key>shortcuts.actionSwitchToKana</key>
<string>Switch to Kana</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>このキーを押したときの動作を選択します</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>英数に切り替え</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>入力ソースに切り替え</string>
<key>shortcuts.actionSwitchToKana</key>
<string>かなに切り替え</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>이 키를 눌렀을 때 실행할 작업을 선택하세요</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>영숫자로 전환</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>입력 소스로 전환</string>
<key>shortcuts.actionSwitchToKana</key>
<string>가나로 전환</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>Chọn điều xảy ra khi phím này được nhấn</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>Chuyển sang chữ và số</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>Chuyển sang nguồn nhập</string>
<key>shortcuts.actionSwitchToKana</key>
<string>Chuyển sang Kana</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>选择按下此键时执行的操作</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>切换到英数</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>切换到输入法</string>
<key>shortcuts.actionSwitchToKana</key>
<string>切换到假名</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@
<string>選擇按下此按鍵時執行的操作</string>
<key>shortcuts.actionSwitchToAlphanumeric</key>
<string>切換到英數</string>
<key>shortcuts.actionSwitchToInputSource</key>
<string>切換到輸入方式</string>
<key>shortcuts.actionSwitchToKana</key>
<string>切換到假名</string>
<key>shortcuts.addButton</key>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
Loading
Loading