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/CandidatePresentationContext.swift b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift new file mode 100644 index 00000000..57e214ec --- /dev/null +++ b/Core/Sources/Core/InputUtils/CandidatePresentationContext.swift @@ -0,0 +1,21 @@ +import KanaKanjiConverterModuleWithDefaultDictionary + +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 + } +} + +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/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 69f5d5eb..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() @@ -38,6 +51,11 @@ public final class SegmentsManager { private var lastOperation: Operation = .other private var shouldShowCandidateWindow = false + private var isShowingAdditionalCandidates = false + private var additionalCandidates: [CandidatePresentation] = [] + private var showingAdditionalCandidateCount = 0 + private var isFixingAdditionalCandidateTop = false + private var shouldShowDebugCandidateWindow: Bool = false private var debugCandidates: [Candidate] = [] @@ -53,6 +71,16 @@ public final class SegmentsManager { candidate.data.map(\.ruby).joined() } + public func makeCandidatePresentations(_ candidates: [Candidate]) -> [CandidatePresentation] { + let additionalPresentations = self.additionalCandidatePresentationsForSelectionIndex + return candidates.indices.map { index in + if index < additionalPresentations.count { + return .init(candidate: candidates[index], displayContext: additionalPresentations[index].displayContext) + } + return .init(candidate: candidates[index]) + } + } + private lazy var zenzaiPersonalizationMode: ConvertRequestOptions.ZenzaiMode.PersonalizationMode? = self.getZenzaiPersonalizationMode() private func getZenzaiPersonalizationMode() -> ConvertRequestOptions.ZenzaiMode.PersonalizationMode? { @@ -106,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, @@ -172,6 +203,7 @@ public final class SegmentsManager { self.composingText.stopComposition() self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } @MainActor @@ -184,6 +216,7 @@ public final class SegmentsManager { self.lastOperation = .other self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } @MainActor @@ -195,6 +228,7 @@ public final class SegmentsManager { self.kanaKanjiConverter.commitUpdateLearningData() self.shouldShowCandidateWindow = false self.selectionIndex = nil + self.resetAdditionalCandidates() } /// 変換キーを押したタイミングで入力の区切りを示す @@ -284,24 +318,52 @@ 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] { + self.additionalCandidatePresentationsForSelectionIndex.map(\.candidate) + } + + private var additionalCandidatePresentationsForSelectionIndex: [CandidatePresentation] { + guard self.isShowingAdditionalCandidates else { + return [] } + guard self.candidateOffsetByAdditionalCandidates > 0 else { + return [] + } + return Array(self.additionalCandidates.suffix(self.candidateOffsetByAdditionalCandidates)) } public var convertTarget: String { @@ -336,6 +398,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 +493,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 +546,8 @@ public final class SegmentsManager { public func requestResettingSelection() { self.selectionIndex = nil + self.isFixingAdditionalCandidateTop = false + self.resetAdditionalCandidates() } public var selectedCandidate: Candidate? { @@ -539,19 +635,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, @@ -564,6 +662,49 @@ public final class SegmentsManager { return candidate } + @MainActor + private func createAdditionalCandidates() -> [CandidatePresentation] { + let candidates: [(candidate: Candidate, annotationText: String?)] = [ + (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( + candidate: $0.candidate, + displayContext: .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/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift new file mode 100644 index 00000000..91036d31 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerAdditionalCandidatesTests.swift @@ -0,0 +1,122 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +private func makeSegmentsManager() -> SegmentsManager { + SegmentsManager( + kanaKanjiConverter: .withDefaultDictionary(), + applicationDirectoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true), + containerURL: nil, + context: .init(useZenzai: false) + ) +} + +@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() + 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.makeCandidatePresentations(candidates).map(\.displayContext) + #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.makeCandidatePresentations(candidates).map(\.displayContext) + 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.makeCandidatePresentations(candidates) + .map(\.displayContext) + .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.makeCandidatePresentations(candidates).map(\.displayContext) + #expect(beforeReset.contains { $0.annotationText != nil }) + + manager.requestResettingSelection() + + 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/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( diff --git a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift index a0e37ad3..598dc537 100644 --- a/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift +++ b/azooKeyMac/InputController/CandidateWindow/BaseCandidateViewController.swift @@ -10,25 +10,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,12 +77,21 @@ 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] = [] + internal var candidates: [CandidatePresentation] = [] internal var tableView: NSTableView! internal var currentSelectedRow: Int = -1 @@ -131,7 +179,7 @@ class BaseCandidateViewController: NSViewController { window.isOpaque = false } - func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint) { + func updateCandidatePresentations(_ candidates: [CandidatePresentation], selectionIndex: Int?, cursorLocation: CGPoint) { self.candidates = candidates self.currentSelectedRow = selectionIndex ?? -1 self.tableView.reloadData() @@ -191,7 +239,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 +256,7 @@ class BaseCandidateViewController: NSViewController { guard currentSelectedRow >= 0 && currentSelectedRow < candidates.count else { return nil } - return candidates[currentSelectedRow] + return candidates[currentSelectedRow].candidate } func selectNextCandidate() { @@ -228,7 +276,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..68b3d8a7 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,9 +12,13 @@ class CandidatesViewController: BaseCandidateViewController { private var showedRows: ClosedRange = 0...8 var showCandidateIndex = false - override func updateCandidates(_ candidates: [Candidate], selectionIndex: Int?, cursorLocation: CGPoint) { + override func updateCandidatePresentations(_ candidates: [CandidatePresentation], selectionIndex: Int?, cursorLocation: CGPoint) { self.showedRows = selectionIndex == nil ? 0...8 : self.showedRows - super.updateCandidates(candidates, selectionIndex: selectionIndex, cursorLocation: cursorLocation) + super.updateCandidatePresentations( + candidates, + selectionIndex: selectionIndex, + cursorLocation: cursorLocation + ) } 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.swift b/azooKeyMac/InputController/azooKeyMacInputController.swift index 415618d1..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,13 +511,23 @@ 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 candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) + self.candidatesViewController.updateCandidatePresentations( + candidatePresentations, + selectionIndex: selectionIndex, + 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 - self.candidatesViewController.updateCandidates(candidates, selectionIndex: selectionIndex, cursorLocation: rect.origin) + let candidatePresentations = self.segmentsManager.makeCandidatePresentations(candidates) + self.candidatesViewController.updateCandidatePresentations( + candidatePresentations, + selectionIndex: selectionIndex, + cursorLocation: rect.origin + ) self.candidatesWindow.orderFront(nil) case .hidden: self.candidatesWindow.setIsVisible(false) @@ -561,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) @@ -603,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) } @@ -854,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("候補ウィンドウ更新完了")