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
117 changes: 0 additions & 117 deletions Sources/Core/CorrectionContext.swift

This file was deleted.

3 changes: 3 additions & 0 deletions Sources/Core/InputEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,9 @@ public final class InputEngine {
let liveContext = captureState.snapshot().context
guard input.context == liveContext else {
_ = stateMachine.updateContext(liveContext)
// The dropped event may have reached the app; don't let a partial
// word buffer up and get "corrected" with a wrong delete count.
stateMachine.invalidateUntilBoundary()
resetDetectorState()
logger.debug("buffer invalidated reason=stale-capture-context")
return
Expand Down
34 changes: 16 additions & 18 deletions Sources/Core/InputSourceManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,27 +238,25 @@ public final class InputSourceManager {
return nil
}
let layoutData = unsafeBitCast(layoutDataReference, to: CFData.self) as Data
guard let keyboardLayout = layoutData.withUnsafeBytes({ pointer in
pointer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self)
}) else {
return nil
}

var deadKeyState: UInt32 = 0
var characters = [UniChar](repeating: 0, count: 4)
var actualLength = 0
let status = UCKeyTranslate(
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDown),
0,
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
characters.count,
&actualLength,
&characters
)
// The layout pointer is only valid inside withUnsafeBytes.
let status = layoutData.withUnsafeBytes { pointer -> OSStatus in
guard let baseAddress = pointer.baseAddress else { return OSStatus(paramErr) }
return UCKeyTranslate(
baseAddress.assumingMemoryBound(to: UCKeyboardLayout.self),
keyCode,
UInt16(kUCKeyActionDown),
0,
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
characters.count,
&actualLength,
&characters
)
}
guard status == noErr, actualLength > 0 else { return nil }
return String(utf16CodeUnits: characters, count: actualLength).first
}
Expand Down
6 changes: 6 additions & 0 deletions Sources/Core/InputStateMachine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ public struct InputStateMachine {
return [.invalidate(.contextChanged)]
}

/// Marks the buffer invalid until the next boundary: used when an event was
/// dropped (stale context), so on-screen text and the buffer may disagree.
public mutating func invalidateUntilBoundary() {
invalidate(untilBoundary: true)
}

