CtlCandidateTDK // Rewrite.
This commit is contained in:
parent
e4a8f34075
commit
339cfb0ad4
|
@ -8,18 +8,20 @@
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
import Shared
|
import Shared
|
||||||
import SwiftUI
|
|
||||||
import SwiftUIBackports
|
|
||||||
|
|
||||||
// MARK: - Candidate Cell
|
// MARK: - Candidate Cell
|
||||||
|
|
||||||
/// 用來管理選字窗內顯示的候選字的單位。用 class 型別會比較方便一些。
|
/// 用來管理選字窗內顯示的候選字的單位。用 class 型別會比較方便一些。
|
||||||
public class CandidateCellData: Hashable {
|
public class CandidateCellData: Hashable {
|
||||||
public var visualDimension: CGSize = .zero
|
public var visualDimension: CGSize = .zero
|
||||||
|
public var visualOrigin: CGPoint = .zero
|
||||||
public var locale = ""
|
public var locale = ""
|
||||||
public static var unifiedSize: Double = 16
|
public static var unifiedSize: Double = 16
|
||||||
|
public static var unifiedCharDimension: Double { ceil(unifiedSize * 1.0125 + 7) }
|
||||||
|
public static var unifiedTextHeight: Double { ceil(unifiedSize * 19 / 16) }
|
||||||
public var selectionKey: String
|
public var selectionKey: String
|
||||||
public var displayedText: String
|
public let displayedText: String
|
||||||
|
public private(set) var textDimension: NSSize
|
||||||
public var spanLength: Int
|
public var spanLength: Int
|
||||||
public var size: Double { Self.unifiedSize }
|
public var size: Double { Self.unifiedSize }
|
||||||
public var isHighlighted: Bool = false
|
public var isHighlighted: Bool = false
|
||||||
|
@ -29,7 +31,6 @@ public class CandidateCellData: Hashable {
|
||||||
// 該候選字詞在當前行/列內的索引編號
|
// 該候選字詞在當前行/列內的索引編號
|
||||||
public var subIndex: Int = 0
|
public var subIndex: Int = 0
|
||||||
|
|
||||||
public var charGlyphWidth: Double { ceil(size * 1.0125 + 7) }
|
|
||||||
public var fontSizeCandidate: Double { size }
|
public var fontSizeCandidate: Double { size }
|
||||||
public var fontSizeKey: Double { max(ceil(fontSizeCandidate * 0.6), 11) }
|
public var fontSizeKey: Double { max(ceil(fontSizeCandidate * 0.6), 11) }
|
||||||
public var fontColorCandidate: NSColor { isHighlighted ? .selectedMenuItemTextColor : .controlTextColor }
|
public var fontColorCandidate: NSColor { isHighlighted ? .selectedMenuItemTextColor : .controlTextColor }
|
||||||
|
@ -64,6 +65,10 @@ public class CandidateCellData: Hashable {
|
||||||
self.displayedText = displayedText
|
self.displayedText = displayedText
|
||||||
spanLength = max(spanningLength ?? displayedText.count, 1)
|
spanLength = max(spanningLength ?? displayedText.count, 1)
|
||||||
isHighlighted = isSelected
|
isHighlighted = isSelected
|
||||||
|
textDimension = .init(width: ceil(Self.unifiedCharDimension * 1.4), height: Self.unifiedTextHeight)
|
||||||
|
if displayedText.count > 1 {
|
||||||
|
textDimension.width = attributedString().boundingDimension.width
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func == (lhs: CandidateCellData, rhs: CandidateCellData) -> Bool {
|
public static func == (lhs: CandidateCellData, rhs: CandidateCellData) -> Bool {
|
||||||
|
@ -76,9 +81,9 @@ public class CandidateCellData: Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func cellLength(isMatrix: Bool = true) -> Double {
|
public func cellLength(isMatrix: Bool = true) -> Double {
|
||||||
let minLength = ceil(charGlyphWidth * 2 + size * 1.25)
|
let minLength = ceil(Self.unifiedCharDimension * 2 + size * 1.25)
|
||||||
if displayedText.count <= 2, isMatrix { return minLength }
|
if displayedText.count <= 2, isMatrix { return minLength }
|
||||||
return ceil(attributedStringPhrase().boundingDimension.width + charGlyphWidth)
|
return textDimension.width
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fonts and NSColors.
|
// MARK: - Fonts and NSColors.
|
||||||
|
@ -202,6 +207,20 @@ public class CandidateCellData: Hashable {
|
||||||
return String(format: "U+%02X %@", $0.value, theName)
|
return String(format: "U+%02X %@", $0.value, theName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func updateMetrics(pool thePool: CandidatePool, origin currentOrigin: CGPoint) {
|
||||||
|
let padding = thePool.padding
|
||||||
|
var cellDimension = textDimension
|
||||||
|
if let givenWidth = thePool.cellWidth(self).min, displayedText.count <= 2 {
|
||||||
|
cellDimension.width = max(cellDimension.width + 4 * padding, givenWidth)
|
||||||
|
} else {
|
||||||
|
cellDimension.width += 4 * padding
|
||||||
|
}
|
||||||
|
cellDimension.width = ceil(cellDimension.width)
|
||||||
|
cellDimension.height = Self.unifiedTextHeight + 2 * padding
|
||||||
|
visualDimension = cellDimension
|
||||||
|
visualOrigin = currentOrigin
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Array Container Extension.
|
// MARK: - Array Container Extension.
|
||||||
|
|
|
@ -23,12 +23,33 @@ public class CandidatePool {
|
||||||
public var reverseLookupResult: [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
|
public private(set) var currentLineNumber = 0
|
||||||
|
public var metrics: UIMetrics = .allZeroed
|
||||||
|
|
||||||
private var recordedLineRangeForCurrentPage: Range<Int>?
|
private var recordedLineRangeForCurrentPage: Range<Int>?
|
||||||
private var previouslyRecordedLineRangeForPreviousPage: Range<Int>?
|
private var previouslyRecordedLineRangeForPreviousPage: Range<Int>?
|
||||||
|
|
||||||
|
public struct UIMetrics {
|
||||||
|
static var allZeroed: UIMetrics {
|
||||||
|
.init(fittingSize: .zero, highlightedLine: .zero, highlightedCandidate: .zero, peripherals: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
let fittingSize: CGSize
|
||||||
|
let highlightedLine: CGRect
|
||||||
|
let highlightedCandidate: CGRect
|
||||||
|
let peripherals: CGRect
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 動態變數
|
// MARK: - 動態變數
|
||||||
|
|
||||||
|
public let padding: CGFloat = 2
|
||||||
|
public let originDelta: CGFloat = 5
|
||||||
|
public let cellTextHeight = CandidatePool.shitCell.textDimension.height
|
||||||
|
public let cellRadius: CGFloat = 4
|
||||||
|
public var windowRadius: CGFloat { originDelta + cellRadius }
|
||||||
|
|
||||||
|
/// 當前資料池是否存在多列/多行候選字詞呈現。
|
||||||
|
public var isMatrix: Bool { maxLinesPerPage > 1 }
|
||||||
|
|
||||||
/// 用來在初期化一個候選字詞資料池的時候研判「橫版多行選字窗每行最大應該塞多少個候選字詞」。
|
/// 用來在初期化一個候選字詞資料池的時候研判「橫版多行選字窗每行最大應該塞多少個候選字詞」。
|
||||||
/// 注意:該參數不用來計算視窗寬度,所以無須算上候選字詞間距。
|
/// 注意:該參數不用來計算視窗寬度,所以無須算上候選字詞間距。
|
||||||
public var maxRowWidth: Double { ceil(Double(maxLineCapacity) * Self.blankCell.cellLength()) }
|
public var maxRowWidth: Double { ceil(Double(maxLineCapacity) * Self.blankCell.cellLength()) }
|
||||||
|
@ -130,6 +151,7 @@ public class CandidatePool {
|
||||||
candidateLines.append(currentColumn)
|
candidateLines.append(currentColumn)
|
||||||
recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage
|
recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage
|
||||||
highlight(at: 0)
|
highlight(at: 0)
|
||||||
|
updateMetrics()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -254,7 +276,7 @@ public extension CandidatePool {
|
||||||
if layout != .vertical, maxLinesPerPage == 1 {
|
if layout != .vertical, maxLinesPerPage == 1 {
|
||||||
min = max(minAccepted, cell.cellLength(isMatrix: false))
|
min = max(minAccepted, cell.cellLength(isMatrix: false))
|
||||||
} else if layout == .vertical, maxLinesPerPage == 1 {
|
} else if layout == .vertical, maxLinesPerPage == 1 {
|
||||||
min = max(Double(CandidateCellData.unifiedSize * 6), 90)
|
min = max(Double(CandidateCellData.unifiedSize * 6), ceil(cell.size * 5.6))
|
||||||
}
|
}
|
||||||
return (min, nil)
|
return (min, nil)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,141 @@
|
||||||
|
|
||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
// MARK: - Using One Single NSAttributedString.
|
// MARK: - UI Metrics.
|
||||||
|
|
||||||
|
extension CandidatePool {
|
||||||
|
public func updateMetrics() {
|
||||||
|
// 開工
|
||||||
|
let initialOrigin: NSPoint = .init(x: originDelta, y: originDelta)
|
||||||
|
var totalAccuSize: NSSize = .zero
|
||||||
|
// Origin is at the top-left corner.
|
||||||
|
var currentOrigin: NSPoint = initialOrigin
|
||||||
|
var highlightedCellRect: CGRect = .zero
|
||||||
|
var highlightedLineRect: CGRect = .zero
|
||||||
|
var currentPageLines = candidateLines[lineRangeForCurrentPage]
|
||||||
|
var blankLines = maxLinesPerPage - currentPageLines.count
|
||||||
|
var fillBlankCells = true
|
||||||
|
switch (layout, isMatrix) {
|
||||||
|
case (.horizontal, false):
|
||||||
|
blankLines = 0
|
||||||
|
fillBlankCells = false
|
||||||
|
case (.vertical, false): blankLines = 0
|
||||||
|
case (_, true): break
|
||||||
|
}
|
||||||
|
while blankLines > 0 {
|
||||||
|
currentPageLines.append(.init(repeating: Self.shitCell, count: maxLineCapacity))
|
||||||
|
blankLines -= 1
|
||||||
|
}
|
||||||
|
Self.shitCell.updateMetrics(pool: self, origin: currentOrigin)
|
||||||
|
Self.shitCell.isHighlighted = false
|
||||||
|
let minimumCellDimension = Self.shitCell.visualDimension
|
||||||
|
currentPageLines.forEach { currentLine in
|
||||||
|
let currentLineOrigin = currentOrigin
|
||||||
|
var accumulatedLineSize: NSSize = .zero
|
||||||
|
var currentLineRect: CGRect { .init(origin: currentLineOrigin, size: accumulatedLineSize) }
|
||||||
|
let lineHasHighlightedCell = currentLine.hasHighlightedCell
|
||||||
|
currentLine.forEach { currentCell in
|
||||||
|
currentCell.updateMetrics(pool: self, origin: currentOrigin)
|
||||||
|
var cellDimension = currentCell.visualDimension
|
||||||
|
if layout == .vertical || currentCell.displayedText.count <= 2 {
|
||||||
|
cellDimension.width = max(minimumCellDimension.width, cellDimension.width)
|
||||||
|
}
|
||||||
|
cellDimension.height = max(minimumCellDimension.height, cellDimension.height)
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal:
|
||||||
|
accumulatedLineSize.width += cellDimension.width
|
||||||
|
accumulatedLineSize.height = max(accumulatedLineSize.height, cellDimension.height)
|
||||||
|
case .vertical:
|
||||||
|
accumulatedLineSize.height += cellDimension.height
|
||||||
|
accumulatedLineSize.width = max(accumulatedLineSize.width, cellDimension.width)
|
||||||
|
}
|
||||||
|
if lineHasHighlightedCell {
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal where currentCell.isHighlighted: highlightedCellRect.size.width = cellDimension.width
|
||||||
|
case .vertical: highlightedCellRect.size.width = max(highlightedCellRect.size.width, cellDimension.width)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
if currentCell.isHighlighted {
|
||||||
|
highlightedCellRect.origin = currentOrigin
|
||||||
|
highlightedCellRect.size.height = cellDimension.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal: currentOrigin.x += cellDimension.width
|
||||||
|
case .vertical: currentOrigin.y += cellDimension.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lineHasHighlightedCell {
|
||||||
|
highlightedLineRect.origin = currentLineRect.origin
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal:
|
||||||
|
highlightedLineRect.size.height = currentLineRect.size.height
|
||||||
|
case .vertical:
|
||||||
|
highlightedLineRect.size.width = currentLineRect.size.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal:
|
||||||
|
highlightedLineRect.size.width = max(currentLineRect.size.width, highlightedLineRect.width)
|
||||||
|
case .vertical:
|
||||||
|
highlightedLineRect.size.height = max(currentLineRect.size.height, highlightedLineRect.height)
|
||||||
|
currentLine.forEach { theCell in
|
||||||
|
theCell.visualDimension.width = accumulatedLineSize.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 終末處理
|
||||||
|
switch self.layout {
|
||||||
|
case .horizontal:
|
||||||
|
currentOrigin.x = originDelta
|
||||||
|
currentOrigin.y += accumulatedLineSize.height
|
||||||
|
totalAccuSize.width = max(totalAccuSize.width, accumulatedLineSize.width)
|
||||||
|
totalAccuSize.height += accumulatedLineSize.height
|
||||||
|
case .vertical:
|
||||||
|
currentOrigin.y = originDelta
|
||||||
|
currentOrigin.x += accumulatedLineSize.width
|
||||||
|
totalAccuSize.height = max(totalAccuSize.height, accumulatedLineSize.height)
|
||||||
|
totalAccuSize.width += accumulatedLineSize.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fillBlankCells {
|
||||||
|
switch layout {
|
||||||
|
case .horizontal:
|
||||||
|
totalAccuSize.width = max(totalAccuSize.width, CGFloat(maxLineCapacity) * minimumCellDimension.width)
|
||||||
|
highlightedLineRect.size.width = totalAccuSize.width
|
||||||
|
case .vertical:
|
||||||
|
totalAccuSize.height = CGFloat(maxLineCapacity) * minimumCellDimension.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 繪製附加內容
|
||||||
|
let strPeripherals = attributedDescriptionBottomPanes
|
||||||
|
var dimensionPeripherals = strPeripherals.boundingDimension
|
||||||
|
dimensionPeripherals.width = ceil(dimensionPeripherals.width)
|
||||||
|
dimensionPeripherals.height = ceil(dimensionPeripherals.height)
|
||||||
|
if finalContainerOrientation == .horizontal {
|
||||||
|
totalAccuSize.width += 5
|
||||||
|
dimensionPeripherals.width += 5
|
||||||
|
let delta = max(CandidateCellData.unifiedTextHeight + padding * 2 - dimensionPeripherals.height, 0)
|
||||||
|
currentOrigin = .init(x: totalAccuSize.width + originDelta, y: ceil(delta / 2) + originDelta)
|
||||||
|
totalAccuSize.width += dimensionPeripherals.width
|
||||||
|
} else {
|
||||||
|
totalAccuSize.height += 2
|
||||||
|
currentOrigin = .init(x: padding + originDelta, y: totalAccuSize.height + originDelta)
|
||||||
|
totalAccuSize.height += dimensionPeripherals.height
|
||||||
|
totalAccuSize.width = max(totalAccuSize.width, dimensionPeripherals.width)
|
||||||
|
}
|
||||||
|
let rectPeripherals = CGRect(origin: currentOrigin, size: dimensionPeripherals)
|
||||||
|
totalAccuSize.width += originDelta * 2
|
||||||
|
totalAccuSize.height += originDelta * 2
|
||||||
|
metrics = .init(fittingSize: totalAccuSize, highlightedLine: highlightedLineRect, highlightedCandidate: highlightedCellRect, peripherals: rectPeripherals)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finalContainerOrientation: NSUserInterfaceLayoutOrientation {
|
||||||
|
if maxLinesPerPage == 1, layout == .horizontal { return .horizontal }
|
||||||
|
return .vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Using One Single NSAttributedString. (Some of them are for debug purposes.)
|
||||||
|
|
||||||
extension CandidatePool {
|
extension CandidatePool {
|
||||||
// MARK: Candidate List with Peripherals.
|
// MARK: Candidate List with Peripherals.
|
||||||
|
@ -143,6 +277,7 @@ extension CandidatePool {
|
||||||
let positionCounterTextSize = max(ceil(CandidateCellData.unifiedSize * 0.7), 11)
|
let positionCounterTextSize = max(ceil(CandidateCellData.unifiedSize * 0.7), 11)
|
||||||
let attrTooltip: [NSAttributedString.Key: AnyObject] = [
|
let attrTooltip: [NSAttributedString.Key: AnyObject] = [
|
||||||
.font: Self.blankCell.phraseFontEmphasized(size: positionCounterTextSize),
|
.font: Self.blankCell.phraseFontEmphasized(size: positionCounterTextSize),
|
||||||
|
.foregroundColor: NSColor.textColor,
|
||||||
]
|
]
|
||||||
let tooltipText = NSAttributedString(
|
let tooltipText = NSAttributedString(
|
||||||
string: " \(tooltip) ", attributes: attrTooltip
|
string: " \(tooltip) ", attributes: attrTooltip
|
||||||
|
@ -154,6 +289,7 @@ extension CandidatePool {
|
||||||
let reverseLookupTextSize = max(ceil(CandidateCellData.unifiedSize * 0.6), 9)
|
let reverseLookupTextSize = max(ceil(CandidateCellData.unifiedSize * 0.6), 9)
|
||||||
let attrReverseLookup: [NSAttributedString.Key: AnyObject] = [
|
let attrReverseLookup: [NSAttributedString.Key: AnyObject] = [
|
||||||
.font: Self.blankCell.phraseFont(size: reverseLookupTextSize),
|
.font: Self.blankCell.phraseFont(size: reverseLookupTextSize),
|
||||||
|
.foregroundColor: NSColor.textColor,
|
||||||
]
|
]
|
||||||
let attrReverseLookupSpacer: [NSAttributedString.Key: AnyObject] = [
|
let attrReverseLookupSpacer: [NSAttributedString.Key: AnyObject] = [
|
||||||
.font: Self.blankCell.phraseFont(size: reverseLookupTextSize),
|
.font: Self.blankCell.phraseFont(size: reverseLookupTextSize),
|
||||||
|
|
|
@ -41,9 +41,16 @@ public class CtlCandidateTDK: CtlCandidate, NSWindowDelegate {
|
||||||
).edgesIgnoringSafeArea(.top)
|
).edgesIgnoringSafeArea(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if USING_STACK_VIEW_IN_TDK_COCOA
|
||||||
|
/// 該視圖模式因算法陳舊而不再維護。
|
||||||
private var theViewCocoa: NSStackView {
|
private var theViewCocoa: NSStackView {
|
||||||
VwrCandidateTDKCocoa(controller: self, thePool: Self.thePool)
|
VwrCandidateTDKCocoa(controller: self, thePool: Self.thePool)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private var theViewAppKit: NSView {
|
||||||
|
VwrCandidateTDKAppKit(controller: self, thePool: Self.thePool)
|
||||||
|
}
|
||||||
|
|
||||||
private var theViewLegacy: NSView {
|
private var theViewLegacy: NSView {
|
||||||
let textField = NSTextField()
|
let textField = NSTextField()
|
||||||
|
@ -120,7 +127,7 @@ public class CtlCandidateTDK: CtlCandidate, NSWindowDelegate {
|
||||||
Self.currentView = NSHostingView(rootView: theView)
|
Self.currentView = NSHostingView(rootView: theView)
|
||||||
break viewCheck
|
break viewCheck
|
||||||
}
|
}
|
||||||
Self.currentView = theViewCocoa
|
Self.currentView = theViewAppKit
|
||||||
}
|
}
|
||||||
window.contentView = Self.currentView
|
window.contentView = Self.currentView
|
||||||
window.setContentSize(Self.currentView.fittingSize)
|
window.setContentSize(Self.currentView.fittingSize)
|
||||||
|
|
|
@ -0,0 +1,246 @@
|
||||||
|
// (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 AppKit
|
||||||
|
import Shared
|
||||||
|
|
||||||
|
/// 田所選字窗的 AppKit 简单版本,繪製效率不受 SwiftUI 的限制。
|
||||||
|
/// 該版本可以使用更少的系統資源來繪製選字窗。
|
||||||
|
|
||||||
|
public class VwrCandidateTDKAppKit: NSView {
|
||||||
|
public weak var controller: CtlCandidateTDK?
|
||||||
|
public var thePool: CandidatePool
|
||||||
|
private var dimension: NSSize = .zero
|
||||||
|
var action: Selector?
|
||||||
|
weak var target: AnyObject?
|
||||||
|
var theMenu: NSMenu?
|
||||||
|
var clickedCell: CandidateCellData = CandidatePool.shitCell
|
||||||
|
|
||||||
|
// MARK: - Variables used for rendering the UI.
|
||||||
|
|
||||||
|
var padding: CGFloat { thePool.padding }
|
||||||
|
var originDelta: CGFloat { thePool.originDelta }
|
||||||
|
var cellRadius: CGFloat { thePool.cellRadius }
|
||||||
|
var windowRadius: CGFloat { thePool.windowRadius }
|
||||||
|
var isMatrix: Bool { thePool.isMatrix }
|
||||||
|
|
||||||
|
// MARK: - Constructors.
|
||||||
|
|
||||||
|
public init(controller: CtlCandidateTDK? = nil, thePool pool: CandidatePool) {
|
||||||
|
self.controller = controller
|
||||||
|
thePool = pool
|
||||||
|
thePool.updateMetrics()
|
||||||
|
super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514)))
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
theMenu?.cancelTrackingWithoutAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interface Renderer (with shared public variables).
|
||||||
|
|
||||||
|
public extension VwrCandidateTDKAppKit {
|
||||||
|
override var isFlipped: Bool { true }
|
||||||
|
|
||||||
|
override var fittingSize: NSSize { thePool.metrics.fittingSize }
|
||||||
|
|
||||||
|
static var candidateListBackground: NSColor {
|
||||||
|
let delta = NSApplication.isDarkMode ? 0.05 : 0.99
|
||||||
|
return .init(white: delta, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func draw(_: NSRect) {
|
||||||
|
let sizesCalculated = thePool.metrics
|
||||||
|
// 先塗底色
|
||||||
|
let allRect = NSRect(origin: .zero, size: sizesCalculated.fittingSize)
|
||||||
|
Self.candidateListBackground.setFill()
|
||||||
|
NSBezierPath(roundedRect: allRect, xRadius: windowRadius, yRadius: windowRadius).fill()
|
||||||
|
// 繪製高亮行背景與高亮候選字詞背景
|
||||||
|
lineBackground(isCurrentLine: true, isMatrix: isMatrix).setFill()
|
||||||
|
NSBezierPath(roundedRect: sizesCalculated.highlightedLine, xRadius: cellRadius, yRadius: cellRadius).fill()
|
||||||
|
var cellHighlightedDrawn = false
|
||||||
|
// 開始繪製候選字詞
|
||||||
|
let allCells = thePool.candidateLines[thePool.lineRangeForCurrentPage].flatMap { $0 }
|
||||||
|
allCells.forEach { currentCell in
|
||||||
|
if currentCell.isHighlighted, !cellHighlightedDrawn {
|
||||||
|
currentCell.themeColorCocoa.setFill()
|
||||||
|
NSBezierPath(roundedRect: sizesCalculated.highlightedCandidate, xRadius: cellRadius, yRadius: cellRadius).fill()
|
||||||
|
cellHighlightedDrawn = true
|
||||||
|
}
|
||||||
|
currentCell.attributedStringHeader.draw(at:
|
||||||
|
.init(
|
||||||
|
x: currentCell.visualOrigin.x + 2 * padding,
|
||||||
|
y: currentCell.visualOrigin.y + ceil(currentCell.visualDimension.height * 0.2)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
currentCell.attributedStringPhrase(isMatrix: false).draw(
|
||||||
|
at: .init(
|
||||||
|
x: currentCell.visualOrigin.x + 2 * padding + ceil(currentCell.size * 0.6),
|
||||||
|
y: currentCell.visualOrigin.y + padding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 繪製附加內容
|
||||||
|
let strPeripherals = thePool.attributedDescriptionBottomPanes
|
||||||
|
strPeripherals.draw(at: sizesCalculated.peripherals.origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mouse Interaction Handlers.
|
||||||
|
|
||||||
|
public extension VwrCandidateTDKAppKit {
|
||||||
|
private func findCell(from mouseEvent: NSEvent) -> Int? {
|
||||||
|
var clickPoint = convert(mouseEvent.locationInWindow, to: self)
|
||||||
|
clickPoint.y = bounds.height - clickPoint.y // 翻轉座標系
|
||||||
|
guard bounds.contains(clickPoint) else { return nil }
|
||||||
|
let flattenedCells = thePool.candidateLines[thePool.lineRangeForCurrentPage].flatMap { $0 }
|
||||||
|
let x = flattenedCells.filter { theCell in
|
||||||
|
NSPointInRect(clickPoint, .init(origin: theCell.visualOrigin, size: theCell.visualDimension))
|
||||||
|
}.first
|
||||||
|
guard let firstValidCell = x else { return nil }
|
||||||
|
return firstValidCell.index
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
guard let cellIndex = findCell(from: event) else { return }
|
||||||
|
guard cellIndex != thePool.highlightedIndex else { return }
|
||||||
|
thePool.highlight(at: cellIndex)
|
||||||
|
thePool.updateMetrics()
|
||||||
|
setNeedsDisplay(bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with event: NSEvent) {
|
||||||
|
mouseDown(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let cellIndex = findCell(from: event) else { return }
|
||||||
|
didSelectCandidateAt(cellIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseUp(with event: NSEvent) {
|
||||||
|
guard let cellIndex = findCell(from: event) else { return }
|
||||||
|
clickedCell = thePool.candidateDataAll[cellIndex]
|
||||||
|
let index = clickedCell.index
|
||||||
|
let candidateText = clickedCell.displayedText
|
||||||
|
let isEnabled: Bool = controller?.delegate?.isCandidateContextMenuEnabled ?? false
|
||||||
|
guard isEnabled, !candidateText.isEmpty, index >= 0 else { return }
|
||||||
|
prepareMenu()
|
||||||
|
var clickPoint = convert(event.locationInWindow, to: self)
|
||||||
|
clickPoint.y = bounds.height - clickPoint.y // 翻轉座標系
|
||||||
|
theMenu?.popUp(positioning: nil, at: clickPoint, in: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Context Menu.
|
||||||
|
|
||||||
|
private extension VwrCandidateTDKAppKit {
|
||||||
|
private func prepareMenu() {
|
||||||
|
let newMenu = NSMenu()
|
||||||
|
let boostMenuItem = NSMenuItem(
|
||||||
|
title: "↑ \(clickedCell.displayedText)",
|
||||||
|
action: #selector(menuActionOfBoosting(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
boostMenuItem.target = self
|
||||||
|
newMenu.addItem(boostMenuItem)
|
||||||
|
|
||||||
|
let nerfMenuItem = NSMenuItem(
|
||||||
|
title: "↓ \(clickedCell.displayedText)",
|
||||||
|
action: #selector(menuActionOfNerfing(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
nerfMenuItem.target = self
|
||||||
|
newMenu.addItem(nerfMenuItem)
|
||||||
|
|
||||||
|
if thePool.isFilterable(target: clickedCell.index) {
|
||||||
|
let filterMenuItem = NSMenuItem(
|
||||||
|
title: "✖︎ \(clickedCell.displayedText)",
|
||||||
|
action: #selector(menuActionOfFiltering(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
filterMenuItem.target = self
|
||||||
|
newMenu.addItem(filterMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
theMenu = newMenu
|
||||||
|
CtlCandidateTDK.currentMenu = newMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfBoosting(_: Any? = nil) {
|
||||||
|
didRightClickCandidateAt(clickedCell.index, action: .toBoost)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfNerfing(_: Any? = nil) {
|
||||||
|
didRightClickCandidateAt(clickedCell.index, action: .toNerf)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfFiltering(_: Any? = nil) {
|
||||||
|
didRightClickCandidateAt(clickedCell.index, action: .toFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Delegate Methods
|
||||||
|
|
||||||
|
private extension VwrCandidateTDKAppKit {
|
||||||
|
func didSelectCandidateAt(_ pos: Int) {
|
||||||
|
controller?.delegate?.candidatePairSelectionConfirmed(at: pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) {
|
||||||
|
controller?.delegate?.candidatePairRightClicked(at: pos, action: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extracted Internal Methods for UI Rendering.
|
||||||
|
|
||||||
|
private extension VwrCandidateTDKAppKit {
|
||||||
|
private func lineBackground(isCurrentLine: Bool, isMatrix: Bool) -> NSColor {
|
||||||
|
if !isCurrentLine { return .clear }
|
||||||
|
let absBg: NSColor = NSApplication.isDarkMode ? .black : .white
|
||||||
|
switch thePool.layout {
|
||||||
|
case .horizontal where isMatrix:
|
||||||
|
return NSApplication.isDarkMode ? .controlTextColor.withAlphaComponent(0.05) : .white
|
||||||
|
case .vertical where isMatrix:
|
||||||
|
return absBg.withAlphaComponent(0.9)
|
||||||
|
default:
|
||||||
|
return .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var finalContainerOrientation: NSUserInterfaceLayoutOrientation {
|
||||||
|
if thePool.maxLinesPerPage == 1, thePool.layout == .horizontal { return .horizontal }
|
||||||
|
return .vertical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Module Using Swift UI.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(macOS 10.15, *)
|
||||||
|
public struct VwrCandidateTDKAppKitForSwiftUI: NSViewRepresentable {
|
||||||
|
public weak var controller: CtlCandidateTDK?
|
||||||
|
public var thePool: CandidatePool
|
||||||
|
|
||||||
|
public func makeNSView(context _: Context) -> VwrCandidateTDKAppKit {
|
||||||
|
let nsView = VwrCandidateTDKAppKit(thePool: thePool)
|
||||||
|
nsView.controller = controller
|
||||||
|
return nsView
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateNSView(_ nsView: VwrCandidateTDKAppKit, context _: Context) {
|
||||||
|
nsView.thePool = thePool
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,11 +6,13 @@
|
||||||
// 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 AppKit
|
#if USING_STACK_VIEW_IN_TDK_COCOA
|
||||||
import Shared
|
|
||||||
|
|
||||||
/// 田所選字窗的 Cocoa 版本,繪製效率不受 SwiftUI 的限制。
|
import AppKit
|
||||||
public class VwrCandidateTDKCocoa: NSStackView {
|
import Shared
|
||||||
|
|
||||||
|
/// 田所選字窗的 Cocoa 版本,繪製效率不受 SwiftUI 的限制。
|
||||||
|
public class VwrCandidateTDKCocoa: NSStackView {
|
||||||
public weak var controller: CtlCandidateTDK?
|
public weak var controller: CtlCandidateTDK?
|
||||||
public var thePool: CandidatePool
|
public var thePool: CandidatePool
|
||||||
private var lineDimension: CGSize = .zero
|
private var lineDimension: CGSize = .zero
|
||||||
|
@ -29,11 +31,11 @@ public class VwrCandidateTDKCocoa: NSStackView {
|
||||||
required init?(coder _: NSCoder) {
|
required init?(coder _: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interface Renderer.
|
// MARK: - Interface Renderer.
|
||||||
|
|
||||||
public extension VwrCandidateTDKCocoa {
|
public extension VwrCandidateTDKCocoa {
|
||||||
func refresh() {
|
func refresh() {
|
||||||
defer {
|
defer {
|
||||||
vCLog(Self.strForConstraintStatistics.description)
|
vCLog(Self.strForConstraintStatistics.description)
|
||||||
|
@ -157,11 +159,11 @@ public extension VwrCandidateTDKCocoa {
|
||||||
subviews.forEach { removeView($0) }
|
subviews.forEach { removeView($0) }
|
||||||
addView(finalContainer, in: .top)
|
addView(finalContainer, in: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interface Components.
|
// MARK: - Interface Components.
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
private extension VwrCandidateTDKCocoa {
|
||||||
private var candidateListBackground: NSColor {
|
private var candidateListBackground: NSColor {
|
||||||
let delta = NSApplication.isDarkMode ? 0.05 : 0.99
|
let delta = NSApplication.isDarkMode ? 0.05 : 0.99
|
||||||
return .init(white: delta, alpha: 1)
|
return .init(white: delta, alpha: 1)
|
||||||
|
@ -206,7 +208,7 @@ private extension VwrCandidateTDKCocoa {
|
||||||
case .horizontal where isMatrix:
|
case .horizontal where isMatrix:
|
||||||
return NSApplication.isDarkMode ? .controlTextColor.withAlphaComponent(0.05) : .white
|
return NSApplication.isDarkMode ? .controlTextColor.withAlphaComponent(0.05) : .white
|
||||||
case .vertical where isMatrix:
|
case .vertical where isMatrix:
|
||||||
return absBg.withAlphaComponent(0.13)
|
return absBg.withAlphaComponent(0.9)
|
||||||
default:
|
default:
|
||||||
return .clear
|
return .clear
|
||||||
}
|
}
|
||||||
|
@ -260,11 +262,11 @@ private extension VwrCandidateTDKCocoa {
|
||||||
textField.sizeToFit()
|
textField.sizeToFit()
|
||||||
return textField
|
return textField
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Constraint Utilities
|
// MARK: - Constraint Utilities
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
private extension VwrCandidateTDKCocoa {
|
||||||
static var strForConstraintStatistics = NSMutableString(string: "TDKCandidates Dimensions (Debug):\n")
|
static var strForConstraintStatistics = NSMutableString(string: "TDKCandidates Dimensions (Debug):\n")
|
||||||
|
|
||||||
static func addStatistics(_ target: NSView, memo: String = "") {
|
static func addStatistics(_ target: NSView, memo: String = "") {
|
||||||
|
@ -282,11 +284,11 @@ private extension VwrCandidateTDKCocoa {
|
||||||
)
|
)
|
||||||
item.addConstraint(widthConstraint)
|
item.addConstraint(widthConstraint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Candidate Cell View
|
// MARK: - Candidate Cell View
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
private extension VwrCandidateTDKCocoa {
|
||||||
class VwrCandidateCell: NSTextField {
|
class VwrCandidateCell: NSTextField {
|
||||||
public var cellData: CandidateCellData
|
public var cellData: CandidateCellData
|
||||||
public init(cell: CandidateCellData) {
|
public init(cell: CandidateCellData) {
|
||||||
|
@ -381,11 +383,11 @@ private extension VwrCandidateTDKCocoa {
|
||||||
target.didRightClickCandidateAt(cellData.index, action: .toFilter)
|
target.didRightClickCandidateAt(cellData.index, action: .toFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delegate Methods
|
// MARK: - Delegate Methods
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
private extension VwrCandidateTDKCocoa {
|
||||||
func didSelectCandidateAt(_ pos: Int) {
|
func didSelectCandidateAt(_ pos: Int) {
|
||||||
controller?.delegate?.candidatePairSelectionConfirmed(at: pos)
|
controller?.delegate?.candidatePairSelectionConfirmed(at: pos)
|
||||||
}
|
}
|
||||||
|
@ -393,14 +395,14 @@ private extension VwrCandidateTDKCocoa {
|
||||||
func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) {
|
func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) {
|
||||||
controller?.delegate?.candidatePairRightClicked(at: pos, action: action)
|
controller?.delegate?.candidatePairRightClicked(at: pos, action: action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Debug Module Using Swift UI.
|
// MARK: - Debug Module Using Swift UI.
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@available(macOS 10.15, *)
|
@available(macOS 10.15, *)
|
||||||
public struct VwrCandidateTDKCocoaForSwiftUI: NSViewRepresentable {
|
public struct VwrCandidateTDKCocoaForSwiftUI: NSViewRepresentable {
|
||||||
public weak var controller: CtlCandidateTDK?
|
public weak var controller: CtlCandidateTDK?
|
||||||
public var thePool: CandidatePool
|
public var thePool: CandidatePool
|
||||||
|
|
||||||
|
@ -414,4 +416,6 @@ public struct VwrCandidateTDKCocoaForSwiftUI: NSViewRepresentable {
|
||||||
nsView.thePool = thePool
|
nsView.thePool = thePool
|
||||||
nsView.refresh()
|
nsView.refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
|
@ -180,7 +180,7 @@ extension VwrCandidateTDK {
|
||||||
case .horizontal where isCurrentLineInMatrix:
|
case .horizontal where isCurrentLineInMatrix:
|
||||||
return colorScheme == .dark ? Color.primary.opacity(0.05) : .white
|
return colorScheme == .dark ? Color.primary.opacity(0.05) : .white
|
||||||
case .vertical where isCurrentLineInMatrix:
|
case .vertical where isCurrentLineInMatrix:
|
||||||
return absoluteBackgroundColor.opacity(0.13)
|
return absoluteBackgroundColor.opacity(0.9)
|
||||||
default:
|
default:
|
||||||
return Color.clear
|
return Color.clear
|
||||||
}
|
}
|
||||||
|
@ -308,9 +308,9 @@ extension VwrCandidateTDK {
|
||||||
|
|
||||||
var absoluteBackgroundColor: Color {
|
var absoluteBackgroundColor: Color {
|
||||||
if colorScheme == .dark {
|
if colorScheme == .dark {
|
||||||
return Color(white: 0)
|
return Color.black
|
||||||
} else {
|
} else {
|
||||||
return Color(white: 1)
|
return Color.white
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -455,6 +455,32 @@ struct VwrCandidateTDK_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
VStack {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text("田所選字窗 CG 模式").bold().font(Font.system(.title))
|
||||||
|
VStack {
|
||||||
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolX).fixedSize()
|
||||||
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize()
|
||||||
|
HStack {
|
||||||
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolY).fixedSize()
|
||||||
|
VwrCandidateTDKAppKitForSwiftUI(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if USING_STACK_VIEW_IN_TDK_COCOA
|
||||||
VStack {
|
VStack {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("田所選字窗 Cocoa 模式").bold().font(Font.system(.title))
|
Text("田所選字窗 Cocoa 模式").bold().font(Font.system(.title))
|
||||||
|
@ -480,5 +506,6 @@ struct VwrCandidateTDK_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue