From f4ee3e278f1883e7163d02efe6e88b73d2c04867 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 02:38:17 +0900 Subject: [PATCH 1/6] feat: support additional candidates --- .../Core/InputUtils/SegmentsManager.swift | 174 ++++++++++++++++-- .../BaseCandidateViewController.swift | 89 ++++++++- .../CandidateWindow/CandidateView.swift | 39 +++- ...nputController+SelectedTextTransform.swift | 5 +- .../azooKeyMacInputController.swift | 20 +- 5 files changed, 292 insertions(+), 35 deletions(-) diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 69f5d5eb..12edc248 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -38,6 +38,16 @@ public final class SegmentsManager { private var lastOperation: Operation = .other private var shouldShowCandidateWindow = false + private struct AdditionalCandidatePresentation { + let candidate: Candidate + let presentationContext: CandidatePresentationContext + } + + private var isShowingAdditionalCandidates = false + private var additionalCandidates: [AdditionalCandidatePresentation] = [] + private var showingAdditionalCandidateCount = 0 + private var isFixingAdditionalCandidateTop = false + private var shouldShowDebugCandidateWindow: Bool = false private var debugCandidates: [Candidate] = [] @@ -49,10 +59,30 @@ public final class SegmentsManager { public var appendText: String } + public struct CandidatePresentationContext: Sendable { + public var annotationText: String? + public var extraValues: [String: String] + + public init(annotationText: String? = nil, extraValues: [String: String] = [:]) { + self.annotationText = annotationText + self.extraValues = extraValues + } + } + private func candidateReading(_ candidate: Candidate) -> String { candidate.data.map(\.ruby).joined() } + public func makeCandidatePresentationContexts(_ candidates: [Candidate]) -> [CandidatePresentationContext] { + let additionalContexts = self.additionalCandidateContextsForSelectionIndex + return candidates.indices.map { index in + if index < additionalContexts.count { + return additionalContexts[index] + } + return .init() + } + } + private lazy var zenzaiPersonalizationMode: ConvertRequestOptions.ZenzaiMode.PersonalizationMode? = self.getZenzaiPersonalizationMode() private func getZenzaiPersonalizationMode() -> ConvertRequestOptions.ZenzaiMode.PersonalizationMode? { @@ -172,6 +202,7 @@ public final class SegmentsManager { self.composingText.stopComposition() self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } @MainActor @@ -184,6 +215,7 @@ public final class SegmentsManager { self.lastOperation = .other self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } @MainActor @@ -195,6 +227,7 @@ public final class SegmentsManager { self.kanaKanjiConverter.commitUpdateLearningData() self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } /// 変換キーを押したタイミングで入力の区切りを示す @@ -284,24 +317,58 @@ public final class SegmentsManager { } private var candidates: [Candidate]? { - if let rawCandidates { - if !self.didExperienceSegmentEdition { - if rawCandidates.firstClauseResults.contains(where: { self.composingText.isWholeComposingText(composingCount: $0.composingCount) }) { - // firstClauseCandidateがmainResultsと同じサイズの場合は、何もしない方が良い - return rawCandidates.mainResults - } else { - // 変換範囲がエディットされていない場合 - let seenAsFirstClauseResults = rawCandidates.firstClauseResults.mapSet(transform: \.text) - return rawCandidates.firstClauseResults + rawCandidates.mainResults.filter { - !seenAsFirstClauseResults.contains($0.text) - } - } - } else { + guard let rawCandidates = self.rawCandidatesList else { + return self.isShowingAdditionalCandidates + ? self.additionalCandidatesForSelectionIndex + : nil + } + return self.isShowingAdditionalCandidates + ? self.additionalCandidatesForSelectionIndex + rawCandidates + : rawCandidates + } + + private var rawCandidatesList: [Candidate]? { + guard let rawCandidates else { + return nil + } + if !self.didExperienceSegmentEdition { + if rawCandidates.firstClauseResults.contains(where: { self.composingText.isWholeComposingText(composingCount: $0.composingCount) }) { + // firstClauseCandidateがmainResultsと同じサイズの場合は、何もしない方が良い return rawCandidates.mainResults + } else { + // 変換範囲がエディットされていない場合 + let seenAsFirstClauseResults = rawCandidates.firstClauseResults.mapSet(transform: \.text) + return rawCandidates.firstClauseResults + rawCandidates.mainResults.filter { + !seenAsFirstClauseResults.contains($0.text) + } } } else { - return nil + return rawCandidates.mainResults + } + } + + private var candidateOffsetByAdditionalCandidates: Int { + self.isShowingAdditionalCandidates ? self.showingAdditionalCandidateCount : 0 + } + + private var additionalCandidatesForSelectionIndex: [Candidate] { + guard self.isShowingAdditionalCandidates else { + return [] + } + guard self.candidateOffsetByAdditionalCandidates > 0 else { + return [] + } + return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)).map(\.candidate) + } + + private var additionalCandidateContextsForSelectionIndex: [CandidatePresentationContext] { + guard self.isShowingAdditionalCandidates else { + return [] + } + guard self.candidateOffsetByAdditionalCandidates > 0 else { + return [] } + return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)).map(\.presentationContext) } public var convertTarget: String { @@ -336,6 +403,7 @@ public final class SegmentsManager { /// - Note: /// This function is executed on the `@MainActor` to ensure UI consistency. @MainActor private func updateRawCandidate(requestRichCandidates: Bool = false, forcedLeftSideContext: String? = nil) { + self.resetAdditionalCandidates() // 不要 if composingText.isEmpty { self.rawCandidates = nil @@ -430,15 +498,46 @@ public final class SegmentsManager { self.shouldShowDebugCandidateWindow = enabled } + @MainActor public func requestSelectingNextCandidate() { + self.isFixingAdditionalCandidateTop = false self.selectionIndex = (self.selectionIndex ?? -1) + 1 } + @MainActor public func requestSelectingPrevCandidate() { - self.selectionIndex = max(0, (self.selectionIndex ?? 1) - 1) + let selectionIndex = self.selectionIndex ?? 0 + + if self.isFixingAdditionalCandidateTop && self.isShowingAdditionalCandidates { + if self.candidateOffsetByAdditionalCandidates < self.additionalCandidates.count { + self.showingAdditionalCandidateCount += 1 + } + self.selectionIndex = 0 + return + } + + if selectionIndex == 0, !self.isShowingAdditionalCandidates { + self.showAdditionalCandidatesIfNeeded() + let additionalCount = self.candidateOffsetByAdditionalCandidates + if additionalCount > 0 { + self.isFixingAdditionalCandidateTop = true + self.selectionIndex = 0 + return + } + } + if selectionIndex == 0, self.isShowingAdditionalCandidates, self.candidateOffsetByAdditionalCandidates < self.additionalCandidates.count { + self.isFixingAdditionalCandidateTop = true + self.showingAdditionalCandidateCount += 1 + self.selectionIndex = 0 + return + } + self.selectionIndex = max(0, selectionIndex - 1) } public func requestSelectingRow(_ index: Int) { + if self.isFixingAdditionalCandidateTop, index != 0 { + self.isFixingAdditionalCandidateTop = false + } self.selectionIndex = max(0, index) } @@ -452,6 +551,8 @@ public final class SegmentsManager { public func requestResettingSelection() { self.selectionIndex = nil + self.isFixingAdditionalCandidateTop = false + self.resetAdditionalCandidates() } public var selectedCandidate: Candidate? { @@ -564,6 +665,49 @@ public final class SegmentsManager { return candidate } + @MainActor + private func createAdditionalCandidates() -> [AdditionalCandidatePresentation] { + let candidates: [(candidate: Candidate, annotationText: String?)] = [ + (self.getModifiedRomanCandidate { $0 }, "英数"), + (self.getModifiedRomanCandidate { $0.applyingTransform(.fullwidthToHalfwidth, reverse: true) ?? $0 }, "全角英数"), + (self.getModifiedRubyCandidate(inputState: .composing) { $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false) ?? $0 }, "半角カナ"), + (self.getModifiedRubyCandidate(inputState: .composing) { $0.toKatakana() }, "カタカナ"), + (self.getModifiedRubyCandidate(inputState: .composing) { $0.toHiragana() }, "ひらがな") + ] + return candidates.map { + .init( + candidate: $0.candidate, + presentationContext: .init(annotationText: $0.annotationText) + ) + } + } + + @MainActor + private func showAdditionalCandidatesIfNeeded() { + if self.isShowingAdditionalCandidates { + return + } + guard !self.convertTarget.isEmpty else { + self.resetAdditionalCandidates() + return + } + let candidates = self.createAdditionalCandidates() + guard !candidates.isEmpty else { + self.resetAdditionalCandidates() + return + } + self.additionalCandidates = candidates + self.isShowingAdditionalCandidates = true + self.showingAdditionalCandidateCount = 1 + } + + private func resetAdditionalCandidates() { + self.isShowingAdditionalCandidates = false + self.additionalCandidates = [] + self.showingAdditionalCandidateCount = 0 + self.isFixingAdditionalCandidateTop = false + } + @MainActor public func commitMarkedText(inputState: InputState) -> String { let markedText = self.getCurrentMarkedText(inputState: inputState) diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index a0e37ad3..7901d982 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -2,6 +2,16 @@ import Cocoa import Core import KanaKanjiConverterModule +struct CandidateDisplayContext { + var annotationText: String? + var extraValues: [String: String] = [:] +} + +struct CandidatePresentation { + let candidate: Candidate + var displayContext: CandidateDisplayContext +} + class NonClickableTableView: NSTableView { override func rightMouseDown(with event: NSEvent) {} override func mouseDown(with event: NSEvent) {} @@ -10,25 +20,64 @@ class NonClickableTableView: NSTableView { class CandidateTableCellView: NSTableCellView { let candidateTextField: NSTextField + let candidateAnnotationTextField: NSTextField + private lazy var candidateTextFieldLeadingConstraint: NSLayoutConstraint = { + self.candidateTextField.leadingAnchor.constraint(equalTo: self.leadingAnchor) + }() + private lazy var candidateTextFieldTrailingToAnnotationConstraint: NSLayoutConstraint = { + self.candidateTextField.trailingAnchor.constraint(lessThanOrEqualTo: self.candidateAnnotationTextField.leadingAnchor, constant: -8) + }() + private lazy var candidateTextFieldTrailingToContainerConstraint: NSLayoutConstraint = { + let constraint = self.candidateTextField.trailingAnchor.constraint(equalTo: self.trailingAnchor) + return constraint + }() + private lazy var candidateAnnotationTextFieldLeadingConstraint: NSLayoutConstraint = { + self.candidateAnnotationTextField.leadingAnchor.constraint(greaterThanOrEqualTo: self.candidateTextField.trailingAnchor, constant: 8) + }() + private lazy var candidateAnnotationTextFieldTrailingConstraint: NSLayoutConstraint = { + self.candidateAnnotationTextField.trailingAnchor.constraint(equalTo: self.trailingAnchor) + }() override init(frame frameRect: NSRect) { self.candidateTextField = NSTextField(labelWithString: "") self.candidateTextField.font = NSFont.systemFont(ofSize: 18) + self.candidateAnnotationTextField = NSTextField(labelWithString: "") + self.candidateAnnotationTextField.font = NSFont.systemFont(ofSize: 12) + self.candidateAnnotationTextField.textColor = .systemGray + self.candidateAnnotationTextField.alignment = .right super.init(frame: frameRect) self.addSubview(self.candidateTextField) + self.addSubview(self.candidateAnnotationTextField) self.candidateTextField.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - self.candidateTextField.leadingAnchor.constraint(equalTo: self.leadingAnchor), - self.candidateTextField.trailingAnchor.constraint(equalTo: self.trailingAnchor), + self.candidateTextFieldLeadingConstraint, + self.candidateTextFieldTrailingToContainerConstraint, self.candidateTextField.centerYAnchor.constraint(equalTo: self.centerYAnchor) ]) + self.candidateTextField.setContentHuggingPriority(.defaultLow, for: .horizontal) + self.candidateTextField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + self.candidateAnnotationTextField.translatesAutoresizingMaskIntoConstraints = false + self.candidateAnnotationTextField.setContentHuggingPriority(.required, for: .horizontal) + self.candidateAnnotationTextField.setContentCompressionResistancePriority(.required, for: .horizontal) + + NSLayoutConstraint.activate([ + self.candidateAnnotationTextField.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) // 基本設定 self.candidateTextField.isEditable = false self.candidateTextField.isBordered = false self.candidateTextField.drawsBackground = false self.candidateTextField.backgroundColor = .clear + + self.candidateAnnotationTextField.isEditable = false + self.candidateAnnotationTextField.isBordered = false + self.candidateAnnotationTextField.drawsBackground = false + self.candidateAnnotationTextField.backgroundColor = .clear + + self.showCandidateAnnotationTextField(false) } required init?(coder: NSCoder) { @@ -38,14 +87,26 @@ class CandidateTableCellView: NSTableCellView { override var backgroundStyle: NSView.BackgroundStyle { didSet { candidateTextField.textColor = backgroundStyle == .emphasized ? .white : NSAppearance.currentDrawing().name == .aqua ? .init(white: 0.3, alpha: 1.0) : .textColor + candidateAnnotationTextField.textColor = .systemGray } } + + func showCandidateAnnotationTextField(_ show: Bool) { + self.candidateTextFieldTrailingToContainerConstraint.isActive = !show + self.candidateTextFieldTrailingToAnnotationConstraint.isActive = show + self.candidateAnnotationTextFieldLeadingConstraint.isActive = show + self.candidateAnnotationTextFieldTrailingConstraint.isActive = show + self.candidateAnnotationTextField.isHidden = !show + } } class BaseCandidateViewController: NSViewController { - internal var candidates: [Candidate] = [] + typealias CandidatePresentationContextResolver = (Candidate) -> CandidateDisplayContext + + internal var candidates: [CandidatePresentation] = [] internal var tableView: NSTableView! internal var currentSelectedRow: Int = -1 + internal var candidateDisplayContextResolver: CandidatePresentationContextResolver? override func loadView() { // 親ビュー(ZStackのような役割) @@ -131,14 +192,24 @@ class BaseCandidateViewController: NSViewController { window.isOpaque = false } - func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint) { - self.candidates = candidates + func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidateDisplayContext]? = nil) { + if let candidateDisplayContexts, candidateDisplayContexts.count == candidates.count { + self.candidates = zip(candidates, candidateDisplayContexts).map { candidate, context in + CandidatePresentation(candidate: candidate, displayContext: context) + } + } else { + self.candidates = candidates.map(self.makeCandidatePresentation) + } self.currentSelectedRow = selectionIndex ?? -1 self.tableView.reloadData() self.resizeWindowToFitContent(cursorLocation: cursorLocation) self.updateSelection(to: selectionIndex ?? -1) } + internal func makeCandidatePresentation(_ candidate: Candidate) -> CandidatePresentation { + .init(candidate: candidate, displayContext: self.candidateDisplayContextResolver?(candidate) ?? .init()) + } + internal func updateSelection(to row: Int) { if row == -1 { return @@ -191,7 +262,7 @@ class BaseCandidateViewController: NSViewController { let rowHeight = self.tableView.rowHeight let tableViewHeight = CGFloat(self.numberOfVisibleRows) * rowHeight - let maxWidth = self.getMaxTextWidth(candidates: self.candidates.lazy.map { $0.text }) + let maxWidth = self.getMaxTextWidth(candidates: self.candidates.lazy.map { $0.candidate.text }) let windowWidth = self.getWindowWidth(maxContentWidth: maxWidth) let newWindowFrame = WindowPositioning.frameNearCursor( currentFrame: .init(window.frame), @@ -208,7 +279,7 @@ class BaseCandidateViewController: NSViewController { guard currentSelectedRow >= 0 && currentSelectedRow < candidates.count else { return nil } - return candidates[currentSelectedRow] + return candidates[currentSelectedRow].candidate } func selectNextCandidate() { @@ -228,7 +299,9 @@ class BaseCandidateViewController: NSViewController { } internal func configureCellView(_ cell: CandidateTableCellView, forRow row: Int) { - cell.candidateTextField.stringValue = candidates[row].text + cell.candidateTextField.stringValue = candidates[row].candidate.text + cell.showCandidateAnnotationTextField(false) + cell.candidateAnnotationTextField.stringValue = "" } } diff --git a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift index 64a92509..fb858067 100644 --- a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift +++ b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift @@ -11,9 +11,14 @@ class CandidatesViewController: BaseCandidateViewController { private var showedRows: ClosedRange = 0...8 var showCandidateIndex = false - override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint) { + override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidateDisplayContext]? = nil) { self.showedRows = selectionIndex == nil ? 0...8 : self.showedRows - super.updateCandidates(candidates, selectionIndex: selectionIndex, cursorLocation: cursorLocation) + super.updateCandidates( + candidates, + selectionIndex: selectionIndex, + cursorLocation: cursorLocation, + candidateDisplayContexts: candidateDisplayContexts + ) } override internal func updateSelectionCallback(_ row: Int) { @@ -29,18 +34,20 @@ class CandidatesViewController: BaseCandidateViewController { } override internal func configureCellView(_ cell: CandidateTableCellView, forRow row: Int) { + let candidate = self.candidates[row].candidate + let annotationText = self.candidates[row].displayContext.annotationText let isWithinShowedRows = self.showedRows.contains(row) let displayIndex = row - self.showedRows.lowerBound + 1 // showedRowsの下限からの相対的な位置 let displayText: String if isWithinShowedRows && self.showCandidateIndex { if displayIndex > 9 { - displayText = " " + self.candidates[row].text // 行番号が10以上の場合、インデントを調整 + displayText = " " + candidate.text // 行番号が10以上の場合、インデントを調整 } else { - displayText = "\(displayIndex). " + self.candidates[row].text + displayText = "\(displayIndex). " + candidate.text } } else { - displayText = self.candidates[row].text // showedRowsの範囲外では番号を付けない + displayText = candidate.text // showedRowsの範囲外では番号を付けない } // 数字部分と候補部分を別々に設定 @@ -56,6 +63,21 @@ class CandidatesViewController: BaseCandidateViewController { } cell.candidateTextField.attributedStringValue = attributedString + + if let annotationText { + let annotationString = NSAttributedString( + string: annotationText, + attributes: [ + .font: NSFont.systemFont(ofSize: 12), + .foregroundColor: currentSelectedRow == row ? NSColor.white : NSColor.systemGray + ] + ) + cell.showCandidateAnnotationTextField(true) + cell.candidateAnnotationTextField.attributedStringValue = annotationString + } else { + cell.showCandidateAnnotationTextField(false) + cell.candidateAnnotationTextField.stringValue = "" + } } func getNumberCandidate(num: Int) -> Int { @@ -73,10 +95,11 @@ class CandidatesViewController: BaseCandidateViewController { } override func getWindowWidth(maxContentWidth: CGFloat) -> CGFloat { + let hasAnnotation = self.candidates.contains { $0.displayContext.annotationText != nil } if self.showCandidateIndex { - maxContentWidth + 48 + return maxContentWidth + 48 + (hasAnnotation ? 56 : 0) } else { - maxContentWidth + 20 + return maxContentWidth + 20 + (hasAnnotation ? 56 : 0) } } } @@ -91,7 +114,7 @@ class PredictionCandidatesViewController: BaseCandidateViewController { } override internal func configureCellView(_ cell: CandidateTableCellView, forRow row: Int) { - let candidateText = candidates[row].text + let candidateText = candidates[row].candidate.text let attributedString = NSMutableAttributedString() let isSelected = currentSelectedRow == row diff --git a/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift b/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift index c2a2dd69..50e9b8f4 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift @@ -185,7 +185,7 @@ extension azooKeyMacInputController { self.segmentsManager.appendDebugMessage("AI translation ignored: prompt window already visible") return true } - guard let client = self.client() else { + guard self.client() != nil else { self.segmentsManager.appendDebugMessage("AI translation ignored: No client available") return false } @@ -440,8 +440,9 @@ extension azooKeyMacInputController { } } + let backendName = backend.rawValue await MainActor.run { - self.segmentsManager.appendDebugMessage("getTransformationPreview: Sending preview request (\(backend.rawValue))") + self.segmentsManager.appendDebugMessage("getTransformationPreview: Sending preview request (\(backendName))") } let modelName = Config.OpenAiModelName().value diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 415618d1..51aed5dd 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -511,13 +511,29 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = true - self.candidatesViewController.updateCandidates(candidates, selectionIndex: selectionIndex, cursorLocation: rect.origin) + let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates).map { + CandidateDisplayContext(annotationText: $0.annotationText, extraValues: $0.extraValues) + } + self.candidatesViewController.updateCandidates( + candidates, + selectionIndex: selectionIndex, + cursorLocation: rect.origin, + candidateDisplayContexts: presentationContexts + ) self.candidatesWindow.orderFront(nil) case .composing(let candidates, let selectionIndex): var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = false - self.candidatesViewController.updateCandidates(candidates, selectionIndex: selectionIndex, cursorLocation: rect.origin) + let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates).map { + CandidateDisplayContext(annotationText: $0.annotationText, extraValues: $0.extraValues) + } + self.candidatesViewController.updateCandidates( + candidates, + selectionIndex: selectionIndex, + cursorLocation: rect.origin, + candidateDisplayContexts: presentationContexts + ) self.candidatesWindow.orderFront(nil) case .hidden: self.candidatesWindow.setIsVisible(false) From e326e77dc65ed7402c7c9c2c956c9380c4e4c6bd Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 02:55:01 +0900 Subject: [PATCH 2/6] fix: minimize diff and add test --- ...entsManagerAdditionalCandidatesTests.swift | 88 +++++++++++++++++++ .../BaseCandidateViewController.swift | 5 +- ...nputController+SelectedTextTransform.swift | 5 +- 3 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift new file mode 100644 index 00000000..462d8aa5 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -0,0 +1,88 @@ +import Core +import Foundation +import Testing + +private func makeSegmentsManager() -> SegmentsManager { + SegmentsManager( + kanaKanjiConverter: .init(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil + ) +} + +@MainActor +@Test func testAdditionalCandidatesExpandInSuffixOrder() async throws { + let manager = makeSegmentsManager() + manager.insertAtCursorPosition("abc", inputStyle: .direct) + manager.requestSetCandidateWindowState(visible: true) + + manager.requestSelectingPrevCandidate() + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, let selectionIndex): + #expect(selectionIndex == 0) + let firstContexts = manager.makeCandidatePresentationContexts(candidates) + #expect(firstContexts.count == candidates.count) + #expect(firstContexts.first?.annotationText == "ひらがな") + case .hidden, .composing: + Issue.record("Expected selecting state after first previous-selection request.") + return + } + + manager.requestSelectingPrevCandidate() + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, let selectionIndex): + #expect(selectionIndex == 0) + let contexts = manager.makeCandidatePresentationContexts(candidates) + if contexts.count >= 2 { + #expect(contexts[0].annotationText == "カタカナ") + #expect(contexts[1].annotationText == "ひらがな") + } else { + Issue.record("Expected at least 2 additional candidates after second request.") + } + case .hidden, .composing: + Issue.record("Expected selecting state after second previous-selection request.") + } +} + +@MainActor +@Test func testAdditionalCandidatesExpansionCapsAtDeclaredCount() async throws { + let manager = makeSegmentsManager() + manager.insertAtCursorPosition("abc", inputStyle: .direct) + manager.requestSetCandidateWindowState(visible: true) + + for _ in 0..<10 { + manager.requestSelectingPrevCandidate() + } + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _): + let annotationTexts = manager.makeCandidatePresentationContexts(candidates) + .compactMap(\.annotationText) + #expect(annotationTexts == ["英数", "全角英数", "半角カナ", "カタカナ", "ひらがな"]) + case .hidden, .composing: + Issue.record("Expected selecting state while additional candidates are expanded.") + } +} + +@MainActor +@Test func testResettingSelectionClearsAdditionalCandidateContexts() async throws { + let manager = makeSegmentsManager() + manager.insertAtCursorPosition("abc", inputStyle: .direct) + manager.requestSetCandidateWindowState(visible: true) + manager.requestSelectingPrevCandidate() + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _): + let beforeReset = manager.makeCandidatePresentationContexts(candidates) + #expect(beforeReset.contains { $0.annotationText != nil }) + + manager.requestResettingSelection() + + let afterReset = manager.makeCandidatePresentationContexts(candidates) + #expect(afterReset.allSatisfy { $0.annotationText == nil }) + case .hidden, .composing: + Issue.record("Expected selecting state after expanding additional candidates.") + } +} diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index 7901d982..413f01de 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -101,12 +101,9 @@ class CandidateTableCellView: NSTableCellView { } class BaseCandidateViewController: NSViewController { - typealias CandidatePresentationContextResolver = (Candidate) -> CandidateDisplayContext - internal var candidates: [CandidatePresentation] = [] internal var tableView: NSTableView! internal var currentSelectedRow: Int = -1 - internal var candidateDisplayContextResolver: CandidatePresentationContextResolver? override func loadView() { // 親ビュー(ZStackのような役割) @@ -207,7 +204,7 @@ class BaseCandidateViewController: NSViewController { } internal func makeCandidatePresentation(_ candidate: Candidate) -> CandidatePresentation { - .init(candidate: candidate, displayContext: self.candidateDisplayContextResolver?(candidate) ?? .init()) + .init(candidate: candidate, displayContext: .init()) } internal func updateSelection(to row: Int) { diff --git a/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift b/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift index 50e9b8f4..c2a2dd69 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController+SelectedTextTransform.swift @@ -185,7 +185,7 @@ extension azooKeyMacInputController { self.segmentsManager.appendDebugMessage("AI translation ignored: prompt window already visible") return true } - guard self.client() != nil else { + guard let client = self.client() else { self.segmentsManager.appendDebugMessage("AI translation ignored: No client available") return false } @@ -440,9 +440,8 @@ extension azooKeyMacInputController { } } - let backendName = backend.rawValue await MainActor.run { - self.segmentsManager.appendDebugMessage("getTransformationPreview: Sending preview request (\(backendName))") + self.segmentsManager.appendDebugMessage("getTransformationPreview: Sending preview request (\(backend.rawValue))") } let modelName = Config.OpenAiModelName().value From 189e5cb6eb8c376446e2a7742dccc4d7d8edafa1 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 03:06:34 +0900 Subject: [PATCH 3/6] fix: minimize diff --- .../Core/InputUtils/CandidatePresentationContext.swift | 9 +++++++++ Core/Sources/Core/InputUtils/SegmentsManager.swift | 10 ---------- .../SegmentsManagerAdditionalCandidatesTests.swift | 3 ++- .../CandidateWindow/BaseCandidateViewController.swift | 9 ++------- .../CandidateWindow/CandidateView.swift | 3 ++- .../InputController/azooKeyMacInputController.swift | 8 ++------ 6 files changed, 17 insertions(+), 25 deletions(-) create mode 100644 Core/Sources/Core/InputUtils/CandidatePresentationContext.swift diff --git a/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift new file mode 100644 index 00000000..5758ab57 --- /dev/null +++ b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift @@ -0,0 +1,9 @@ +public struct CandidatePresentationContext: Sendable { + public var annotationText: String? + public var extraValues: [String: String] + + public init(annotationText: String? = nil, extraValues: [String: String] = [:]) { + self.annotationText = annotationText + self.extraValues = extraValues + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 12edc248..b3733410 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -59,16 +59,6 @@ public final class SegmentsManager { public var appendText: String } - public struct CandidatePresentationContext: Sendable { - public var annotationText: String? - public var extraValues: [String: String] - - public init(annotationText: String? = nil, extraValues: [String: String] = [:]) { - self.annotationText = annotationText - self.extraValues = extraValues - } - } - private func candidateReading(_ candidate: Candidate) -> String { candidate.data.map(\.ruby).joined() } diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift index 462d8aa5..41a9a83d 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -1,10 +1,11 @@ import Core import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary import Testing private func makeSegmentsManager() -> SegmentsManager { SegmentsManager( - kanaKanjiConverter: .init(), + kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), containerURL: nil ) diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index 413f01de..4487b1b6 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -2,14 +2,9 @@ import Cocoa import Core import KanaKanjiConverterModule -struct CandidateDisplayContext { - var annotationText: String? - var extraValues: [String: String] = [:] -} - struct CandidatePresentation { let candidate: Candidate - var displayContext: CandidateDisplayContext + var displayContext: CandidatePresentationContext } class NonClickableTableView: NSTableView { @@ -189,7 +184,7 @@ class BaseCandidateViewController: NSViewController { window.isOpaque = false } - func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidateDisplayContext]? = nil) { + func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidatePresentationContext]? = nil) { if let candidateDisplayContexts, candidateDisplayContexts.count == candidates.count { self.candidates = zip(candidates, candidateDisplayContexts).map { candidate, context in CandidatePresentation(candidate: candidate, displayContext: context) diff --git a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift index fb858067..7efc467c 100644 --- a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift +++ b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift @@ -1,4 +1,5 @@ import Cocoa +import Core import KanaKanjiConverterModule protocol CandidatesViewControllerDelegate: AnyObject { @@ -11,7 +12,7 @@ class CandidatesViewController: BaseCandidateViewController { private var showedRows: ClosedRange = 0...8 var showCandidateIndex = false - override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidateDisplayContext]? = nil) { + override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidatePresentationContext]? = nil) { self.showedRows = selectionIndex == nil ? 0...8 : self.showedRows super.updateCandidates( candidates, diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 51aed5dd..c13f203c 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -511,9 +511,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = true - let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates).map { - CandidateDisplayContext(annotationText: $0.annotationText, extraValues: $0.extraValues) - } + let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates) self.candidatesViewController.updateCandidates( candidates, selectionIndex: selectionIndex, @@ -525,9 +523,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = false - let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates).map { - CandidateDisplayContext(annotationText: $0.annotationText, extraValues: $0.extraValues) - } + let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates) self.candidatesViewController.updateCandidates( candidates, selectionIndex: selectionIndex, From 05ac073f7aa92cb2af32441d3fb7084c0d5c557b Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 11:36:51 +0900 Subject: [PATCH 4/6] refactor: clean up duplicated declarations --- .../CandidatePresentationContext.swift | 12 ++++++ .../Core/InputUtils/SegmentsManager.swift | 33 +++++---------- ...entsManagerAdditionalCandidatesTests.swift | 11 ++--- .../BaseCandidateViewController.swift | 19 +-------- .../CandidateWindow/CandidateView.swift | 7 ++-- .../azooKeyMacInputController.swift | 42 ++++++++++++------- 6 files changed, 60 insertions(+), 64 deletions(-) diff --git a/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift index 5758ab57..57e214ec 100644 --- a/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift +++ b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift @@ -1,3 +1,5 @@ +import KanaKanjiConverterModuleWithDefaultDictionary + public struct CandidatePresentationContext: Sendable { public var annotationText: String? public var extraValues: [String: String] @@ -7,3 +9,13 @@ public struct CandidatePresentationContext: Sendable { self.extraValues = extraValues } } + +public struct CandidatePresentation: Sendable { + public var candidate: Candidate + public var displayContext: CandidatePresentationContext + + public init(candidate: Candidate, displayContext: CandidatePresentationContext = .init()) { + self.candidate = candidate + self.displayContext = displayContext + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index b3733410..250cdc8d 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -38,13 +38,8 @@ public final class SegmentsManager { private var lastOperation: Operation = .other private var shouldShowCandidateWindow = false - private struct AdditionalCandidatePresentation { - let candidate: Candidate - let presentationContext: CandidatePresentationContext - } - private var isShowingAdditionalCandidates = false - private var additionalCandidates: [AdditionalCandidatePresentation] = [] + private var additionalCandidates: [CandidatePresentation] = [] private var showingAdditionalCandidateCount = 0 private var isFixingAdditionalCandidateTop = false @@ -63,13 +58,13 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } - public func makeCandidatePresentationContexts(_ candidates: [Candidate]) -> [CandidatePresentationContext] { - let additionalContexts = self.additionalCandidateContextsForSelectionIndex + public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { + let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex return candidates.indices.map { index in - if index < additionalContexts.count { - return additionalContexts[index] + if index < additionalPresentations.count { + return .init(candidate: candidates[index], displayContext: additionalPresentations[index].displayContext) } - return .init() + return .init(candidate: candidates[index]) } } @@ -342,23 +337,17 @@ public final class SegmentsManager { } private var additionalCandidatesForSelectionIndex: [Candidate] { - guard self.isShowingAdditionalCandidates else { - return [] - } - guard self.candidateOffsetByAdditionalCandidates > 0 else { - return [] - } - return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)).map(\.candidate) + self.additionalCandidatePresentationsForSelectionIndex.map(\.candidate) } - private var additionalCandidateContextsForSelectionIndex: [CandidatePresentationContext] { + private var additionalCandidatePresentationsForSelectionIndex: [CandidatePresentation] { guard self.isShowingAdditionalCandidates else { return [] } guard self.candidateOffsetByAdditionalCandidates > 0 else { return [] } - return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)).map(\.presentationContext) + return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)) } public var convertTarget: String { @@ -656,7 +645,7 @@ public final class SegmentsManager { } @MainActor - private func createAdditionalCandidates() -> [AdditionalCandidatePresentation] { + private func createAdditionalCandidates() -> [CandidatePresentation] { let candidates: [(candidate: Candidate, annotationText: String?)] = [ (self.getModifiedRomanCandidate { $0 }, "英数"), (self.getModifiedRomanCandidate { $0.applyingTransform(.fullwidthToHalfwidth, reverse: true) ?? $0 }, "全角英数"), @@ -667,7 +656,7 @@ public final class SegmentsManager { return candidates.map { .init( candidate: $0.candidate, - presentationContext: .init(annotationText: $0.annotationText) + displayContext: .init(annotationText: $0.annotationText) ) } } diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift index 41a9a83d..662b941f 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -22,7 +22,7 @@ private func makeSegmentsManager() -> SegmentsManager { switch manager.getCurrentCandidateWindow(inputState: .selecting) { case .selecting(let candidates, let selectionIndex): #expect(selectionIndex == 0) - let firstContexts = manager.makeCandidatePresentationContexts(candidates) + let firstContexts = manager.makeCandidatePresentations(candidates).map(\.displayContext) #expect(firstContexts.count == candidates.count) #expect(firstContexts.first?.annotationText == "ひらがな") case .hidden, .composing: @@ -35,7 +35,7 @@ private func makeSegmentsManager() -> SegmentsManager { switch manager.getCurrentCandidateWindow(inputState: .selecting) { case .selecting(let candidates, let selectionIndex): #expect(selectionIndex == 0) - let contexts = manager.makeCandidatePresentationContexts(candidates) + let contexts = manager.makeCandidatePresentations(candidates).map(\.displayContext) if contexts.count >= 2 { #expect(contexts[0].annotationText == "カタカナ") #expect(contexts[1].annotationText == "ひらがな") @@ -59,7 +59,8 @@ private func makeSegmentsManager() -> SegmentsManager { switch manager.getCurrentCandidateWindow(inputState: .selecting) { case .selecting(let candidates, _): - let annotationTexts = manager.makeCandidatePresentationContexts(candidates) + let annotationTexts = manager.makeCandidatePresentations(candidates) + .map(\.displayContext) .compactMap(\.annotationText) #expect(annotationTexts == ["英数", "全角英数", "半角カナ", "カタカナ", "ひらがな"]) case .hidden, .composing: @@ -76,12 +77,12 @@ private func makeSegmentsManager() -> SegmentsManager { switch manager.getCurrentCandidateWindow(inputState: .selecting) { case .selecting(let candidates, _): - let beforeReset = manager.makeCandidatePresentationContexts(candidates) + let beforeReset = manager.makeCandidatePresentations(candidates).map(\.displayContext) #expect(beforeReset.contains { $0.annotationText != nil }) manager.requestResettingSelection() - let afterReset = manager.makeCandidatePresentationContexts(candidates) + let afterReset = manager.makeCandidatePresentations(candidates).map(\.displayContext) #expect(afterReset.allSatisfy { $0.annotationText == nil }) case .hidden, .composing: Issue.record("Expected selecting state after expanding additional candidates.") diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index 4487b1b6..598dc537 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -2,11 +2,6 @@ import Cocoa import Core import KanaKanjiConverterModule -struct CandidatePresentation { - let candidate: Candidate - var displayContext: CandidatePresentationContext -} - class NonClickableTableView: NSTableView { override func rightMouseDown(with event: NSEvent) {} override func mouseDown(with event: NSEvent) {} @@ -184,24 +179,14 @@ class BaseCandidateViewController: NSViewController { window.isOpaque = false } - func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidatePresentationContext]? = nil) { - if let candidateDisplayContexts, candidateDisplayContexts.count == candidates.count { - self.candidates = zip(candidates, candidateDisplayContexts).map { candidate, context in - CandidatePresentation(candidate: candidate, displayContext: context) - } - } else { - self.candidates = candidates.map(self.makeCandidatePresentation) - } + func updateCandidatePresentations(_ candidates: [CandidatePresentation], selectionIndex: Int?, cursorLocation: CGPoint) { + self.candidates = candidates self.currentSelectedRow = selectionIndex ?? -1 self.tableView.reloadData() self.resizeWindowToFitContent(cursorLocation: cursorLocation) self.updateSelection(to: selectionIndex ?? -1) } - internal func makeCandidatePresentation(_ candidate: Candidate) -> CandidatePresentation { - .init(candidate: candidate, displayContext: .init()) - } - internal func updateSelection(to row: Int) { if row == -1 { return diff --git a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift index 7efc467c..68b3d8a7 100644 --- a/azooKeyMac/InputController/CandidateWindow/CandidateView.swift +++ b/azooKeyMac/InputController/CandidateWindow/CandidateView.swift @@ -12,13 +12,12 @@ class CandidatesViewController: BaseCandidateViewController { private var showedRows: ClosedRange = 0...8 var showCandidateIndex = false - override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint, candidateDisplayContexts: [CandidatePresentationContext]? = nil) { + override func updateCandidatePresentations(_ candidates: [CandidatePresentation], selectionIndex: Int?, cursorLocation: CGPoint) { self.showedRows = selectionIndex == nil ? 0...8 : self.showedRows - super.updateCandidates( + super.updateCandidatePresentations( candidates, selectionIndex: selectionIndex, - cursorLocation: cursorLocation, - candidateDisplayContexts: candidateDisplayContexts + cursorLocation: cursorLocation ) } diff --git a/azooKeyMac/InputController/azooKeyMacInputController.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index c13f203c..81fa6ada 100644 --- a/azooKeyMac/InputController/azooKeyMacInputController.swift +++ b/azooKeyMac/InputController/azooKeyMacInputController.swift @@ -130,9 +130,9 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s client.overrideKeyboard(withKeyboardNamed: Config.KeyboardLayout().value.layoutIdentifier) var rect: NSRect = .zero client.attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.candidatesViewController.updateCandidates([], selectionIndex: nil, cursorLocation: rect.origin) + self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: rect.origin) } else { - self.candidatesViewController.updateCandidates([], selectionIndex: nil, cursorLocation: .zero) + self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) } self.refreshCandidateWindow() self.refreshPredictionWindow() @@ -144,7 +144,7 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s self.candidatesWindow.orderOut(nil) self.predictionWindow.orderOut(nil) self.replaceSuggestionWindow.orderOut(nil) - self.candidatesViewController.updateCandidates([], selectionIndex: nil, cursorLocation: .zero) + self.candidatesViewController.updateCandidatePresentations([], selectionIndex: nil, cursorLocation: .zero) super.deactivateServer(sender) } @@ -511,24 +511,22 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = true - let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates) - self.candidatesViewController.updateCandidates( - candidates, + let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) + self.candidatesViewController.updateCandidatePresentations( + candidatePresentations, selectionIndex: selectionIndex, - cursorLocation: rect.origin, - candidateDisplayContexts: presentationContexts + cursorLocation: rect.origin ) self.candidatesWindow.orderFront(nil) case .composing(let candidates, let selectionIndex): var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) self.candidatesViewController.showCandidateIndex = false - let presentationContexts = self.segmentsManager.makeCandidatePresentationContexts(candidates) - self.candidatesViewController.updateCandidates( - candidates, + let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) + self.candidatesViewController.updateCandidatePresentations( + candidatePresentations, selectionIndex: selectionIndex, - cursorLocation: rect.origin, - candidateDisplayContexts: presentationContexts + cursorLocation: rect.origin ) self.candidatesWindow.orderFront(nil) case .hidden: @@ -573,7 +571,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.predictionViewController.updateCandidates(candidates, selectionIndex: nil, cursorLocation: rect.origin) + self.predictionViewController.updateCandidatePresentations( + candidates.map { .init(candidate: $0) }, + selectionIndex: nil, + cursorLocation: rect.origin + ) if Config.LiveConversion().value { self.predictionWindow.orderFront(nil) @@ -615,7 +617,11 @@ class azooKeyMacInputController: IMKInputController, NSMenuItemValidation { // s } var rect: NSRect = .zero self.client().attributes(forCharacterIndex: 0, lineHeightRectangle: &rect) - self.predictionViewController.updateCandidates(candidates, selectionIndex: nil, cursorLocation: rect.origin) + self.predictionViewController.updateCandidatePresentations( + candidates.map { .init(candidate: $0) }, + selectionIndex: nil, + cursorLocation: rect.origin + ) self.predictionWindow.orderFront(nil) } @@ -866,7 +872,11 @@ extension azooKeyMacInputController { self.segmentsManager.appendDebugMessage("候補ウィンドウ更新中...") if !candidates.isEmpty { self.segmentsManager.setReplaceSuggestions(candidates) - self.replaceSuggestionsViewController.updateCandidates(candidates, selectionIndex: nil, cursorLocation: getCursorLocation()) + self.replaceSuggestionsViewController.updateCandidatePresentations( + candidates.map { .init(candidate: $0) }, + selectionIndex: nil, + cursorLocation: getCursorLocation() + ) self.replaceSuggestionWindow.setIsVisible(true) self.replaceSuggestionWindow.makeKeyAndOrderFront(nil) self.segmentsManager.appendDebugMessage("候補ウィンドウ更新完了") From 6909569c3481258204b53a4ea178047e5aa3fefa Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 12:13:21 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat:=20edit=20segment=E6=99=82=E3=81=AE?= =?UTF-8?q?=E5=8B=95=E4=BD=9C=E3=82=92=E8=AA=BF=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Extensions/InputPiece+String.swift | 16 ++++++++++ Core/Sources/Core/InputUtils/InputState.swift | 18 +++-------- .../Core/InputUtils/SegmentsManager.swift | 30 +++++++++--------- ...entsManagerAdditionalCandidatesTests.swift | 31 +++++++++++++++++++ .../UserActionOptionPunctuationTests.swift | 12 +------ 5 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 Core/Sources/Core/Extensions/InputPiece+String.swift diff --git a/Core/Sources/Core/Extensions/InputPiece+String.swift b/Core/Sources/Core/Extensions/InputPiece+String.swift new file mode 100644 index 00000000..e01c7f63 --- /dev/null +++ b/Core/Sources/Core/Extensions/InputPiece+String.swift @@ -0,0 +1,16 @@ +import KanaKanjiConverterModule + +public extension Sequence where Element == InputPiece { + func inputString(preferIntention: Bool = true) -> String { + String(self.compactMap { + switch $0 { + case .character(let c): + c + case .key(intention: let intention, input: let input, modifiers: _): + preferIntention ? (intention ?? input) : input + case .compositionSeparator: + nil + } + }) + } +} diff --git a/Core/Sources/Core/InputUtils/InputState.swift b/Core/Sources/Core/InputUtils/InputState.swift index 936ca185..76638638 100644 --- a/Core/Sources/Core/InputUtils/InputState.swift +++ b/Core/Sources/Core/InputUtils/InputState.swift @@ -55,7 +55,7 @@ public enum InputState: Sendable, Hashable { return (.appendPieceToMarkedText(string), .transition(.composing)) case .english: // 連結する - return (.insertWithoutMarkedText(inputPiecesToString(string)), .fallthrough) + return (.insertWithoutMarkedText(string.inputString(preferIntention: true)), .fallthrough) } case .deadKey(let diacritic): if inputLanguage == .english { @@ -94,7 +94,7 @@ public enum InputState: Sendable, Hashable { case .attachDiacritic(let diacritic): switch userAction { case .input(let string): - let string = self.inputPiecesToString(string) + let string = string.inputString(preferIntention: true) if let result = DiacriticAttacher.attach(deadKeyChar: diacritic, with: string, shift: event.modifierFlags.contains(.shift)) { return (.insertWithoutMarkedText(result), .transition(.none)) } else { @@ -252,7 +252,7 @@ public enum InputState: Sendable, Hashable { case .selecting: switch userAction { case .input(let string): - let s = self.inputPiecesToString(string) + let s = string.inputString(preferIntention: true) if s == "d" && enableDebugWindow { return (.enableDebugWindow, .fallthrough) } else if s == "D" && enableDebugWindow { @@ -369,7 +369,7 @@ public enum InputState: Sendable, Hashable { case .unicodeInput(let codePoint): switch userAction { case .input(let pieces): - let input = inputPiecesToString(pieces).lowercased() + let input = pieces.inputString(preferIntention: true).lowercased() // 16進数のみ受け付ける let hexChars = CharacterSet(charactersIn: "0123456789abcdef") let filteredInput = input.unicodeScalars.filter { hexChars.contains($0) }.map { String($0) }.joined() @@ -401,14 +401,4 @@ public enum InputState: Sendable, Hashable { } } } - - private func inputPiecesToString(_ inputPieces: [InputPiece]) -> String { - String(inputPieces.compactMap { - switch $0 { - case .character(let c): c - case .key(intention: let cint, input: let cinp, modifiers: _): cint ?? cinp - case .compositionSeparator: nil - } - }) - } } diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 250cdc8d..39f1dbc8 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -619,19 +619,21 @@ public final class SegmentsManager { } @MainActor - public func getModifiedRomanCandidate(_ transform: (String) -> String) -> Candidate { - let inputString = String(self.composingText.input.compactMap { - switch $0.piece { - case .compositionSeparator: nil - case .character(let c): c - case .key(intention: _, input: let input, modifiers: _): input - } - }) + public func getModifiedRomanCandidate(inputState: InputState = .composing, _ transform: (String) -> String) -> Candidate { + let targetComposingText: ComposingText + switch inputState { + case .selecting: + targetComposingText = self.composingText.prefixToCursorPosition() + case .composing, .previewing, .none, .replaceSuggestion, .attachDiacritic, .unicodeInput: + targetComposingText = self.composingText + } + let inputString = targetComposingText.input.map(\.piece).inputString(preferIntention: false) + let composingCount: ComposingCount = .inputCount(targetComposingText.input.count) let candidateText = transform(inputString) let candidate = Candidate( text: candidateText, value: 0, - composingCount: .inputCount(composingText.input.count), + composingCount: composingCount, lastMid: 0, data: [DicdataElement( word: candidateText, @@ -647,11 +649,11 @@ public final class SegmentsManager { @MainActor private func createAdditionalCandidates() -> [CandidatePresentation] { let candidates: [(candidate: Candidate, annotationText: String?)] = [ - (self.getModifiedRomanCandidate { $0 }, "英数"), - (self.getModifiedRomanCandidate { $0.applyingTransform(.fullwidthToHalfwidth, reverse: true) ?? $0 }, "全角英数"), - (self.getModifiedRubyCandidate(inputState: .composing) { $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false) ?? $0 }, "半角カナ"), - (self.getModifiedRubyCandidate(inputState: .composing) { $0.toKatakana() }, "カタカナ"), - (self.getModifiedRubyCandidate(inputState: .composing) { $0.toHiragana() }, "ひらがな") + (self.getModifiedRomanCandidate(inputState: .selecting) { $0 }, "英数"), + (self.getModifiedRomanCandidate(inputState: .selecting) { $0.applyingTransform(.fullwidthToHalfwidth, reverse: true) ?? $0 }, "全角英数"), + (self.getModifiedRubyCandidate(inputState: .selecting) { $0.toKatakana().applyingTransform(.fullwidthToHalfwidth, reverse: false) ?? $0 }, "半角カナ"), + (self.getModifiedRubyCandidate(inputState: .selecting) { $0.toKatakana() }, "カタカナ"), + (self.getModifiedRubyCandidate(inputState: .selecting) { $0.toHiragana() }, "ひらがな") ] return candidates.map { .init( diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift index 662b941f..6630ece8 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -11,6 +11,37 @@ private func makeSegmentsManager() -> SegmentsManager { ) } +@MainActor +private func makeEditedRangeScenario() -> (manager: SegmentsManager, selectedRuby: String) { + let manager = makeSegmentsManager() + manager.insertAtCursorPosition("hennkann", inputStyle: .roman2kana) + manager.editSegment(count: -1) + manager.requestSetCandidateWindowState(visible: true) + _ = manager.getCurrentCandidateWindow(inputState: .selecting) + + let selectedRuby = manager.selectedCandidate!.data.map(\.ruby).joined() + return (manager, selectedRuby) +} + +@MainActor +@Test func testAdditionalHiraganaCandidateUsesEditedSelectionRuby() async throws { + let (manager, selectedRuby) = makeEditedRangeScenario() + manager.requestSelectingPrevCandidate() + + switch manager.getCurrentCandidateWindow(inputState: .selecting) { + case .selecting(let candidates, _): + let presentations = manager.makeCandidatePresentations(candidates) + guard let hiraganaPresentation = presentations.first(where: { $0.displayContext.annotationText == "ひらがな" }) else { + Issue.record("Expected additional hiragana candidate.") + return + } + let hiraganaRuby = hiraganaPresentation.candidate.data.map(\.ruby).joined() + #expect(hiraganaRuby == selectedRuby) + case .hidden, .composing: + Issue.record("Expected selecting state while showing additional candidates.") + } +} + @MainActor @Test func testAdditionalCandidatesExpandInSuffixOrder() async throws { let manager = makeSegmentsManager() diff --git a/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift b/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift index 0b98353e..f9e0dd57 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/UserActionOptionPunctuationTests.swift @@ -3,21 +3,11 @@ import Foundation import KanaKanjiConverterModule import Testing -private func inputPiecesToString(_ inputPieces: [InputPiece]) -> String { - String(inputPieces.compactMap { - switch $0 { - case .character(let c): c - case .key(intention: let cint, input: let cinp, modifiers: _): cint ?? cinp - case .compositionSeparator: nil - } - }) -} - private func inputString(from action: UserAction) -> String? { guard case .input(let pieces) = action else { return nil } - return inputPiecesToString(pieces) + return pieces.inputString(preferIntention: true) } private func makeEvent( From b54fcbc51d33f2e439347cf76497c05f4df561b2 Mon Sep 17 00:00:00 2001 From: Miwa / Ensan Date: Sun, 15 Feb 2026 12:24:52 +0900 Subject: [PATCH 6/6] fix: avoid zenzai in tests --- .../Core/InputUtils/SegmentsManager.swift | 20 +++++++++++++++++-- ...entsManagerAdditionalCandidatesTests.swift | 5 +++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 39f1dbc8..ecf1d80c 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -5,17 +5,30 @@ public final class SegmentsManager { public init( kanaKanjiConverter: KanaKanjiConverter, applicationDirectoryURL: URL, - containerURL: URL? + containerURL: URL?, + context: Context = Context() ) { self.kanaKanjiConverter = kanaKanjiConverter self.applicationDirectoryURL = applicationDirectoryURL self.containerURL = containerURL + self.context = context + } + + /// テストなどの設定注入のための型。外部には設定を露出させない。 + public struct Context { + public init() {} + init(useZenzai: Bool) { + self.useZenzai = useZenzai + } + + var useZenzai: Bool = true } public weak var delegate: (any SegmentManagerDelegate)? private var kanaKanjiConverter: KanaKanjiConverter private let applicationDirectoryURL: URL private let containerURL: URL? + private let context: Context private var composingText: ComposingText = ComposingText() @@ -121,7 +134,10 @@ public final class SegmentsManager { } private func zenzaiMode(leftSideContext: String?, requestRichCandidates: Bool) -> ConvertRequestOptions.ZenzaiMode { - .on( + if !self.context.useZenzai { + return .off + } + return .on( weight: Bundle.main.bundleURL.appendingPathComponent("Contents/Resources/ggml-model-Q5_K_M.gguf", isDirectory: false), inferenceLimit: Config.ZenzaiInferenceLimit().value, requestRichCandidates: requestRichCandidates, diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift index 6630ece8..91036d31 100644 --- a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -1,4 +1,4 @@ -import Core +@testable import Core import Foundation import KanaKanjiConverterModuleWithDefaultDictionary import Testing @@ -7,7 +7,8 @@ private func makeSegmentsManager() -> SegmentsManager { SegmentsManager( kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), - containerURL: nil + containerURL: nil, + context: .init(useZenzai: false) ) }