From 2bfad15422011fb85b4900ef418b91664a882832 Mon Sep 17 00:00:00 2001 From: ShikiSuen Date: Sat, 18 Feb 2023 16:49:50 +0800 Subject: [PATCH] TDKCandidates // Massive renovation + Cocoa legacy mode. --- .../CandidateWindow/CandidateCellData.swift | 221 ------- .../CandidateCellData_Core.swift | 191 ++++++ .../CandidateCellData_SwiftUIImpl.swift | 85 +++ .../CandidateWindow/CandidatePool.swift | 555 +++++++++--------- .../CandidatePool_CocoaImpl.swift | 171 ++++++ .../TDKCandidates/CtlCandidateTDK.swift | 262 +++------ .../TDKCandidates/VwrCandidateTDK.swift | 508 ++++++++++++++++ .../VwrCandidateHorizontal.swift | 161 ----- .../VwrCandidateVertical.swift | 171 ------ .../VwrCandidateHorizontalBackports.swift | 166 ------ .../VwrCandidateVerticalBackports.swift | 176 ------ .../CandidatePoolTests.swift | 4 +- Source/Modules/PrefMgr_Extension.swift | 1 - Source/Modules/SessionCtl_HandleDisplay.swift | 6 +- 14 files changed, 1341 insertions(+), 1337 deletions(-) delete mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData.swift create mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift create mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_SwiftUIImpl.swift create mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift create mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK.swift delete mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateHorizontal.swift delete mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateVertical.swift delete mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateHorizontalBackports.swift delete mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateVerticalBackports.swift diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData.swift deleted file mode 100644 index 9f68b8dc..00000000 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData.swift +++ /dev/null @@ -1,221 +0,0 @@ -// (c) 2022 and onwards The vChewing Project (MIT-NTL License). -// ==================== -// This code is released under the MIT license (SPDX-License-Identifier: MIT) -// ... with NTL restriction stating that: -// No trademark license is granted to use the trade names, trademarks, service -// marks, or product names of Contributor, except as required to fulfill notice -// requirements defined in MIT License. - -import Cocoa -import Shared -import SwiftUI -import SwiftUIBackports - -// MARK: - Classes used by Candidate Window - -/// 用來管理選字窗內顯示的候選字的單位。用 class 型別會比較方便一些。 -public class CandidateCellData: Hashable { - public var locale = "" - public static var unifiedSize: Double = 16 - public static var highlightBackground: NSColor = { - if #available(macOS 10.14, *) { - return .selectedContentBackgroundColor - } - return NSColor.alternateSelectedControlColor - }() - - public var key: String - public var displayedText: String - public var size: Double { Self.unifiedSize } - public var isSelected: Bool = false - public var whichRow: Int = 0 // 橫排選字窗專用 - public var whichColumn: Int = 0 // 縱排選字窗專用 - public var index: Int = 0 - public var subIndex: Int = 0 - - public var fontSizeCandidate: Double { CandidateCellData.unifiedSize } - public var fontSizeKey: Double { max(ceil(CandidateCellData.unifiedSize * 0.6), 11) } - public var fontColorKey: NSColor { - isSelected ? .selectedMenuItemTextColor.withAlphaComponent(0.8) : .secondaryLabelColor - } - - public var fontColorCandidate: NSColor { isSelected ? .selectedMenuItemTextColor : .labelColor } - - public init(key: String, displayedText: String, isSelected: Bool = false) { - self.key = key - self.displayedText = displayedText - self.isSelected = isSelected - } - - public static func == (lhs: CandidateCellData, rhs: CandidateCellData) -> Bool { - lhs.key == rhs.key && lhs.displayedText == rhs.displayedText - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(key) - hasher.combine(displayedText) - } - - public var cellLength: Int { - if displayedText.count <= 2 { return Int(ceil(size * 3)) } - return Int(ceil(attributedStringForLengthCalculation.boundingDimension.width)) - } - - public var attributedStringHeader: NSAttributedString { - let paraStyleKey = NSMutableParagraphStyle() - paraStyleKey.setParagraphStyle(NSParagraphStyle.default) - paraStyleKey.alignment = .natural - let paraStyle = NSMutableParagraphStyle() - paraStyle.setParagraphStyle(NSParagraphStyle.default) - paraStyle.alignment = .natural - let theFontForCandidateKey: NSFont = { - if #available(macOS 10.15, *) { - return NSFont.monospacedSystemFont(ofSize: fontSizeKey, weight: .regular) - } - return NSFont.monospacedDigitSystemFont(ofSize: fontSizeKey, weight: .regular) - }() - var attrKey: [NSAttributedString.Key: AnyObject] = [ - .font: theFontForCandidateKey, - .paragraphStyle: paraStyleKey, - ] - if isSelected { - attrKey[.foregroundColor] = NSColor.white.withAlphaComponent(0.8) - } else { - attrKey[.foregroundColor] = NSColor.secondaryLabelColor - } - let attrStrKey = NSMutableAttributedString(string: key, attributes: attrKey) - return attrStrKey - } - - public var attributedStringForLengthCalculation: NSAttributedString { - let paraStyleKey = NSMutableParagraphStyle() - paraStyleKey.setParagraphStyle(NSParagraphStyle.default) - paraStyleKey.alignment = .natural - let paraStyle = NSMutableParagraphStyle() - paraStyle.setParagraphStyle(NSParagraphStyle.default) - paraStyle.alignment = .natural - paraStyle.lineBreakMode = .byWordWrapping - let attrCandidate: [NSAttributedString.Key: AnyObject] = [ - .font: NSFont.monospacedDigitSystemFont(ofSize: size, weight: .regular), - .paragraphStyle: paraStyle, - ] - let attrStrCandidate = NSMutableAttributedString(string: displayedText + " ", attributes: attrCandidate) - return attrStrCandidate - } - - public var attributedString: NSAttributedString { - let paraStyleKey = NSMutableParagraphStyle() - paraStyleKey.setParagraphStyle(NSParagraphStyle.default) - paraStyleKey.alignment = .natural - let paraStyle = NSMutableParagraphStyle() - paraStyle.setParagraphStyle(NSParagraphStyle.default) - paraStyle.alignment = .natural - paraStyle.lineBreakMode = .byWordWrapping - var attrCandidate: [NSAttributedString.Key: AnyObject] = [ - .font: NSFont.monospacedDigitSystemFont(ofSize: size, weight: .regular), - .paragraphStyle: paraStyle, - ] - if isSelected { - attrCandidate[.foregroundColor] = NSColor.white - } else { - attrCandidate[.foregroundColor] = NSColor.labelColor - } - if #available(macOS 12, *) { - if UserDefaults.standard.bool(forKey: UserDef.kHandleDefaultCandidateFontsByLangIdentifier.rawValue) { - attrCandidate[.languageIdentifier] = self.locale as AnyObject - } - } - let attrStrCandidate = NSMutableAttributedString(string: displayedText, attributes: attrCandidate) - return attrStrCandidate - } - - public var charDescriptions: String { - var result = displayedText - if displayedText.contains("("), displayedText.count > 2 { - result = displayedText.replacingOccurrences(of: "(", with: "").replacingOccurrences(of: ")", with: "") - } - return result.charDescriptions.joined(separator: "\n") - } - - public var minWidthToDrawInSwiftUI: Double { - Double(cellLength) + ((displayedText.count > 2) ? 0 : fontSizeKey + 0) + ceil(fontSizeCandidate * 0.4) - } -} - -// MARK: - Contents specifically made for macOS 12 and newer. - -@available(macOS 12, *) -public extension CandidateCellData { - var attributedStringForSwiftUI: some View { - var result: some View { - ZStack(alignment: .leading) { - if isSelected { - Color(nsColor: CandidateCellData.highlightBackground).ignoresSafeArea().cornerRadius(6) - } - VStack(spacing: 0) { - HStack(spacing: 4) { - if UserDefaults.standard.bool(forKey: UserDef.kHandleDefaultCandidateFontsByLangIdentifier.rawValue) { - Text(AttributedString(attributedStringHeader)) - Text(AttributedString(attributedString)) - } else { - Text(verbatim: key).font(.system(size: fontSizeKey).monospaced()) - .foregroundColor(.init(nsColor: fontColorKey)).lineLimit(1) - Text(verbatim: displayedText) - .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) - .foregroundColor(.init(nsColor: fontColorCandidate)).lineLimit(1) - } - }.padding(3) - }.frame(minWidth: minWidthToDrawInSwiftUI, alignment: .leading) - }.fixedSize(horizontal: false, vertical: true) - } - return result - } -} - -// MARK: - Contents specifically made for macOS 10.15 and macOS 11. - -@available(macOS 10.15, *) -public extension CandidateCellData { - var themeColorBackports: some View { - // 設定當前高亮候選字的背景顏色。 - let result: Color = { - switch locale { - case "zh-Hans": return Color.red - case "zh-Hant": return Color.blue - case "ja": return Color.pink - default: return Color.accentColor - } - }() - return result.opacity(0.85) - } - - var attributedStringForSwiftUIBackports: some View { - var result: some View { - ZStack(alignment: .leading) { - if isSelected { - themeColorBackports.cornerRadius(6) - VStack(spacing: 0) { - HStack(spacing: 4) { - Text(verbatim: key).font(.custom("Menlo", size: fontSizeKey)) - .foregroundColor(Color.white.opacity(0.8)).lineLimit(1) - Text(verbatim: displayedText) - .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) - .foregroundColor(Color(white: 1)).lineLimit(1) - }.padding(3).foregroundColor(Color(white: 0.9)) - }.frame(minWidth: minWidthToDrawInSwiftUI, alignment: .leading) - } else { - VStack(spacing: 0) { - HStack(spacing: 4) { - Text(verbatim: key).font(.custom("Menlo", size: fontSizeKey)) - .foregroundColor(Color.secondary).lineLimit(1) - Text(verbatim: displayedText) - .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) - .foregroundColor(Color.primary).lineLimit(1) - }.padding(3).foregroundColor(Color(white: 0.9)) - }.frame(minWidth: minWidthToDrawInSwiftUI, alignment: .leading) - } - }.fixedSize(horizontal: false, vertical: true) - } - return result - } -} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift new file mode 100644 index 00000000..77fc823a --- /dev/null +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift @@ -0,0 +1,191 @@ +// (c) 2022 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import Cocoa +import Shared +import SwiftUI +import SwiftUIBackports + +// MARK: - Candidate Cell + +/// 用來管理選字窗內顯示的候選字的單位。用 class 型別會比較方便一些。 +public class CandidateCellData: Hashable { + public var locale = "" + public static var unifiedSize: Double = 16 + public var key: String + public var displayedText: String + public var size: Double { Self.unifiedSize } + public var isHighlighted: Bool = false + public var whichLine: Int = 0 + // 該候選字詞在資料池內的總索引編號 + public var index: Int = 0 + // 該候選字詞在當前行/列內的索引編號 + public var subIndex: Int = 0 + + public var charGlyphWidth: Double { ceil(size * 1.0125 + 7) } + public var fontSizeCandidate: Double { size } + public var fontSizeKey: Double { max(ceil(fontSizeCandidate * 0.6), 11) } + public var fontColorKey: NSColor { + isHighlighted ? .selectedMenuItemTextColor.withAlphaComponent(0.8) : .secondaryLabelColor + } + + public var fontColorCandidate: NSColor { isHighlighted ? .selectedMenuItemTextColor : .labelColor } + + public init(key: String, displayedText: String, isSelected: Bool = false) { + self.key = key + self.displayedText = displayedText + isHighlighted = isSelected + } + + public static func == (lhs: CandidateCellData, rhs: CandidateCellData) -> Bool { + lhs.key == rhs.key && lhs.displayedText == rhs.displayedText + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(key) + hasher.combine(displayedText) + } + + public func cellLength(isMatrix: Bool = true) -> Double { + let minLength = ceil(charGlyphWidth * 2 + size) + if displayedText.count <= 2, isMatrix { return minLength } + return ceil(attributedStringForLengthCalculation.boundingDimension.width) + } + + public static let sharedParagraphStyle: NSParagraphStyle = { + let paraStyle = NSMutableParagraphStyle() + paraStyle.setParagraphStyle(NSParagraphStyle.default) + paraStyle.alignment = .natural + paraStyle.lineBreakMode = .byWordWrapping + return paraStyle + }() + + var phraseFont: NSFont { + CTFontCreateUIFontForLanguage(.system, size, locale as CFString) ?? NSFont.systemFont(ofSize: size) + } + + var highlightedNSColor: NSColor { + var result = NSColor.alternateSelectedControlColor + var colorBlendAmount: Double = NSApplication.isDarkMode ? 0.3 : 0.0 + if #available(macOS 10.14, *), !NSApplication.isDarkMode, locale == "zh-Hant" { + colorBlendAmount = 0.15 + } + // 設定當前高亮候選字的背景顏色。 + switch locale { + case "zh-Hans": + result = NSColor.systemRed + case "zh-Hant": + result = NSColor.systemBlue + case "ja": + result = NSColor.systemBrown + default: break + } + var blendingAgainstTarget: NSColor = NSApplication.isDarkMode ? NSColor.black : NSColor.white + if #unavailable(macOS 10.14) { + colorBlendAmount = 0.3 + blendingAgainstTarget = NSColor.white + } + return result.blended(withFraction: colorBlendAmount, of: blendingAgainstTarget)! + } + + public var attributedStringForLengthCalculation: NSAttributedString { + let attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: size, weight: .regular), + .paragraphStyle: Self.sharedParagraphStyle, + ] + let attrStrCandidate = NSAttributedString(string: displayedText + " ", attributes: attrCandidate) + return attrStrCandidate + } + + public func attributedString( + noSpacePadding: Bool = true, withHighlight: Bool = false, isMatrix: Bool = false + ) -> NSAttributedString { + let attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: size, weight: .regular), + .paragraphStyle: Self.sharedParagraphStyle, + ] + let result: NSMutableAttributedString = { + if noSpacePadding { + let resultNeo = NSMutableAttributedString(string: " ", attributes: attrCandidate) + resultNeo.insert(attributedStringPhrase(isMatrix: isMatrix), at: 1) + resultNeo.insert(attributedStringHeader, at: 0) + return resultNeo + } + let resultNeo = NSMutableAttributedString(string: " ", attributes: attrCandidate) + resultNeo.insert(attributedStringPhrase(isMatrix: isMatrix), at: 2) + resultNeo.insert(attributedStringHeader, at: 1) + return resultNeo + }() + if withHighlight, isHighlighted { + result.addAttribute( + .backgroundColor, value: highlightedNSColor, + range: NSRange(location: 0, length: result.string.utf16.count) + ) + } + return result + } + + public var attributedStringHeader: NSAttributedString { + let theFontForCandidateKey: NSFont = { + if #available(macOS 10.15, *) { + return NSFont.monospacedSystemFont(ofSize: fontSizeKey, weight: .regular) + } + return NSFont.monospacedDigitSystemFont(ofSize: fontSizeKey, weight: .regular) + }() + var attrKey: [NSAttributedString.Key: AnyObject] = [ + .font: theFontForCandidateKey, + .paragraphStyle: Self.sharedParagraphStyle, + ] + if isHighlighted { + attrKey[.foregroundColor] = NSColor.white.withAlphaComponent(0.8) + } else { + attrKey[.foregroundColor] = NSColor.secondaryLabelColor + } + let attrStrKey = NSAttributedString(string: key, attributes: attrKey) + return attrStrKey + } + + public func attributedStringPhrase(isMatrix: Bool = false) -> NSAttributedString { + var attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: phraseFont, + .paragraphStyle: Self.sharedParagraphStyle, + ] + if isHighlighted { + attrCandidate[.foregroundColor] = NSColor.white + } else { + attrCandidate[.foregroundColor] = NSColor.labelColor + } + if #available(macOS 12, *) { + if UserDefaults.standard.bool( + forKey: UserDef.kLegacyCandidateViewTypesettingMethodEnabled.rawValue + ) { + attrCandidate[.languageIdentifier] = self.locale as AnyObject + } + } + let delta: String = (isMatrix && displayedText.count < 2) ? "  " : "" + let attrStrCandidate = NSAttributedString( + string: displayedText + delta, attributes: attrCandidate + ) + return attrStrCandidate + } + + public var charDescriptions: [String] { + var result = displayedText + if displayedText.contains("("), displayedText.count > 2 { + result = displayedText.replacingOccurrences(of: "(", with: "").replacingOccurrences(of: ")", with: "") + } + return result.flatMap(\.unicodeScalars).compactMap { + let theName: String = $0.properties.name ?? "" + return String(format: "U+%02X %@", $0.value, theName) + } + } + + public func minWidthToDraw(isMatrix: Bool = true) -> Double { + cellLength(isMatrix: isMatrix) + ceil(fontSizeKey * 0.1) + } +} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_SwiftUIImpl.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_SwiftUIImpl.swift new file mode 100644 index 00000000..7c63dfe5 --- /dev/null +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_SwiftUIImpl.swift @@ -0,0 +1,85 @@ +// (c) 2022 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import Foundation +import Shared +import SwiftUI + +// MARK: - Contents specifically made for SwiftUI. + +@available(macOS 10.15, *) +public extension CandidateCellData { + var themeColor: Color { + // 設定當前高亮候選字的背景顏色。 + let result: Color = { + switch locale { + case "zh-Hans": return Color.red + case "zh-Hant": return Color.blue + case "ja": return Color(red: 0.64, green: 0.52, blue: 0.37) + default: return Color.accentColor + } + }() + return result.opacity(0.85) + } + + var attributedStringForSwiftUIBackports: some View { + var result: some View { + ZStack(alignment: .leading) { + if isHighlighted { + themeColor.cornerRadius(6) + VStack(spacing: 0) { + HStack(spacing: 4) { + Text(verbatim: key).font(.custom("Menlo", size: fontSizeKey)) + .foregroundColor(Color.white.opacity(0.8)).lineLimit(1) + Text(verbatim: displayedText) + .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) + .foregroundColor(Color(white: 1)).lineLimit(1) + }.padding(3).foregroundColor(Color(white: 0.9)) + }.frame(alignment: .leading) + } else { + VStack(spacing: 0) { + HStack(spacing: 4) { + Text(verbatim: key).font(.custom("Menlo", size: fontSizeKey)) + .foregroundColor(Color.secondary).lineLimit(1) + Text(verbatim: displayedText) + .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) + .foregroundColor(Color.primary).lineLimit(1) + }.padding(3).foregroundColor(Color(white: 0.9)) + }.frame(alignment: .leading) + } + }.fixedSize(horizontal: false, vertical: true) + } + return result + } + + @available(macOS 12, *) + var attributedStringForSwiftUI: some View { + var result: some View { + ZStack(alignment: .leading) { + if isHighlighted { + themeColor.ignoresSafeArea().cornerRadius(6) + } + VStack(spacing: 0) { + HStack(spacing: 4) { + if UserDefaults.standard.bool(forKey: UserDef.kLegacyCandidateViewTypesettingMethodEnabled.rawValue) { + Text(AttributedString(attributedStringHeader)) + Text(AttributedString(attributedStringPhrase())) + } else { + Text(verbatim: key).font(.system(size: fontSizeKey).monospaced()) + .foregroundColor(.init(nsColor: fontColorKey)).lineLimit(1) + Text(verbatim: displayedText) + .font(.init(CTFontCreateUIFontForLanguage(.system, fontSizeCandidate, locale as CFString)!)) + .foregroundColor(.init(nsColor: fontColorCandidate)).lineLimit(1) + } + }.padding(3) + }.frame(alignment: .leading) + }.fixedSize(horizontal: false, vertical: true) + } + return result + } +} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift index 2984ef7d..c7101ea4 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift @@ -6,343 +6,200 @@ // marks, or product names of Contributor, except as required to fulfill notice // requirements defined in MIT License. -import Cocoa +import Foundation import Shared -/// 候選字窗會用到的資料池單位。 +/// 候選字窗會用到的資料池單位,即用即拋。 public struct CandidatePool { - public let blankCell = CandidateCellData(key: " ", displayedText: " ", isSelected: false) - public var currentLayout: NSUserInterfaceLayoutOrientation = .horizontal - public private(set) var candidateDataAll: [CandidateCellData] = [] - public private(set) var selectionKeys: String + public let blankCell: CandidateCellData + public let maxLinesPerPage: Int + public let layout: LayoutOrientation + public let selectionKeys: String + public let candidateDataAll: [CandidateCellData] + public var candidateLines: [[CandidateCellData]] = [] + public var tooltip: String = "" + public var reverseLookupResult: [String] = [] public private(set) var highlightedIndex: Int = 0 + public private(set) var currentLineNumber = 0 - // 下述變數只有橫排選字窗才會用到 - private var currentRowNumber = 0 - private var maxRowsPerPage = 3 - private var maxRowCapacity: Int = 6 - private var candidateRows: [[CandidateCellData]] = [] - - // 下述變數只有縱排選字窗才會用到 - private var currentColumnNumber = 0 - private var maxColumnsPerPage = 3 - private var maxColumnCapacity: Int = 6 - private var candidateColumns: [[CandidateCellData]] = [] + private var recordedLineRangeForCurrentPage: Range? + private var previouslyRecordedLineRangeForPreviousPage: Range? // MARK: - 動態變數 - public var maxRowWidth: Int { Int(ceil((Double(maxRowCapacity + 3) * 2 - 0.5) * CandidateCellData.unifiedSize)) } - public var maxWindowWidth: Double { - Double(maxRowCapacity) * (blankCell.minWidthToDrawInSwiftUI + ceil(CandidateCellData.unifiedSize * 0.5)) + /// 用來在初期化一個候選字詞資料池的時候研判「橫版多行選字窗每行最大應該塞多少個候選字詞」。 + /// 注意:該參數不用來計算視窗寬度,所以無須算上候選字詞間距。 + public var maxRowWidth: Double { ceil(Double(maxLineCapacity) * blankCell.minWidthToDraw()) } + + /// 當前高亮的候選字詞的順序標籤(同時顯示資料池內已有的全部的候選字詞的數量) + public var currentPositionLabelText: String { + (highlightedIndex + 1).description + "/" + candidateDataAll.count.description } - public var currentLineNumber: Int { - switch currentLayout { - case .horizontal: - return currentRowNumber - case .vertical: - return currentColumnNumber - @unknown default: - return 0 - } + /// 當前高亮的候選字詞。 + public var currentCandidate: CandidateCellData? { + (0 ..< candidateDataAll.count).contains(highlightedIndex) ? candidateDataAll[highlightedIndex] : nil } - public var candidateLines: [[CandidateCellData]] { - switch currentLayout { - case .horizontal: - return candidateRows - case .vertical: - return candidateColumns - @unknown default: - return [] - } + /// 當前高亮的候選字詞的文本。如果相關資料不存在或者不合規的話,則返回空字串。 + public var currentSelectedCandidateText: String? { currentCandidate?.displayedText ?? nil } + + /// 每行/每列理論上應該最多塞多少個候選字詞。這其實就是當前啟用的選字鍵的數量。 + public var maxLineCapacity: Int { selectionKeys.count } + + /// 當選字窗處於單行模式時,如果一行內的內容過少的話,該變數會指出需要再插入多少個空白候選字詞單位。 + public var dummyCellsRequiredForCurrentLine: Int { + maxLineCapacity - candidateLines[currentLineNumber].count } - public var maxLineCapacity: Int { - switch currentLayout { - case .horizontal: - return maxRowCapacity - case .vertical: - return maxColumnCapacity - @unknown default: - return 0 - } + /// 如果當前的行數小於最大行數的話,該變數會指出還需要多少空白行。 + public var lineRangeForFinalPageBlanked: Range { + 0 ..< (maxLinesPerPage - lineRangeForCurrentPage.count) } - public var maxLinesPerPage: Int { - get { - switch currentLayout { - case .horizontal: - return maxRowsPerPage - case .vertical: - return maxColumnsPerPage - @unknown default: - return 0 - } - } - set { - switch currentLayout { - case .horizontal: - maxRowsPerPage = newValue - case .vertical: - maxColumnsPerPage = newValue - @unknown default: - return - } - } + /// 當前頁所在的行範圍。 + public var lineRangeForCurrentPage: Range { + recordedLineRangeForCurrentPage ?? fallbackedLineRangeForCurrentPage } - public var rangeForLastPageBlanked: Range { - switch currentLayout { - case .horizontal: return rangeForLastHorizontalPageBlanked - case .vertical: return rangeForLastVerticalPageBlanked - @unknown default: return 0 ..< 0 - } - } - - public var rangeForCurrentPage: Range { - switch currentLayout { - case .horizontal: return rangeForCurrentHorizontalPage - case .vertical: return rangeForCurrentVerticalPage - @unknown default: return 0 ..< 0 - } + /// 當前高亮候選字所在的某個相容頁的行範圍。該參數僅用作墊底回退之用途、或者其它極端用途。 + public var fallbackedLineRangeForCurrentPage: Range { + currentLineNumber ..< min(candidateLines.count, currentLineNumber + maxLinesPerPage) } // MARK: - Constructors - /// 初期化一個縱排候選字窗專用資料池。 + /// 初期化一個候選字窗專用資料池。 /// - Parameters: /// - candidates: 要塞入的候選字詞陣列。 - /// - columnCapacity: (第一縱列的最大候選字詞數量, 陣列畫面展開之後的每一縱列的最大候選字詞數量)。 /// - selectionKeys: 選字鍵。 + /// - direction: 橫向排列還是縱向排列(預設情況下是縱向)。 /// - locale: 區域編碼。例:「zh-Hans」或「zh-Hant」。 public init( - candidates: [String], columnCapacity: Int, columns: Int = 3, selectionKeys: String = "123456789", - locale: String = "" + candidates: [String], lines: Int = 3, selectionKeys: String = "123456789", + layout: LayoutOrientation = .vertical, locale: String = "" ) { - maxColumnsPerPage = max(1, columns) - maxColumnCapacity = max(1, columnCapacity) - self.selectionKeys = selectionKeys - candidateDataAll = candidates.map { .init(key: "0", displayedText: $0) } + self.layout = layout + maxLinesPerPage = max(1, lines) + blankCell = CandidateCellData(key: " ", displayedText: " ", isSelected: false) + blankCell.locale = locale + self.selectionKeys = selectionKeys.isEmpty ? "123456789" : selectionKeys + var allCandidates = candidates.map { CandidateCellData(key: " ", displayedText: $0) } + if allCandidates.isEmpty { allCandidates.append(blankCell) } + candidateDataAll = allCandidates var currentColumn: [CandidateCellData] = [] for (i, candidate) in candidateDataAll.enumerated() { candidate.index = i - candidate.whichColumn = candidateColumns.count - if currentColumn.count == maxColumnCapacity, !currentColumn.isEmpty { - candidateColumns.append(currentColumn) + candidate.whichLine = candidateLines.count + var isOverflown: Bool = (currentColumn.count == maxLineCapacity) && !currentColumn.isEmpty + if layout == .horizontal { + isOverflown = isOverflown + || currentColumn.map { $0.cellLength() }.reduce(0, +) >= maxRowWidth - candidate.cellLength() + } + if isOverflown { + candidateLines.append(currentColumn) currentColumn.removeAll() - candidate.whichColumn += 1 + candidate.whichLine += 1 } candidate.subIndex = currentColumn.count candidate.locale = locale currentColumn.append(candidate) } - candidateColumns.append(currentColumn) - currentLayout = .vertical - } - - /// 初期化一個橫排候選字窗專用資料池。 - /// - Parameters: - /// - candidates: 要塞入的候選字詞陣列。 - /// - rowCapacity: (第一橫行的最大候選字詞數量, 陣列畫面展開之後的每一橫行的最大候選字詞數量)。 - /// - selectionKeys: 選字鍵。 - /// - locale: 區域編碼。例:「zh-Hans」或「zh-Hant」。 - public init( - candidates: [String], rowCapacity: Int, rows: Int = 3, selectionKeys: String = "123456789", locale: String = "" - ) { - maxRowsPerPage = max(1, rows) - maxRowCapacity = max(1, rowCapacity) - self.selectionKeys = selectionKeys - candidateDataAll = candidates.map { .init(key: "0", displayedText: $0) } - var currentRow: [CandidateCellData] = [] - for (i, candidate) in candidateDataAll.enumerated() { - candidate.index = i - candidate.whichRow = candidateRows.count - let isOverflown: Bool = currentRow.map(\.cellLength).reduce(0, +) > maxRowWidth - if isOverflown || currentRow.count == maxRowCapacity, !currentRow.isEmpty { - candidateRows.append(currentRow) - currentRow.removeAll() - candidate.whichRow += 1 - } - candidate.subIndex = currentRow.count - candidate.locale = locale - currentRow.append(candidate) - } - candidateRows.append(currentRow) - currentLayout = .horizontal - } - - // MARK: Public Functions - - public mutating func selectNewNeighborLine(isForward: Bool) { - switch currentLayout { - case .horizontal: selectNewNeighborRow(direction: isForward ? .down : .up) - case .vertical: selectNewNeighborColumn(direction: isForward ? .right : .left) - @unknown default: break - } - } - - public mutating func highlight(at indexSpecified: Int) { - switch currentLayout { - case .horizontal: highlightHorizontal(at: indexSpecified) - case .vertical: highlightVertical(at: indexSpecified) - @unknown default: break - } - vCLog("\n" + candidateDataAll[highlightedIndex].charDescriptions) + candidateLines.append(currentColumn) + recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage + highlight(at: 0) } } -// MARK: - Private Functions +// MARK: - Public Functions (for all OS) -extension CandidatePool { - private enum VerticalDirection { - case up - case down +public extension CandidatePool { + /// 選字窗的候選字詞陳列方向。 + enum LayoutOrientation { + case horizontal + case vertical } - private enum HorizontalDirection { - case left - case right + /// 往指定的方向翻頁。 + /// - Parameter isBackward: 是否逆向翻頁。 + /// - Returns: 操作是否順利。 + @discardableResult mutating func flipPage(isBackward: Bool) -> Bool { + backupLineRangeForCurrentPage() + defer { flipLineRangeToNeighborPage(isBackward: isBackward) } + return consecutivelyFlipLines(isBackward: isBackward, count: maxLinesPerPage) } - private var rangeForLastHorizontalPageBlanked: Range { - 0 ..< (maxRowsPerPage - rangeForCurrentHorizontalPage.count) + /// 嘗試用給定的行內編號推算該候選字在資料池內的總編號。 + /// - Parameter subIndex: 給定的行內編號。 + /// - Returns: 推算結果(可能會是 nil)。 + func calculateCandidateIndex(subIndex: Int) -> Int? { + let arrCurrentLine = candidateLines[currentLineNumber] + if !(0 ..< arrCurrentLine.count).contains(subIndex) { return nil } + return arrCurrentLine[subIndex].index } - private var rangeForLastVerticalPageBlanked: Range { - 0 ..< (maxColumnsPerPage - rangeForCurrentVerticalPage.count) - } - - private var rangeForCurrentHorizontalPage: Range { - currentRowNumber ..< min(candidateRows.count, currentRowNumber + maxRowsPerPage) - } - - private var rangeForCurrentVerticalPage: Range { - currentColumnNumber ..< min(candidateColumns.count, currentColumnNumber + maxColumnsPerPage) - } - - private mutating func selectNewNeighborRow(direction: VerticalDirection) { - let currentSubIndex = candidateDataAll[highlightedIndex].subIndex - var result = currentSubIndex - switch direction { - case .up: - if currentRowNumber <= 0 { - if candidateRows.isEmpty { break } - let firstRow = candidateRows[0] - let newSubIndex = min(currentSubIndex, firstRow.count - 1) - highlightHorizontal(at: firstRow[newSubIndex].index) - break + /// 往指定的方向連續翻行。 + /// - Parameters: + /// - isBackward: 是否逆向翻行。 + /// - count: 翻幾行。 + /// - Returns: 操作是否順利。 + @discardableResult mutating func consecutivelyFlipLines(isBackward: Bool, count: Int) -> Bool { + switch isBackward { + case false where currentLineNumber == candidateLines.count - 1: + return highlightNeighborCandidate(isBackward: false) + case true where currentLineNumber == 0: + return highlightNeighborCandidate(isBackward: true) + default: + if count <= 0 { return false } + for _ in 0 ..< min(maxLinesPerPage, count) { + selectNewNeighborLine(isBackward: isBackward) } - if currentRowNumber >= candidateRows.count - 1 { currentRowNumber = candidateRows.count - 1 } - if candidateRows[currentRowNumber].count != candidateRows[currentRowNumber - 1].count { - let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateRows[currentRowNumber].count)) - result = Int(floor(Double(candidateRows[currentRowNumber - 1].count) * ratio)) - } - let targetRow = candidateRows[currentRowNumber - 1] - let newSubIndex = min(result, targetRow.count - 1) - highlightHorizontal(at: targetRow[newSubIndex].index) - case .down: - if currentRowNumber >= candidateRows.count - 1 { - if candidateRows.isEmpty { break } - let finalRow = candidateRows[candidateRows.count - 1] - let newSubIndex = min(currentSubIndex, finalRow.count - 1) - highlightHorizontal(at: finalRow[newSubIndex].index) - break - } - if candidateRows[currentRowNumber].count != candidateRows[currentRowNumber + 1].count { - let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateRows[currentRowNumber].count)) - result = Int(floor(Double(candidateRows[currentRowNumber + 1].count) * ratio)) - } - let targetRow = candidateRows[currentRowNumber + 1] - let newSubIndex = min(result, targetRow.count - 1) - highlightHorizontal(at: targetRow[newSubIndex].index) + return true } } - private mutating func selectNewNeighborColumn(direction: HorizontalDirection) { - let currentSubIndex = candidateDataAll[highlightedIndex].subIndex - switch direction { - case .left: - if currentColumnNumber <= 0 { - if candidateColumns.isEmpty { break } - let firstColumn = candidateColumns[0] - let newSubIndex = min(currentSubIndex, firstColumn.count - 1) - highlightVertical(at: firstColumn[newSubIndex].index) - break - } - if currentColumnNumber >= candidateColumns.count - 1 { currentColumnNumber = candidateColumns.count - 1 } - let targetColumn = candidateColumns[currentColumnNumber - 1] - let newSubIndex = min(currentSubIndex, targetColumn.count - 1) - highlightVertical(at: targetColumn[newSubIndex].index) - case .right: - if currentColumnNumber >= candidateColumns.count - 1 { - if candidateColumns.isEmpty { break } - let finalColumn = candidateColumns[candidateColumns.count - 1] - let newSubIndex = min(currentSubIndex, finalColumn.count - 1) - highlightVertical(at: finalColumn[newSubIndex].index) - break - } - let targetColumn = candidateColumns[currentColumnNumber + 1] - let newSubIndex = min(currentSubIndex, targetColumn.count - 1) - highlightVertical(at: targetColumn[newSubIndex].index) + /// 嘗試高亮前方或者後方的鄰近候選字詞。 + /// - Parameter isBackward: 是否是後方的鄰近候選字詞。 + /// - Returns: 是否成功。 + @discardableResult mutating func highlightNeighborCandidate(isBackward: Bool) -> Bool { + switch isBackward { + case false where highlightedIndex >= candidateDataAll.count - 1: + highlight(at: 0) + return false + case true where highlightedIndex <= 0: + highlight(at: candidateDataAll.count - 1) + return false + default: + highlight(at: highlightedIndex + (isBackward ? -1 : 1)) + return true } } - private mutating func highlightHorizontal(at indexSpecified: Int) { + /// 高亮指定的候選字。 + /// - Parameter indexSpecified: 給定的候選字詞索引編號,得是資料池內的總索引編號。 + mutating func highlight(at indexSpecified: Int) { var indexSpecified = indexSpecified + let isBackward: Bool = indexSpecified > highlightedIndex highlightedIndex = indexSpecified if !(0 ..< candidateDataAll.count).contains(highlightedIndex) { switch highlightedIndex { case candidateDataAll.count...: - currentRowNumber = candidateRows.count - 1 + currentLineNumber = candidateLines.count - 1 highlightedIndex = max(0, candidateDataAll.count - 1) indexSpecified = highlightedIndex case ..<0: highlightedIndex = 0 - currentRowNumber = 0 + currentLineNumber = 0 indexSpecified = highlightedIndex default: break } } for (i, candidate) in candidateDataAll.enumerated() { - candidate.isSelected = (indexSpecified == i) - if candidate.isSelected { currentRowNumber = candidate.whichRow } + candidate.isHighlighted = (indexSpecified == i) + if candidate.isHighlighted { currentLineNumber = candidate.whichLine } } - for (i, candidateRow) in candidateRows.enumerated() { - if i != currentRowNumber { - candidateRow.forEach { - $0.key = " " - } - } else { - for (i, neta) in candidateRow.enumerated() { - neta.key = selectionKeys.map(\.description)[i] - } - } - } - } - - private mutating func highlightVertical(at indexSpecified: Int) { - var indexSpecified = indexSpecified - highlightedIndex = indexSpecified - if !(0 ..< candidateDataAll.count).contains(highlightedIndex) { - switch highlightedIndex { - case candidateDataAll.count...: - currentColumnNumber = candidateColumns.count - 1 - highlightedIndex = max(0, candidateDataAll.count - 1) - indexSpecified = highlightedIndex - case ..<0: - highlightedIndex = 0 - currentColumnNumber = 0 - indexSpecified = highlightedIndex - default: break - } - } - for (i, candidate) in candidateDataAll.enumerated() { - candidate.isSelected = (indexSpecified == i) - if candidate.isSelected { currentColumnNumber = candidate.whichColumn } - } - for (i, candidateColumn) in candidateColumns.enumerated() { - if i != currentColumnNumber { + for (i, candidateColumn) in candidateLines.enumerated() { + if i != currentLineNumber { candidateColumn.forEach { $0.key = " " } @@ -353,5 +210,167 @@ extension CandidatePool { } } } + if highlightedIndex != 0, indexSpecified == 0 { + recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage + } else { + fixLineRange(isBackward: isBackward) + } + } +} + +// MARK: - Private Functions + +private extension CandidatePool { + enum VerticalDirection { + case up + case down + } + + enum HorizontalDirection { + case left + case right + } + + /// 第一頁所在的行範圍。 + var lineRangeForFirstPage: Range { + 0 ..< min(maxLinesPerPage, candidateLines.count) + } + + /// 最後一頁所在的行範圍。 + var lineRangeForFinalPage: Range { + max(0, candidateLines.count - maxLinesPerPage) ..< candidateLines.count + } + + mutating func selectNewNeighborLine(isBackward: Bool) { + switch layout { + case .horizontal: selectNewNeighborRow(direction: isBackward ? .up : .down) + case .vertical: selectNewNeighborColumn(direction: isBackward ? .left : .right) + } + } + + mutating func fixLineRange(isBackward: Bool = false) { + if !lineRangeForCurrentPage.contains(currentLineNumber) { + switch isBackward { + case false: + let theMin = currentLineNumber + let theMax = min(theMin + maxLinesPerPage, candidateLines.count) + recordedLineRangeForCurrentPage = theMin ..< theMax + case true: + let theMax = currentLineNumber + 1 + let theMin = max(0, theMax - maxLinesPerPage) + recordedLineRangeForCurrentPage = theMin ..< theMax + } + } + } + + mutating func backupLineRangeForCurrentPage() { + previouslyRecordedLineRangeForPreviousPage = lineRangeForCurrentPage + } + + mutating func flipLineRangeToNeighborPage(isBackward: Bool = false) { + guard let prevRange = previouslyRecordedLineRangeForPreviousPage else { return } + var lowerBound = prevRange.lowerBound + var upperBound = prevRange.upperBound + // 先對上下邊界資料值做模進處理。 + lowerBound += maxLinesPerPage * (isBackward ? -1 : 1) + upperBound += maxLinesPerPage * (isBackward ? -1 : 1) + // 然後糾正可能出錯的資料值。 + branch1: switch isBackward { + case false: + if upperBound < candidateLines.count { break branch1 } + if lowerBound < lineRangeForFinalPage.lowerBound { break branch1 } + let isOverFlipped = !lineRangeForFinalPage.contains(currentLineNumber) + recordedLineRangeForCurrentPage = isOverFlipped ? lineRangeForFirstPage : lineRangeForFinalPage + return + case true: + if lowerBound > 0 { break branch1 } + if upperBound > lineRangeForFirstPage.upperBound { break branch1 } + let isOverFlipped = !lineRangeForFirstPage.contains(currentLineNumber) + recordedLineRangeForCurrentPage = isOverFlipped ? lineRangeForFinalPage : lineRangeForFirstPage + return + } + let result = lowerBound ..< upperBound + if result.contains(currentLineNumber) { + recordedLineRangeForCurrentPage = result + return + } + // 應該不會有漏檢的情形了。 + } + + mutating func selectNewNeighborRow(direction: VerticalDirection) { + let currentSubIndex = candidateDataAll[highlightedIndex].subIndex + var result = currentSubIndex + branch: switch direction { + case .up: + if currentLineNumber <= 0 { + if candidateLines.isEmpty { break } + let firstRow = candidateLines[0] + let newSubIndex = min(currentSubIndex, firstRow.count - 1) + highlight(at: firstRow[newSubIndex].index) + fixLineRange(isBackward: false) + break branch + } + if currentLineNumber >= candidateLines.count - 1 { currentLineNumber = candidateLines.count - 1 } + result = currentSubIndex + // 考慮到選字窗末行往往都是將選字窗貼左排列的(而非左右平鋪排列),所以這裡對「↑」鍵不採用這段特殊處理。 + // if candidateLines[currentLineNumber].count != candidateLines[currentLineNumber - 1].count { + // let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateLines[currentLineNumber].count)) + // result = max(Int(floor(Double(candidateLines[currentLineNumber - 1].count) * ratio)), result) + // } + let targetRow = candidateLines[currentLineNumber - 1] + let newSubIndex = min(result, targetRow.count - 1) + highlight(at: targetRow[newSubIndex].index) + fixLineRange(isBackward: true) + case .down: + if currentLineNumber >= candidateLines.count - 1 { + if candidateLines.isEmpty { break } + let finalRow = candidateLines[candidateLines.count - 1] + let newSubIndex = min(currentSubIndex, finalRow.count - 1) + highlight(at: finalRow[newSubIndex].index) + fixLineRange(isBackward: true) + break branch + } + result = currentSubIndex + // 特殊處理。 + if candidateLines[currentLineNumber].count != candidateLines[currentLineNumber + 1].count { + let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateLines[currentLineNumber].count)) + result = max(Int(floor(Double(candidateLines[currentLineNumber + 1].count) * ratio)), result) + } + let targetRow = candidateLines[currentLineNumber + 1] + let newSubIndex = min(result, targetRow.count - 1) + highlight(at: targetRow[newSubIndex].index) + fixLineRange(isBackward: false) + } + } + + mutating func selectNewNeighborColumn(direction: HorizontalDirection) { + let currentSubIndex = candidateDataAll[highlightedIndex].subIndex + switch direction { + case .left: + if currentLineNumber <= 0 { + if candidateLines.isEmpty { break } + let firstColumn = candidateLines[0] + let newSubIndex = min(currentSubIndex, firstColumn.count - 1) + highlight(at: firstColumn[newSubIndex].index) + break + } + if currentLineNumber >= candidateLines.count - 1 { currentLineNumber = candidateLines.count - 1 } + let targetColumn = candidateLines[currentLineNumber - 1] + let newSubIndex = min(currentSubIndex, targetColumn.count - 1) + highlight(at: targetColumn[newSubIndex].index) + fixLineRange(isBackward: true) + case .right: + if currentLineNumber >= candidateLines.count - 1 { + if candidateLines.isEmpty { break } + let finalColumn = candidateLines[candidateLines.count - 1] + let newSubIndex = min(currentSubIndex, finalColumn.count - 1) + highlight(at: finalColumn[newSubIndex].index) + break + } + let targetColumn = candidateLines[currentLineNumber + 1] + let newSubIndex = min(currentSubIndex, targetColumn.count - 1) + highlight(at: targetColumn[newSubIndex].index) + fixLineRange(isBackward: false) + } } } diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift new file mode 100644 index 00000000..b9effd43 --- /dev/null +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift @@ -0,0 +1,171 @@ +// (c) 2022 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import Cocoa + +extension CandidatePool { + public var attributedDescription: NSAttributedString { + switch layout { + case .horizontal: return attributedDescriptionHorizontal + case .vertical: return attributedDescriptionVertical + } + } + + /// 將當前資料池以橫版的形式列印成 NSAttributedString。 + private var attributedDescriptionHorizontal: NSAttributedString { + let paragraphStyle = CandidateCellData.sharedParagraphStyle as! NSMutableParagraphStyle + paragraphStyle.lineSpacing = ceil(blankCell.size * 0.3) + paragraphStyle.lineBreakStrategy = .pushOut + let attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: blankCell.size, weight: .regular), + .paragraphStyle: paragraphStyle, + ] + let result = NSMutableAttributedString(string: "", attributes: attrCandidate) + let spacer = NSAttributedString(string: " ", attributes: attrCandidate) + let lineFeed = NSAttributedString(string: "\n", attributes: attrCandidate) + for lineID in lineRangeForCurrentPage { + let arrLine = candidateLines[lineID] + arrLine.enumerated().forEach { cellID, currentCell in + let cellString = NSMutableAttributedString( + attributedString: currentCell.attributedString( + noSpacePadding: false, withHighlight: true, isMatrix: maxLinesPerPage > 1 + ) + ) + if lineID != currentLineNumber { + cellString.addAttribute( + .foregroundColor, value: NSColor.controlTextColor, + range: .init(location: 0, length: cellString.string.utf16.count) + ) + } + result.append(cellString) + if cellID < arrLine.count - 1 { + result.append(spacer) + } + } + if lineID < lineRangeForCurrentPage.upperBound - 1 || maxLinesPerPage > 1 { + result.append(lineFeed) + } else { + result.append(spacer) + } + } + // 這裡已經換行過了。 + result.append(attributedDescriptionBottomPanes) + return result + } + + private var attributedDescriptionVertical: NSAttributedString { + let paragraphStyle = CandidateCellData.sharedParagraphStyle as! NSMutableParagraphStyle + paragraphStyle.lineSpacing = ceil(blankCell.size * 0.3) + paragraphStyle.lineBreakStrategy = .pushOut + let attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: blankCell.size, weight: .regular), + .paragraphStyle: paragraphStyle, + ] + let result = NSMutableAttributedString(string: "", attributes: attrCandidate) + let spacer = NSMutableAttributedString(string: " ", attributes: attrCandidate) + let lineFeed = NSAttributedString(string: "\n", attributes: attrCandidate) + for (inlineIndex, _) in selectionKeys.enumerated() { + for (lineID, lineData) in candidateLines.enumerated() { + if !fallbackedLineRangeForCurrentPage.contains(lineID) { continue } + if !(0 ..< lineData.count).contains(inlineIndex) { continue } + let currentCell = lineData[inlineIndex] + let cellString = NSMutableAttributedString( + attributedString: currentCell.attributedString( + noSpacePadding: false, withHighlight: true, isMatrix: maxLinesPerPage > 1 + ) + ) + if lineID != currentLineNumber { + cellString.addAttribute( + .foregroundColor, value: NSColor.gray, + range: .init(location: 0, length: cellString.string.utf16.count) + ) + } + result.append(cellString) + if maxLinesPerPage > 1, currentCell.displayedText.count > 1 { + if currentCell.isHighlighted { + spacer.addAttribute( + .backgroundColor, + value: currentCell.highlightedNSColor, + range: .init(location: 0, length: spacer.string.utf16.count) + ) + } else { + spacer.removeAttribute( + .backgroundColor, + range: .init(location: 0, length: spacer.string.utf16.count) + ) + } + result.append(spacer) + } + } + result.append(lineFeed) + } + // 這裡已經換行過了。 + result.append(attributedDescriptionBottomPanes) + return result + } + + private var attributedDescriptionBottomPanes: NSAttributedString { + let paragraphStyle = CandidateCellData.sharedParagraphStyle as! NSMutableParagraphStyle + paragraphStyle.lineSpacing = ceil(blankCell.size * 0.3) + paragraphStyle.lineBreakStrategy = .pushOut + let attrCandidate: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: blankCell.size, weight: .regular), + .paragraphStyle: paragraphStyle, + ] + let result = NSMutableAttributedString(string: "", attributes: attrCandidate) + let positionCounterColorBG = NSApplication.isDarkMode + ? NSColor(white: 0.215, alpha: 0.7) + : NSColor(white: 0.9, alpha: 0.7) + let positionCounterColorText = NSColor.controlTextColor + let positionCounterTextSize = max(ceil(CandidateCellData.unifiedSize * 0.7), 11) + let attrPositionCounter: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: positionCounterTextSize, weight: .bold), + .paragraphStyle: paragraphStyle, + .backgroundColor: positionCounterColorBG, + .foregroundColor: positionCounterColorText, + ] + let positionCounter = NSAttributedString( + string: " \(currentPositionLabelText) ", attributes: attrPositionCounter + ) + result.append(positionCounter) + + if !tooltip.isEmpty { + let attrTooltip: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: positionCounterTextSize, weight: .regular), + .paragraphStyle: paragraphStyle, + ] + let tooltipText = NSAttributedString( + string: " \(tooltip) ", attributes: attrTooltip + ) + result.append(tooltipText) + } + + if !reverseLookupResult.isEmpty { + let reverseLookupTextSize = max(ceil(CandidateCellData.unifiedSize * 0.6), 9) + let reverseLookupColorBG = NSApplication.isDarkMode + ? NSColor(white: 0.1, alpha: 1) + : NSColor(white: 0.9, alpha: 1) + let attrReverseLookup: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: reverseLookupTextSize, weight: .regular), + .paragraphStyle: paragraphStyle, + .backgroundColor: reverseLookupColorBG, + ] + let attrReverseLookupSpacer: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.monospacedDigitSystemFont(ofSize: reverseLookupTextSize, weight: .regular), + .paragraphStyle: paragraphStyle, + ] + for neta in reverseLookupResult { + result.append(NSAttributedString(string: " ", attributes: attrReverseLookupSpacer)) + result.append(NSAttributedString(string: " \(neta) ", attributes: attrReverseLookup)) + if maxLinesPerPage == 1 { break } + } + } + result.addAttribute(.paragraphStyle, value: paragraphStyle, range: .init(location: 0, length: result.string.utf16.count)) + return result + } +} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift index 38ea1d93..8dad9851 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift @@ -9,61 +9,44 @@ import Cocoa import CocoaExtension import Shared +import SwiftExtension import SwiftUI -@available(macOS 10.15, *) +private extension NSUserInterfaceLayoutOrientation { + var layoutTDK: CandidatePool.LayoutOrientation { + switch self { + case .horizontal: + return .horizontal + case .vertical: + return .vertical + @unknown default: + return .horizontal + } + } +} + public class CtlCandidateTDK: CtlCandidate { public var maxLinesPerPage: Int = 0 - - private static var thePoolHorizontal: CandidatePool = .init(candidates: [], rowCapacity: 6) - private static var thePoolVertical: CandidatePool = .init(candidates: [], columnCapacity: 6) + public var isLegacyMode: Bool = false + private static var thePool: CandidatePool = .init(candidates: []) private static var currentView: NSView = .init() - @available(macOS 12, *) - private var theViewHorizontal: some View { - VwrCandidateHorizontal( - controller: self, thePool: Self.thePoolHorizontal, - tooltip: tooltip, reverseLookupResult: reverseLookupResult + @available(macOS 10.15, *) + private var theView: some View { + VwrCandidateTDK( + controller: self, thePool: Self.thePool ).edgesIgnoringSafeArea(.top) } - @available(macOS 12, *) - private var theViewVertical: some View { - VwrCandidateVertical( - controller: self, thePool: Self.thePoolVertical, - tooltip: tooltip, reverseLookupResult: reverseLookupResult - ).edgesIgnoringSafeArea(.top) - } - - private var theViewHorizontalBackports: some View { - VwrCandidateHorizontalBackports( - controller: self, thePool: Self.thePoolHorizontal, - tooltip: tooltip, reverseLookupResult: reverseLookupResult - ).edgesIgnoringSafeArea(.top) - } - - private var theViewVerticalBackports: some View { - VwrCandidateVerticalBackports( - controller: self, thePool: Self.thePoolVertical, - tooltip: tooltip, reverseLookupResult: reverseLookupResult - ).edgesIgnoringSafeArea(.top) - } - - private var thePool: CandidatePool { - get { - switch currentLayout { - case .horizontal: return Self.thePoolHorizontal - case .vertical: return Self.thePoolVertical - @unknown default: return .init(candidates: [], rowCapacity: 0) - } - } - set { - switch currentLayout { - case .horizontal: Self.thePoolHorizontal = newValue - case .vertical: Self.thePoolVertical = newValue - @unknown default: break - } - } + private var theViewLegacy: NSView { + let textField = NSTextField( + labelWithAttributedString: Self.thePool.attributedDescription + ) + textField.isSelectable = false + textField.allowsEditingTextAttributes = false + textField.preferredMaxLayoutWidth = textField.frame.width + textField.backgroundColor = .controlBackgroundColor + return textField } // MARK: - Constructors @@ -93,165 +76,108 @@ public class CtlCandidateTDK: CtlCandidate { // MARK: - Public functions override public func reloadData() { - CandidateCellData.highlightBackground = highlightedColor() CandidateCellData.unifiedSize = candidateFont.pointSize guard let delegate = delegate else { return } - - switch currentLayout { - case .horizontal: - Self.thePoolHorizontal = .init( - candidates: delegate.candidatePairs(conv: true).map(\.1), rowCapacity: 6, - rows: maxLinesPerPage, selectionKeys: delegate.selectionKeys, locale: locale - ) - Self.thePoolHorizontal.highlight(at: 0) - case .vertical: - Self.thePoolVertical = .init( - candidates: delegate.candidatePairs(conv: true).map(\.1), columnCapacity: 6, - columns: maxLinesPerPage, selectionKeys: delegate.selectionKeys, locale: locale - ) - Self.thePoolVertical.highlight(at: 0) - @unknown default: - return - } + Self.thePool = .init( + candidates: delegate.candidatePairs(conv: true).map(\.1), lines: maxLinesPerPage, + selectionKeys: delegate.selectionKeys, layout: currentLayout.layoutTDK, locale: locale + ) + Self.thePool.tooltip = tooltip + Self.thePool.reverseLookupResult = reverseLookupResult + Self.thePool.highlight(at: 0) updateDisplay() } override open func updateDisplay() { guard let window = window else { return } - reverseLookupResult = delegate?.reverseLookup(for: currentSelectedCandidateText) ?? [] - switch currentLayout { - case .horizontal: - DispatchQueue.main.async { [self] in - if #available(macOS 12, *) { - Self.currentView = NSHostingView(rootView: theViewHorizontal) - } else { - Self.currentView = NSHostingView(rootView: theViewHorizontalBackports) + if let currentCandidateText = Self.thePool.currentSelectedCandidateText { + reverseLookupResult = delegate?.reverseLookup(for: currentCandidateText) ?? [] + Self.thePool.reverseLookupResult = reverseLookupResult + } + DispatchQueue.main.async { [self] in + if #available(macOS 10.15, *) { + if isLegacyMode { + updateNSWindowLegacy(window) + return } + window.isOpaque = false + window.backgroundColor = NSColor.clear + Self.currentView = NSHostingView(rootView: theView) let newSize = Self.currentView.fittingSize window.contentView = Self.currentView window.setContentSize(newSize) + } else { + updateNSWindowLegacy(window) } - case .vertical: - DispatchQueue.main.async { [self] in - if #available(macOS 12, *) { - Self.currentView = NSHostingView(rootView: theViewVertical) - } else { - Self.currentView = NSHostingView(rootView: theViewVerticalBackports) - } - let newSize = Self.currentView.fittingSize - window.contentView = Self.currentView - window.setContentSize(newSize) - } - @unknown default: - return } } + func updateNSWindowLegacy(_ window: NSWindow) { + window.isOpaque = true + window.backgroundColor = NSColor.controlBackgroundColor + let viewToDraw = theViewLegacy + let coreSize = viewToDraw.fittingSize + let padding: Double = 5 + let outerSize: NSSize = .init( + width: coreSize.width + 2 * padding, + height: coreSize.height + 2 * padding + ) + let innerOrigin: NSPoint = .init(x: padding, y: padding) + let outerRect: NSRect = .init(origin: .zero, size: outerSize) + viewToDraw.setFrameOrigin(innerOrigin) + Self.currentView = NSView(frame: outerRect) + Self.currentView.addSubview(viewToDraw) + window.contentView = Self.currentView + window.setContentSize(outerSize) + } + + // Already implemented in CandidatePool. @discardableResult override public func showNextPage() -> Bool { - showNextLine(count: thePool.maxLinesPerPage) - } - - @discardableResult override public func showNextLine() -> Bool { - showNextLine(count: 1) - } - - public func showNextLine(count: Int) -> Bool { - if thePool.currentLineNumber == thePool.candidateLines.count - 1 { - return highlightNextCandidate() - } - if count <= 0 { return false } - for _ in 0 ..< min(thePool.maxLinesPerPage, count) { - thePool.selectNewNeighborLine(isForward: true) - } - updateDisplay() - return true + defer { updateDisplay() } + return Self.thePool.flipPage(isBackward: false) } + // Already implemented in CandidatePool. @discardableResult override public func showPreviousPage() -> Bool { - showPreviousLine(count: thePool.maxLinesPerPage) + defer { updateDisplay() } + return Self.thePool.flipPage(isBackward: true) } + // Already implemented in CandidatePool. @discardableResult override public func showPreviousLine() -> Bool { - showPreviousLine(count: 1) + defer { updateDisplay() } + return Self.thePool.consecutivelyFlipLines(isBackward: true, count: 1) } - public func showPreviousLine(count: Int) -> Bool { - if thePool.currentLineNumber == 0 { - return highlightPreviousCandidate() - } - if count <= 0 { return false } - for _ in 0 ..< min(thePool.maxLinesPerPage, count) { - thePool.selectNewNeighborLine(isForward: false) - } - updateDisplay() - return true + // Already implemented in CandidatePool. + @discardableResult override public func showNextLine() -> Bool { + defer { updateDisplay() } + return Self.thePool.consecutivelyFlipLines(isBackward: false, count: 1) } + // Already implemented in CandidatePool. @discardableResult override public func highlightNextCandidate() -> Bool { - if thePool.highlightedIndex == thePool.candidateDataAll.count - 1 { - thePool.highlight(at: 0) - updateDisplay() - return false - } - thePool.highlight(at: thePool.highlightedIndex + 1) - updateDisplay() - return true + defer { updateDisplay() } + return Self.thePool.highlightNeighborCandidate(isBackward: false) } + // Already implemented in CandidatePool. @discardableResult override public func highlightPreviousCandidate() -> Bool { - if thePool.highlightedIndex == 0 { - thePool.highlight(at: thePool.candidateDataAll.count - 1) - updateDisplay() - return false - } - thePool.highlight(at: thePool.highlightedIndex - 1) - updateDisplay() - return true + defer { updateDisplay() } + return Self.thePool.highlightNeighborCandidate(isBackward: true) } - override public func candidateIndexAtKeyLabelIndex(_ id: Int) -> Int { - let arrCurrentLine = thePool.candidateLines[thePool.currentLineNumber] - if !(0 ..< arrCurrentLine.count).contains(id) { return -114_514 } - let actualID = max(0, min(id, arrCurrentLine.count - 1)) - return arrCurrentLine[actualID].index + // Already implemented in CandidatePool. + override public func candidateIndexAtKeyLabelIndex(_ id: Int) -> Int? { + Self.thePool.calculateCandidateIndex(subIndex: id) } + // Already implemented in CandidatePool. override public var highlightedIndex: Int { - get { thePool.highlightedIndex } + get { Self.thePool.highlightedIndex } set { - thePool.highlight(at: newValue) + Self.thePool.highlight(at: newValue) updateDisplay() } } } - -@available(macOS 10.15, *) -extension CtlCandidateTDK { - private var isMontereyAvailable: Bool { - if #unavailable(macOS 12) { return false } - return true - } - - private var currentSelectedCandidateText: String { - if thePool.candidateDataAll.count > highlightedIndex { - return thePool.candidateDataAll[highlightedIndex].displayedText - } - return "" - } -} - -@available(macOS 10.15, *) -public extension CtlCandidateTDK { - var highlightedColorUIBackports: some View { - // 設定當前高亮候選字的背景顏色。 - let result: Color = { - switch locale { - case "zh-Hans": return Color.red - case "zh-Hant": return Color.blue - case "ja": return Color.pink - default: return Color.accentColor - } - }() - return result.opacity(0.85) - } -} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK.swift new file mode 100644 index 00000000..88f779f0 --- /dev/null +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK.swift @@ -0,0 +1,508 @@ +// (c) 2022 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import Cocoa +import Shared +import SwiftUI +import SwiftUIBackports + +// MARK: - Main View + +@available(macOS 10.15, *) +public struct VwrCandidateTDK: View { + public weak var controller: CtlCandidateTDK? + @Environment(\.colorScheme) var colorScheme + @State public var thePool: CandidatePool + @State public var forceCatalinaCompatibility: Bool = false + var tooltip: String { thePool.tooltip } + var reverseLookupResult: [String] { thePool.reverseLookupResult } + + let horizontalCellSpacing: CGFloat = 0 + + public var body: some View { + Group { + VStack(alignment: .leading, spacing: 0) { + switch thePool.layout { + case .horizontal: + ZStack { + candidateListBackground + HStack { + mainViewHorizontal + if thePool.maxLinesPerPage == 1 { + rightPanes + } + } + } + default: + mainViewVertical.background(candidateListBackground) + } + if thePool.maxLinesPerPage > 1 || thePool.layout == .vertical { + if controller?.delegate?.showReverseLookupResult ?? true, !tooltip.isEmpty { + ZStack(alignment: .leading) { + bottomPanelBackgroundTDK + .opacity(colorScheme == .dark ? 0 : 0.35) + reverseLookupPane.padding([.top, .horizontal], 4).padding([.bottom], 2) + } + } + statusBar.background( + bottomPanelBackgroundTDK + .opacity(colorScheme == .dark ? 0.35 : 0.5) + ) + } + } + .frame(minWidth: windowWidth, maxWidth: windowWidth) + .background(candidateListBackground) + .overlay( + RoundedRectangle(cornerRadius: 10).stroke( + absoluteBackgroundColor.opacity(colorScheme == .dark ? 1 : 0.1), lineWidth: 0.5 + ) + ) + .cornerRadius(10) + } + } +} + +// MARK: - Main Views. + +@available(macOS 10.15, *) +extension VwrCandidateTDK { + var mainViewHorizontal: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 1.6) { + ForEach(thePool.lineRangeForCurrentPage, id: \.self) { rowIndex in + ZStack(alignment: .leading) { + lineBackground(lineID: rowIndex).cornerRadius(6) + HStack(spacing: horizontalCellSpacing) { + ForEach(Array(thePool.candidateLines[rowIndex]), id: \.self) { currentCandidate in + drawCandidate(currentCandidate).fixedSize() + } + } + } + .opacity(rowIndex == thePool.currentLineNumber ? 1 : 0.95) + .id(rowIndex) + } + if thePool.maxLinesPerPage - thePool.lineRangeForCurrentPage.count > 0 { + ForEach(thePool.lineRangeForFinalPageBlanked, id: \.self) { _ in + HStack(spacing: 0) { + attributedStringFor(cell: thePool.blankCell) + .frame(alignment: .topLeading) + .contentShape(Rectangle()) + Spacer() + }.frame( + minWidth: 0, + maxWidth: thePool.maxLinesPerPage != 1 ? .infinity : nil, + alignment: .topLeading + ) + } + } + } + } + .fixedSize(horizontal: thePool.maxLinesPerPage == 1, vertical: true) + .padding([.horizontal, .top], 5) + .padding([.bottom], thePool.maxLinesPerPage == 1 ? 5 : 2) + } + + var mainViewVertical: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 4) { + ForEach(Array(thePool.lineRangeForCurrentPage.enumerated()), id: \.offset) { loopIndex, columnIndex in + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(thePool.candidateLines[columnIndex]), id: \.self) { currentCandidate in + drawCandidate(currentCandidate) + } + if thePool.candidateLines[columnIndex].count < thePool.maxLineCapacity { + ForEach(0 ..< thePool.dummyCellsRequiredForCurrentLine, id: \.self) { _ in + drawCandidate(thePool.blankCell) + } + } + } + .background(lineBackground(lineID: columnIndex)).cornerRadius(6) + .opacity(columnIndex == thePool.currentLineNumber ? 1 : 0.95) + .frame( + minWidth: thePool.maxLinesPerPage == 1 + ? max(Double(CandidateCellData.unifiedSize * 6), 90) + : nil, + alignment: .topLeading + ) + .id(columnIndex) + if thePool.maxLinesPerPage > 1, thePool.maxLinesPerPage <= loopIndex + 1 { + Spacer(minLength: 0) + } + } + if thePool.maxLinesPerPage - thePool.lineRangeForCurrentPage.count > 0 { + ForEach(Array(thePool.lineRangeForFinalPageBlanked.enumerated()), id: \.offset) { loopIndex, _ in + VStack(alignment: .leading, spacing: 0) { + ForEach(0 ..< thePool.maxLineCapacity, id: \.self) { _ in + attributedStringFor(cell: thePool.blankCell).fixedSize() + .frame( + width: ceil(thePool.blankCell.minWidthToDraw(isMatrix: true)), + alignment: .topLeading + ) + .contentShape(Rectangle()) + } + }.frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) + if thePool.maxLinesPerPage > 1, + loopIndex >= thePool.maxLinesPerPage - thePool.lineRangeForCurrentPage.count - 1 + { + Spacer(minLength: 0) + } + } + } + } + } + .fixedSize(horizontal: true, vertical: false) + .padding([.horizontal, .top], 5) + .padding([.bottom], thePool.maxLinesPerPage == 1 ? 5 : 2) + } +} + +// MARK: - Common Components. + +@available(macOS 10.15, *) +extension VwrCandidateTDK { + func drawCandidate(_ cell: CandidateCellData) -> some View { + attributedStringFor(cell: cell) + .frame(minWidth: cellWidth(cell).min, maxWidth: cellWidth(cell).max, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture { didSelectCandidateAt(cell.index) } + .contextMenu { + if controller?.delegate?.isCandidateContextMenuEnabled ?? false { + Button { + didRightClickCandidateAt(cell.index, action: .toBoost) + } label: { + Text("↑ " + cell.displayedText) + } + Button { + didRightClickCandidateAt(cell.index, action: .toNerf) + } label: { + Text("↓ " + cell.displayedText) + } + Button { + didRightClickCandidateAt(cell.index, action: .toFilter) + } label: { + Text("✖︎ " + cell.displayedText) + } + } + } + } + + func lineBackground(lineID: Int) -> Color { + let isCurrentLineInMatrix = lineID == thePool.currentLineNumber && thePool.maxLinesPerPage != 1 + switch thePool.layout { + case .horizontal where isCurrentLineInMatrix: + return Color.primary.opacity(0.05) + case .vertical where isCurrentLineInMatrix: + return absoluteBackgroundColor.opacity(0.15) + default: + return Color.clear + } + } + + func cellWidth(_ cell: CandidateCellData) -> (min: CGFloat?, max: CGFloat?) { + let minAccepted = ceil(thePool.blankCell.minWidthToDraw(isMatrix: false) * 1.1) + let defaultMin: CGFloat = cell.minWidthToDraw(isMatrix: thePool.maxLinesPerPage != 1) + var min: CGFloat = defaultMin + if thePool.layout != .vertical, thePool.maxLinesPerPage == 1 { + min = max(minAccepted, cell.minWidthToDraw(isMatrix: false)) + } + return (min, nil) + } + + var windowWidth: CGFloat? { + let paddings: CGFloat = 10.0 + let spacings: CGFloat = horizontalCellSpacing * Double(thePool.maxLineCapacity - 1) + let maxWindowWith: CGFloat + = ceil( + Double(thePool.maxLineCapacity) * (thePool.blankCell.minWidthToDraw()) + + paddings + spacings + ) + return thePool.layout == .horizontal && thePool.maxLinesPerPage > 1 ? maxWindowWith : nil + } + + var firstReverseLookupResult: String { + reverseLookupResult.first?.trimmingCharacters(in: .newlines) ?? "" + } + + /// 以系統字型就給定的粗細狀態與字號來測量給定的字串的渲染寬度,且給出其「向上取整值」。 + /// - Remark: 所有 SwiftUI Text 元件必須手動在介面元素尺寸處理這方面加上向上取整的步驟, + /// 否則的話:當元素尺寸不是整數、且整個視窗內部的 View 都在 .fixedSize() 的時候, + /// 視窗內整個 View 的橫向或縱向起始座標可能就不是 0 而是 -0.5。 + /// - Parameters: + /// - text: 給定的字串。 + /// - fontSize: 給定的字號。 + /// - isBold: 給定的粗細狀態。 + /// - Returns: 測量出來的字串渲染寬度,經過向上取整之處理。 + func getTextWidth(text: String, fontSize: CGFloat, isBold: Bool) -> CGFloat? { + guard !text.isEmpty else { return nil } + let attributes: [NSAttributedString.Key: AnyObject] = [ + .font: NSFont.systemFont(ofSize: fontSize, weight: isBold ? .bold : .regular), + .paragraphStyle: CandidateCellData.sharedParagraphStyle, + ] + let attrString = NSAttributedString(string: text, attributes: attributes) + return ceil(attrString.boundingDimension.width) + } + + var positionLabelView: some View { + ZStack { + Color(white: colorScheme == .dark ? 0.215 : 0.9).cornerRadius(4) + Text(thePool.currentPositionLabelText).lineLimit(1) + .font(.system(size: max(ceil(CandidateCellData.unifiedSize * 0.7), 11), weight: .bold)) + .frame( + width: getTextWidth( + text: thePool.currentPositionLabelText, + fontSize: max(ceil(CandidateCellData.unifiedSize * 0.7), 11), + isBold: true + ) + ) + .padding([.horizontal], 2) + .foregroundColor(.primary.opacity(0.8)) + }.fixedSize() + } + + var rightPanes: some View { + HStack { + if !tooltip.isEmpty { + ZStack(alignment: .center) { + Circle().fill(highlightBackgroundTDK.opacity(0.8)) + Text(tooltip.first?.description ?? "").padding(2).font(.system(size: CandidateCellData.unifiedSize)) + }.frame(width: ceil(CandidateCellData.unifiedSize * 1.7), height: ceil(CandidateCellData.unifiedSize * 1.7)) + } + VStack(alignment: .center, spacing: 1) { + positionLabelView + if controller?.delegate?.showReverseLookupResult ?? true { + if !firstReverseLookupResult.isEmpty { + ZStack(alignment: .center) { + Color(white: colorScheme == .dark ? 0.2 : 0.9).cornerRadius(4) + Text(firstReverseLookupResult) + .font(.system(size: max(ceil(CandidateCellData.unifiedSize * 0.6), 9))) + .frame( + width: getTextWidth( + text: firstReverseLookupResult, + fontSize: max(ceil(CandidateCellData.unifiedSize * 0.6), 9), + isBold: false + ) + ) + .opacity(0.8).padding([.horizontal], 4) + }.fixedSize() + } + } + } + .opacity(0.9) + .fixedSize() + .padding([.trailing], 12) + } + } + + var reverseLookupPane: some View { + HStack(alignment: .center, spacing: 2) { + if thePool.maxLinesPerPage == 1 { + if !firstReverseLookupResult.isEmpty { + ZStack(alignment: .center) { + Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) + Text("\(firstReverseLookupResult.trimmingCharacters(in: .newlines))") + .lineLimit(1).padding([.horizontal], 2) + }.fixedSize() + } + } else { + Text("→").opacity(0.8) + ForEach(reverseLookupResult, id: \.self) { currentResult in + ZStack(alignment: .center) { + Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) + Text("\(currentResult.trimmingCharacters(in: .newlines))") + .lineLimit(1).padding([.horizontal], 2) + }.fixedSize() + } + } + } + .font(.system(size: max(ceil(CandidateCellData.unifiedSize * 0.6), 9))) + .foregroundColor(colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9)) + } + + var statusBar: some View { + HStack(alignment: .center) { + if !tooltip.isEmpty { + Text(tooltip).lineLimit(1) + } else { + if controller?.delegate?.showReverseLookupResult ?? true, tooltip.isEmpty { + reverseLookupPane.padding(0) + } + } + Spacer() + positionLabelView + } + .font(.system(size: max(ceil(CandidateCellData.unifiedSize * 0.7), 11), weight: .bold)) + .padding([.bottom, .horizontal], 7).padding([.top], 2) + .fixedSize(horizontal: false, vertical: true) + } + + var highlightBackgroundTDK: Color { + tooltip.isEmpty ? Color(white: colorScheme == .dark ? 0.2 : 0.9) : thePool.blankCell.themeColor + } + + var candidateListBackground: some View { + Group { + absoluteBackgroundColor + if colorScheme == .dark { + Color.primary.opacity(0.05) + } else { + Color.primary.opacity(0.01) + } + } + } + + var absoluteBackgroundColor: Color { + if colorScheme == .dark { + return Color(white: 0) + } else { + return Color(white: 1) + } + } + + var bottomPanelBackgroundTDK: Color { + Color(white: colorScheme == .dark ? 0.145 : 0.95) + } + + func attributedStringFor(cell theCell: CandidateCellData) -> some View { + let defaultResult = theCell.attributedStringForSwiftUIBackports + if forceCatalinaCompatibility { + return defaultResult + } + if #available(macOS 12, *) { + return theCell.attributedStringForSwiftUI + } + return defaultResult + } +} + +// MARK: - Delegate Methods + +@available(macOS 10.15, *) +extension VwrCandidateTDK { + func didSelectCandidateAt(_ pos: Int) { + controller?.delegate?.candidatePairSelected(at: pos) + } + + func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) { + controller?.delegate?.candidatePairRightClicked(at: pos, action: action) + } +} + +// MARK: - Preview + +import SwiftExtension + +@available(macOS 10.15, *) +struct AttributedLabel_Previews: PreviewProvider { + @State static var testCandidates: [String] = [ + "二十四歲是學生", "二十四歲", "昏睡紅茶", "食雪漢", "意味深", "學生", "便乗", + "🐂🍺🐂🍺", "🐃🍺", "🐂🍺", "🐃🐂🍺🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", + "迫真", "驚愕", "論證", "正論", "惱", "悲", "屑", "食", "雪", "漢", "意", "味", + "深", "二", "十", "四", "歲", "是", "學", "生", "昏", "睡", "紅", "茶", "便", "乗", + "嗯", "哼", "啊", + ] + @State static var reverseLookupResult = ["mmmmm", "dddd"] + @State static var tooltip = "📼" + @State static var oldOS: Bool = true + + static var thePoolX: CandidatePool { + var result = CandidatePool( + candidates: testCandidates, lines: 4, + selectionKeys: "123456", layout: .horizontal + ) + result.reverseLookupResult = Self.reverseLookupResult + result.tooltip = Self.tooltip + result.highlight(at: 0) + return result + } + + static var thePoolXS: CandidatePool { + var result = CandidatePool( + candidates: testCandidates, lines: 1, + selectionKeys: "123456", layout: .horizontal + ) + result.reverseLookupResult = Self.reverseLookupResult + result.tooltip = Self.tooltip + result.highlight(at: 1) + return result + } + + static var thePoolY: CandidatePool { + var result = CandidatePool( + candidates: testCandidates, lines: 4, + selectionKeys: "123456", layout: .vertical + ) + result.reverseLookupResult = Self.reverseLookupResult + result.tooltip = Self.tooltip + result.flipPage(isBackward: false) + result.highlight(at: 2) + return result + } + + static var thePoolYS: CandidatePool { + var result = CandidatePool( + candidates: testCandidates, lines: 1, + selectionKeys: "123456", layout: .vertical + ) + result.reverseLookupResult = Self.reverseLookupResult + result.tooltip = Self.tooltip + result.highlight(at: 1) + return result + } + + static var candidateListBackground: Color { + if NSApplication.isDarkMode { + return Color(white: 0.05) + } else { + return Color(white: 0.99) + } + } + + static var previews: some View { + VStack { + HStack(alignment: .top) { + Text("田所選字窗 效能模式").bold().font(Font.system(.title)) + VStack { + AttributedLabel(attributedString: Self.thePoolX.attributedDescription) + .padding(5) + .background(candidateListBackground) + .cornerRadius(10).fixedSize() + AttributedLabel(attributedString: Self.thePoolXS.attributedDescription) + .padding(5) + .background(candidateListBackground) + .cornerRadius(10).fixedSize() + HStack { + AttributedLabel(attributedString: Self.thePoolY.attributedDescription) + .padding(5) + .background(candidateListBackground) + .cornerRadius(10).fixedSize() + AttributedLabel(attributedString: Self.thePoolYS.attributedDescription) + .padding(5) + .background(candidateListBackground) + .cornerRadius(10).fixedSize() + } + } + } + Divider() + HStack(alignment: .top) { + Text("田所選字窗 SwiftUI 模式").bold().font(Font.system(.title)) + VStack { + VwrCandidateTDK(controller: nil, thePool: thePoolX, forceCatalinaCompatibility: oldOS).fixedSize() + VwrCandidateTDK(controller: nil, thePool: thePoolXS, forceCatalinaCompatibility: oldOS).fixedSize() + HStack { + VwrCandidateTDK(controller: nil, thePool: thePoolY, forceCatalinaCompatibility: oldOS).fixedSize() + VwrCandidateTDK(controller: nil, thePool: thePoolYS, forceCatalinaCompatibility: oldOS).fixedSize() + } + } + } + } + } +} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateHorizontal.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateHorizontal.swift deleted file mode 100644 index 7bae386a..00000000 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateHorizontal.swift +++ /dev/null @@ -1,161 +0,0 @@ -// (c) 2022 and onwards The vChewing Project (MIT-NTL License). -// ==================== -// This code is released under the MIT license (SPDX-License-Identifier: MIT) -// ... with NTL restriction stating that: -// No trademark license is granted to use the trade names, trademarks, service -// marks, or product names of Contributor, except as required to fulfill notice -// requirements defined in MIT License. - -import Cocoa -import Shared -import SwiftUI - -// MARK: - Some useless tests - -@available(macOS 12, *) -struct CandidatePoolViewUIHorizontal_Previews: PreviewProvider { - @State static var testCandidates: [String] = [ - "二十四歲是學生", "二十四歲", "昏睡紅茶", "食雪漢", "意味深", "學生", "便乗", - "🐂🍺🐂🍺", "🐃🍺", "🐂🍺", "🐃🐂🍺🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", - "迫真", "驚愕", "論證", "正論", "惱", "悲", "屑", "食", "雪", "漢", "意", "味", - "深", "二", "十", "四", "歲", "是", "學", "生", "昏", "睡", "紅", "茶", "便", "乗", - "嗯", "哼", "啊", - ] - static var thePool: CandidatePool { - var result = CandidatePool(candidates: testCandidates, rowCapacity: 6) - // 下一行待解決:無論這裡怎麼指定高亮選中項是哪一筆,其所在行都得被卷動到使用者眼前。 - result.highlight(at: 5) - return result - } - - static var previews: some View { - VwrCandidateHorizontal(controller: nil, thePool: thePool).fixedSize() - } -} - -@available(macOS 12, *) -public struct VwrCandidateHorizontal: View { - public weak var controller: CtlCandidateTDK? - @Environment(\.colorScheme) var colorScheme - @State public var thePool: CandidatePool - @State public var tooltip: String = "" - @State public var reverseLookupResult: [String] = [] - - private var positionLabel: String { - (thePool.highlightedIndex + 1).description + "/" + thePool.candidateDataAll.count.description - } - - private func didSelectCandidateAt(_ pos: Int) { - if let delegate = controller?.delegate { - delegate.candidatePairSelected(at: pos) - } - } - - private func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) { - if let delegate = controller?.delegate { - delegate.candidatePairRightClicked(at: pos, action: action) - } - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 1.6) { - ForEach(thePool.rangeForCurrentPage, id: \.self) { rowIndex in - HStack(spacing: ceil(CandidateCellData.unifiedSize * 0.35)) { - ForEach(Array(thePool.candidateLines[rowIndex]), id: \.self) { currentCandidate in - currentCandidate.attributedStringForSwiftUI.fixedSize() - .contentShape(Rectangle()) - .frame(alignment: .topLeading) - .onTapGesture { didSelectCandidateAt(currentCandidate.index) } - .contextMenu { - if controller?.delegate?.isCandidateContextMenuEnabled ?? false { - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toBoost) - } label: { - Text("↑ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toNerf) - } label: { - Text("↓ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toFilter) - } label: { - Text("✖︎ " + currentCandidate.displayedText) - } - } - } - } - Spacer() - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ).id(rowIndex) - Divider() - } - if thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count > 0 { - ForEach(thePool.rangeForLastPageBlanked, id: \.self) { _ in - HStack(spacing: 0) { - thePool.blankCell.attributedStringForSwiftUI - .contentShape(Rectangle()) - .frame(alignment: .topLeading) - Spacer() - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - Divider() - } - } - } - } - .fixedSize(horizontal: false, vertical: true) - .padding([.horizontal], 5).padding([.top], 5).padding([.bottom], -1) - if controller?.delegate?.showReverseLookupResult ?? true { - ZStack(alignment: .leading) { - Color(white: colorScheme == .dark ? 0.15 : 0.97) - HStack(alignment: .center, spacing: 4) { - Text("→") - ForEach(reverseLookupResult, id: \.self) { currentResult in - ZStack(alignment: .center) { - Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) - Text(" \(currentResult.trimmingCharacters(in: .newlines)) ").lineLimit(1) - }.fixedSize() - } - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.6, 9))) - .padding([.horizontal], 4).padding([.vertical], 4) - .foregroundColor(colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9)) - } - } - ZStack(alignment: .trailing) { - Color(nsColor: tooltip.isEmpty ? .windowBackgroundColor : CandidateCellData.highlightBackground) - .ignoresSafeArea() - HStack(alignment: .center) { - if !tooltip.isEmpty { - Text(tooltip).lineLimit(1) - Spacer() - } - Text(positionLabel).lineLimit(1) - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.7, 11), weight: .bold)) - .padding(7).foregroundColor( - .init(nsColor: tooltip.isEmpty ? .controlTextColor : .selectedMenuItemTextColor.withAlphaComponent(0.9)) - ) - } - .fixedSize(horizontal: false, vertical: true) - } - .frame(minWidth: thePool.maxWindowWidth, maxWidth: thePool.maxWindowWidth) - .background(Color(nsColor: NSColor.controlBackgroundColor).ignoresSafeArea()) - .overlay( - RoundedRectangle(cornerRadius: 10).stroke( - Color(white: colorScheme == .dark ? 0 : 1).opacity(colorScheme == .dark ? 1 : 0.1), lineWidth: 0.5 - ) - ) - .cornerRadius(10) - } -} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateVertical.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateVertical.swift deleted file mode 100644 index f2e4fb32..00000000 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK/VwrCandidateVertical.swift +++ /dev/null @@ -1,171 +0,0 @@ -// (c) 2022 and onwards The vChewing Project (MIT-NTL License). -// ==================== -// This code is released under the MIT license (SPDX-License-Identifier: MIT) -// ... with NTL restriction stating that: -// No trademark license is granted to use the trade names, trademarks, service -// marks, or product names of Contributor, except as required to fulfill notice -// requirements defined in MIT License. - -import Cocoa -import Shared -import SwiftUI - -// MARK: - Some useless tests - -@available(macOS 12, *) -struct CandidatePoolViewUIVertical_Previews: PreviewProvider { - @State static var testCandidates: [String] = [ - "二十四歲是學生", "二十四歲", "昏睡紅茶", "食雪漢", "意味深", "學生", "便乗", - "🐂🍺🐂🍺", "🐃🍺", "🐂🍺", "🐃🐂🍺🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", - "迫真", "驚愕", "論證", "正論", "惱", "悲", "屑", "食", "雪", "漢", "意", "味", - "深", "二", "十", "四", "歲", "是", "學", "生", "昏", "睡", "紅", "茶", "便", "乗", - "嗯", "哼", "啊", - ] - static var thePool: CandidatePool { - var result = CandidatePool(candidates: testCandidates, columnCapacity: 6, selectionKeys: "123456789") - // 下一行待解決:無論這裡怎麼指定高亮選中項是哪一筆,其所在行都得被卷動到使用者眼前。 - result.highlight(at: 5) - return result - } - - static var previews: some View { - VwrCandidateVertical(controller: nil, thePool: thePool).fixedSize() - } -} - -@available(macOS 12, *) -public struct VwrCandidateVertical: View { - public weak var controller: CtlCandidateTDK? - @Environment(\.colorScheme) var colorScheme - @State public var thePool: CandidatePool - @State public var tooltip: String = "" - @State public var reverseLookupResult: [String] = [] - - private var positionLabel: String { - (thePool.highlightedIndex + 1).description + "/" + thePool.candidateDataAll.count.description - } - - private func didSelectCandidateAt(_ pos: Int) { - if let delegate = controller?.delegate { - delegate.candidatePairSelected(at: pos) - } - } - - private func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) { - if let delegate = controller?.delegate { - delegate.candidatePairRightClicked(at: pos, action: action) - } - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 10) { - ForEach(Array(thePool.rangeForCurrentPage.enumerated()), id: \.offset) { loopIndex, columnIndex in - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(thePool.candidateLines[columnIndex]), id: \.self) { currentCandidate in - HStack(spacing: 0) { - currentCandidate.attributedStringForSwiftUI.fixedSize(horizontal: false, vertical: true) - .frame( - maxWidth: .infinity, - alignment: .topLeading - ) - .contentShape(Rectangle()) - .onTapGesture { didSelectCandidateAt(currentCandidate.index) } - .contextMenu { - if controller?.delegate?.isCandidateContextMenuEnabled ?? false { - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toBoost) - } label: { - Text("↑ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toNerf) - } label: { - Text("↓ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toFilter) - } label: { - Text("✖︎ " + currentCandidate.displayedText) - } - } - } - } - } - }.frame( - minWidth: Double(CandidateCellData.unifiedSize * 5), - alignment: .topLeading - ).id(columnIndex) - if loopIndex < thePool.maxLinesPerPage - 1 { - Divider() - } else if thePool.maxLinesPerPage > 1 { - Spacer(minLength: 0) - } - } - if thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count > 0 { - ForEach(Array(thePool.rangeForLastPageBlanked.enumerated()), id: \.offset) { loopIndex, _ in - VStack(alignment: .leading, spacing: 0) { - ForEach(0 ..< thePool.maxLineCapacity, id: \.self) { _ in - thePool.blankCell.attributedStringForSwiftUI.fixedSize() - .frame(width: Double(CandidateCellData.unifiedSize * 5), alignment: .topLeading) - .contentShape(Rectangle()) - } - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - if loopIndex < thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count - 1 { - Divider() - } else if thePool.maxLinesPerPage > 1 { - Spacer(minLength: 0) - } - } - } - } - } - .fixedSize(horizontal: true, vertical: false).padding(5) - if controller?.delegate?.showReverseLookupResult ?? true { - ZStack(alignment: .leading) { - Color(white: colorScheme == .dark ? 0.15 : 0.97) - HStack(alignment: .center, spacing: 4) { - Text("→") - ForEach(reverseLookupResult, id: \.self) { currentResult in - ZStack(alignment: .center) { - Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) - Text(" \(currentResult.trimmingCharacters(in: .newlines)) ").lineLimit(1) - }.fixedSize() - } - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.6, 9))) - .padding([.horizontal], 4).padding([.vertical], 4) - .foregroundColor(colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9)) - } - } - ZStack(alignment: .trailing) { - Color(nsColor: tooltip.isEmpty ? .windowBackgroundColor : CandidateCellData.highlightBackground) - .ignoresSafeArea() - HStack(alignment: .center) { - if !tooltip.isEmpty { - Text(tooltip).lineLimit(1) - Spacer() - } - Text(positionLabel).lineLimit(1) - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.7, 11), weight: .bold)) - .padding(7).foregroundColor( - .init(nsColor: tooltip.isEmpty ? .controlTextColor : .selectedMenuItemTextColor.withAlphaComponent(0.9)) - ) - } - .fixedSize(horizontal: false, vertical: true) - } - .background(Color(nsColor: NSColor.controlBackgroundColor).ignoresSafeArea()) - .overlay( - RoundedRectangle(cornerRadius: 10).stroke( - Color(white: colorScheme == .dark ? 0 : 1).opacity(colorScheme == .dark ? 1 : 0.1), lineWidth: 0.5 - ) - ) - .cornerRadius(10) - } -} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateHorizontalBackports.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateHorizontalBackports.swift deleted file mode 100644 index 6d46c7df..00000000 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateHorizontalBackports.swift +++ /dev/null @@ -1,166 +0,0 @@ -// (c) 2022 and onwards The vChewing Project (MIT-NTL License). -// ==================== -// This code is released under the MIT license (SPDX-License-Identifier: MIT) -// ... with NTL restriction stating that: -// No trademark license is granted to use the trade names, trademarks, service -// marks, or product names of Contributor, except as required to fulfill notice -// requirements defined in MIT License. - -import Cocoa -import Shared -import SwiftUI -import SwiftUIBackports - -// MARK: - Some useless tests - -@available(macOS 10.15, *) -struct CandidatePoolViewUIHorizontalBackports_Previews: PreviewProvider { - @State static var testCandidates: [String] = [ - "二十四歲是學生", "二十四歲", "昏睡紅茶", "食雪漢", "意味深", "學生", "便乗", - "🐂🍺🐂🍺", "🐃🍺", "🐂🍺", "🐃🐂🍺🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", - "迫真", "驚愕", "論證", "正論", "惱", "悲", "屑", "食", "雪", "漢", "意", "味", - "深", "二", "十", "四", "歲", "是", "學", "生", "昏", "睡", "紅", "茶", "便", "乗", - "嗯", "哼", "啊", - ] - static var thePool: CandidatePool { - var result = CandidatePool(candidates: testCandidates, rowCapacity: 6) - // 下一行待解決:無論這裡怎麼指定高亮選中項是哪一筆,其所在行都得被卷動到使用者眼前。 - result.highlight(at: 5) - return result - } - - static var previews: some View { - VwrCandidateHorizontalBackports(controller: nil, thePool: thePool).fixedSize() - } -} - -@available(macOS 10.15, *) -public struct VwrCandidateHorizontalBackports: View { - public weak var controller: CtlCandidateTDK? - @Environment(\.colorScheme) var colorScheme - @State public var thePool: CandidatePool - @State public var tooltip: String = "" - @State public var reverseLookupResult: [String] = [] - - private var positionLabel: String { - (thePool.highlightedIndex + 1).description + "/" + thePool.candidateDataAll.count.description - } - - private func didSelectCandidateAt(_ pos: Int) { - if let delegate = controller?.delegate { - delegate.candidatePairSelected(at: pos) - } - } - - private func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) { - if let delegate = controller?.delegate { - delegate.candidatePairRightClicked(at: pos, action: action) - } - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 1.6) { - ForEach(thePool.rangeForCurrentPage, id: \.self) { rowIndex in - HStack(spacing: ceil(CandidateCellData.unifiedSize * 0.35)) { - ForEach(Array(thePool.candidateLines[rowIndex]), id: \.self) { currentCandidate in - currentCandidate.attributedStringForSwiftUIBackports.fixedSize() - .contentShape(Rectangle()) - .frame(alignment: .topLeading) - .onTapGesture { didSelectCandidateAt(currentCandidate.index) } - .contextMenu { - if controller?.delegate?.isCandidateContextMenuEnabled ?? false { - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toBoost) - } label: { - Text("↑ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toNerf) - } label: { - Text("↓ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toFilter) - } label: { - Text("✖︎ " + currentCandidate.displayedText) - } - } - } - } - Spacer() - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ).id(rowIndex) - Divider() - } - if thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count > 0 { - ForEach(thePool.rangeForLastPageBlanked, id: \.self) { _ in - HStack(spacing: 0) { - thePool.blankCell.attributedStringForSwiftUIBackports - .contentShape(Rectangle()) - .frame(alignment: .topLeading) - Spacer() - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - Divider() - } - } - } - } - .fixedSize(horizontal: false, vertical: true) - .padding([.horizontal], 5).padding([.top], 5).padding([.bottom], -1) - if controller?.delegate?.showReverseLookupResult ?? true { - ZStack(alignment: .leading) { - Color(white: colorScheme == .dark ? 0.15 : 0.97) - HStack(alignment: .center, spacing: 4) { - Text("→") - ForEach(reverseLookupResult, id: \.self) { currentResult in - ZStack(alignment: .center) { - Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) - Text(" \(currentResult.trimmingCharacters(in: .newlines)) ").lineLimit(1) - }.fixedSize() - } - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.6, 9))) - .padding([.horizontal], 4).padding([.vertical], 4) - .foregroundColor(colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9)) - } - } - ZStack(alignment: .trailing) { - if tooltip.isEmpty { - Color(white: colorScheme == .dark ? 0.2 : 0.9) - } else { - Color(white: colorScheme == .dark ? 0.0 : 1) - controller?.highlightedColorUIBackports - } - HStack(alignment: .center) { - if !tooltip.isEmpty { - Text(tooltip).lineLimit(1) - Spacer() - } - Text(positionLabel).lineLimit(1) - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.7, 11), weight: .bold)) - .padding(7).foregroundColor( - tooltip.isEmpty && colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9) - ) - } - .fixedSize(horizontal: false, vertical: true) - } - .frame(minWidth: thePool.maxWindowWidth, maxWidth: thePool.maxWindowWidth) - .background(Color(white: colorScheme == .dark ? 0.1 : 1)) - .overlay( - RoundedRectangle(cornerRadius: 10).stroke( - Color(white: colorScheme == .dark ? 0 : 1).opacity(colorScheme == .dark ? 1 : 0.1), lineWidth: 0.5 - ) - ) - .cornerRadius(10) - } -} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateVerticalBackports.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateVerticalBackports.swift deleted file mode 100644 index 8ec542e5..00000000 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Backports/VwrCandidateVerticalBackports.swift +++ /dev/null @@ -1,176 +0,0 @@ -// (c) 2022 and onwards The vChewing Project (MIT-NTL License). -// ==================== -// This code is released under the MIT license (SPDX-License-Identifier: MIT) -// ... with NTL restriction stating that: -// No trademark license is granted to use the trade names, trademarks, service -// marks, or product names of Contributor, except as required to fulfill notice -// requirements defined in MIT License. - -import Cocoa -import Shared -import SwiftUI -import SwiftUIBackports - -// MARK: - Some useless tests - -@available(macOS 10.15, *) -struct CandidatePoolViewUIVerticalBackports_Previews: PreviewProvider { - @State static var testCandidates: [String] = [ - "二十四歲是學生", "二十四歲", "昏睡紅茶", "食雪漢", "意味深", "學生", "便乗", - "🐂🍺🐂🍺", "🐃🍺", "🐂🍺", "🐃🐂🍺🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", "🐂🍺", "🐃🍺", - "迫真", "驚愕", "論證", "正論", "惱", "悲", "屑", "食", "雪", "漢", "意", "味", - "深", "二", "十", "四", "歲", "是", "學", "生", "昏", "睡", "紅", "茶", "便", "乗", - "嗯", "哼", "啊", - ] - static var thePool: CandidatePool { - var result = CandidatePool(candidates: testCandidates, columnCapacity: 6, selectionKeys: "123456789") - // 下一行待解決:無論這裡怎麼指定高亮選中項是哪一筆,其所在行都得被卷動到使用者眼前。 - result.highlight(at: 5) - return result - } - - static var previews: some View { - VwrCandidateVerticalBackports(controller: nil, thePool: thePool).fixedSize() - } -} - -@available(macOS 10.15, *) -public struct VwrCandidateVerticalBackports: View { - public weak var controller: CtlCandidateTDK? - @Environment(\.colorScheme) var colorScheme - @State public var thePool: CandidatePool - @State public var tooltip: String = "" - @State public var reverseLookupResult: [String] = [] - - private var positionLabel: String { - (thePool.highlightedIndex + 1).description + "/" + thePool.candidateDataAll.count.description - } - - private func didSelectCandidateAt(_ pos: Int) { - if let delegate = controller?.delegate { - delegate.candidatePairSelected(at: pos) - } - } - - private func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) { - if let delegate = controller?.delegate { - delegate.candidatePairRightClicked(at: pos, action: action) - } - } - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 10) { - ForEach(Array(thePool.rangeForCurrentPage.enumerated()), id: \.offset) { loopIndex, columnIndex in - VStack(alignment: .leading, spacing: 0) { - ForEach(Array(thePool.candidateLines[columnIndex]), id: \.self) { currentCandidate in - HStack(spacing: 0) { - currentCandidate.attributedStringForSwiftUIBackports.fixedSize(horizontal: false, vertical: true) - .frame( - maxWidth: .infinity, - alignment: .topLeading - ) - .contentShape(Rectangle()) - .onTapGesture { didSelectCandidateAt(currentCandidate.index) } - .contextMenu { - if controller?.delegate?.isCandidateContextMenuEnabled ?? false { - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toBoost) - } label: { - Text("↑ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toNerf) - } label: { - Text("↓ " + currentCandidate.displayedText) - } - Button { - didRightClickCandidateAt(currentCandidate.index, action: .toFilter) - } label: { - Text("✖︎ " + currentCandidate.displayedText) - } - } - } - } - } - }.frame( - minWidth: Double(CandidateCellData.unifiedSize * 5), - alignment: .topLeading - ).id(columnIndex) - if loopIndex < thePool.maxLinesPerPage - 1 { - Divider() - } else if thePool.maxLinesPerPage > 1 { - Spacer(minLength: 0) - } - } - if thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count > 0 { - ForEach(Array(thePool.rangeForLastPageBlanked.enumerated()), id: \.offset) { loopIndex, _ in - VStack(alignment: .leading, spacing: 0) { - ForEach(0 ..< thePool.maxLineCapacity, id: \.self) { _ in - thePool.blankCell.attributedStringForSwiftUIBackports.fixedSize() - .frame(width: Double(CandidateCellData.unifiedSize * 5), alignment: .topLeading) - .contentShape(Rectangle()) - } - }.frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - if loopIndex < thePool.maxLinesPerPage - thePool.rangeForCurrentPage.count - 1 { - Divider() - } else if thePool.maxLinesPerPage > 1 { - Spacer(minLength: 0) - } - } - } - } - } - .fixedSize(horizontal: true, vertical: false).padding(5) - if controller?.delegate?.showReverseLookupResult ?? true { - ZStack(alignment: .leading) { - Color(white: colorScheme == .dark ? 0.15 : 0.97) - HStack(alignment: .center, spacing: 4) { - Text("→") - ForEach(reverseLookupResult, id: \.self) { currentResult in - ZStack(alignment: .center) { - Color(white: colorScheme == .dark ? 0.3 : 0.9).cornerRadius(3) - Text(" \(currentResult.trimmingCharacters(in: .newlines)) ").lineLimit(1) - }.fixedSize() - } - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.6, 9))) - .padding([.horizontal], 4).padding([.vertical], 4) - .foregroundColor(colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9)) - } - } - ZStack(alignment: .trailing) { - if tooltip.isEmpty { - Color(white: colorScheme == .dark ? 0.2 : 0.9) - } else { - Color(white: colorScheme == .dark ? 0.0 : 1) - controller?.highlightedColorUIBackports - } - HStack(alignment: .center) { - if !tooltip.isEmpty { - Text(tooltip).lineLimit(1) - Spacer() - } - Text(positionLabel).lineLimit(1) - } - .font(.system(size: max(CandidateCellData.unifiedSize * 0.7, 11), weight: .bold)) - .padding(7).foregroundColor( - tooltip.isEmpty && colorScheme == .light ? Color(white: 0.1) : Color(white: 0.9) - ) - } - .fixedSize(horizontal: false, vertical: true) - } - .background(Color(white: colorScheme == .dark ? 0.1 : 1)) - .overlay( - RoundedRectangle(cornerRadius: 10).stroke( - Color(white: colorScheme == .dark ? 0 : 1).opacity(colorScheme == .dark ? 1 : 0.1), lineWidth: 0.5 - ) - ) - .cornerRadius(10) - } -} diff --git a/Packages/vChewing_CandidateWindow/Tests/CandidateWindowTests/CandidatePoolTests.swift b/Packages/vChewing_CandidateWindow/Tests/CandidateWindowTests/CandidatePoolTests.swift index 5e3f0758..c36c818d 100644 --- a/Packages/vChewing_CandidateWindow/Tests/CandidateWindowTests/CandidatePoolTests.swift +++ b/Packages/vChewing_CandidateWindow/Tests/CandidateWindowTests/CandidatePoolTests.swift @@ -20,7 +20,7 @@ final class CandidatePoolTests: XCTestCase { ] func testPoolHorizontal() throws { - let pool = CandidatePool(candidates: testCandidates, rowCapacity: 6) + let pool = CandidatePool(candidates: testCandidates, selectionKeys: "123456", layout: .horizontal) var strOutput = "" pool.candidateLines.forEach { $0.forEach { @@ -33,7 +33,7 @@ final class CandidatePoolTests: XCTestCase { } func testPoolVertical() throws { - let pool = CandidatePool(candidates: testCandidates, columnCapacity: 6) + let pool = CandidatePool(candidates: testCandidates, selectionKeys: "123456", layout: .vertical) var strOutput = "" pool.candidateLines.forEach { $0.forEach { diff --git a/Source/Modules/PrefMgr_Extension.swift b/Source/Modules/PrefMgr_Extension.swift index 6760bd84..f3369c81 100644 --- a/Source/Modules/PrefMgr_Extension.swift +++ b/Source/Modules/PrefMgr_Extension.swift @@ -14,7 +14,6 @@ public extension PrefMgr { func fixOddPreferences() { // macOS 10.15 開始才能使用 SwiftUI 構建的田所選字窗。 if #unavailable(macOS 10.15) { - useIMKCandidateWindow = true legacyCandidateViewTypesettingMethodEnabled = false togglingAlphanumericalModeWithRShift = false togglingAlphanumericalModeWithLShift = false diff --git a/Source/Modules/SessionCtl_HandleDisplay.swift b/Source/Modules/SessionCtl_HandleDisplay.swift index 2885b21d..ed457bc0 100644 --- a/Source/Modules/SessionCtl_HandleDisplay.swift +++ b/Source/Modules/SessionCtl_HandleDisplay.swift @@ -86,15 +86,15 @@ public extension SessionCtl { /// 先取消既有的選字窗的內容顯示。否則可能會重複生成選字窗的 NSWindow()。 candidateUI?.visible = false /// 然後再重新初期化。 - if #available(macOS 10.15, *) { + if #available(macOS 10.13, *) { candidateUI = PrefMgr.shared.useIMKCandidateWindow ? CtlCandidateIMK(candidateLayout) : CtlCandidateTDK(candidateLayout) if let candidateTDK = candidateUI as? CtlCandidateTDK { - candidateTDK.maxLinesPerPage = isVerticalTyping ? 1 : 3 + candidateTDK.maxLinesPerPage = isVerticalTyping ? 1 : 4 } } else { - candidateUI = CtlCandidateIMK(candidateLayout) + candidateUI = CtlCandidateTDK(candidateLayout) } candidateUI?.candidateFont = Self.candidateFont(