TDKCandidates // Massive renovation + Cocoa legacy mode.
This commit is contained in:
parent
4a2db996a0
commit
2bfad15422
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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<Int>?
|
||||
private var previouslyRecordedLineRangeForPreviousPage: Range<Int>?
|
||||
|
||||
// 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<Int> {
|
||||
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<Int> {
|
||||
recordedLineRangeForCurrentPage ?? fallbackedLineRangeForCurrentPage
|
||||
}
|
||||
|
||||
public var rangeForLastPageBlanked: Range<Int> {
|
||||
switch currentLayout {
|
||||
case .horizontal: return rangeForLastHorizontalPageBlanked
|
||||
case .vertical: return rangeForLastVerticalPageBlanked
|
||||
@unknown default: return 0 ..< 0
|
||||
}
|
||||
}
|
||||
|
||||
public var rangeForCurrentPage: Range<Int> {
|
||||
switch currentLayout {
|
||||
case .horizontal: return rangeForCurrentHorizontalPage
|
||||
case .vertical: return rangeForCurrentVerticalPage
|
||||
@unknown default: return 0 ..< 0
|
||||
}
|
||||
/// 當前高亮候選字所在的某個相容頁的行範圍。該參數僅用作墊底回退之用途、或者其它極端用途。
|
||||
public var fallbackedLineRangeForCurrentPage: Range<Int> {
|
||||
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<Int> {
|
||||
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<Int> {
|
||||
0 ..< (maxColumnsPerPage - rangeForCurrentVerticalPage.count)
|
||||
}
|
||||
|
||||
private var rangeForCurrentHorizontalPage: Range<Int> {
|
||||
currentRowNumber ..< min(candidateRows.count, currentRowNumber + maxRowsPerPage)
|
||||
}
|
||||
|
||||
private var rangeForCurrentVerticalPage: Range<Int> {
|
||||
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<Int> {
|
||||
0 ..< min(maxLinesPerPage, candidateLines.count)
|
||||
}
|
||||
|
||||
/// 最後一頁所在的行範圍。
|
||||
var lineRangeForFinalPage: Range<Int> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue