vChewing-macOS/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_SwiftUI.swift

492 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// (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 {
statusBarContent
}
}
.fixedSize()
.background(candidateListBackground)
.cornerRadius(10)
}
}
}
// MARK: - Main Views.
@available(macOS 10.15, *)
private 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).frame(minWidth: minLineWidth)
HStack(spacing: horizontalCellSpacing) {
ForEach(Array(thePool.candidateLines[rowIndex]), id: \.self) { currentCandidate in
drawCandidate(currentCandidate).fixedSize()
}
.opacity(rowIndex == thePool.currentLineNumber ? 1 : 0.85)
}
}
.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()
.padding([.horizontal, .top], 5)
.padding([.bottom], thePool.maxLinesPerPage == 1 ? 5 : 0)
}
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)
}
.opacity(columnIndex == thePool.currentLineNumber ? 1 : 0.85)
if thePool.candidateLines[columnIndex].count < thePool.maxLineCapacity {
ForEach(0 ..< thePool.dummyCellsRequiredForCurrentLine, id: \.self) { _ in
drawCandidate(thePool.blankCell)
}
}
}
.background(lineBackground(lineID: columnIndex)).cornerRadius(6)
.frame(
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.cellLength(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], 0)
}
}
// MARK: - Common Components.
@available(macOS 10.15, *)
extension VwrCandidateTDK {
func drawCandidate(_ cell: CandidateCellData) -> some View {
attributedStringFor(cell: cell)
.frame(minWidth: thePool.cellWidth(cell).min, maxWidth: thePool.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 colorScheme == .dark ? Color.primary.opacity(0.05) : .white
case .vertical where isCurrentLineInMatrix:
return absoluteBackgroundColor.opacity(0.13)
default:
return Color.clear
}
}
var minLineWidth: CGFloat? {
let spacings: CGFloat = horizontalCellSpacing * Double(thePool.maxLineCapacity - 1)
let maxWindowWith: CGFloat
= ceil(
Double(thePool.maxLineCapacity) * (thePool.blankCell.cellLength())
+ 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.9))
}.fixedSize()
}
var rightPanes: some View {
HStack {
if !tooltip.isEmpty {
ZStack(alignment: .center) {
Circle().fill(thePool.blankCell.themeColor.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) {
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) {
Text("\(firstReverseLookupResult.trimmingCharacters(in: .newlines))")
.lineLimit(1).padding([.horizontal], 2)
}.fixedSize()
}
} else {
ForEach(reverseLookupResult, id: \.self) { currentResult in
ZStack(alignment: .center) {
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 statusBarContent: some View {
HStack(alignment: .center) {
positionLabelView
if !tooltip.isEmpty {
Text(tooltip).lineLimit(1)
}
if controller?.delegate?.showReverseLookupResult ?? true, !tooltip.isEmpty {
reverseLookupPane.padding(0)
}
Spacer(minLength: 0)
}
.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 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)
}
}
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, *)
private 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 VwrCandidateTDK_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()
}
}
}
}
VStack {
HStack(alignment: .top) {
Text("田所選字窗 Cocoa 模式").bold().font(Font.system(.title))
VStack {
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolX).fixedSize()
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize()
HStack {
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolY).fixedSize()
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolYS).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()
}
}
}
}
}
}