diff --git a/Source/Modules/ControllerModules/InputSignal.swift b/Source/Modules/ControllerModules/InputSignal.swift index 180d7cb8..a1cc8b96 100644 --- a/Source/Modules/ControllerModules/InputSignal.swift +++ b/Source/Modules/ControllerModules/InputSignal.swift @@ -214,6 +214,11 @@ struct InputSignal: CustomStringConvertible { 18: "1", 19: "2", 20: "3", 21: "4", 23: "5", 22: "6", 26: "7", 28: "8", 25: "9", 29: "0", ] + var isCandidateKey: Bool { + mgrPrefs.candidateKeys.contains(inputText) + || mgrPrefs.candidateKeys.contains(inputTextIgnoringModifiers ?? "114514") + } + /// 單獨用 flags 來判定數字小鍵盤輸入的方法已經失效了,所以必須再增補用 KeyCode 判定的方法。 var isNumericPadKey: Bool { arrNumpadKeyCodes.contains(keyCode) } var isMainAreaNumKey: Bool { arrMainAreaNumKey.contains(keyCode) } diff --git a/Source/Modules/ControllerModules/ctlInputMethod_Core.swift b/Source/Modules/ControllerModules/ctlInputMethod_Core.swift index 02c59768..2393b1e4 100644 --- a/Source/Modules/ControllerModules/ctlInputMethod_Core.swift +++ b/Source/Modules/ControllerModules/ctlInputMethod_Core.swift @@ -25,7 +25,7 @@ class ctlInputMethod: IMKInputController { static var areWeNerfing = false /// 目前在用的的選字窗副本。 - static var ctlCandidateCurrent: ctlCandidateProtocol = ctlCandidateUniversal.init(.horizontal) + static var ctlCandidateCurrent: ctlCandidateProtocol = ctlCandidateIMK.init(.horizontal) /// 工具提示視窗的副本。 static let tooltipController = TooltipController() @@ -47,7 +47,7 @@ class ctlInputMethod: IMKInputController { } /// `handle(event:)` 會利用這個參數判定某次 Shift 按鍵是否用來切換中英文輸入。 - private var rencentKeyHandledByKeyHandler = false + var rencentKeyHandledByKeyHandler = false // MARK: - 工具函式 @@ -205,6 +205,12 @@ class ctlInputMethod: IMKInputController { } } + /// IMK 選字窗處理,當且僅當啟用了 IMK 選字窗的時候才會生效。 + if let ctlCandidateCurrent = ctlInputMethod.ctlCandidateCurrent as? ctlCandidateIMK, ctlCandidateCurrent.visible { + ctlCandidateCurrent.interpretKeyEvents([event]) + return true + } + /// 這裡仍舊需要判斷 flags。之前使輸入法狀態卡住無法敲漢字的問題已在 KeyHandler 內修復。 /// 這裡不判斷 flags 的話,用方向鍵前後定位光標之後,再次試圖觸發組字區時、反而會在首次按鍵時失敗。 /// 同時注意:必須在 event.type == .flagsChanged 結尾插入 return false, @@ -270,14 +276,12 @@ class ctlInputMethod: IMKInputController { let theConverted = IME.kanjiConversionIfRequired(theCandidate.1) return (theCandidate.1 == theConverted) ? theCandidate.1 : "\(theConverted)(\(theCandidate.1))" } - } - if let state = state as? InputState.ChoosingCandidate { + } else if let state = state as? InputState.SymbolTable { return state.candidates.map { theCandidate -> String in let theConverted = IME.kanjiConversionIfRequired(theCandidate.1) return (theCandidate.1 == theConverted) ? theCandidate.1 : "\(theConverted)(\(theCandidate.1))" } - } - if let state = state as? InputState.SymbolTable { + } else if let state = state as? InputState.ChoosingCandidate { return state.candidates.map { theCandidate -> String in let theConverted = IME.kanjiConversionIfRequired(theCandidate.1) return (theCandidate.1 == theConverted) ? theCandidate.1 : "\(theConverted)(\(theCandidate.1))" @@ -285,4 +289,53 @@ class ctlInputMethod: IMKInputController { } return .init() } + + override open func candidateSelectionChanged(_: NSAttributedString!) { + // 暫時不需要擴充這個函數。 + } + + override open func candidateSelected(_ candidateString: NSAttributedString!) { + if state is InputState.AssociatedPhrases { + if !mgrPrefs.alsoConfirmAssociatedCandidatesByEnter { + handle(state: InputState.EmptyIgnoringPreviousState()) + handle(state: InputState.Empty()) + return + } + } + + var indexDeducted = 0 + if let state = state as? InputState.AssociatedPhrases { + for (i, neta) in state.candidates.map(\.1).enumerated() { + let theConverted = IME.kanjiConversionIfRequired(neta) + let netaShown = (neta == theConverted) ? neta : "\(theConverted)(\(neta))" + if candidateString.string == netaShown { + indexDeducted = i + break + } + } + } else if let state = state as? InputState.SymbolTable { + for (i, neta) in state.candidates.map(\.1).enumerated() { + let theConverted = IME.kanjiConversionIfRequired(neta) + let netaShown = (neta == theConverted) ? neta : "\(theConverted)(\(neta))" + if candidateString.string == netaShown { + indexDeducted = i + break + } + } + } else if let state = state as? InputState.ChoosingCandidate { + for (i, neta) in state.candidates.map(\.1).enumerated() { + let theConverted = IME.kanjiConversionIfRequired(neta) + let netaShown = (neta == theConverted) ? neta : "\(theConverted)(\(neta))" + if candidateString.string == netaShown { + indexDeducted = i + break + } + } + } + keyHandler( + keyHandler, + didSelectCandidateAt: indexDeducted, + ctlCandidate: ctlInputMethod.ctlCandidateCurrent + ) + } } diff --git a/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift b/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift index ead447f3..755e7d4b 100644 --- a/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift +++ b/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift @@ -49,7 +49,70 @@ extension ctlInputMethod: KeyHandlerDelegate { // MARK: - Candidate Controller Delegate extension ctlInputMethod: ctlCandidateDelegate { - func handleDelegateEvent(_ event: NSEvent!) -> Bool { handle(event, client: client()) } + func handleDelegateEvent(_ event: NSEvent!) -> Bool { + // 用 Shift 開關半形英數模式,僅對 macOS 10.15 及之後的 macOS 有效。 + if #available(macOS 10.15, *) { + if ShiftKeyUpChecker.check(event) { + if !rencentKeyHandledByKeyHandler { + NotifierController.notify( + message: String( + format: "%@%@%@", NSLocalizedString("Alphanumerical Mode", comment: ""), "\n", + toggleASCIIMode() + ? NSLocalizedString("NotificationSwitchON", comment: "") + : NSLocalizedString("NotificationSwitchOFF", comment: "") + ) + ) + } + rencentKeyHandledByKeyHandler = false + return false + } + } + + /// 這裡仍舊需要判斷 flags。之前使輸入法狀態卡住無法敲漢字的問題已在 KeyHandler 內修復。 + /// 這裡不判斷 flags 的話,用方向鍵前後定位光標之後,再次試圖觸發組字區時、反而會在首次按鍵時失敗。 + /// 同時注意:必須在 event.type == .flagsChanged 結尾插入 return false, + /// 否則,每次處理這種判斷時都會觸發 NSInternalInconsistencyException。 + if event.type == .flagsChanged { return false } + + // 準備修飾鍵,用來判定要新增的詞彙是否需要賦以非常低的權重。 + ctlInputMethod.areWeNerfing = event.modifierFlags.contains([.shift, .command]) + + var textFrame = NSRect.zero + + let attributes: [AnyHashable: Any]? = client().attributes( + forCharacterIndex: 0, lineHeightRectangle: &textFrame + ) + + let isTypingVertical = + (attributes?["IMKTextOrientation"] as? NSNumber)?.intValue == 0 || false + + if client().bundleIdentifier() + == "org.atelierInmu.vChewing.vChewingPhraseEditor" + { + IME.areWeUsingOurOwnPhraseEditor = true + } else { + IME.areWeUsingOurOwnPhraseEditor = false + } + + var input = InputSignal(event: event, isVerticalTyping: isTypingVertical) + input.isASCIIModeInput = isASCIIMode + + // 無法列印的訊號輸入,一概不作處理。 + // 這個過程不能放在 KeyHandler 內,否則不會起作用。 + if !input.charCode.isPrintable { + return false + } + + /// 將按鍵行為與當前輸入法狀態結合起來、交給按鍵調度模組來處理。 + /// 再根據返回的 result bool 數值來告知 IMK「這個按鍵事件是被處理了還是被放行了」。 + let result = keyHandler.handleCandidate(state: state, input: input) { newState in + self.handle(state: newState) + } errorCallback: { + clsSFX.beep() + } + rencentKeyHandledByKeyHandler = result + return result + } func candidateCountForController(_ controller: ctlCandidateProtocol) -> Int { _ = controller // 防止格式整理工具毀掉與此對應的參數。 diff --git a/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift b/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift index 4ec14a69..942f53c7 100644 --- a/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift +++ b/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift @@ -46,6 +46,8 @@ extension ctlInputMethod { candidates = state.candidates } if isTypingVertical { return true } + // 接下來的判斷並非適用於 IMK 選字窗,所以先插入排除語句。 + guard ctlInputMethod.ctlCandidateCurrent is ctlCandidateUniversal else { return false } // 以上是通用情形。接下來決定橫排輸入時是否使用縱排選字窗。 // 因為在拿候選字陣列時已經排序過了,所以這裡不用再多排序。 // 測量每頁顯示候選字的累計總長度。如果太長的話就強制使用縱排候選字窗。 @@ -65,11 +67,11 @@ extension ctlInputMethod { /// 該問題徹底解決的價值並不大,直接等到 macOS 10.x 全線淘汰之後用 SwiftUI 重寫選字窗吧。 if isCandidateWindowVertical { // 縱排輸入時強制使用縱排選字窗 - ctlInputMethod.ctlCandidateCurrent = ctlCandidateUniversal.init(.vertical) + ctlInputMethod.ctlCandidateCurrent = ctlCandidateIMK.init(.vertical) } else if mgrPrefs.useHorizontalCandidateList { - ctlInputMethod.ctlCandidateCurrent = ctlCandidateUniversal.init(.horizontal) + ctlInputMethod.ctlCandidateCurrent = ctlCandidateIMK.init(.horizontal) } else { - ctlInputMethod.ctlCandidateCurrent = ctlCandidateUniversal.init(.vertical) + ctlInputMethod.ctlCandidateCurrent = ctlCandidateIMK.init(.vertical) } // set the attributes for the candidate panel (which uses NSAttributedString) diff --git a/Source/Modules/SFX/clsSFX.swift b/Source/Modules/SFX/clsSFX.swift index e0260e47..ab7d3bbd 100644 --- a/Source/Modules/SFX/clsSFX.swift +++ b/Source/Modules/SFX/clsSFX.swift @@ -18,6 +18,14 @@ public enum clsSFX { AudioServicesPlaySystemSound(soundID) } + static func fart() { + let filePath = Bundle.main.path(forResource: "Fart", ofType: "m4a")! + let fileURL = URL(fileURLWithPath: filePath) + var soundID: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(fileURL as CFURL, &soundID) + AudioServicesPlaySystemSound(soundID) + } + static func beep(count: Int = 1) { if count <= 1 { clsSFX.beep() diff --git a/Source/UI/CandidateUI/ctlCandidateIMK.swift b/Source/UI/CandidateUI/ctlCandidateIMK.swift index 4d5a415b..1ac5a04e 100644 --- a/Source/UI/CandidateUI/ctlCandidateIMK.swift +++ b/Source/UI/CandidateUI/ctlCandidateIMK.swift @@ -18,8 +18,6 @@ public class ctlCandidateIMK: IMKCandidates, ctlCandidateProtocol { } } - public var selectedCandidateIndex: Int = .max - public var visible: Bool = false { didSet { if visible { @@ -30,10 +28,14 @@ public class ctlCandidateIMK: IMKCandidates, ctlCandidateProtocol { } } - public var windowTopLeftPoint: NSPoint = .init(x: 0, y: 0) { - didSet { + public var windowTopLeftPoint: NSPoint { + get { + let frameRect = candidateFrame() + return NSPoint(x: frameRect.minX, y: frameRect.maxY) + } + set { DispatchQueue.main.asyncAfter(deadline: DispatchTime.now()) { - self.set(windowTopLeftPoint: self.windowTopLeftPoint, bottomOutOfScreenAdjustmentHeight: 0) + self.set(windowTopLeftPoint: newValue, bottomOutOfScreenAdjustmentHeight: 0) } } } @@ -60,7 +62,8 @@ public class ctlCandidateIMK: IMKCandidates, ctlCandidateProtocol { case .vertical: setPanelType(kIMKSingleColumnScrollingCandidatePanel) } - setAttributes([IMKCandidatesSendServerKeyEventFirst: false]) + // 設為 true 表示先交給 ctlIME 處理 + setAttributes([IMKCandidatesSendServerKeyEventFirst: true]) } public required init(_ layout: CandidateLayout = .horizontal) { @@ -86,40 +89,151 @@ public class ctlCandidateIMK: IMKCandidates, ctlCandidateProtocol { update() } + /// 幹話:這裡很多函式內容亂寫也都無所謂了,因為都被 IMKCandidates 代管執行。 + /// 對於所有 IMK 選字窗的選字判斷動作,不是在 keyHandler 中,而是在 `ctlIME_Core` 中。 + + private var currentPageIndex: Int = 0 + + private var pageCount: Int { + guard let delegate = delegate else { + return 0 + } + let totalCount = delegate.candidateCountForController(self) + let keyLabelCount = keyLabels.count + return totalCount / keyLabelCount + ((totalCount % keyLabelCount) != 0 ? 1 : 0) + } + public func showNextPage() -> Bool { + guard delegate != nil else { return false } + if pageCount == 1 { return highlightNextCandidate() } + if currentPageIndex + 1 >= pageCount { clsSFX.beep() } + currentPageIndex = (currentPageIndex + 1 >= pageCount) ? 0 : currentPageIndex + 1 if selectedCandidateIndex == candidates(self).count - 1 { return false } selectedCandidateIndex = min(selectedCandidateIndex + keyCount, candidates(self).count - 1) - return selectCandidate(withIdentifier: selectedCandidateIndex) + pageDownAndModifySelection(self) + return true } public func showPreviousPage() -> Bool { + guard delegate != nil else { return false } + if pageCount == 1 { return highlightPreviousCandidate() } + if currentPageIndex == 0 { clsSFX.beep() } + currentPageIndex = (currentPageIndex == 0) ? pageCount - 1 : currentPageIndex - 1 if selectedCandidateIndex == 0 { return true } selectedCandidateIndex = max(selectedCandidateIndex - keyCount, 0) - return selectCandidate(withIdentifier: selectedCandidateIndex) + pageUpAndModifySelection(self) + return true } public func highlightNextCandidate() -> Bool { - if selectedCandidateIndex == candidates(self).count - 1 { return false } - selectedCandidateIndex = min(selectedCandidateIndex + 1, candidates(self).count - 1) - return selectCandidate(withIdentifier: selectedCandidateIndex) + guard let delegate = delegate else { return false } + selectedCandidateIndex = + (selectedCandidateIndex + 1 >= delegate.candidateCountForController(self)) + ? 0 : selectedCandidateIndex + 1 + switch currentLayout { + case .horizontal: + moveRight(self) + return true + case .vertical: + moveDown(self) + return true + } } public func highlightPreviousCandidate() -> Bool { - if selectedCandidateIndex == 0 { return true } - selectedCandidateIndex = max(selectedCandidateIndex - 1, 0) - return selectCandidate(withIdentifier: selectedCandidateIndex) - } - - public func candidateIndexAtKeyLabelIndex(_: Int) -> Int { - selectedCandidateIndex - } - - public func set(windowTopLeftPoint: NSPoint, bottomOutOfScreenAdjustmentHeight _: CGFloat = 0) { - setCandidateFrameTopLeft(windowTopLeftPoint) - } - - override public func handle(_ event: NSEvent!, client _: Any!) -> Bool { guard let delegate = delegate else { return false } - return delegate.handleDelegateEvent(event) + selectedCandidateIndex = + (selectedCandidateIndex == 0) + ? delegate.candidateCountForController(self) - 1 : selectedCandidateIndex - 1 + switch currentLayout { + case .horizontal: + moveLeft(self) + return true + case .vertical: + moveUp(self) + return true + } + } + + public func candidateIndexAtKeyLabelIndex(_ index: Int) -> Int { + guard let delegate = delegate else { + return Int.max + } + + let result = currentPageIndex * keyLabels.count + index + return result < delegate.candidateCountForController(self) ? result : Int.max + } + + public var selectedCandidateIndex: Int { + get { + selectedCandidate() + } + set { + selectCandidate(withIdentifier: newValue) + } + } + + public func set(windowTopLeftPoint: NSPoint, bottomOutOfScreenAdjustmentHeight height: CGFloat) { + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now()) { + self.doSet( + windowTopLeftPoint: windowTopLeftPoint, bottomOutOfScreenAdjustmentHeight: height + ) + } + } + + func doSet(windowTopLeftPoint: NSPoint, bottomOutOfScreenAdjustmentHeight height: CGFloat) { + var adjustedPoint = windowTopLeftPoint + var adjustedHeight = height + + var screenFrame = NSScreen.main?.visibleFrame ?? NSRect.zero + for screen in NSScreen.screens { + let frame = screen.visibleFrame + if windowTopLeftPoint.x >= frame.minX, windowTopLeftPoint.x <= frame.maxX, + windowTopLeftPoint.y >= frame.minY, windowTopLeftPoint.y <= frame.maxY + { + screenFrame = frame + break + } + } + + if adjustedHeight > screenFrame.size.height / 2.0 { + adjustedHeight = 0.0 + } + + let windowSize = candidateFrame().size + + // bottom beneath the screen? + if adjustedPoint.y - windowSize.height < screenFrame.minY { + adjustedPoint.y = windowTopLeftPoint.y + adjustedHeight + windowSize.height + } + + // top over the screen? + if adjustedPoint.y >= screenFrame.maxY { + adjustedPoint.y = screenFrame.maxY - 1.0 + } + + // right + if adjustedPoint.x + windowSize.width >= screenFrame.maxX { + adjustedPoint.x = screenFrame.maxX - windowSize.width + } + + // left + if adjustedPoint.x < screenFrame.minX { + adjustedPoint.x = screenFrame.minX + } + + setCandidateFrameTopLeft(adjustedPoint) + } + + override public func interpretKeyEvents(_ eventArray: [NSEvent]) { + guard !eventArray.isEmpty else { return } + let event = eventArray[0] + let input = InputSignal(event: event) + guard let delegate = delegate else { return } + if input.isEsc || input.isBackSpace || input.isDelete || input.isShiftHold { + _ = delegate.handleDelegateEvent(event) + } else { + super.interpretKeyEvents(eventArray) + } } }