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
|
// marks, or product names of Contributor, except as required to fulfill notice
|
||||||
// requirements defined in MIT License.
|
// requirements defined in MIT License.
|
||||||
|
|
||||||
import Cocoa
|
import Foundation
|
||||||
import Shared
|
import Shared
|
||||||
|
|
||||||
/// 候選字窗會用到的資料池單位。
|
/// 候選字窗會用到的資料池單位,即用即拋。
|
||||||
public struct CandidatePool {
|
public struct CandidatePool {
|
||||||
public let blankCell = CandidateCellData(key: " ", displayedText: " ", isSelected: false)
|
public let blankCell: CandidateCellData
|
||||||
public var currentLayout: NSUserInterfaceLayoutOrientation = .horizontal
|
public let maxLinesPerPage: Int
|
||||||
public private(set) var candidateDataAll: [CandidateCellData] = []
|
public let layout: LayoutOrientation
|
||||||
public private(set) var selectionKeys: String
|
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 highlightedIndex: Int = 0
|
||||||
|
public private(set) var currentLineNumber = 0
|
||||||
|
|
||||||
// 下述變數只有橫排選字窗才會用到
|
private var recordedLineRangeForCurrentPage: Range<Int>?
|
||||||
private var currentRowNumber = 0
|
private var previouslyRecordedLineRangeForPreviousPage: Range<Int>?
|
||||||
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]] = []
|
|
||||||
|
|
||||||
// MARK: - 動態變數
|
// 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 {
|
public var currentCandidate: CandidateCellData? {
|
||||||
case .horizontal:
|
(0 ..< candidateDataAll.count).contains(highlightedIndex) ? candidateDataAll[highlightedIndex] : nil
|
||||||
return currentRowNumber
|
|
||||||
case .vertical:
|
|
||||||
return currentColumnNumber
|
|
||||||
@unknown default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var candidateLines: [[CandidateCellData]] {
|
/// 當前高亮的候選字詞的文本。如果相關資料不存在或者不合規的話,則返回空字串。
|
||||||
switch currentLayout {
|
public var currentSelectedCandidateText: String? { currentCandidate?.displayedText ?? nil }
|
||||||
case .horizontal:
|
|
||||||
return candidateRows
|
/// 每行/每列理論上應該最多塞多少個候選字詞。這其實就是當前啟用的選字鍵的數量。
|
||||||
case .vertical:
|
public var maxLineCapacity: Int { selectionKeys.count }
|
||||||
return candidateColumns
|
|
||||||
@unknown default:
|
/// 當選字窗處於單行模式時,如果一行內的內容過少的話,該變數會指出需要再插入多少個空白候選字詞單位。
|
||||||
return []
|
public var dummyCellsRequiredForCurrentLine: Int {
|
||||||
}
|
maxLineCapacity - candidateLines[currentLineNumber].count
|
||||||
}
|
}
|
||||||
|
|
||||||
public var maxLineCapacity: Int {
|
/// 如果當前的行數小於最大行數的話,該變數會指出還需要多少空白行。
|
||||||
switch currentLayout {
|
public var lineRangeForFinalPageBlanked: Range<Int> {
|
||||||
case .horizontal:
|
0 ..< (maxLinesPerPage - lineRangeForCurrentPage.count)
|
||||||
return maxRowCapacity
|
|
||||||
case .vertical:
|
|
||||||
return maxColumnCapacity
|
|
||||||
@unknown default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public var maxLinesPerPage: Int {
|
/// 當前頁所在的行範圍。
|
||||||
get {
|
public var lineRangeForCurrentPage: Range<Int> {
|
||||||
switch currentLayout {
|
recordedLineRangeForCurrentPage ?? fallbackedLineRangeForCurrentPage
|
||||||
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 rangeForLastPageBlanked: Range<Int> {
|
/// 當前高亮候選字所在的某個相容頁的行範圍。該參數僅用作墊底回退之用途、或者其它極端用途。
|
||||||
switch currentLayout {
|
public var fallbackedLineRangeForCurrentPage: Range<Int> {
|
||||||
case .horizontal: return rangeForLastHorizontalPageBlanked
|
currentLineNumber ..< min(candidateLines.count, currentLineNumber + maxLinesPerPage)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constructors
|
// MARK: - Constructors
|
||||||
|
|
||||||
/// 初期化一個縱排候選字窗專用資料池。
|
/// 初期化一個候選字窗專用資料池。
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - candidates: 要塞入的候選字詞陣列。
|
/// - candidates: 要塞入的候選字詞陣列。
|
||||||
/// - columnCapacity: (第一縱列的最大候選字詞數量, 陣列畫面展開之後的每一縱列的最大候選字詞數量)。
|
|
||||||
/// - selectionKeys: 選字鍵。
|
/// - selectionKeys: 選字鍵。
|
||||||
|
/// - direction: 橫向排列還是縱向排列(預設情況下是縱向)。
|
||||||
/// - locale: 區域編碼。例:「zh-Hans」或「zh-Hant」。
|
/// - locale: 區域編碼。例:「zh-Hans」或「zh-Hant」。
|
||||||
public init(
|
public init(
|
||||||
candidates: [String], columnCapacity: Int, columns: Int = 3, selectionKeys: String = "123456789",
|
candidates: [String], lines: Int = 3, selectionKeys: String = "123456789",
|
||||||
locale: String = ""
|
layout: LayoutOrientation = .vertical, locale: String = ""
|
||||||
) {
|
) {
|
||||||
maxColumnsPerPage = max(1, columns)
|
self.layout = layout
|
||||||
maxColumnCapacity = max(1, columnCapacity)
|
maxLinesPerPage = max(1, lines)
|
||||||
self.selectionKeys = selectionKeys
|
blankCell = CandidateCellData(key: " ", displayedText: " ", isSelected: false)
|
||||||
candidateDataAll = candidates.map { .init(key: "0", displayedText: $0) }
|
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] = []
|
var currentColumn: [CandidateCellData] = []
|
||||||
for (i, candidate) in candidateDataAll.enumerated() {
|
for (i, candidate) in candidateDataAll.enumerated() {
|
||||||
candidate.index = i
|
candidate.index = i
|
||||||
candidate.whichColumn = candidateColumns.count
|
candidate.whichLine = candidateLines.count
|
||||||
if currentColumn.count == maxColumnCapacity, !currentColumn.isEmpty {
|
var isOverflown: Bool = (currentColumn.count == maxLineCapacity) && !currentColumn.isEmpty
|
||||||
candidateColumns.append(currentColumn)
|
if layout == .horizontal {
|
||||||
|
isOverflown = isOverflown
|
||||||
|
|| currentColumn.map { $0.cellLength() }.reduce(0, +) >= maxRowWidth - candidate.cellLength()
|
||||||
|
}
|
||||||
|
if isOverflown {
|
||||||
|
candidateLines.append(currentColumn)
|
||||||
currentColumn.removeAll()
|
currentColumn.removeAll()
|
||||||
candidate.whichColumn += 1
|
candidate.whichLine += 1
|
||||||
}
|
}
|
||||||
candidate.subIndex = currentColumn.count
|
candidate.subIndex = currentColumn.count
|
||||||
candidate.locale = locale
|
candidate.locale = locale
|
||||||
currentColumn.append(candidate)
|
currentColumn.append(candidate)
|
||||||
}
|
}
|
||||||
candidateColumns.append(currentColumn)
|
candidateLines.append(currentColumn)
|
||||||
currentLayout = .vertical
|
recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage
|
||||||
}
|
highlight(at: 0)
|
||||||
|
|
||||||
/// 初期化一個橫排候選字窗專用資料池。
|
|
||||||
/// - 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Functions
|
// MARK: - Public Functions (for all OS)
|
||||||
|
|
||||||
extension CandidatePool {
|
public extension CandidatePool {
|
||||||
private enum VerticalDirection {
|
/// 選字窗的候選字詞陳列方向。
|
||||||
case up
|
enum LayoutOrientation {
|
||||||
case down
|
case horizontal
|
||||||
|
case vertical
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum HorizontalDirection {
|
/// 往指定的方向翻頁。
|
||||||
case left
|
/// - Parameter isBackward: 是否逆向翻頁。
|
||||||
case right
|
/// - 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)
|
/// - Parameters:
|
||||||
}
|
/// - isBackward: 是否逆向翻行。
|
||||||
|
/// - count: 翻幾行。
|
||||||
private var rangeForCurrentHorizontalPage: Range<Int> {
|
/// - Returns: 操作是否順利。
|
||||||
currentRowNumber ..< min(candidateRows.count, currentRowNumber + maxRowsPerPage)
|
@discardableResult mutating func consecutivelyFlipLines(isBackward: Bool, count: Int) -> Bool {
|
||||||
}
|
switch isBackward {
|
||||||
|
case false where currentLineNumber == candidateLines.count - 1:
|
||||||
private var rangeForCurrentVerticalPage: Range<Int> {
|
return highlightNeighborCandidate(isBackward: false)
|
||||||
currentColumnNumber ..< min(candidateColumns.count, currentColumnNumber + maxColumnsPerPage)
|
case true where currentLineNumber == 0:
|
||||||
}
|
return highlightNeighborCandidate(isBackward: true)
|
||||||
|
default:
|
||||||
private mutating func selectNewNeighborRow(direction: VerticalDirection) {
|
if count <= 0 { return false }
|
||||||
let currentSubIndex = candidateDataAll[highlightedIndex].subIndex
|
for _ in 0 ..< min(maxLinesPerPage, count) {
|
||||||
var result = currentSubIndex
|
selectNewNeighborLine(isBackward: isBackward)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if currentRowNumber >= candidateRows.count - 1 { currentRowNumber = candidateRows.count - 1 }
|
return true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mutating func selectNewNeighborColumn(direction: HorizontalDirection) {
|
/// 嘗試高亮前方或者後方的鄰近候選字詞。
|
||||||
let currentSubIndex = candidateDataAll[highlightedIndex].subIndex
|
/// - Parameter isBackward: 是否是後方的鄰近候選字詞。
|
||||||
switch direction {
|
/// - Returns: 是否成功。
|
||||||
case .left:
|
@discardableResult mutating func highlightNeighborCandidate(isBackward: Bool) -> Bool {
|
||||||
if currentColumnNumber <= 0 {
|
switch isBackward {
|
||||||
if candidateColumns.isEmpty { break }
|
case false where highlightedIndex >= candidateDataAll.count - 1:
|
||||||
let firstColumn = candidateColumns[0]
|
highlight(at: 0)
|
||||||
let newSubIndex = min(currentSubIndex, firstColumn.count - 1)
|
return false
|
||||||
highlightVertical(at: firstColumn[newSubIndex].index)
|
case true where highlightedIndex <= 0:
|
||||||
break
|
highlight(at: candidateDataAll.count - 1)
|
||||||
}
|
return false
|
||||||
if currentColumnNumber >= candidateColumns.count - 1 { currentColumnNumber = candidateColumns.count - 1 }
|
default:
|
||||||
let targetColumn = candidateColumns[currentColumnNumber - 1]
|
highlight(at: highlightedIndex + (isBackward ? -1 : 1))
|
||||||
let newSubIndex = min(currentSubIndex, targetColumn.count - 1)
|
return true
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mutating func highlightHorizontal(at indexSpecified: Int) {
|
/// 高亮指定的候選字。
|
||||||
|
/// - Parameter indexSpecified: 給定的候選字詞索引編號,得是資料池內的總索引編號。
|
||||||
|
mutating func highlight(at indexSpecified: Int) {
|
||||||
var indexSpecified = indexSpecified
|
var indexSpecified = indexSpecified
|
||||||
|
let isBackward: Bool = indexSpecified > highlightedIndex
|
||||||
highlightedIndex = indexSpecified
|
highlightedIndex = indexSpecified
|
||||||
if !(0 ..< candidateDataAll.count).contains(highlightedIndex) {
|
if !(0 ..< candidateDataAll.count).contains(highlightedIndex) {
|
||||||
switch highlightedIndex {
|
switch highlightedIndex {
|
||||||
case candidateDataAll.count...:
|
case candidateDataAll.count...:
|
||||||
currentRowNumber = candidateRows.count - 1
|
currentLineNumber = candidateLines.count - 1
|
||||||
highlightedIndex = max(0, candidateDataAll.count - 1)
|
highlightedIndex = max(0, candidateDataAll.count - 1)
|
||||||
indexSpecified = highlightedIndex
|
indexSpecified = highlightedIndex
|
||||||
case ..<0:
|
case ..<0:
|
||||||
highlightedIndex = 0
|
highlightedIndex = 0
|
||||||
currentRowNumber = 0
|
currentLineNumber = 0
|
||||||
indexSpecified = highlightedIndex
|
indexSpecified = highlightedIndex
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (i, candidate) in candidateDataAll.enumerated() {
|
for (i, candidate) in candidateDataAll.enumerated() {
|
||||||
candidate.isSelected = (indexSpecified == i)
|
candidate.isHighlighted = (indexSpecified == i)
|
||||||
if candidate.isSelected { currentRowNumber = candidate.whichRow }
|
if candidate.isHighlighted { currentLineNumber = candidate.whichLine }
|
||||||
}
|
}
|
||||||
for (i, candidateRow) in candidateRows.enumerated() {
|
for (i, candidateColumn) in candidateLines.enumerated() {
|
||||||
if i != currentRowNumber {
|
if i != currentLineNumber {
|
||||||
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 {
|
|
||||||
candidateColumn.forEach {
|
candidateColumn.forEach {
|
||||||
$0.key = " "
|
$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 Cocoa
|
||||||
import CocoaExtension
|
import CocoaExtension
|
||||||
import Shared
|
import Shared
|
||||||
|
import SwiftExtension
|
||||||
import SwiftUI
|
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 class CtlCandidateTDK: CtlCandidate {
|
||||||
public var maxLinesPerPage: Int = 0
|
public var maxLinesPerPage: Int = 0
|
||||||
|
public var isLegacyMode: Bool = false
|
||||||
private static var thePoolHorizontal: CandidatePool = .init(candidates: [], rowCapacity: 6)
|
private static var thePool: CandidatePool = .init(candidates: [])
|
||||||
private static var thePoolVertical: CandidatePool = .init(candidates: [], columnCapacity: 6)
|
|
||||||
private static var currentView: NSView = .init()
|
private static var currentView: NSView = .init()
|
||||||
|
|
||||||
@available(macOS 12, *)
|
@available(macOS 10.15, *)
|
||||||
private var theViewHorizontal: some View {
|
private var theView: some View {
|
||||||
VwrCandidateHorizontal(
|
VwrCandidateTDK(
|
||||||
controller: self, thePool: Self.thePoolHorizontal,
|
controller: self, thePool: Self.thePool
|
||||||
tooltip: tooltip, reverseLookupResult: reverseLookupResult
|
|
||||||
).edgesIgnoringSafeArea(.top)
|
).edgesIgnoringSafeArea(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(macOS 12, *)
|
private var theViewLegacy: NSView {
|
||||||
private var theViewVertical: some View {
|
let textField = NSTextField(
|
||||||
VwrCandidateVertical(
|
labelWithAttributedString: Self.thePool.attributedDescription
|
||||||
controller: self, thePool: Self.thePoolVertical,
|
)
|
||||||
tooltip: tooltip, reverseLookupResult: reverseLookupResult
|
textField.isSelectable = false
|
||||||
).edgesIgnoringSafeArea(.top)
|
textField.allowsEditingTextAttributes = false
|
||||||
}
|
textField.preferredMaxLayoutWidth = textField.frame.width
|
||||||
|
textField.backgroundColor = .controlBackgroundColor
|
||||||
private var theViewHorizontalBackports: some View {
|
return textField
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constructors
|
// MARK: - Constructors
|
||||||
|
@ -93,165 +76,108 @@ public class CtlCandidateTDK: CtlCandidate {
|
||||||
// MARK: - Public functions
|
// MARK: - Public functions
|
||||||
|
|
||||||
override public func reloadData() {
|
override public func reloadData() {
|
||||||
CandidateCellData.highlightBackground = highlightedColor()
|
|
||||||
CandidateCellData.unifiedSize = candidateFont.pointSize
|
CandidateCellData.unifiedSize = candidateFont.pointSize
|
||||||
guard let delegate = delegate else { return }
|
guard let delegate = delegate else { return }
|
||||||
|
Self.thePool = .init(
|
||||||
switch currentLayout {
|
candidates: delegate.candidatePairs(conv: true).map(\.1), lines: maxLinesPerPage,
|
||||||
case .horizontal:
|
selectionKeys: delegate.selectionKeys, layout: currentLayout.layoutTDK, locale: locale
|
||||||
Self.thePoolHorizontal = .init(
|
)
|
||||||
candidates: delegate.candidatePairs(conv: true).map(\.1), rowCapacity: 6,
|
Self.thePool.tooltip = tooltip
|
||||||
rows: maxLinesPerPage, selectionKeys: delegate.selectionKeys, locale: locale
|
Self.thePool.reverseLookupResult = reverseLookupResult
|
||||||
)
|
Self.thePool.highlight(at: 0)
|
||||||
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
|
|
||||||
}
|
|
||||||
updateDisplay()
|
updateDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
override open func updateDisplay() {
|
override open func updateDisplay() {
|
||||||
guard let window = window else { return }
|
guard let window = window else { return }
|
||||||
reverseLookupResult = delegate?.reverseLookup(for: currentSelectedCandidateText) ?? []
|
if let currentCandidateText = Self.thePool.currentSelectedCandidateText {
|
||||||
switch currentLayout {
|
reverseLookupResult = delegate?.reverseLookup(for: currentCandidateText) ?? []
|
||||||
case .horizontal:
|
Self.thePool.reverseLookupResult = reverseLookupResult
|
||||||
DispatchQueue.main.async { [self] in
|
}
|
||||||
if #available(macOS 12, *) {
|
DispatchQueue.main.async { [self] in
|
||||||
Self.currentView = NSHostingView(rootView: theViewHorizontal)
|
if #available(macOS 10.15, *) {
|
||||||
} else {
|
if isLegacyMode {
|
||||||
Self.currentView = NSHostingView(rootView: theViewHorizontalBackports)
|
updateNSWindowLegacy(window)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
window.isOpaque = false
|
||||||
|
window.backgroundColor = NSColor.clear
|
||||||
|
Self.currentView = NSHostingView(rootView: theView)
|
||||||
let newSize = Self.currentView.fittingSize
|
let newSize = Self.currentView.fittingSize
|
||||||
window.contentView = Self.currentView
|
window.contentView = Self.currentView
|
||||||
window.setContentSize(newSize)
|
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 {
|
@discardableResult override public func showNextPage() -> Bool {
|
||||||
showNextLine(count: thePool.maxLinesPerPage)
|
defer { updateDisplay() }
|
||||||
}
|
return Self.thePool.flipPage(isBackward: false)
|
||||||
|
|
||||||
@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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already implemented in CandidatePool.
|
||||||
@discardableResult override public func showPreviousPage() -> Bool {
|
@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 {
|
@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 {
|
// Already implemented in CandidatePool.
|
||||||
if thePool.currentLineNumber == 0 {
|
@discardableResult override public func showNextLine() -> Bool {
|
||||||
return highlightPreviousCandidate()
|
defer { updateDisplay() }
|
||||||
}
|
return Self.thePool.consecutivelyFlipLines(isBackward: false, count: 1)
|
||||||
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 highlightNextCandidate() -> Bool {
|
@discardableResult override public func highlightNextCandidate() -> Bool {
|
||||||
if thePool.highlightedIndex == thePool.candidateDataAll.count - 1 {
|
defer { updateDisplay() }
|
||||||
thePool.highlight(at: 0)
|
return Self.thePool.highlightNeighborCandidate(isBackward: false)
|
||||||
updateDisplay()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
thePool.highlight(at: thePool.highlightedIndex + 1)
|
|
||||||
updateDisplay()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already implemented in CandidatePool.
|
||||||
@discardableResult override public func highlightPreviousCandidate() -> Bool {
|
@discardableResult override public func highlightPreviousCandidate() -> Bool {
|
||||||
if thePool.highlightedIndex == 0 {
|
defer { updateDisplay() }
|
||||||
thePool.highlight(at: thePool.candidateDataAll.count - 1)
|
return Self.thePool.highlightNeighborCandidate(isBackward: true)
|
||||||
updateDisplay()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
thePool.highlight(at: thePool.highlightedIndex - 1)
|
|
||||||
updateDisplay()
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override public func candidateIndexAtKeyLabelIndex(_ id: Int) -> Int {
|
// Already implemented in CandidatePool.
|
||||||
let arrCurrentLine = thePool.candidateLines[thePool.currentLineNumber]
|
override public func candidateIndexAtKeyLabelIndex(_ id: Int) -> Int? {
|
||||||
if !(0 ..< arrCurrentLine.count).contains(id) { return -114_514 }
|
Self.thePool.calculateCandidateIndex(subIndex: id)
|
||||||
let actualID = max(0, min(id, arrCurrentLine.count - 1))
|
|
||||||
return arrCurrentLine[actualID].index
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Already implemented in CandidatePool.
|
||||||
override public var highlightedIndex: Int {
|
override public var highlightedIndex: Int {
|
||||||
get { thePool.highlightedIndex }
|
get { Self.thePool.highlightedIndex }
|
||||||
set {
|
set {
|
||||||
thePool.highlight(at: newValue)
|
Self.thePool.highlight(at: newValue)
|
||||||
updateDisplay()
|
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 {
|
func testPoolHorizontal() throws {
|
||||||
let pool = CandidatePool(candidates: testCandidates, rowCapacity: 6)
|
let pool = CandidatePool(candidates: testCandidates, selectionKeys: "123456", layout: .horizontal)
|
||||||
var strOutput = ""
|
var strOutput = ""
|
||||||
pool.candidateLines.forEach {
|
pool.candidateLines.forEach {
|
||||||
$0.forEach {
|
$0.forEach {
|
||||||
|
@ -33,7 +33,7 @@ final class CandidatePoolTests: XCTestCase {
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPoolVertical() throws {
|
func testPoolVertical() throws {
|
||||||
let pool = CandidatePool(candidates: testCandidates, columnCapacity: 6)
|
let pool = CandidatePool(candidates: testCandidates, selectionKeys: "123456", layout: .vertical)
|
||||||
var strOutput = ""
|
var strOutput = ""
|
||||||
pool.candidateLines.forEach {
|
pool.candidateLines.forEach {
|
||||||
$0.forEach {
|
$0.forEach {
|
||||||
|
|
|
@ -14,7 +14,6 @@ public extension PrefMgr {
|
||||||
func fixOddPreferences() {
|
func fixOddPreferences() {
|
||||||
// macOS 10.15 開始才能使用 SwiftUI 構建的田所選字窗。
|
// macOS 10.15 開始才能使用 SwiftUI 構建的田所選字窗。
|
||||||
if #unavailable(macOS 10.15) {
|
if #unavailable(macOS 10.15) {
|
||||||
useIMKCandidateWindow = true
|
|
||||||
legacyCandidateViewTypesettingMethodEnabled = false
|
legacyCandidateViewTypesettingMethodEnabled = false
|
||||||
togglingAlphanumericalModeWithRShift = false
|
togglingAlphanumericalModeWithRShift = false
|
||||||
togglingAlphanumericalModeWithLShift = false
|
togglingAlphanumericalModeWithLShift = false
|
||||||
|
|
|
@ -86,15 +86,15 @@ public extension SessionCtl {
|
||||||
/// 先取消既有的選字窗的內容顯示。否則可能會重複生成選字窗的 NSWindow()。
|
/// 先取消既有的選字窗的內容顯示。否則可能會重複生成選字窗的 NSWindow()。
|
||||||
candidateUI?.visible = false
|
candidateUI?.visible = false
|
||||||
/// 然後再重新初期化。
|
/// 然後再重新初期化。
|
||||||
if #available(macOS 10.15, *) {
|
if #available(macOS 10.13, *) {
|
||||||
candidateUI =
|
candidateUI =
|
||||||
PrefMgr.shared.useIMKCandidateWindow
|
PrefMgr.shared.useIMKCandidateWindow
|
||||||
? CtlCandidateIMK(candidateLayout) : CtlCandidateTDK(candidateLayout)
|
? CtlCandidateIMK(candidateLayout) : CtlCandidateTDK(candidateLayout)
|
||||||
if let candidateTDK = candidateUI as? CtlCandidateTDK {
|
if let candidateTDK = candidateUI as? CtlCandidateTDK {
|
||||||
candidateTDK.maxLinesPerPage = isVerticalTyping ? 1 : 3
|
candidateTDK.maxLinesPerPage = isVerticalTyping ? 1 : 4
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
candidateUI = CtlCandidateIMK(candidateLayout)
|
candidateUI = CtlCandidateTDK(candidateLayout)
|
||||||
}
|
}
|
||||||
|
|
||||||
candidateUI?.candidateFont = Self.candidateFont(
|
candidateUI?.candidateFont = Self.candidateFont(
|
||||||
|
|
Loading…
Reference in New Issue