diff --git a/Core/Sources/Core/InputUtils/InputSession.swift b/Core/Sources/Core/InputUtils/InputSession.swift new file mode 100644 index 00000000..24d013d6 --- /dev/null +++ b/Core/Sources/Core/InputUtils/InputSession.swift @@ -0,0 +1,300 @@ +import Foundation +import KanaKanjiConverterModule + +/// Owns the synchronous input state and composition used by an input-method client. +/// +/// Platform adapters translate native key events into ``KeyEventCore`` and apply +/// the returned effects to their text client and windows. +public final class InputSession { + public struct Configuration { + public var inputStyle: InputStyle + public var liveConversionEnabled: Bool + public var enableDebugWindow: Bool + public var enableSuggestion: Bool + + public init( + inputStyle: InputStyle, + liveConversionEnabled: Bool, + enableDebugWindow: Bool, + enableSuggestion: Bool + ) { + self.inputStyle = inputStyle + self.liveConversionEnabled = liveConversionEnabled + self.enableDebugWindow = enableDebugWindow + self.enableSuggestion = enableSuggestion + } + } + + public enum Effect { + case insertText(String) + case selectInputLanguage(InputLanguage) + case selectNextReplaceSuggestionCandidate + case selectPrevReplaceSuggestionCandidate + case submitReplaceSuggestionCandidate + case hideReplaceSuggestionWindow + case requestReplaceSuggestion + case showPromptInputWindow + case transformSelectedText(String, String) + } + + public struct Result { + public var handled: Bool + public var effects: [Effect] + + public init(handled: Bool, effects: [Effect] = []) { + self.handled = handled + self.effects = effects + } + } + + public let segmentsManager: SegmentsManager + public private(set) var inputState: InputState + public private(set) var inputLanguage: InputLanguage + + public init( + segmentsManager: SegmentsManager, + inputState: InputState = .none, + inputLanguage: InputLanguage = .japanese + ) { + self.segmentsManager = segmentsManager + self.inputState = inputState + self.inputLanguage = inputLanguage + } + + @MainActor + public func handle( + eventCore: KeyEventCore, + userAction: UserAction, + configuration: Configuration, + numberCandidateIndex: (Int) -> Int = { $0 - 1 } + ) -> Result { + let (clientAction, callback) = self.inputState.event( + eventCore: eventCore, + userAction: userAction, + inputLanguage: self.inputLanguage, + liveConversionEnabled: configuration.liveConversionEnabled, + enableDebugWindow: configuration.enableDebugWindow, + enableSuggestion: configuration.enableSuggestion + ) + return self.handle( + clientAction, + callback: callback, + inputStyle: configuration.inputStyle, + numberCandidateIndex: numberCandidateIndex + ) + } + + // This switch intentionally mirrors every ClientAction so platform clients + // cannot accidentally fall back after a new synchronous action is added. + // swiftlint:disable cyclomatic_complexity + @MainActor + public func handle( + _ clientAction: ClientAction, + callback: ClientActionCallback, + inputStyle: InputStyle, + numberCandidateIndex: (Int) -> Int = { $0 - 1 } + ) -> Result { + var effects: [Effect] = [] + switch clientAction { + case .showCandidateWindow: + self.segmentsManager.requestSetCandidateWindowState(visible: true) + case .hideCandidateWindow: + self.segmentsManager.requestSetCandidateWindowState(visible: false) + case .enterFirstCandidatePreviewMode: + self.segmentsManager.insertCompositionSeparator(inputStyle: inputStyle, skipUpdate: false) + self.segmentsManager.requestSetCandidateWindowState(visible: false) + case .enterCandidateSelectionMode: + self.segmentsManager.insertCompositionSeparator(inputStyle: inputStyle, skipUpdate: true) + self.segmentsManager.update(requestRichCandidates: true) + case .appendToMarkedText(let string): + self.segmentsManager.insertAtCursorPosition(string, inputStyle: self.compositionInputStyle(inputStyle)) + case .appendPieceToMarkedText(let pieces): + self.segmentsManager.insertAtCursorPosition(pieces: pieces, inputStyle: self.compositionInputStyle(inputStyle)) + case .insertWithoutMarkedText(let string): + effects.append(.insertText(string)) + case .editSegment(let count): + self.segmentsManager.editSegment(count: count) + case .commitMarkedText: + effects.append(.insertText(self.segmentsManager.commitMarkedText(inputState: self.inputState))) + case .commitMarkedTextAndAppendToMarkedText(let string): + effects.append(.insertText(self.segmentsManager.commitMarkedText(inputState: self.inputState))) + self.segmentsManager.insertAtCursorPosition(string, inputStyle: self.compositionInputStyle(inputStyle)) + case .commitMarkedTextAndAppendPieceToMarkedText(let pieces): + effects.append(.insertText(self.segmentsManager.commitMarkedText(inputState: self.inputState))) + self.segmentsManager.insertAtCursorPosition(pieces: pieces, inputStyle: self.compositionInputStyle(inputStyle)) + case .submitSelectedCandidate: + effects += self.submitSelectedCandidate() + case .removeLastMarkedText: + self.segmentsManager.deleteBackwardFromCursorPosition() + self.segmentsManager.requestResettingSelection() + case .selectPrevCandidate: + self.segmentsManager.requestSelectingPrevCandidate() + case .selectNextCandidate: + self.segmentsManager.requestSelectingNextCandidate() + case .selectNumberCandidate(let number): + self.segmentsManager.requestSelectingRow(numberCandidateIndex(number)) + effects += self.submitSelectedCandidate() + case .submitHiraganaCandidate: + effects += self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { + $0.toHiragana() + }) + case .submitKatakanaCandidate: + effects += self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { + $0.toKatakana() + }) + case .submitHankakuKatakanaCandidate: + effects += self.submitCandidate(self.segmentsManager.getModifiedRubyCandidate(inputState: self.inputState) { + $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false)! + }) + case .submitFullWidthRomanCandidate: + effects += self.submitCandidate(self.segmentsManager.getModifiedRomanCandidate { + $0.applyingTransform(.fullwidthToHalfwidth, reverse: true)! + }) + case .submitHalfWidthRomanCandidate: + effects += self.submitCandidate(self.segmentsManager.getModifiedRomanCandidate { + $0.applyingTransform(.fullwidthToHalfwidth, reverse: false)! + }) + case .enableDebugWindow: + self.segmentsManager.requestDebugWindowMode(enabled: true) + case .disableDebugWindow: + self.segmentsManager.requestDebugWindowMode(enabled: false) + case .stopComposition: + self.segmentsManager.stopComposition() + case .forgetMemory: + self.segmentsManager.forgetMemory() + case .selectInputLanguage(let language): + effects += self.selectInputLanguage(language) + case .commitMarkedTextAndSelectInputLanguage(let language): + effects.append(.insertText(self.segmentsManager.commitMarkedText(inputState: self.inputState))) + effects += self.selectInputLanguage(language) + case .requestPredictiveSuggestion: + self.segmentsManager.insertAtCursorPosition("つづき", inputStyle: inputStyle) + effects.append(.requestReplaceSuggestion) + case .acceptPredictionCandidate: + self.acceptPredictionCandidate() + case .requestReplaceSuggestion: + effects.append(.requestReplaceSuggestion) + case .selectNextReplaceSuggestionCandidate: + effects.append(.selectNextReplaceSuggestionCandidate) + case .selectPrevReplaceSuggestionCandidate: + effects.append(.selectPrevReplaceSuggestionCandidate) + case .submitReplaceSuggestionCandidate: + effects.append(.submitReplaceSuggestionCandidate) + case .hideReplaceSuggestionWindow: + effects.append(.hideReplaceSuggestionWindow) + case .showPromptInputWindow: + self.segmentsManager.appendDebugMessage("Executing showPromptInputWindow") + effects.append(.showPromptInputWindow) + case .transformSelectedText(let selectedText, let prompt): + self.segmentsManager.appendDebugMessage("Executing transformSelectedText with text: '\(selectedText)' and prompt: '\(prompt)'") + effects.append(.transformSelectedText(selectedText, prompt)) + case .enterUnicodeInputMode, .appendToUnicodeInput, .removeLastUnicodeInput, .cancelUnicodeInput, .consume: + break + case .submitUnicodeInput(let codePoint): + if let scalar = UInt32(codePoint, radix: 16), let unicodeScalar = Unicode.Scalar(scalar) { + effects.append(.insertText(String(Character(unicodeScalar)))) + } + case .submitSelectedCandidateAndEnterUnicodeInputMode: + effects += self.submitSelectedCandidate() + if !self.segmentsManager.isEmpty { + effects.append(.insertText(self.segmentsManager.convertTarget)) + self.segmentsManager.stopComposition() + } + case .fallthrough: + return Result(handled: false) + } + + effects += self.apply(callback) + return Result(handled: true, effects: effects) + } + // swiftlint:enable cyclomatic_complexity + + @MainActor + public func commitComposition() -> Result { + if case .unicodeInput = self.inputState { + self.inputState = .none + return Result(handled: true) + } + guard !self.segmentsManager.isEmpty else { + return Result(handled: false) + } + let text = self.segmentsManager.commitMarkedText(inputState: self.inputState) + self.inputState = .none + return Result(handled: true, effects: [.insertText(text)]) + } + + @MainActor + public func synchronizeInputLanguage(_ language: InputLanguage) { + self.inputLanguage = language + if language == .english { + self.segmentsManager.stopJapaneseInput() + } + } + + @MainActor + public func submitCandidate(_ candidate: Candidate) -> [Effect] { + let leftSideContext = self.segmentsManager.getCleanLeftSideContext(maxCount: 30) ?? "" + self.segmentsManager.prefixCandidateCommited(candidate, leftSideContext: leftSideContext) + return [.insertText(candidate.text)] + } + + @MainActor + public func submitSelectedCandidate() -> [Effect] { + guard let candidate = self.segmentsManager.selectedCandidate else { + return [] + } + let effects = self.submitCandidate(candidate) + self.segmentsManager.requestResettingSelection() + return effects + } + + private func compositionInputStyle(_ inputStyle: InputStyle) -> InputStyle { + self.inputLanguage == .english ? .direct : inputStyle + } + + @MainActor + private func selectInputLanguage(_ language: InputLanguage) -> [Effect] { + self.synchronizeInputLanguage(language) + return [.selectInputLanguage(language)] + } + + @MainActor + private func apply(_ callback: ClientActionCallback) -> [Effect] { + switch callback { + case .fallthrough: + return [] + case .transition(let inputState): + var effects: [Effect] = [] + if inputState != .replaceSuggestion { + effects.append(.hideReplaceSuggestionWindow) + } + if inputState == .none { + effects += self.selectInputLanguage(self.inputLanguage) + } + self.inputState = inputState + return effects + case .basedOnBackspace(let ifIsEmpty, let ifIsNotEmpty), + .basedOnSubmitCandidate(let ifIsEmpty, let ifIsNotEmpty): + self.inputState = self.segmentsManager.isEmpty ? ifIsEmpty : ifIsNotEmpty + return [] + } + } + + @MainActor + private func acceptPredictionCandidate() { + let predictions = SegmentsManager.preferredPredictionCandidates( + typoCorrectionCandidates: self.segmentsManager.requestTypoCorrectionPredictionCandidates(), + predictionCandidates: self.segmentsManager.requestPredictionCandidates() + ) + guard let prediction = predictions.first else { + return + } + if prediction.deleteCount > 0 { + self.segmentsManager.deleteBackwardFromCursorPosition(count: prediction.deleteCount) + } + guard !prediction.appendText.isEmpty else { + return + } + self.segmentsManager.insertAtCursorPosition(prediction.appendText, inputStyle: .direct) + } +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/InputSessionTests.swift b/Core/Tests/CoreTests/InputUtilsTests/InputSessionTests.swift new file mode 100644 index 00000000..a8849af3 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/InputSessionTests.swift @@ -0,0 +1,121 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Suite("Synchronous local input session") +struct InputSessionTests { + @Test func japaneseKeystrokesAreConsumedAndImmediatelyUpdateMarkedText() { + let session = self.makeSession() + + for event in [self.keyEvent("k", keyCode: 40), self.keyEvent("a", keyCode: 0)] { + let result = self.handle(event, with: session) + + #expect(result.handled) + #expect(self.insertedTexts(in: result).isEmpty) + #expect(!session.segmentsManager.convertTarget.isEmpty) + } + + #expect(session.inputState == .composing) + #expect(session.segmentsManager.convertTarget == "か") + let markedText = session.segmentsManager.getCurrentMarkedText(inputState: session.inputState) + #expect(markedText.map(\.content).joined() == "か") + } + + @Test func consecutiveKeystrokesNeverFallThroughWhileComposing() { + let session = self.makeSession() + let events = [ + self.keyEvent("k", keyCode: 40), + self.keyEvent("a", keyCode: 0), + self.keyEvent("n", keyCode: 45), + self.keyEvent("j", keyCode: 38), + self.keyEvent("i", keyCode: 34) + ] + + let results = events.map { self.handle($0, with: session) } + + #expect(results.allSatisfy { $0.handled }) + #expect(results.flatMap(self.insertedTexts(in:)).isEmpty) + #expect(session.inputState == .composing) + #expect(session.segmentsManager.convertTarget == "かんじ") + } + + @Test func enterCommitsCompositionAsAnInsertEffect() { + let session = self.makeSession() + _ = self.handle(self.keyEvent("k", keyCode: 40), with: session) + _ = self.handle(self.keyEvent("a", keyCode: 0), with: session) + + let result = self.handle( + KeyEventCore( + modifierFlags: [], + characters: "\r", + charactersIgnoringModifiers: "\r", + keyCode: 0x24 + ), + with: session + ) + + #expect(result.handled) + #expect(self.insertedTexts(in: result) == ["か"]) + #expect(session.inputState == .none) + #expect(session.segmentsManager.isEmpty) + } + + @Test func unknownKeyInEmptyStateFallsThrough() { + let session = self.makeSession() + let event = KeyEventCore( + modifierFlags: [], + characters: nil, + charactersIgnoringModifiers: nil, + keyCode: 0x73 + ) + + let result = self.handle(event, with: session) + + #expect(!result.handled) + #expect(session.inputState == .none) + #expect(session.segmentsManager.isEmpty) + } + + private func makeSession() -> InputSession { + let segmentsManager = SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: FileManager.default.temporaryDirectory, + containerURL: nil, + context: .init(useZenzai: false) + ) + return InputSession(segmentsManager: segmentsManager) + } + + private func keyEvent(_ character: String, keyCode: UInt16) -> KeyEventCore { + KeyEventCore( + modifierFlags: [], + characters: character, + charactersIgnoringModifiers: character, + keyCode: keyCode + ) + } + + private func handle(_ event: KeyEventCore, with session: InputSession) -> InputSession.Result { + session.handle( + eventCore: event, + userAction: UserAction.getUserAction(eventCore: event, inputLanguage: session.inputLanguage), + configuration: .init( + inputStyle: .roman2kana, + liveConversionEnabled: false, + enableDebugWindow: false, + enableSuggestion: false + ) + ) + } + + private func insertedTexts(in result: InputSession.Result) -> [String] { + result.effects.compactMap { effect in + guard case .insertText(let text) = effect else { + return nil + } + return text + } + } +} diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 61562d32..d0bb0b67 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -5,11 +5,16 @@ import KanaKanjiConverterModuleWithDefaultDictionary @objc(azooKeyMacInputController) class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // swiftlint:disable:this type_name - var segmentsManager: SegmentsManager - let converterServerClient = ConverterServerClient() - private var currentConverterView: ConverterSessionSnapshot? - private(set) var inputState: InputState = .none - private var inputLanguage: InputLanguage = .japanese + let inputSession: InputSession + var segmentsManager: SegmentsManager { + self.inputSession.segmentsManager + } + var inputState: InputState { + self.inputSession.inputState + } + private var inputLanguage: InputLanguage { + self.inputSession.inputLanguage + } var liveConversionEnabled: Bool { Config.LiveConversion().value } @@ -107,11 +112,12 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) { let applicationDirectoryURL = AppGroup.memoryDirectoryURL() let containerURL = AppGroup.containerURL() - self.segmentsManager = SegmentsManager( + let segmentsManager = SegmentsManager( kanaKanjiConverter: (NSApplication.shared.delegate as? AppDelegate)!.kanaKanjiConverter, applicationDirectoryURL: applicationDirectoryURL, containerURL: containerURL ) + self.inputSession = InputSession(segmentsManager: segmentsManager) self.appMenu = NSMenu(title: "azooKey") self.liveConversionToggleMenuItem = NSMenuItem() @@ -138,9 +144,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.candidatesViewController.delegate = self self.replaceSuggestionsViewController.delegate = self self.segmentsManager.delegate = self - self.converterServerClient.onLog = { [weak self] message in - self?.segmentsManager.appendDebugMessage(message) - } self.setupMenu() } @@ -156,18 +159,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // ピン留めプロンプトのキャッシュを更新 self.reloadPinnedPromptsCache() self.segmentsManager.activate() - self.converterServerClient.openSession { [weak self] sessionID in - guard let self, sessionID != nil else { - return - } - self.syncConverterServerSessionConfig() - self.converterServerClient.sendIfSessionOpen({ _ in .lifecycle(.activate) }, completion: { [weak self] response in - guard let self, let response else { - return - } - self.currentConverterView = response.snapshot - }) - } if let client = sender as? IMKTextInput { client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) @@ -188,8 +179,6 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s @MainActor override func deactivateServer(_ sender: Any!) { self.segmentsManager.deactivate() - self.converterServerClient.sendIfSessionOpen({ _ in .lifecycle(.deactivate) }, completion: { _ in }) - self.currentConverterView = nil self.candidatesWindow.orderOut(nil) self.predictionWindow.orderOut(nil) self.replaceSuggestionWindow.orderOut(nil) @@ -199,25 +188,13 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s @MainActor override func commitComposition(_ sender: Any!) { - // Unicode入力モードの場合は状態だけリセットして終了 - // マウスクリック等でOSがMarkedTextを確定した場合、IME側からは消せないため - if case .unicodeInput = self.inputState { - self.inputState = .none + let result = self.inputSession.commitComposition() + guard result.handled else { return } - if self.currentConverterView?.isEmpty == false, - let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .composition(.commit(inputState: ConverterInputState(self.inputState))) - }) { - self.currentConverterView = response.snapshot - if let client = sender as? IMKTextInput { - for effect in response.effects { - self.apply(effect, client: client) - } - } + if let client = sender as? IMKTextInput { + self.apply(result.effects, client: client) } - self.segmentsManager.stopComposition() - self.inputState = .none self.refreshMarkedText() self.refreshCandidateWindow() self.refreshPredictionWindow() @@ -239,15 +216,15 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // メニューバーやshortcut経由の切り替えに対応する。 // composing中でも英数キーMarkedTextを保ったまま英語入力へ移る。 if self.inputLanguage == .japanese { - self.inputLanguage = .english - self.segmentsManager.stopJapaneseInput() + self.inputSession.synchronizeInputLanguage(.english) self.refreshCandidateWindow() self.refreshPredictionWindow() } } else { // 日本語モードへの切り替え if self.inputLanguage == .english { - self.inputLanguage = .japanese + self.inputSession.synchronizeInputLanguage(.japanese) + _ = self.handleClientAction(.selectInputLanguage(.japanese), clientActionCallback: .fallthrough, client: self.client()) } } } @@ -281,6 +258,20 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return true } + let eventModifiers = KeyEventCore.ModifierFlag(from: event.modifierFlags) + let charactersForOptionDirectInput = event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.option)) + if Config.OptionDirectFullWidthInput().value, + let text = OptionDirectInputResolver.resolve( + characters: charactersForOptionDirectInput, + modifierFlags: eventModifiers, + inputLanguage: inputLanguage, + inputState: inputState, + typeBackSlash: Config.TypeBackSlash().value + ) { + client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + return true + } + let userAction = UserAction.getUserAction(eventCore: event.keyEventCore, inputLanguage: inputLanguage) // 英数キー(keyCode 102)の処理 @@ -294,6 +285,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s return true } } + if !self.segmentsManager.isEmpty { + _ = self.handleClientAction(.submitHalfWidthRomanCandidate, clientActionCallback: .transition(.none), client: client) + _ = self.handleClientAction(.selectInputLanguage(.english), clientActionCallback: .fallthrough, client: client) + return true + } } } @@ -315,6 +311,12 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s // Handle suggest action with selected text check (prevent recursive calls) if case .suggest = userAction { + // If AI backend is off, ignore the suggest action + if !aiBackendEnabled { + self.segmentsManager.appendDebugMessage("Suggest action ignored: AI backend is off") + return false + } + // Prevent recursive window calls if self.isPromptWindowVisible { self.segmentsManager.appendDebugMessage("Suggest action ignored: prompt window already visible") @@ -324,127 +326,28 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s let selectedRange = client.selectedRange() self.segmentsManager.appendDebugMessage("Suggest action detected. Selected range: \(selectedRange)") if selectedRange.length > 0 { - guard aiBackendEnabled else { - self.segmentsManager.appendDebugMessage("Suggest action ignored: AI backend is off") - return false - } self.segmentsManager.appendDebugMessage("Selected text found, showing prompt input window") // There is selected text, show prompt input window - self.showPromptInputWindow() - return true + return self.handleClientAction(.showPromptInputWindow, clientActionCallback: .fallthrough, client: client) } else { self.segmentsManager.appendDebugMessage("No selected text, using normal suggest behavior") } } - if let handled = self.handleKeyEventWithConverterServer( - event: event.keyEventCore, - client: client, - enableSuggestion: aiBackendEnabled, - optionDirectInputText: event.characters(byApplyingModifiers: event.modifierFlags.subtracting(.option)) - ) { - return handled - } - - return false - } - - @MainActor - private func handleKeyEventWithConverterServer( - event: KeyEventCore, - client: IMKTextInput, - enableSuggestion: Bool, - optionDirectInputText: String? = nil - ) -> Bool? { - guard self.converterServerClient.canSendOrReconnect else { - return nil - } - if !self.segmentsManager.isEmpty { - self.segmentsManager.stopComposition() - } - - let request = ConverterKeyEventRequest( - event: event, - inputState: ConverterInputState(self.inputState), - inputLanguage: self.inputLanguage, - inputStyle: ConverterInputStyle(self.inputStyle), - liveConversionEnabled: Config.LiveConversion().value, - enableDebugWindow: Config.DebugWindow().value, - enableSuggestion: enableSuggestion, - enablePredictiveTyping: Config.DebugPredictiveTyping().value, - enableTypoCorrection: Config.DebugTypoCorrection().value, - enableOptionDirectFullWidthInput: Config.OptionDirectFullWidthInput().value, - typeBackSlash: Config.TypeBackSlash().value, - optionDirectInputText: optionDirectInputText, - context: self.currentConverterTextContext() - ) - guard let response = self.converterServerClient.sendSync({ _ in - .handleKeyEvent(request) - }) else { - return nil - } - - if response.effects.contains(.fallthroughToApplication), !response.handled { - return false - } - - if let inputLanguage = response.inputLanguage { - self.inputLanguage = inputLanguage - } - self.inputState = response.inputState.inputState - self.currentConverterView = response.snapshot - for effect in response.effects { - self.apply(effect, client: client) - } - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - self.refreshReplaceSuggestionWindow() - return response.handled - } - - @MainActor - func requestPredictiveSuggestionWithConverterServer(client: IMKTextInput) -> Bool { - self.handleKeyEventWithConverterServer( - event: KeyEventCore( - modifierFlags: [.control], - characters: "s", - charactersIgnoringModifiers: "s", - keyCode: 1 + // IMK のキー所有権は handle の戻り値で同期的に決まるため、通常入力は + // XPC の一時切断に左右されないローカル composition で処理する。 + let result = self.inputSession.handle( + eventCore: event.keyEventCore, + userAction: userAction, + configuration: .init( + inputStyle: self.inputStyle, + liveConversionEnabled: Config.LiveConversion().value, + enableDebugWindow: Config.DebugWindow().value, + enableSuggestion: aiBackendEnabled ), - client: client, - enableSuggestion: Config.AIBackendPreference().value != .off - ) ?? false - } - - @MainActor - // swiftlint:disable:next cyclomatic_complexity - private func apply(_ effect: ConverterClientEffect, client: IMKTextInput) { - switch effect { - case .insertText(let text): - client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) - case .switchInputLanguage(let language): - self.switchInputLanguage(language, client: client) - case .requestPredictiveSuggestion: - self.requestReplaceSuggestion() - case .requestReplaceSuggestion: - self.requestReplaceSuggestion() - case .selectNextReplaceSuggestionCandidate: - self.selectReplaceSuggestionCandidate(offset: 1) - case .selectPreviousReplaceSuggestionCandidate: - self.selectReplaceSuggestionCandidate(offset: -1) - case .submitReplaceSuggestionCandidate: - self.submitSelectedSuggestionCandidate() - case .hideReplaceSuggestionWindow: - self.replaceSuggestionWindow.setIsVisible(false) - self.replaceSuggestionWindow.orderOut(nil) - case .showPromptInputWindow: - self.showPromptInputWindow() - case .transformSelectedText(let selectedText, let prompt): - self.transformSelectedText(selectedText: selectedText, prompt: prompt) - case .fallthroughToApplication: - break - } + numberCandidateIndex: self.candidatesViewController.getNumberCandidate(num:) + ) + return self.apply(result, client: client) } private var inputStyle: InputStyle { @@ -466,79 +369,75 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } - private var converterServerSessionConfig: ConverterSessionConfig { - ConverterSessionConfig( - aiBackendPreference: Config.AIBackendPreference().value, - openAIModelName: Config.OpenAiModelName().value, - openAIEndpoint: Config.OpenAiApiEndpoint().value, - openAIAPIKey: .init(Config.OpenAiApiKey().value), - includeContextInAITransform: Config.IncludeContextInAITransform().value - ) - } - - private func syncConverterServerSessionConfig() { - let config = self.converterServerSessionConfig - self.converterServerClient.sendIfSessionOpen( - { _ in .updateConfig(config) }, - completion: { _ in } + @MainActor func handleClientAction(_ clientAction: ClientAction, clientActionCallback: ClientActionCallback, client: IMKTextInput) -> Bool { + let result = self.inputSession.handle( + clientAction, + callback: clientActionCallback, + inputStyle: self.inputStyle, + numberCandidateIndex: self.candidatesViewController.getNumberCandidate(num:) ) + return self.apply(result, client: client) } - @discardableResult - private func syncConverterServerSessionConfigSync() -> Bool { - let config = self.converterServerSessionConfig - return self.converterServerClient.sendIfSessionOpenSync({ _ in - .updateConfig(config) - }) != nil + @MainActor + private func apply(_ result: InputSession.Result, client: IMKTextInput) -> Bool { + guard result.handled else { + return false + } + self.apply(result.effects, client: client) + self.refreshMarkedText() + self.refreshCandidateWindow() + self.refreshPredictionWindow() + return true } - private func refreshConverterViewForCurrentInputState() { - guard let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .composition(.snapshot(inputState: ConverterInputState(self.inputState))) - }) else { - return + @MainActor + private func apply(_ effects: [InputSession.Effect], client: IMKTextInput) { + for effect in effects { + switch effect { + case .insertText(let text): + client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0)) + case .selectInputLanguage(let language): + self.applyInputLanguage(language, client: client) + case .selectNextReplaceSuggestionCandidate: + self.replaceSuggestionsViewController.selectNextCandidate() + case .selectPrevReplaceSuggestionCandidate: + self.replaceSuggestionsViewController.selectPrevCandidate() + case .submitReplaceSuggestionCandidate: + self.submitSelectedSuggestionCandidate() + case .hideReplaceSuggestionWindow: + self.replaceSuggestionWindow.setIsVisible(false) + self.replaceSuggestionWindow.orderOut(nil) + case .requestReplaceSuggestion: + self.requestReplaceSuggestion() + case .showPromptInputWindow: + self.showPromptInputWindow() + case .transformSelectedText(let selectedText, let prompt): + self.transformSelectedText(selectedText: selectedText, prompt: prompt) + } } - self.currentConverterView = response.snapshot } - @MainActor func switchInputLanguage(_ language: InputLanguage, client: IMKTextInput) { - self.inputLanguage = language + @MainActor + private func applyInputLanguage(_ language: InputLanguage, client: IMKTextInput) { client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) switch language { case .english: client.selectMode("dev.ensan.inputmethod.azooKeyMac.Roman") - self.segmentsManager.stopJapaneseInput() case .japanese: client.selectMode("dev.ensan.inputmethod.azooKeyMac.Japanese") } } - private func discardConverterServerComposition() { - self.currentConverterView = nil - self.converterServerClient.sendIfSessionOpen( - { _ in .composition(.stopComposition) }, - completion: { _ in } - ) - } - func refreshCandidateWindow() { - if let currentConverterView { - self.refreshCandidateWindow(currentConverterView.candidateWindow) - return - } - self.candidatesWindow.setIsVisible(false) - self.candidatesWindow.orderOut(nil) - self.candidatesViewController.hide() - } - - private func refreshCandidateWindow(_ candidateWindow: ConverterCandidateWindow) { - switch candidateWindow { + switch self.segmentsManager.getCurrentCandidateWindow(inputState: self.inputState) { case .selecting(let candidates, let selectionIndex): var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = true + let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) self.candidatesViewController.updateCandidatePresentations( - candidates.map(\.candidatePresentation), + candidatePresentations, selectionIndex: selectionIndex, cursorLocation: rect.origin ) @@ -547,8 +446,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = false + let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) self.candidatesViewController.updateCandidatePresentations( - candidates.map(\.candidatePresentation), + candidatePresentations, selectionIndex: selectionIndex, cursorLocation: rect.origin ) @@ -560,62 +460,13 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } } - @MainActor private func refreshReplaceSuggestionWindow() { - guard self.inputState == .replaceSuggestion, - let currentConverterView, - !currentConverterView.replaceSuggestionCandidates.isEmpty else { - self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) - self.replaceSuggestionWindow.setIsVisible(false) - self.replaceSuggestionWindow.orderOut(nil) - return - } - self.replaceSuggestionsViewController.updateCandidatePresentations( - currentConverterView.replaceSuggestionCandidates.map(\.candidatePresentation), - selectionIndex: currentConverterView.replaceSuggestionSelectionIndex, - cursorLocation: self.getCursorLocation() - ) - self.replaceSuggestionWindow.setIsVisible(true) - self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) - } - - @MainActor private func selectReplaceSuggestionCandidate(offset: Int) { - guard let view = self.currentConverterView, - !view.replaceSuggestionCandidates.isEmpty else { - return - } - let count = view.replaceSuggestionCandidates.count - let current = view.replaceSuggestionSelectionIndex ?? (offset > 0 ? -1 : 0) - let next = (current + offset + count) % count - if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .replaceSuggestion(.selectReplaceSuggestionCandidate(index: next)) - }) { - self.currentConverterView = response.snapshot - self.inputState = response.inputState.inputState - self.refreshMarkedText() - self.refreshReplaceSuggestionWindow() - } - } - - @MainActor private func showReplaceSuggestionError(message: String) { - self.segmentsManager.appendDebugMessage("APIリクエストエラー: \(message)") - let alert = NSAlert() - alert.messageText = "変換に失敗しました" - alert.informativeText = message - alert.alertStyle = .warning - alert.addButton(withTitle: "OK") - alert.runModal() - } - func refreshPredictionWindow() { guard self.inputState == .composing else { self.hidePredictionWindow() return } - guard let predictions = self.currentConverterView?.predictionCandidates else { - self.hidePredictionWindow() - return - } + let predictions = self.requestPreferredPredictionCandidates() if predictions.isEmpty { let now = Date().timeIntervalSince1970 let elapsed = now - self.lastPredictionUpdateTime @@ -727,6 +578,13 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.predictionHideWorkItem = nil } + private func requestPreferredPredictionCandidates() -> [SegmentsManager.PredictionCandidate] { + SegmentsManager.preferredPredictionCandidates( + typoCorrectionCandidates: self.segmentsManager.requestTypoCorrectionPredictionCandidates(), + predictionCandidates: self.segmentsManager.requestPredictionCandidates() + ) + } + var retryCount = 0 let maxRetries = 3 @@ -752,8 +610,8 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s at: NSRange(location: NSNotFound, length: 0) ) as? [NSAttributedString.Key: Any] let text = NSMutableAttributedString(string: "") - let currentMarkedText = self.currentMarkedText() - for part in currentMarkedText.elements where !part.content.isEmpty { + let currentMarkedText = self.segmentsManager.getCurrentMarkedText(inputState: self.inputState) + for part in currentMarkedText where !part.content.isEmpty { let attributes: [NSAttributedString.Key: Any]? = switch part.focus { case .focused: highlight case .unfocused: underline @@ -768,72 +626,42 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } self.client()?.setMarkedText( text, - selectionRange: currentMarkedText.selectionRange.nsRange, + selectionRange: currentMarkedText.selectionRange, replacementRange: NSRange(location: NSNotFound, length: 0) ) } - private func currentMarkedText() -> ConverterMarkedText { - switch self.inputState { - case .attachDiacritic, .unicodeInput: - return ConverterMarkedText(self.segmentsManager.getCurrentMarkedText(inputState: self.inputState)) - case .none, .composing, .previewing, .selecting, .replaceSuggestion: - break + @MainActor + func submitCandidate(_ candidate: Candidate) { + if let client = self.client() { + self.apply(self.inputSession.submitCandidate(candidate), client: client) } - if let currentConverterView { - return currentConverterView.markedText + } + + @MainActor + func submitSelectedCandidate() { + if let client = self.client() { + self.apply(self.inputSession.submitSelectedCandidate(), client: client) } - return ConverterSessionSnapshot.empty.markedText } } extension azooKeyMacInputController: CandidatesViewControllerDelegate { func candidateSubmitted() { Task { @MainActor in - if self.currentConverterView != nil { - if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .candidate(.submitSelectedCandidate(context: self.currentConverterTextContext())) - }) { - self.currentConverterView = response.snapshot - if let client = self.client() { - for effect in response.effects { - self.apply(effect, client: client) - } - } - self.inputState = response.inputState.inputState - self.refreshConverterViewForCurrentInputState() - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - return - } - } + self.submitSelectedCandidate() } } func candidateSelectionChanged(_ row: Int) { Task { @MainActor in - if self.currentConverterView != nil, - let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .candidate(.selectCandidate(index: row)) - }) { - self.currentConverterView = response.snapshot - self.refreshMarkedText() - return - } + self.segmentsManager.requestSelectingRow(row) } } } extension azooKeyMacInputController: SegmentManagerDelegate { - private func currentConverterTextContext() -> ConverterTextContext { - ConverterTextContext( - leftSideContext: self.getLeftSideContext(), - rightSideContext: self.getRightSideContext() - ) - } - - func getLeftSideContext(maxCount: Int = ConverterTextContext.transportCharacterLimit) -> String? { + func getLeftSideContext(maxCount: Int) -> String? { let endIndex = self.contextRange().location let leftRange = NSRange(location: max(endIndex - maxCount, 0), length: min(endIndex, maxCount)) var actual = NSRange() @@ -843,7 +671,7 @@ extension azooKeyMacInputController: SegmentManagerDelegate { return leftSideContext } - func getRightSideContext(maxCount: Int = ConverterTextContext.transportCharacterLimit) -> String? { + func getRightSideContext(maxCount: Int) -> String? { let range = self.contextRange() let startIndex = range.location + range.length let documentLength = self.client().length() @@ -872,22 +700,22 @@ extension azooKeyMacInputController: SegmentManagerDelegate { extension azooKeyMacInputController: ReplaceSuggestionsViewControllerDelegate { @MainActor func replaceSuggestionSelectionChanged(_ row: Int) { - guard self.currentConverterView?.replaceSuggestionSelectionIndex != row else { - return - } - if let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .replaceSuggestion(.selectReplaceSuggestionCandidate(index: row)) - }) { - self.currentConverterView = response.snapshot - self.inputState = response.inputState.inputState - self.refreshMarkedText() - self.refreshReplaceSuggestionWindow() - } + self.segmentsManager.requestSelectingSuggestionRow(row) } func replaceSuggestionSubmitted() { Task { @MainActor in - self.submitSelectedSuggestionCandidate() + if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { + if let client = self.client() { + // 選択された候補をテキストとして挿入 + client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) + // サジェスト候補ウィンドウを非表示にする + self.replaceSuggestionWindow.setIsVisible(false) + self.replaceSuggestionWindow.orderOut(nil) + // 変換状態をリセット + self.segmentsManager.stopComposition() + } + } } } } @@ -899,40 +727,105 @@ extension azooKeyMacInputController { self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: 開始") // リクエスト開始時に前回の候補をクリアし、ウィンドウを非表示にする - self.replaceSuggestionsViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) + self.segmentsManager.setReplaceSuggestions([]) self.replaceSuggestionWindow.setIsVisible(false) self.replaceSuggestionWindow.orderOut(nil) - guard let currentConverterView, !currentConverterView.isEmpty else { - self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because converter server composition is empty") + // Get selected backend preference + let preference = Config.AIBackendPreference().value + + // If backend is off, do nothing + if preference == .off { + self.segmentsManager.appendDebugMessage("AI backend is off, skipping suggestion") return } - guard self.syncConverterServerSessionConfigSync() else { - self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: skipped because session config sync failed") + + let composingText = self.segmentsManager.convertTarget + + // プロンプトを取得 + let prompt = self.getLeftSideContext(maxCount: 100) ?? "" + + self.segmentsManager.appendDebugMessage("プロンプト取得成功: \(prompt) << \(composingText)") + + let apiKey = Config.OpenAiApiKey().value + let modelName = Config.OpenAiModelName().value + let request = OpenAIRequest(prompt: prompt, target: composingText, modelName: modelName) + self.segmentsManager.appendDebugMessage("APIリクエスト準備完了: prompt=\(prompt), target=\(composingText), modelName=\(modelName)") + + // Get selected backend + let backend: AIBackend + switch preference { + case .off: + // Already checked above, but defensive programming + self.segmentsManager.appendDebugMessage("Unexpected .off state in backend selection") return - } - self.converterServerClient.sendIfSessionOpen( - { _ in .replaceSuggestion(.request(context: self.currentConverterTextContext())) }, - completion: { [weak self] response in - Task { @MainActor in - guard let self else { - return - } - guard let response else { - self.showReplaceSuggestionError(message: "ConverterServerから候補を取得できませんでした") - return + case .foundationModels: + backend = .foundationModels + case .openAI: + backend = .openAI + } + self.segmentsManager.appendDebugMessage("Using backend: \(backend.rawValue)") + + // 非同期タスクでリクエストを送信 + Task { + do { + self.segmentsManager.appendDebugMessage("APIリクエスト送信中...") + let predictions = try await AIClient.sendRequest( + request, + backend: backend, + apiKey: apiKey, + apiEndpoint: Config.OpenAiApiEndpoint().value, + logger: { [weak self] message in + self?.segmentsManager.appendDebugMessage(message) } - guard self.currentConverterView?.convertTarget == response.snapshot.convertTarget else { - self.segmentsManager.appendDebugMessage("候補ウィンドウ更新をスキップ: composition changed") - return + ) + self.segmentsManager.appendDebugMessage("APIレスポンス受信成功: \(predictions)") + + // String配列からCandidate配列に変換 + let candidates = predictions.map { text in + Candidate( + text: text, + value: PValue(0), + composingCount: .surfaceCount(composingText.count), + lastMid: 0, + data: [], + actions: [], + inputable: true + ) + } + + self.segmentsManager.appendDebugMessage("候補変換成功: \(candidates.map { $0.text })") + + // 候補をウィンドウに更新 + await MainActor.run { + self.segmentsManager.appendDebugMessage("候補ウィンドウ更新中...") + if !candidates.isEmpty { + self.segmentsManager.setReplaceSuggestions(candidates) + self.replaceSuggestionsViewController.updateCandidatePresentations( + candidates.map { .init(candidate: $0) }, + selectionIndex: nil, + cursorLocation: getCursorLocation() + ) + self.replaceSuggestionWindow.setIsVisible(true) + self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) + self.segmentsManager.appendDebugMessage("候補ウィンドウ更新完了") } - self.currentConverterView = response.snapshot - self.inputState = response.inputState.inputState - self.refreshMarkedText() - self.refreshReplaceSuggestionWindow() + } + } catch { + let errorMessage = "APIリクエストエラー: \(error.localizedDescription)" + self.segmentsManager.appendDebugMessage(errorMessage) + + // ユーザーに通知 + await MainActor.run { + let alert = NSAlert() + alert.messageText = "変換に失敗しました" + alert.informativeText = error.localizedDescription + alert.alertStyle = .warning + alert.addButton(withTitle: "OK") + alert.runModal() } } - ) + } self.segmentsManager.appendDebugMessage("requestReplaceSuggestion: 終了") } @@ -943,33 +836,14 @@ extension azooKeyMacInputController { } @MainActor func submitSelectedSuggestionCandidate() { - guard let response = self.converterServerClient.sendIfSessionOpenSync({ _ in - .replaceSuggestion(.submitSelectedReplaceSuggestion) - }) else { - return - } - self.currentConverterView = response.snapshot - if let client = self.client() { - for effect in response.effects { - self.apply(effect, client: client) + if let candidate = self.replaceSuggestionsViewController.getSelectedCandidate() { + if let client = self.client() { + client.insertText(candidate.text, replacementRange: NSRange(location: NSNotFound, length: 0)) + self.replaceSuggestionWindow.setIsVisible(false) + self.replaceSuggestionWindow.orderOut(nil) + self.segmentsManager.stopComposition() } } - self.inputState = response.inputState.inputState - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - self.refreshReplaceSuggestionWindow() - } - - @MainActor private func finishReplaceSuggestionComposition() { - if self.currentConverterView != nil { - self.discardConverterServerComposition() - } - self.inputState = .none - self.refreshMarkedText() - self.refreshCandidateWindow() - self.refreshPredictionWindow() - self.refreshReplaceSuggestionWindow() } // MARK: - Helper Methods diff --git a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift index 7e1a89b0..c9346d83 100644 --- a/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift +++ b/azooKeyMac/InputController/azooKeyMacInputControllerHelper.swift @@ -50,10 +50,17 @@ extension azooKeyMacInputController { } let hasSelection = client.selectedRange().length > 0 if hasSelection { - self.showPromptInputWindow() + _ = self.handleClientAction(.showPromptInputWindow, clientActionCallback: .fallthrough, client: client) return } - _ = self.requestPredictiveSuggestionWithConverterServer(client: client) + switch self.inputState { + case .composing, .replaceSuggestion: + _ = self.handleClientAction(.requestReplaceSuggestion, clientActionCallback: .transition(.replaceSuggestion), client: client) + case .none: + _ = self.handleClientAction(.requestPredictiveSuggestion, clientActionCallback: .transition(.replaceSuggestion), client: client) + default: + break + } } @MainActor @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {