Skip to content
Open
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
44 changes: 8 additions & 36 deletions Sources/Core/InputSourceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,6 @@ import Carbon
public class InputSourceManager {
public static let shared = InputSourceManager()

private var cachedLayout: Layout?
private var cachedLayoutTimeNs: UInt64 = 0
private static let layoutCacheTTLNs: UInt64 = 120_000_000 // 120ms

/// Maps each Layout to the user's actual installed input source ID.
/// Populated at startup by `discoverInstalledSources()`.
private var installedSourceIDs: [Layout: String] = [:]
Expand All @@ -17,21 +13,9 @@ public class InputSourceManager {
private static let bKeyCode: UInt16 = 11

private init() {
// Listen for input source changes to invalidate cache
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(inputSourceChanged),
name: NSNotification.Name(kTISNotifySelectedKeyboardInputSourceChanged as String),
object: nil
)
discoverInstalledSources()
}

@objc private func inputSourceChanged() {
cachedLayout = nil
cachedLayoutTimeNs = 0
}

public struct InputSourceDescriptor: Equatable {
public let id: String
public let name: String
Expand All @@ -47,12 +31,16 @@ public class InputSourceManager {
guard let sourceID = stringProperty(source, kTISPropertyInputSourceID) else { continue }
let sourceName = stringProperty(source, kTISPropertyLocalizedName)

for layout in Layout.allCases {
if layout.matches(sourceID: sourceID) && installedSourceIDs[layout] == nil {
installedSourceIDs[layout] = sourceID
NSLog("[SwitchFix] Discovered layout: %@ → %@", layout.rawValue, sourceID)
if let matchingLayout = Layout.allCases.first(where: { $0.matches(sourceID: sourceID) }) {
if installedSourceIDs[matchingLayout] == nil {
installedSourceIDs[matchingLayout] = sourceID
NSLog("[SwitchFix] Discovered layout: %@ → %@", matchingLayout.rawValue, sourceID)
}
}
else {
NSLog("[SwitchFix] Unmatched input source: %@ (%@)", sourceID, sourceName ?? "unnamed")
}


if Layout.ukrainian.matches(sourceID: sourceID) && ukrainianVariantBySourceID[sourceID] == nil {
let variant = detectUkrainianVariant(for: source, sourceName: sourceName)
Expand All @@ -64,24 +52,10 @@ public class InputSourceManager {

/// Get the current active keyboard layout (cached until input source changes).
public func currentLayout() -> Layout {
let now = DispatchTime.now().uptimeNanoseconds
if let cachedLayout,
now &- cachedLayoutTimeNs < InputSourceManager.layoutCacheTTLNs {
return cachedLayout
}

let layout = fetchCurrentLayout()
cachedLayout = layout
cachedLayoutTimeNs = now
return layout
}

/// Force-refresh the cached layout after a programmatic switch.
public func invalidateCache() {
cachedLayout = nil
cachedLayoutTimeNs = 0
}

/// The raw input source ID of the current keyboard layout.
public func currentInputSourceID() -> String {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else {
Expand Down Expand Up @@ -137,8 +111,6 @@ public class InputSourceManager {
if sourceID == targetID {
let status = TISSelectInputSource(source)
NSLog("[SwitchFix] switchTo(%@): selected %@ (status: %d)", layout.rawValue, sourceID, status)
cachedLayout = layout
cachedLayoutTimeNs = DispatchTime.now().uptimeNanoseconds
return
}
}
Expand Down
14 changes: 14 additions & 0 deletions Sources/Core/LayoutDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ public struct DetectionResult {
public let convertedWord: String
public let originalWord: String
public let shouldSwitchLayout: Bool

public init(
sourceLayout: Layout,
targetLayout: Layout,
convertedWord: String,
originalWord: String,
shouldSwitchLayout: Bool
) {
self.sourceLayout = sourceLayout
self.targetLayout = targetLayout
self.convertedWord = convertedWord
self.originalWord = originalWord
self.shouldSwitchLayout = shouldSwitchLayout
}
}

/// State machine for layout detection.
Expand Down
25 changes: 13 additions & 12 deletions Sources/Core/LayoutMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,29 @@ public enum Layout: String, CaseIterable, Equatable {
public var inputSourceIDs: [String] {
switch self {
case .english: return [
"com.apple.keylayout.US",
"com.apple.keylayout.ABC",
"com.apple.keylayout.British",
"com.apple.keylayout.USInternational-PC",
"com.apple.keylayout.Colemak",
"com.apple.keylayout.Dvorak",
"keylayout.US",
"keylayout.USExtended",
"keylayout.ABC",
"keylayout.British",
"keylayout.USInternational-PC",
"keylayout.Colemak",
"keylayout.Dvorak",
]
case .ukrainian: return [
"com.apple.keylayout.Ukrainian",
"com.apple.keylayout.Ukrainian-PC",
"keylayout.Ukrainian",
"keylayout.Ukrainian-PC",
]
case .russian: return [
"com.apple.keylayout.Russian",
"com.apple.keylayout.RussianWin",
"com.apple.keylayout.Russian-Phonetic",
"keylayout.Russian",
"keylayout.RussianWin",
"keylayout.Russian-Phonetic",
]
}
}

/// Check if a given input source ID matches this layout.
public func matches(sourceID: String) -> Bool {
return inputSourceIDs.contains(sourceID)
return inputSourceIDs.contains(where: { sourceID.hasSuffix($0) })
}
}

Expand Down
12 changes: 9 additions & 3 deletions Sources/Core/TextCorrector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,12 @@ public class TextCorrector {
}

/// Perform correction on selected text by replacing it via clipboard paste.
public func performSelectionCorrection(selectedText: String, convertedText: String, targetLayout: Layout) {
public func performSelectionCorrection(
selectedText: String,
convertedText: String,
targetLayout: Layout,
shouldSwitchLayout: Bool = true
) {
lastOriginalText = selectedText
lastCorrectedText = convertedText
lastCorrectionTime = Date()
Expand All @@ -219,8 +224,9 @@ public class TextCorrector {
// Simulate Cmd+V to replace selection
simulatePaste()

// Switch layout to target
inputSourceManager.switchTo(targetLayout)
if shouldSwitchLayout {
inputSourceManager.switchTo(targetLayout)
}

// Restore clipboard and resume monitoring after paste completes
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
Expand Down
122 changes: 114 additions & 8 deletions Sources/SwitchFixApp/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
private var isCurrentAppAllowed: Bool = true
private var capsLockConflictProbeToken: UUID?
private var monitoringObserversRegistered: Bool = false
private var previousLayout: Layout = .english
private var isCorrectionInProgress: Bool = false
private static let capsLockKeyCode: UInt16 = 57

func applicationDidFinishLaunching(_ notification: Notification) {
Expand All @@ -26,6 +28,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setupCorrectionEngine()
prewarmDictionaries()

previousLayout = inputSourceManager.currentLayout()

Permissions.ensureRequiredPermissions { [weak self] in
guard let self else { return }
if self.startMonitoring() {
Expand Down Expand Up @@ -58,10 +62,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {

textCorrector = TextCorrector()
textCorrector?.onCorrectionStarted = { [weak self] in
self?.isCorrectionInProgress = true
self?.keyboardMonitor?.isPaused = true
self?.layoutDetector?.beginCorrection()
}
textCorrector?.onCorrectionFinished = { [weak self] in
self?.isCorrectionInProgress = false
self?.keyboardMonitor?.isPaused = false
self?.layoutDetector?.endCorrection()
}
Expand Down Expand Up @@ -130,7 +136,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
self,
selector: #selector(selectedInputSourceChanged),
name: NSNotification.Name(kTISNotifySelectedKeyboardInputSourceChanged as String),
object: nil
object: nil,
suspensionBehavior: .deliverImmediately
)
return true
}
Expand All @@ -145,10 +152,46 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}

@objc private func selectedInputSourceChanged() {
guard capsLockConflictProbeToken != nil else { return }
capsLockConflictProbeToken = nil
SystemHotkeyConflicts.markObservedCapsLockConflict()
NSLog("[SwitchFix] Warning: observed CapsLock conflict (input source changed immediately after revert hotkey)")
let newLayout = inputSourceManager.currentLayout()
defer { previousLayout = newLayout }

if capsLockConflictProbeToken != nil {
capsLockConflictProbeToken = nil
SystemHotkeyConflicts.markObservedCapsLockConflict()
NSLog("[SwitchFix] Warning: observed CapsLock conflict (input source changed immediately after revert hotkey)")
return
}

NSLog("[SwitchFix] Input source changed: %@ -> %@", previousLayout.rawValue, newLayout.rawValue)

guard !isCorrectionInProgress else { return }
guard PreferencesManager.shared.correctionMode == .layoutSwitch else { return }
guard PreferencesManager.shared.isEnabled else { return }
guard isCurrentAppAllowed else { return }
guard newLayout != previousLayout else { return }

triggerLayoutSwitchCorrection(from: previousLayout, to: newLayout)
}

/// Returns true when `text` contains at least one letter belonging to the script
/// of `layout` (Latin for English, Cyrillic for Ukrainian/Russian). Text that has
/// no letters of the expected script is considered foreign and should not be
/// transcribed from `layout`.
private func textContainsScript(of layout: Layout, text: String) -> Bool {
let latinLowercase: ClosedRange<UInt32> = 0x0061...0x007A
let latinUppercase: ClosedRange<UInt32> = 0x0041...0x005A
let cyrillicRange: ClosedRange<UInt32> = 0x0400...0x04FF

switch layout {
case .english:
return text.unicodeScalars.contains { scalar in
latinLowercase.contains(scalar.value) || latinUppercase.contains(scalar.value)
}
case .ukrainian, .russian:
return text.unicodeScalars.contains { scalar in
cyrillicRange.contains(scalar.value)
}
}
}

private func startCapsLockConflictProbe() {
Expand Down Expand Up @@ -215,6 +258,67 @@ class AppDelegate: NSObject, NSApplicationDelegate {
textCorrector?.recordUserInput(kind: .other)
return true
}

private func triggerLayoutSwitchCorrection(from fromLayout: Layout, to toLayout: Layout) {
let fromVariant = inputSourceManager.currentUkrainianVariant() ?? inputSourceManager.preferredUkrainianVariant()
let toVariant = inputSourceManager.preferredUkrainianVariant()

if let selection = Permissions.getSelectedText(), !selection.isEmpty {
guard textContainsScript(of: fromLayout, text: selection) else {
NSLog("[SwitchFix] Layout-switch correction: selection not in %@ script, skipping", fromLayout.rawValue)
return
}
let converted = LayoutMapper.convert(
selection,
from: fromLayout,
to: toLayout,
ukrainianFromVariant: fromVariant,
ukrainianToVariant: toVariant
)
guard converted != selection else { return }
NSLog("[SwitchFix] Layout-switch correction: selection '%@' → '%@' (%@→%@)", selection, converted, fromLayout.rawValue, toLayout.rawValue)
layoutDetector?.discardBuffer()
textCorrector?.performSelectionCorrection(
selectedText: selection,
convertedText: converted,
targetLayout: toLayout,
shouldSwitchLayout: false
)
return
}

guard let detector = layoutDetector, !detector.currentBuffer.isEmpty else { return }
let originalWord = detector.currentBuffer
guard textContainsScript(of: fromLayout, text: originalWord) else {
NSLog("[SwitchFix] Layout-switch correction: buffer '%@' not in %@ script, skipping",
originalWord, fromLayout.rawValue)
detector.discardBuffer()
return
}
let converted = LayoutMapper.convert(
originalWord,
from: fromLayout,
to: toLayout,
ukrainianFromVariant: fromVariant,
ukrainianToVariant: toVariant
)
guard converted != originalWord else { return }

NSLog("[SwitchFix] Layout-switch correction: buffer '%@' → '%@' (%@→%@)",
originalWord, converted, fromLayout.rawValue, toLayout.rawValue)

let result = DetectionResult(
sourceLayout: fromLayout,
targetLayout: toLayout,
convertedWord: converted,
originalWord: originalWord,
shouldSwitchLayout: false
)
detector.discardBuffer()
textCorrector?.performCorrection(result: result, boundaryCharacter: nil)
textCorrector?.recordUserInput(kind: .other)
}

@objc private func preferencesDidUpdate() {
guard let monitor = keyboardMonitor else { return }
monitor.hotkeyKeyCode = PreferencesManager.shared.hotkeyKeyCode
Expand Down Expand Up @@ -249,16 +353,18 @@ extension AppDelegate: KeyboardMonitorDelegate {
guard isCurrentAppAllowed else { return }
textCorrector?.noteUserEdit()

if PreferencesManager.shared.correctionMode == .automatic {
switch PreferencesManager.shared.correctionMode {
case .automatic:
// Automatic mode: flush triggers detection + correction
// Pass boundary character (space, punctuation, newline, etc.) so correction can retype it
let layout = inputSourceManager.currentLayout()
layoutDetector?.currentLayout = layout
refreshLayoutVariants(for: layout)
let boundary = character.isEmpty ? nil : character
layoutDetector?.flushBuffer(boundaryCharacter: boundary)
} else {
// Hotkey mode: just discard the buffer (word boundary passed)
case .hotkey, .layoutSwitch:
// Discard the buffer on word boundary — correction is triggered elsewhere
// (via hotkey, or via system keyboard-layout change)
layoutDetector?.discardBuffer()
}
textCorrector?.recordUserInput(kind: .boundary)
Expand Down
1 change: 1 addition & 0 deletions Sources/UI/PreferencesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ServiceManagement
public enum CorrectionMode: String {
case automatic
case hotkey
case layoutSwitch
}

public class PreferencesManager {
Expand Down
Loading