public mutating func updatePreferences(_ preferences: InputPreferencesSnapshot) -> [InputStateCommand] {
let wasEnabled = self.preferences.isEnabled
self.preferences = preferences
Expand Down
44 changes: 26 additions & 18 deletions Sources/Core/KeyboardMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public final class KeyboardMonitor {
private let translations = OSAllocatedUnfairLock(initialState: [TranslationKey: String]())
private var diagnosticRing = [EventMetadata?](repeating: nil, count: 256)
private var diagnosticRingIndex = 0
// Tap-callback-thread confined: tracks caps lock toggle state for edge detection.
private var lastAlphaShiftState: Bool?
private var tapResetCount: UInt64 = 0

private static let spaceKeyCode: UInt16 = 49
Expand All @@ -48,12 +50,13 @@ public final class KeyboardMonitor {

private static let functionKeyCodes: Set<UInt16> = Set([
122, 120, 99, 118, 96, 97, 98, 100, 101, 109, 103, 111,
105, 107, 113, 106,
105, 107, 113, 106, 64, 79, 80,
])

private static let navigationKeyCodes: Set<UInt16> = Set([
123, 124, 125, 126,
115, 119, 116, 121,
117, // forward delete: must not reach the character buffer as U+F728
])

private static let boundaryCharacterSet: CharacterSet = {
Expand Down Expand Up @@ -315,6 +318,12 @@ public final class KeyboardMonitor {
) else {
return nil
}
// Caps lock emits flagsChanged on both press and release with the same
// toggled state; fire only when the alpha-shift bit actually flips so a
// single press cannot trigger the revert hotkey twice.
let alphaShiftEngaged = flags.contains(.maskAlphaShift)
guard alphaShiftEngaged != lastAlphaShiftState else { return nil }
lastAlphaShiftState = alphaShiftEngaged
return .revertHotkey
}

Expand Down Expand Up @@ -412,27 +421,26 @@ public final class KeyboardMonitor {
return nil
}
let layoutData = unsafeBitCast(layoutDataReference, to: CFData.self) as Data
guard let keyboardLayout = layoutData.withUnsafeBytes({ pointer in
pointer.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self)
}) else {
return nil
}
let modifierState: UInt32 = shifted ? UInt32(shiftKey >> 8) : 0
var deadKeyState: UInt32 = 0
var characters = [UniChar](repeating: 0, count: 8)
var actualLength = 0
let status = UCKeyTranslate(
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDown),
modifierState,
UInt32(LMGetKbdType()),
OptionBits(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
characters.count,
&actualLength,
&characters
)
// The layout pointer is only valid inside withUnsafeBytes.
let status = layoutData.withUnsafeBytes { pointer -> OSStatus in
guard let baseAddress = pointer.baseAddress else { return OSStatus(paramErr) }
return UCKeyTranslate(
baseAddress.assumingMemoryBound(to: UCKeyboardLayout.self),
keyCode,
UInt16(kUCKeyActionDown),
modifierState,
UInt32(LMGetKbdType()),
OptionBits(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
characters.count,
&actualLength,
&characters
)
}
guard status == noErr, actualLength > 0 else { return nil }
return String(utf16CodeUnits: characters, count: actualLength)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Core/LayoutDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ public class LayoutDetector {
var hasCyrillic = false
for char in text {
for scalar in char.unicodeScalars {
if (scalar.value >= 0x0041 && scalar.value <= 0x007A) {
if (scalar.value >= 0x0041 && scalar.value <= 0x005A) || (scalar.value >= 0x0061 && scalar.value <= 0x007A) {
hasLatin = true
} else if (scalar.value >= 0x0400 && scalar.value <= 0x04FF) {
hasCyrillic = true
Expand Down
2 changes: 1 addition & 1 deletion Sources/Core/ScriptAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct ScriptAnalyzer {
var hasCyrillic = false
for scalar in text.unicodeScalars {
let value = scalar.value
if (value >= 0x0041 && value <= 0x007A) || (value >= 0x0061 && value <= 0x007A) { // Quick basic latin check (A-Z, a-z)
if (value >= 0x0041 && value <= 0x005A) || (value >= 0x0061 && value <= 0x007A) { // Quick basic latin check (A-Z, a-z)
hasLatin = true
} else if (value >= 0x0400 && value <= 0x04FF) {
hasCyrillic = true
Expand Down
27 changes: 22 additions & 5 deletions Sources/Core/TextCorrector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ public final class TextCorrector {
undoState.withLock { $0 = UndoState(plan: plan) }
if let layout = plan.targetLayout,
plan.isEligible(using: latestCaptureState()) {
inputSourceManager.switchTo(layout)
// TIS APIs are main-thread-only; apply() runs on the correction queue.
DispatchQueue.main.async { [inputSourceManager] in
inputSourceManager.switchTo(layout)
}
}
logger.debug("correction applied delete_count=\(plan.deleteCount, privacy: .public)")
return true
Expand Down Expand Up @@ -230,7 +233,11 @@ public final class TextCorrector {
post(events, targetPID: inverse.targetPID)
undoState.withLock { $0 = nil }
if inverse.isEligible(using: latestCaptureState()) {
inputSourceManager.switchTo(undo.plan.originalLayout)
let layout = undo.plan.originalLayout
// TIS APIs are main-thread-only; undo() runs on the correction queue.
DispatchQueue.main.async { [inputSourceManager] in
inputSourceManager.switchTo(layout)
}
}
return true
}
Expand Down Expand Up @@ -262,7 +269,17 @@ public final class TextCorrector {
}

let pasteboard = NSPasteboard.general
let previousItems = pasteboard.pasteboardItems
// Snapshot item data into fresh items: items read from a pasteboard are
// invalidated by clearContents() and cannot be written back.
let previousItems: [NSPasteboardItem] = (pasteboard.pasteboardItems ?? []).map { item in
let copy = NSPasteboardItem()
for type in item.types {
if let data = item.data(forType: type) {
copy.setData(data, forType: type)
}
}
return copy
}
pasteboard.clearContents()
pasteboard.setString(convertedText, forType: .string)
let replacementChangeCount = pasteboard.changeCount
Expand All @@ -280,7 +297,7 @@ public final class TextCorrector {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
guard pasteboard.changeCount == replacementChangeCount else { return }
pasteboard.clearContents()
if let previousItems, !previousItems.isEmpty {
if !previousItems.isEmpty {
pasteboard.writeObjects(previousItems)
}
}
Expand Down Expand Up @@ -309,7 +326,7 @@ public final class TextCorrector {
}

private func makeCorrectionEvents(plan: CorrectionPlan) -> [CGEvent]? {
guard eventSource != nil else { return nil }
guard eventSource != nil, !plan.replacementText.isEmpty else { return nil }
var events: [CGEvent] = []
events.reserveCapacity(plan.deleteCount * 2 + 2)
for _ in 0..<plan.deleteCount {
Expand Down
19 changes: 13 additions & 6 deletions Sources/Dictionary/BloomFilter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,24 @@ public class BloomFilter {
/// Create a BloomFilter with the specified number of bits and hash functions.
/// For 50K words with ~1% false positive rate: bitCount ≈ 480,000, hashCount = 7
public init(bitCount: Int, hashCount: Int) {
self.bitCount = bitCount
self.hashCount = hashCount
let byteCount = (bitCount + 7) / 8
// Guard against modulo-by-zero in hashing and zero-pass lookups.
self.bitCount = max(1, bitCount)
self.hashCount = max(1, hashCount)
let byteCount = (self.bitCount + 7) / 8
self.bits = [UInt8](repeating: 0, count: byteCount)
}

/// Create a BloomFilter from a serialized bit-array.
public init(bitCount: Int, hashCount: Int, bits: [UInt8]) {
self.bitCount = bitCount
self.hashCount = hashCount
self.bits = bits
self.bitCount = max(1, bitCount)
self.hashCount = max(1, hashCount)
let requiredBytes = (self.bitCount + 7) / 8
if bits.count >= requiredBytes {
self.bits = bits
} else {
// Undersized payload would index out of bounds; pad instead of trapping.
self.bits = bits + [UInt8](repeating: 0, count: requiredBytes - bits.count)
}
}

/// Create a BloomFilter optimized for a given number of items and false positive rate.
Expand Down
4 changes: 4 additions & 0 deletions Sources/Dictionary/DictionaryLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ public class DictionaryLoader {
}

public func mightContain(_ word: String, language: Language) -> Bool {
// Bloom hashing and the binary index operate on raw UTF-8 bytes; the
// compiled dictionaries store NFC, so normalize before byte-level lookups.
let word = word.precomposedStringWithCanonicalMapping
return lock.withLock {
guard let index = ensureIndexLoaded(for: language) else { return false }
let denyList = denyLists[language] ?? []
Expand All @@ -75,6 +78,7 @@ public class DictionaryLoader {
}

public func containsExact(_ word: String, language: Language) -> Bool {
let word = word.precomposedStringWithCanonicalMapping
return lock.withLock {
guard let index = ensureIndexLoaded(for: language) else { return false }

Expand Down
Loading