From 42cc9234253d26f73a3d25371c610894fd4211c0 Mon Sep 17 00:00:00 2001 From: ShikiSuen Date: Wed, 6 Jul 2022 11:42:17 +0800 Subject: [PATCH] ctlIME // Tear ctlInputMethod into parts. --- .../ctlInputMethod_Core.swift | 477 +----------------- .../ctlInputMethod_Delegates.swift | 152 ++++++ .../ctlInputMethod_HandleDisplay.swift | 169 +++++++ .../ctlInputMethod_HandleStates.swift | 228 +++++++++ vChewing.xcodeproj/project.pbxproj | 12 + 5 files changed, 564 insertions(+), 474 deletions(-) create mode 100644 Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift create mode 100644 Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift create mode 100644 Source/Modules/ControllerModules/ctlInputMethod_HandleStates.swift diff --git a/Source/Modules/ControllerModules/ctlInputMethod_Core.swift b/Source/Modules/ControllerModules/ctlInputMethod_Core.swift index b4cd31bb..b35ea3c1 100644 --- a/Source/Modules/ControllerModules/ctlInputMethod_Core.swift +++ b/Source/Modules/ControllerModules/ctlInputMethod_Core.swift @@ -41,7 +41,7 @@ class ctlInputMethod: IMKInputController { static var areWeDeleting = false /// 目前在用的的選字窗副本。 - private var ctlCandidateCurrent = ctlCandidateUniversal.init(.horizontal) + var ctlCandidateCurrent = ctlCandidateUniversal.init(.horizontal) /// 工具提示視窗的副本。 static let tooltipController = TooltipController() @@ -49,9 +49,9 @@ class ctlInputMethod: IMKInputController { // MARK: - /// 按鍵調度模組的副本。 - private var keyHandler: KeyHandler = .init() + var keyHandler: KeyHandler = .init() /// 用以記錄當前輸入法狀態的變數。 - private var state: InputStateProtocol = InputState.Empty() + var state: InputStateProtocol = InputState.Empty() // MARK: - 工具函式 @@ -233,474 +233,3 @@ class ctlInputMethod: IMKInputController { resetKeyHandler() } } - -// MARK: - 狀態調度 (State Handling) - -extension ctlInputMethod { - /// 針對傳入的新狀態進行調度。 - /// - /// 先將舊狀態單獨記錄起來,再將新舊狀態作為參數, - /// 根據新狀態本身的狀態種類來判斷交給哪一個專門的函式來處理。 - /// - Parameter newState: 新狀態。 - private func handle(state newState: InputStateProtocol) { - let prevState = state - state = newState - - switch newState { - case let newState as InputState.Deactivated: - handle(state: newState, previous: prevState) - case let newState as InputState.Empty: - handle(state: newState, previous: prevState) - case let newState as InputState.EmptyIgnoringPreviousState: - handle(state: newState, previous: prevState) - case let newState as InputState.Committing: - handle(state: newState, previous: prevState) - case let newState as InputState.Inputting: - handle(state: newState, previous: prevState) - case let newState as InputState.Marking: - handle(state: newState, previous: prevState) - case let newState as InputState.ChoosingCandidate: - handle(state: newState, previous: prevState) - case let newState as InputState.AssociatedPhrases: - handle(state: newState, previous: prevState) - case let newState as InputState.SymbolTable: - handle(state: newState, previous: prevState) - default: break - } - } - - /// 針對受 .NotEmpty() 管轄的非空狀態,在組字區內顯示游標。 - private func setInlineDisplayWithCursor() { - guard let state = state as? InputState.NotEmpty else { - clearInlineDisplay() - return - } - - var identifier: AnyObject { - switch IME.currentInputMode { - case InputMode.imeModeCHS: - if #available(macOS 12.0, *) { - return "zh-Hans" as AnyObject - } - case InputMode.imeModeCHT: - if #available(macOS 12.0, *) { - return (mgrPrefs.shiftJISShinjitaiOutputEnabled || mgrPrefs.chineseConversionEnabled) - ? "ja" as AnyObject : "zh-Hant" as AnyObject - } - default: - break - } - return "" as AnyObject - } - - // [Shiki's Note] This might needs to be bug-reported to Apple: - // The LanguageIdentifier attribute of an NSAttributeString designated to - // IMK Client().SetMarkedText won't let the actual font respect your languageIdentifier - // settings. Still, this might behaves as Apple's current expectation, I'm afraid. - if #available(macOS 12.0, *) { - state.attributedString.setAttributes( - [.languageIdentifier: identifier], - range: NSRange( - location: 0, - length: state.composingBuffer.utf16.count - ) - ) - } - - /// 所謂選區「selectionRange」,就是「可見游標位置」的位置,只不過長度 - /// 是 0 且取代範圍(replacementRange)為「NSNotFound」罷了。 - /// 也就是說,內文組字區該在哪裡出現,得由客體軟體來作主。 - client().setMarkedText( - state.attributedString, selectionRange: NSRange(location: state.cursorIndex, length: 0), - replacementRange: NSRange(location: NSNotFound, length: NSNotFound) - ) - } - - /// 在處理不受 .NotEmpty() 管轄的狀態時可能要用到的函式,會清空螢幕上顯示的內文組字區。 - /// 當 setInlineDisplayWithCursor() 在錯誤的狀態下被呼叫時,也會觸發這個函式。 - private func clearInlineDisplay() { - client().setMarkedText( - "", selectionRange: NSRange(location: 0, length: 0), - replacementRange: NSRange(location: NSNotFound, length: NSNotFound) - ) - } - - /// 遞交組字區內容。 - /// 注意:必須在 IMK 的 commitComposition 函式當中也間接或者直接執行這個處理。 - private func commit(text: String) { - let buffer = IME.kanjiConversionIfRequired(text) - if buffer.isEmpty { - return - } - - client().insertText( - buffer, replacementRange: NSRange(location: NSNotFound, length: NSNotFound) - ) - } - - private func handle(state: InputState.Deactivated, previous: InputStateProtocol) { - _ = state // 防止格式整理工具毀掉與此對應的參數。 - ctlCandidateCurrent.delegate = nil - ctlCandidateCurrent.visible = false - hideTooltip() - if let previous = previous as? InputState.NotEmpty { - commit(text: previous.composingBuffer) - } - clearInlineDisplay() - } - - private func handle(state: InputState.Empty, previous: InputStateProtocol) { - _ = state // 防止格式整理工具毀掉與此對應的參數。 - ctlCandidateCurrent.visible = false - hideTooltip() - // 全專案用以判斷「.EmptyIgnoringPreviousState」的地方僅此一處。 - if let previous = previous as? InputState.NotEmpty, - !(state is InputState.EmptyIgnoringPreviousState) - { - commit(text: previous.composingBuffer) - } - clearInlineDisplay() - } - - private func handle( - state: InputState.EmptyIgnoringPreviousState, previous: InputStateProtocol - ) { - _ = state // 防止格式整理工具毀掉與此對應的參數。 - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - // 這個函式就是去掉 previous state 使得沒有任何東西可以 commit。 - handle(state: InputState.Empty()) - } - - private func handle(state: InputState.Committing, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - ctlCandidateCurrent.visible = false - hideTooltip() - let textToCommit = state.textToCommit - if !textToCommit.isEmpty { - commit(text: textToCommit) - } - clearInlineDisplay() - } - - private func handle(state: InputState.Inputting, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - ctlCandidateCurrent.visible = false - hideTooltip() - let textToCommit = state.textToCommit - if !textToCommit.isEmpty { - commit(text: textToCommit) - } - setInlineDisplayWithCursor() - if !state.tooltip.isEmpty { - show( - tooltip: state.tooltip, composingBuffer: state.composingBuffer, - cursorIndex: state.cursorIndex - ) - } - } - - private func handle(state: InputState.Marking, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - ctlCandidateCurrent.visible = false - setInlineDisplayWithCursor() - if state.tooltip.isEmpty { - hideTooltip() - } else { - show( - tooltip: state.tooltip, composingBuffer: state.composingBuffer, - cursorIndex: state.markerIndex - ) - } - } - - private func handle(state: InputState.ChoosingCandidate, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - hideTooltip() - setInlineDisplayWithCursor() - show(candidateWindowWith: state) - } - - private func handle(state: InputState.SymbolTable, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - hideTooltip() - setInlineDisplayWithCursor() - show(candidateWindowWith: state) - } - - private func handle(state: InputState.AssociatedPhrases, previous: InputStateProtocol) { - _ = previous // 防止格式整理工具毀掉與此對應的參數。 - hideTooltip() - clearInlineDisplay() - show(candidateWindowWith: state) - } -} - -// MARK: - - -extension ctlInputMethod { - private func show(candidateWindowWith state: InputStateProtocol) { - var isTypingVertical: Bool { - if let state = state as? InputState.ChoosingCandidate { - return state.isTypingVertical - } else if let state = state as? InputState.AssociatedPhrases { - return state.isTypingVertical - } - return false - } - var isCandidateWindowVertical: Bool { - var candidates: [String] = [] - if let state = state as? InputState.ChoosingCandidate { - candidates = state.candidates - } else if let state = state as? InputState.AssociatedPhrases { - candidates = state.candidates - } - if isTypingVertical { return true } - // 以上是通用情形。接下來決定橫排輸入時是否使用縱排選字窗。 - candidates.sort { - $0.count > $1.count - } - // 測量每頁顯示候選字的累計總長度。如果太長的話就強制使用縱排候選字窗。 - // 範例:「屬實牛逼」(會有一大串各種各樣的「鼠食牛Beer」的 emoji)。 - let maxCandidatesPerPage = mgrPrefs.candidateKeys.count - let firstPageCandidates = candidates[0.. Int(round(Double(maxCandidatesPerPage) * 1.8)) - // 上面這句如果是 true 的話,就會是縱排;反之則為橫排。 - } - - ctlCandidateCurrent.delegate = nil - - /// 下面這一段本可直接指定 currentLayout,但這樣的話翻頁按鈕位置無法精準地重新繪製。 - /// 所以只能重新初期化。壞處就是得在 ctlCandidate() 當中與 SymbolTable 控制有關的地方 - /// 新增一個空狀態請求、防止縱排與橫排選字窗同時出現。 - /// layoutCandidateView 在這裡無法起到糾正作用。 - /// 該問題徹底解決的價值並不大,直接等到 macOS 10.x 全線淘汰之後用 SwiftUI 重寫選字窗吧。 - - if isCandidateWindowVertical { // 縱排輸入時強制使用縱排選字窗 - ctlCandidateCurrent = .init(.vertical) - } else if mgrPrefs.useHorizontalCandidateList { - ctlCandidateCurrent = .init(.horizontal) - } else { - ctlCandidateCurrent = .init(.vertical) - } - - // set the attributes for the candidate panel (which uses NSAttributedString) - let textSize = mgrPrefs.candidateListTextSize - let keyLabelSize = max(textSize / 2, mgrPrefs.minKeyLabelSize) - - func labelFont(name: String?, size: CGFloat) -> NSFont { - if let name = name { - return NSFont(name: name, size: size) ?? NSFont.systemFont(ofSize: size) - } - return NSFont.systemFont(ofSize: size) - } - - func candidateFont(name: String?, size: CGFloat) -> NSFont { - let currentMUIFont = - (keyHandler.inputMode == InputMode.imeModeCHS) - ? "Sarasa Term Slab SC" : "Sarasa Term Slab TC" - var finalReturnFont = - NSFont(name: currentMUIFont, size: size) ?? NSFont.systemFont(ofSize: size) - // 對更紗黑體的依賴到 macOS 11 Big Sur 為止。macOS 12 Monterey 開始則依賴系統內建的函式使用蘋方來處理。 - if #available(macOS 12.0, *) { finalReturnFont = NSFont.systemFont(ofSize: size) } - if let name = name { - return NSFont(name: name, size: size) ?? finalReturnFont - } - return finalReturnFont - } - - ctlCandidateCurrent.keyLabelFont = labelFont( - name: mgrPrefs.candidateKeyLabelFontName, size: keyLabelSize - ) - ctlCandidateCurrent.candidateFont = candidateFont( - name: mgrPrefs.candidateTextFontName, size: textSize - ) - - let candidateKeys = mgrPrefs.candidateKeys - let keyLabels = - candidateKeys.count > 4 ? Array(candidateKeys) : Array(mgrPrefs.defaultCandidateKeys) - let keyLabelSuffix = state is InputState.AssociatedPhrases ? "^" : "" - ctlCandidateCurrent.keyLabels = keyLabels.map { - CandidateKeyLabel(key: String($0), displayedText: String($0) + keyLabelSuffix) - } - - ctlCandidateCurrent.delegate = self - ctlCandidateCurrent.reloadData() - - ctlCandidateCurrent.visible = true - - var lineHeightRect = NSRect(x: 0.0, y: 0.0, width: 16.0, height: 16.0) - var cursor = 0 - - if let state = state as? InputState.ChoosingCandidate { - cursor = state.cursorIndex - if cursor == state.composingBuffer.count, cursor != 0 { - cursor -= 1 - } - } - - while lineHeightRect.origin.x == 0, lineHeightRect.origin.y == 0, cursor >= 0 { - client().attributes( - forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect - ) - cursor -= 1 - } - - if isTypingVertical { - ctlCandidateCurrent.set( - windowTopLeftPoint: NSPoint( - x: lineHeightRect.origin.x + lineHeightRect.size.width + 4.0, y: lineHeightRect.origin.y - 4.0 - ), - bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0 - ) - } else { - ctlCandidateCurrent.set( - windowTopLeftPoint: NSPoint(x: lineHeightRect.origin.x, y: lineHeightRect.origin.y - 4.0), - bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0 - ) - } - } - - private func show(tooltip: String, composingBuffer: String, cursorIndex: Int) { - var lineHeightRect = NSRect(x: 0.0, y: 0.0, width: 16.0, height: 16.0) - var cursor = cursorIndex - if cursor == composingBuffer.count, cursor != 0 { - cursor -= 1 - } - while lineHeightRect.origin.x == 0, lineHeightRect.origin.y == 0, cursor >= 0 { - client().attributes( - forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect - ) - cursor -= 1 - } - ctlInputMethod.tooltipController.show(tooltip: tooltip, at: lineHeightRect.origin) - } - - private func hideTooltip() { - ctlInputMethod.tooltipController.hide() - } -} - -// MARK: - - -extension ctlInputMethod: KeyHandlerDelegate { - func ctlCandidate() -> ctlCandidate { ctlCandidateCurrent } - - func keyHandler( - _: KeyHandler, didSelectCandidateAt index: Int, - ctlCandidate controller: ctlCandidate - ) { - ctlCandidate(controller, didSelectCandidateAtIndex: index) - } - - func keyHandler(_ keyHandler: KeyHandler, didRequestWriteUserPhraseWith state: InputStateProtocol) - -> Bool - { - guard let state = state as? InputState.Marking else { - return false - } - if !state.validToWrite { - return false - } - let refInputModeReversed: InputMode = - (keyHandler.inputMode == InputMode.imeModeCHT) - ? InputMode.imeModeCHS : InputMode.imeModeCHT - if !mgrLangModel.writeUserPhrase( - state.userPhrase, inputMode: keyHandler.inputMode, - areWeDuplicating: state.chkIfUserPhraseExists, - areWeDeleting: ctlInputMethod.areWeDeleting - ) - || !mgrLangModel.writeUserPhrase( - state.userPhraseConverted, inputMode: refInputModeReversed, - areWeDuplicating: false, - areWeDeleting: ctlInputMethod.areWeDeleting - ) - { - return false - } - return true - } -} - -// MARK: - - -extension ctlInputMethod: ctlCandidateDelegate { - func candidateCountForController(_ controller: ctlCandidate) -> Int { - _ = controller // 防止格式整理工具毀掉與此對應的參數。 - if let state = state as? InputState.ChoosingCandidate { - return state.candidates.count - } else if let state = state as? InputState.AssociatedPhrases { - return state.candidates.count - } - return 0 - } - - func ctlCandidate(_ controller: ctlCandidate, candidateAtIndex index: Int) - -> String - { - _ = controller // 防止格式整理工具毀掉與此對應的參數。 - if let state = state as? InputState.ChoosingCandidate { - return state.candidates[index] - } else if let state = state as? InputState.AssociatedPhrases { - return state.candidates[index] - } - return "" - } - - func ctlCandidate(_ controller: ctlCandidate, didSelectCandidateAtIndex index: Int) { - _ = controller // 防止格式整理工具毀掉與此對應的參數。 - - if let state = state as? InputState.SymbolTable, - let node = state.node.children?[index] - { - if let children = node.children, !children.isEmpty { - handle(state: InputState.Empty()) // 防止縱橫排選字窗同時出現 - handle( - state: InputState.SymbolTable(node: node, isTypingVertical: state.isTypingVertical) - ) - } else { - handle(state: InputState.Committing(textToCommit: node.title)) - handle(state: InputState.Empty()) - } - return - } - - if let state = state as? InputState.ChoosingCandidate { - let selectedValue = state.candidates[index] - keyHandler.fixNode(value: selectedValue, respectCursorPushing: true) - - let inputting = keyHandler.buildInputtingState - - if mgrPrefs.useSCPCTypingMode { - keyHandler.clear() - let composingBuffer = inputting.composingBuffer - handle(state: InputState.Committing(textToCommit: composingBuffer)) - if mgrPrefs.associatedPhrasesEnabled, - let associatePhrases = keyHandler.buildAssociatePhraseState( - withKey: composingBuffer, isTypingVertical: state.isTypingVertical - ), !associatePhrases.candidates.isEmpty - { - handle(state: associatePhrases) - } else { - handle(state: InputState.Empty()) - } - } else { - handle(state: inputting) - } - return - } - - if let state = state as? InputState.AssociatedPhrases { - let selectedValue = state.candidates[index] - handle(state: InputState.Committing(textToCommit: selectedValue)) - if mgrPrefs.associatedPhrasesEnabled, - let associatePhrases = keyHandler.buildAssociatePhraseState( - withKey: selectedValue, isTypingVertical: state.isTypingVertical - ), !associatePhrases.candidates.isEmpty - { - handle(state: associatePhrases) - } else { - handle(state: InputState.Empty()) - } - } - } -} diff --git a/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift b/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift new file mode 100644 index 00000000..d55f76f6 --- /dev/null +++ b/Source/Modules/ControllerModules/ctlInputMethod_Delegates.swift @@ -0,0 +1,152 @@ +// Copyright (c) 2011 and onwards The OpenVanilla Project (MIT License). +// All possible vChewing-specific modifications are of: +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +/* +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +2. No trademark license is granted to use the trade names, trademarks, service +marks, or product names of Contributor, except as required to fulfill notice +requirements above. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import Foundation + +// MARK: - KeyHandler Delegate + +extension ctlInputMethod: KeyHandlerDelegate { + func ctlCandidate() -> ctlCandidate { ctlCandidateCurrent } + + func keyHandler( + _: KeyHandler, didSelectCandidateAt index: Int, + ctlCandidate controller: ctlCandidate + ) { + ctlCandidate(controller, didSelectCandidateAtIndex: index) + } + + func keyHandler(_ keyHandler: KeyHandler, didRequestWriteUserPhraseWith state: InputStateProtocol) + -> Bool + { + guard let state = state as? InputState.Marking else { + return false + } + if !state.validToWrite { + return false + } + let refInputModeReversed: InputMode = + (keyHandler.inputMode == InputMode.imeModeCHT) + ? InputMode.imeModeCHS : InputMode.imeModeCHT + if !mgrLangModel.writeUserPhrase( + state.userPhrase, inputMode: keyHandler.inputMode, + areWeDuplicating: state.chkIfUserPhraseExists, + areWeDeleting: ctlInputMethod.areWeDeleting + ) + || !mgrLangModel.writeUserPhrase( + state.userPhraseConverted, inputMode: refInputModeReversed, + areWeDuplicating: false, + areWeDeleting: ctlInputMethod.areWeDeleting + ) + { + return false + } + return true + } +} + +// MARK: - Candidate Controller Delegate + +extension ctlInputMethod: ctlCandidateDelegate { + func candidateCountForController(_ controller: ctlCandidate) -> Int { + _ = controller // 防止格式整理工具毀掉與此對應的參數。 + if let state = state as? InputState.ChoosingCandidate { + return state.candidates.count + } else if let state = state as? InputState.AssociatedPhrases { + return state.candidates.count + } + return 0 + } + + func ctlCandidate(_ controller: ctlCandidate, candidateAtIndex index: Int) + -> String + { + _ = controller // 防止格式整理工具毀掉與此對應的參數。 + if let state = state as? InputState.ChoosingCandidate { + return state.candidates[index] + } else if let state = state as? InputState.AssociatedPhrases { + return state.candidates[index] + } + return "" + } + + func ctlCandidate(_ controller: ctlCandidate, didSelectCandidateAtIndex index: Int) { + _ = controller // 防止格式整理工具毀掉與此對應的參數。 + + if let state = state as? InputState.SymbolTable, + let node = state.node.children?[index] + { + if let children = node.children, !children.isEmpty { + handle(state: InputState.Empty()) // 防止縱橫排選字窗同時出現 + handle( + state: InputState.SymbolTable(node: node, isTypingVertical: state.isTypingVertical) + ) + } else { + handle(state: InputState.Committing(textToCommit: node.title)) + handle(state: InputState.Empty()) + } + return + } + + if let state = state as? InputState.ChoosingCandidate { + let selectedValue = state.candidates[index] + keyHandler.fixNode(value: selectedValue, respectCursorPushing: true) + + let inputting = keyHandler.buildInputtingState + + if mgrPrefs.useSCPCTypingMode { + keyHandler.clear() + let composingBuffer = inputting.composingBuffer + handle(state: InputState.Committing(textToCommit: composingBuffer)) + if mgrPrefs.associatedPhrasesEnabled, + let associatePhrases = keyHandler.buildAssociatePhraseState( + withKey: composingBuffer, isTypingVertical: state.isTypingVertical + ), !associatePhrases.candidates.isEmpty + { + handle(state: associatePhrases) + } else { + handle(state: InputState.Empty()) + } + } else { + handle(state: inputting) + } + return + } + + if let state = state as? InputState.AssociatedPhrases { + let selectedValue = state.candidates[index] + handle(state: InputState.Committing(textToCommit: selectedValue)) + if mgrPrefs.associatedPhrasesEnabled, + let associatePhrases = keyHandler.buildAssociatePhraseState( + withKey: selectedValue, isTypingVertical: state.isTypingVertical + ), !associatePhrases.candidates.isEmpty + { + handle(state: associatePhrases) + } else { + handle(state: InputState.Empty()) + } + } + } +} diff --git a/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift b/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift new file mode 100644 index 00000000..fb1819a5 --- /dev/null +++ b/Source/Modules/ControllerModules/ctlInputMethod_HandleDisplay.swift @@ -0,0 +1,169 @@ +// Copyright (c) 2011 and onwards The OpenVanilla Project (MIT License). +// All possible vChewing-specific modifications are of: +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +/* +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +2. No trademark license is granted to use the trade names, trademarks, service +marks, or product names of Contributor, except as required to fulfill notice +requirements above. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import Cocoa +import Foundation + +// MARK: - Tooltip Display and Candidate Display Methods + +extension ctlInputMethod { + func show(tooltip: String, composingBuffer: String, cursorIndex: Int) { + var lineHeightRect = NSRect(x: 0.0, y: 0.0, width: 16.0, height: 16.0) + var cursor = cursorIndex + if cursor == composingBuffer.count, cursor != 0 { + cursor -= 1 + } + while lineHeightRect.origin.x == 0, lineHeightRect.origin.y == 0, cursor >= 0 { + client().attributes( + forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect + ) + cursor -= 1 + } + ctlInputMethod.tooltipController.show(tooltip: tooltip, at: lineHeightRect.origin) + } + + func show(candidateWindowWith state: InputStateProtocol) { + var isTypingVertical: Bool { + if let state = state as? InputState.ChoosingCandidate { + return state.isTypingVertical + } else if let state = state as? InputState.AssociatedPhrases { + return state.isTypingVertical + } + return false + } + var isCandidateWindowVertical: Bool { + var candidates: [String] = [] + if let state = state as? InputState.ChoosingCandidate { + candidates = state.candidates + } else if let state = state as? InputState.AssociatedPhrases { + candidates = state.candidates + } + if isTypingVertical { return true } + // 以上是通用情形。接下來決定橫排輸入時是否使用縱排選字窗。 + candidates.sort { + $0.count > $1.count + } + // 測量每頁顯示候選字的累計總長度。如果太長的話就強制使用縱排候選字窗。 + // 範例:「屬實牛逼」(會有一大串各種各樣的「鼠食牛Beer」的 emoji)。 + let maxCandidatesPerPage = mgrPrefs.candidateKeys.count + let firstPageCandidates = candidates[0.. Int(round(Double(maxCandidatesPerPage) * 1.8)) + // 上面這句如果是 true 的話,就會是縱排;反之則為橫排。 + } + + ctlCandidateCurrent.delegate = nil + + /// 下面這一段本可直接指定 currentLayout,但這樣的話翻頁按鈕位置無法精準地重新繪製。 + /// 所以只能重新初期化。壞處就是得在 ctlCandidate() 當中與 SymbolTable 控制有關的地方 + /// 新增一個空狀態請求、防止縱排與橫排選字窗同時出現。 + /// layoutCandidateView 在這裡無法起到糾正作用。 + /// 該問題徹底解決的價值並不大,直接等到 macOS 10.x 全線淘汰之後用 SwiftUI 重寫選字窗吧。 + + if isCandidateWindowVertical { // 縱排輸入時強制使用縱排選字窗 + ctlCandidateCurrent = .init(.vertical) + } else if mgrPrefs.useHorizontalCandidateList { + ctlCandidateCurrent = .init(.horizontal) + } else { + ctlCandidateCurrent = .init(.vertical) + } + + // set the attributes for the candidate panel (which uses NSAttributedString) + let textSize = mgrPrefs.candidateListTextSize + let keyLabelSize = max(textSize / 2, mgrPrefs.minKeyLabelSize) + + func labelFont(name: String?, size: CGFloat) -> NSFont { + if let name = name { + return NSFont(name: name, size: size) ?? NSFont.systemFont(ofSize: size) + } + return NSFont.systemFont(ofSize: size) + } + + func candidateFont(name: String?, size: CGFloat) -> NSFont { + let currentMUIFont = + (keyHandler.inputMode == InputMode.imeModeCHS) + ? "Sarasa Term Slab SC" : "Sarasa Term Slab TC" + var finalReturnFont = + NSFont(name: currentMUIFont, size: size) ?? NSFont.systemFont(ofSize: size) + // 對更紗黑體的依賴到 macOS 11 Big Sur 為止。macOS 12 Monterey 開始則依賴系統內建的函式使用蘋方來處理。 + if #available(macOS 12.0, *) { finalReturnFont = NSFont.systemFont(ofSize: size) } + if let name = name { + return NSFont(name: name, size: size) ?? finalReturnFont + } + return finalReturnFont + } + + ctlCandidateCurrent.keyLabelFont = labelFont( + name: mgrPrefs.candidateKeyLabelFontName, size: keyLabelSize + ) + ctlCandidateCurrent.candidateFont = candidateFont( + name: mgrPrefs.candidateTextFontName, size: textSize + ) + + let candidateKeys = mgrPrefs.candidateKeys + let keyLabels = + candidateKeys.count > 4 ? Array(candidateKeys) : Array(mgrPrefs.defaultCandidateKeys) + let keyLabelSuffix = state is InputState.AssociatedPhrases ? "^" : "" + ctlCandidateCurrent.keyLabels = keyLabels.map { + CandidateKeyLabel(key: String($0), displayedText: String($0) + keyLabelSuffix) + } + + ctlCandidateCurrent.delegate = self + ctlCandidateCurrent.reloadData() + + ctlCandidateCurrent.visible = true + + var lineHeightRect = NSRect(x: 0.0, y: 0.0, width: 16.0, height: 16.0) + var cursor = 0 + + if let state = state as? InputState.ChoosingCandidate { + cursor = state.cursorIndex + if cursor == state.composingBuffer.count, cursor != 0 { + cursor -= 1 + } + } + + while lineHeightRect.origin.x == 0, lineHeightRect.origin.y == 0, cursor >= 0 { + client().attributes( + forCharacterIndex: cursor, lineHeightRectangle: &lineHeightRect + ) + cursor -= 1 + } + + if isTypingVertical { + ctlCandidateCurrent.set( + windowTopLeftPoint: NSPoint( + x: lineHeightRect.origin.x + lineHeightRect.size.width + 4.0, y: lineHeightRect.origin.y - 4.0 + ), + bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0 + ) + } else { + ctlCandidateCurrent.set( + windowTopLeftPoint: NSPoint(x: lineHeightRect.origin.x, y: lineHeightRect.origin.y - 4.0), + bottomOutOfScreenAdjustmentHeight: lineHeightRect.size.height + 4.0 + ) + } + } +} diff --git a/Source/Modules/ControllerModules/ctlInputMethod_HandleStates.swift b/Source/Modules/ControllerModules/ctlInputMethod_HandleStates.swift new file mode 100644 index 00000000..20f2ba73 --- /dev/null +++ b/Source/Modules/ControllerModules/ctlInputMethod_HandleStates.swift @@ -0,0 +1,228 @@ +// Copyright (c) 2011 and onwards The OpenVanilla Project (MIT License). +// All possible vChewing-specific modifications are of: +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +/* +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +1. The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +2. No trademark license is granted to use the trade names, trademarks, service +marks, or product names of Contributor, except as required to fulfill notice +requirements above. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +*/ + +import Foundation + +// MARK: - 狀態調度 (State Handling) + +extension ctlInputMethod { + /// 針對傳入的新狀態進行調度。 + /// + /// 先將舊狀態單獨記錄起來,再將新舊狀態作為參數, + /// 根據新狀態本身的狀態種類來判斷交給哪一個專門的函式來處理。 + /// - Parameter newState: 新狀態。 + func handle(state newState: InputStateProtocol) { + let prevState = state + state = newState + + switch newState { + case let newState as InputState.Deactivated: + handle(state: newState, previous: prevState) + case let newState as InputState.Empty: + handle(state: newState, previous: prevState) + case let newState as InputState.EmptyIgnoringPreviousState: + handle(state: newState, previous: prevState) + case let newState as InputState.Committing: + handle(state: newState, previous: prevState) + case let newState as InputState.Inputting: + handle(state: newState, previous: prevState) + case let newState as InputState.Marking: + handle(state: newState, previous: prevState) + case let newState as InputState.ChoosingCandidate: + handle(state: newState, previous: prevState) + case let newState as InputState.AssociatedPhrases: + handle(state: newState, previous: prevState) + case let newState as InputState.SymbolTable: + handle(state: newState, previous: prevState) + default: break + } + } + + /// 針對受 .NotEmpty() 管轄的非空狀態,在組字區內顯示游標。 + private func setInlineDisplayWithCursor() { + guard let state = state as? InputState.NotEmpty else { + clearInlineDisplay() + return + } + + var identifier: AnyObject { + switch IME.currentInputMode { + case InputMode.imeModeCHS: + if #available(macOS 12.0, *) { + return "zh-Hans" as AnyObject + } + case InputMode.imeModeCHT: + if #available(macOS 12.0, *) { + return (mgrPrefs.shiftJISShinjitaiOutputEnabled || mgrPrefs.chineseConversionEnabled) + ? "ja" as AnyObject : "zh-Hant" as AnyObject + } + default: + break + } + return "" as AnyObject + } + + // [Shiki's Note] This might needs to be bug-reported to Apple: + // The LanguageIdentifier attribute of an NSAttributeString designated to + // IMK Client().SetMarkedText won't let the actual font respect your languageIdentifier + // settings. Still, this might behaves as Apple's current expectation, I'm afraid. + if #available(macOS 12.0, *) { + state.attributedString.setAttributes( + [.languageIdentifier: identifier], + range: NSRange( + location: 0, + length: state.composingBuffer.utf16.count + ) + ) + } + + /// 所謂選區「selectionRange」,就是「可見游標位置」的位置,只不過長度 + /// 是 0 且取代範圍(replacementRange)為「NSNotFound」罷了。 + /// 也就是說,內文組字區該在哪裡出現,得由客體軟體來作主。 + client().setMarkedText( + state.attributedString, selectionRange: NSRange(location: state.cursorIndex, length: 0), + replacementRange: NSRange(location: NSNotFound, length: NSNotFound) + ) + } + + /// 在處理不受 .NotEmpty() 管轄的狀態時可能要用到的函式,會清空螢幕上顯示的內文組字區。 + /// 當 setInlineDisplayWithCursor() 在錯誤的狀態下被呼叫時,也會觸發這個函式。 + private func clearInlineDisplay() { + client().setMarkedText( + "", selectionRange: NSRange(location: 0, length: 0), + replacementRange: NSRange(location: NSNotFound, length: NSNotFound) + ) + } + + /// 遞交組字區內容。 + /// 注意:必須在 IMK 的 commitComposition 函式當中也間接或者直接執行這個處理。 + private func commit(text: String) { + let buffer = IME.kanjiConversionIfRequired(text) + if buffer.isEmpty { + return + } + + client().insertText( + buffer, replacementRange: NSRange(location: NSNotFound, length: NSNotFound) + ) + } + + private func handle(state: InputState.Deactivated, previous: InputStateProtocol) { + _ = state // 防止格式整理工具毀掉與此對應的參數。 + ctlCandidateCurrent.delegate = nil + ctlCandidateCurrent.visible = false + ctlInputMethod.tooltipController.hide() + if let previous = previous as? InputState.NotEmpty { + commit(text: previous.composingBuffer) + } + clearInlineDisplay() + } + + private func handle(state: InputState.Empty, previous: InputStateProtocol) { + _ = state // 防止格式整理工具毀掉與此對應的參數。 + ctlCandidateCurrent.visible = false + ctlInputMethod.tooltipController.hide() + // 全專案用以判斷「.EmptyIgnoringPreviousState」的地方僅此一處。 + if let previous = previous as? InputState.NotEmpty, + !(state is InputState.EmptyIgnoringPreviousState) + { + commit(text: previous.composingBuffer) + } + clearInlineDisplay() + } + + private func handle( + state: InputState.EmptyIgnoringPreviousState, previous: InputStateProtocol + ) { + _ = state // 防止格式整理工具毀掉與此對應的參數。 + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + // 這個函式就是去掉 previous state 使得沒有任何東西可以 commit。 + handle(state: InputState.Empty()) + } + + private func handle(state: InputState.Committing, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlCandidateCurrent.visible = false + ctlInputMethod.tooltipController.hide() + let textToCommit = state.textToCommit + if !textToCommit.isEmpty { + commit(text: textToCommit) + } + clearInlineDisplay() + } + + private func handle(state: InputState.Inputting, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlCandidateCurrent.visible = false + ctlInputMethod.tooltipController.hide() + let textToCommit = state.textToCommit + if !textToCommit.isEmpty { + commit(text: textToCommit) + } + setInlineDisplayWithCursor() + if !state.tooltip.isEmpty { + show( + tooltip: state.tooltip, composingBuffer: state.composingBuffer, + cursorIndex: state.cursorIndex + ) + } + } + + private func handle(state: InputState.Marking, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlCandidateCurrent.visible = false + setInlineDisplayWithCursor() + if state.tooltip.isEmpty { + ctlInputMethod.tooltipController.hide() + } else { + show( + tooltip: state.tooltip, composingBuffer: state.composingBuffer, + cursorIndex: state.markerIndex + ) + } + } + + private func handle(state: InputState.ChoosingCandidate, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlInputMethod.tooltipController.hide() + setInlineDisplayWithCursor() + show(candidateWindowWith: state) + } + + private func handle(state: InputState.SymbolTable, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlInputMethod.tooltipController.hide() + setInlineDisplayWithCursor() + show(candidateWindowWith: state) + } + + private func handle(state: InputState.AssociatedPhrases, previous: InputStateProtocol) { + _ = previous // 防止格式整理工具毀掉與此對應的參數。 + ctlInputMethod.tooltipController.hide() + clearInlineDisplay() + show(candidateWindowWith: state) + } +} diff --git a/vChewing.xcodeproj/project.pbxproj b/vChewing.xcodeproj/project.pbxproj index 5fe10402..561c4b0f 100644 --- a/vChewing.xcodeproj/project.pbxproj +++ b/vChewing.xcodeproj/project.pbxproj @@ -9,6 +9,9 @@ /* Begin PBXBuildFile section */ 5B0AF8B527B2C8290096FE54 /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0AF8B427B2C8290096FE54 /* StringExtension.swift */; }; 5B11328927B94CFB00E58451 /* AppleKeyboardConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B11328827B94CFB00E58451 /* AppleKeyboardConverter.swift */; }; + 5B21176C287539BB000443A9 /* ctlInputMethod_HandleStates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B21176B287539BB000443A9 /* ctlInputMethod_HandleStates.swift */; }; + 5B21176E28753B35000443A9 /* ctlInputMethod_HandleDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B21176D28753B35000443A9 /* ctlInputMethod_HandleDisplay.swift */; }; + 5B21177028753B9D000443A9 /* ctlInputMethod_Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B21176F28753B9D000443A9 /* ctlInputMethod_Delegates.swift */; }; 5B242403284B0D6500520FE4 /* ctlCandidateUniversal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B242402284B0D6500520FE4 /* ctlCandidateUniversal.swift */; }; 5B3133BF280B229700A4A505 /* KeyHandler_States.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B3133BE280B229700A4A505 /* KeyHandler_States.swift */; }; 5B38F59A281E2E49007D5F5D /* 6_Unigram.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A0D4F1D15FC0EB100ABF4B3 /* 6_Unigram.swift */; }; @@ -199,6 +202,9 @@ 5B18BA7227C7BD8B0056EB19 /* LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = LICENSE.txt; sourceTree = ""; }; 5B18BA7327C7BD8C0056EB19 /* LICENSE-JPN.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LICENSE-JPN.txt"; sourceTree = ""; }; 5B18BA7427C7BD8C0056EB19 /* LICENSE-CHT.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "LICENSE-CHT.txt"; sourceTree = ""; }; + 5B21176B287539BB000443A9 /* ctlInputMethod_HandleStates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ctlInputMethod_HandleStates.swift; sourceTree = ""; }; + 5B21176D28753B35000443A9 /* ctlInputMethod_HandleDisplay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ctlInputMethod_HandleDisplay.swift; sourceTree = ""; }; + 5B21176F28753B9D000443A9 /* ctlInputMethod_Delegates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ctlInputMethod_Delegates.swift; sourceTree = ""; }; 5B242402284B0D6500520FE4 /* ctlCandidateUniversal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ctlCandidateUniversal.swift; sourceTree = ""; }; 5B2DB17127AF8771006D874E /* Makefile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.make; name = Makefile; path = Data/Makefile; sourceTree = ""; }; 5B2F2BB3286216A500B8557B /* vChewingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = vChewingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -446,6 +452,9 @@ children = ( 5B11328827B94CFB00E58451 /* AppleKeyboardConverter.swift */, D4A13D5927A59D5C003BE359 /* ctlInputMethod_Core.swift */, + 5B21176F28753B9D000443A9 /* ctlInputMethod_Delegates.swift */, + 5B21176D28753B35000443A9 /* ctlInputMethod_HandleDisplay.swift */, + 5B21176B287539BB000443A9 /* ctlInputMethod_HandleStates.swift */, 5BB802D927FABA8300CF1C19 /* ctlInputMethod_Menu.swift */, D4E569DA27A34CC100AC2CEF /* CTools.h */, D4E569DB27A34CC100AC2CEF /* CTools.m */, @@ -1141,6 +1150,8 @@ 5BA9FD4827FEF3C9002DE248 /* PreferencesWindowController.swift in Sources */, 5BD0113B28180D6100609769 /* LMInstantiator.swift in Sources */, D4E569DC27A34D0E00AC2CEF /* CTools.m in Sources */, + 5B21177028753B9D000443A9 /* ctlInputMethod_Delegates.swift in Sources */, + 5B21176E28753B35000443A9 /* ctlInputMethod_HandleDisplay.swift in Sources */, 5B84579F2871AD2200C93B01 /* HotenkaChineseConverter.swift in Sources */, 5B887F302826AEA400B6651E /* lmCoreEX.swift in Sources */, 5BA9FD4627FEF3C9002DE248 /* Container.swift in Sources */, @@ -1163,6 +1174,7 @@ 5B11328927B94CFB00E58451 /* AppleKeyboardConverter.swift in Sources */, 5B54E743283A7D89001ECBDC /* lmCoreNS.swift in Sources */, 5B62A32927AE77D100A19448 /* FSEventStreamHelper.swift in Sources */, + 5B21176C287539BB000443A9 /* ctlInputMethod_HandleStates.swift in Sources */, 5B38F59B281E2E49007D5F5D /* 7_KeyValuePaired.swift in Sources */, 5B62A33627AE795800A19448 /* mgrPrefs.swift in Sources */, 5B38F5A4281E2E49007D5F5D /* 5_LanguageModel.swift in Sources */,