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,8 +41,15 @@ public class CtlCandidateTDK: CtlCandidate, NSWindowDelegate {
|
||||||
).edgesIgnoringSafeArea(.top)
|
).edgesIgnoringSafeArea(.top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var theViewCocoa: NSStackView {
|
#if USING_STACK_VIEW_IN_TDK_COCOA
|
||||||
VwrCandidateTDKCocoa(controller: self, thePool: Self.thePool)
|
/// 該視圖模式因算法陳舊而不再維護。
|
||||||
|
private var theViewCocoa: NSStackView {
|
||||||
|
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 {
|
||||||
|
@ -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,412 +6,416 @@
|
||||||
// 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
|
||||||
public weak var controller: CtlCandidateTDK?
|
|
||||||
public var thePool: CandidatePool
|
|
||||||
private var lineDimension: CGSize = .zero
|
|
||||||
private var candidateAreaDimension: CGSize = .zero
|
|
||||||
|
|
||||||
// MARK: - Constructors.
|
/// 田所選字窗的 Cocoa 版本,繪製效率不受 SwiftUI 的限制。
|
||||||
|
public class VwrCandidateTDKCocoa: NSStackView {
|
||||||
|
public weak var controller: CtlCandidateTDK?
|
||||||
|
public var thePool: CandidatePool
|
||||||
|
private var lineDimension: CGSize = .zero
|
||||||
|
private var candidateAreaDimension: CGSize = .zero
|
||||||
|
|
||||||
public init(controller: CtlCandidateTDK? = nil, thePool pool: CandidatePool) {
|
// MARK: - Constructors.
|
||||||
self.controller = controller
|
|
||||||
thePool = pool
|
|
||||||
super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514)))
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(*, unavailable)
|
public init(controller: CtlCandidateTDK? = nil, thePool pool: CandidatePool) {
|
||||||
required init?(coder _: NSCoder) {
|
self.controller = controller
|
||||||
fatalError("init(coder:) has not been implemented")
|
thePool = pool
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Interface Renderer.
|
|
||||||
|
|
||||||
public extension VwrCandidateTDKCocoa {
|
|
||||||
func refresh() {
|
|
||||||
defer {
|
|
||||||
vCLog(Self.strForConstraintStatistics.description)
|
|
||||||
Self.strForConstraintStatistics = .init()
|
|
||||||
}
|
|
||||||
// 用來登記全部的行容器,方便在收尾階段統一設定 constraints。
|
|
||||||
var arrStackViewsOfLines = [NSStackView]()
|
|
||||||
// 清理兩個計數器。
|
|
||||||
lineDimension = .zero
|
|
||||||
candidateAreaDimension = .zero
|
|
||||||
// 容器自身美化。
|
|
||||||
edgeInsets = .init(top: 5, left: 5, bottom: 5, right: 5)
|
|
||||||
wantsLayer = true
|
|
||||||
layer?.backgroundColor = candidateListBackground.cgColor
|
|
||||||
layer?.cornerRadius = 10
|
|
||||||
// 現在開始準備容器內容。
|
|
||||||
let isVerticalListing: Bool = thePool.layout == .vertical
|
|
||||||
let candidateContainer = NSStackView()
|
|
||||||
// 這是行陳列方向,不是候選字詞陳列方向。
|
|
||||||
candidateContainer.orientation = isVerticalListing ? .horizontal : .vertical
|
|
||||||
candidateContainer.alignment = isVerticalListing ? .top : .leading
|
|
||||||
candidateContainer.spacing = 0
|
|
||||||
candidateContainer.setHuggingPriority(.fittingSizeCompression, for: .horizontal)
|
|
||||||
candidateContainer.setHuggingPriority(.fittingSizeCompression, for: .vertical)
|
|
||||||
for lineID in thePool.lineRangeForCurrentPage {
|
|
||||||
var theLine = thePool.candidateLines[lineID]
|
|
||||||
let vwrCurrentLine = generateLineContainer(&theLine)
|
|
||||||
candidateContainer.addView(vwrCurrentLine, in: isVerticalListing ? .top : .leading)
|
|
||||||
arrStackViewsOfLines.append(vwrCurrentLine)
|
|
||||||
}
|
|
||||||
if thePool.maxLinesPerPage - thePool.lineRangeForCurrentPage.count > 0 {
|
|
||||||
thePool.lineRangeForFinalPageBlanked.enumerated().forEach { _ in
|
|
||||||
var theLine = [CandidateCellData]()
|
|
||||||
let copied = CandidatePool.blankCell.cleanCopy
|
|
||||||
for _ in 0 ..< thePool.maxLineCapacity {
|
|
||||||
theLine.append(copied)
|
|
||||||
}
|
|
||||||
let vwrCurrentLine = generateLineContainer(&theLine)
|
|
||||||
candidateContainer.addView(vwrCurrentLine, in: isVerticalListing ? .top : .leading)
|
|
||||||
arrStackViewsOfLines.append(vwrCurrentLine)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 處理行寬或列高。
|
|
||||||
switch thePool.layout {
|
|
||||||
case .vertical:
|
|
||||||
var accumulatedWidth: CGFloat = 0
|
|
||||||
var lines = [[CandidateCellData]]()
|
|
||||||
thePool.lineRangeForCurrentPage.forEach { lines.append(thePool.candidateLines[$0]) }
|
|
||||||
arrStackViewsOfLines.enumerated().forEach { viewLineID, vwrCurrentLine in
|
|
||||||
var columnWidth: CGFloat = 0
|
|
||||||
if (0 ..< lines.count).contains(viewLineID), !lines.isEmpty {
|
|
||||||
let line = Array(lines[viewLineID])
|
|
||||||
columnWidth = line.map(\.visualDimension.width).max() ?? lineDimension.width
|
|
||||||
} else {
|
|
||||||
columnWidth = CandidatePool.blankCell.visualDimension.width
|
|
||||||
}
|
|
||||||
accumulatedWidth += columnWidth
|
|
||||||
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .width, relation: .equal, value: columnWidth)
|
|
||||||
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .height, relation: .equal, value: lineDimension.height)
|
|
||||||
Self.addStatistics(vwrCurrentLine, memo: "vwrCurrentLine")
|
|
||||||
}
|
|
||||||
candidateAreaDimension.width = accumulatedWidth
|
|
||||||
candidateAreaDimension.height = lineDimension.height
|
|
||||||
case .horizontal:
|
|
||||||
arrStackViewsOfLines.forEach { vwrCurrentLine in
|
|
||||||
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .width, relation: .equal, value: lineDimension.width)
|
|
||||||
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .height, relation: .equal, value: lineDimension.height)
|
|
||||||
Self.addStatistics(vwrCurrentLine, memo: "vwrCurrentLine")
|
|
||||||
}
|
|
||||||
candidateAreaDimension.width = lineDimension.width
|
|
||||||
candidateAreaDimension.height = lineDimension.height * Double(thePool.maxLinesPerPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
Self.makeSimpleConstraint(item: candidateContainer, attribute: .width, relation: .equal, value: candidateAreaDimension.width)
|
|
||||||
Self.makeSimpleConstraint(item: candidateContainer, attribute: .height, relation: .equal, value: candidateAreaDimension.height)
|
|
||||||
Self.addStatistics(candidateContainer, memo: "candidateContainer")
|
|
||||||
|
|
||||||
let vwrPeripherals = Self.makeLabel(thePool.attributedDescriptionBottomPanes)
|
|
||||||
Self.makeSimpleConstraint(
|
|
||||||
item: vwrPeripherals, attribute: .height, relation: .greaterThanOrEqual,
|
|
||||||
value: vwrPeripherals.fittingSize.height
|
|
||||||
)
|
|
||||||
Self.makeSimpleConstraint(
|
|
||||||
item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual,
|
|
||||||
value: vwrPeripherals.fittingSize.width
|
|
||||||
)
|
|
||||||
|
|
||||||
// 組裝。
|
|
||||||
let finalContainer = NSStackView()
|
|
||||||
let finalContainerOrientation: NSUserInterfaceLayoutOrientation = {
|
|
||||||
if thePool.maxLinesPerPage == 1, thePool.layout == .horizontal { return .horizontal }
|
|
||||||
return .vertical
|
|
||||||
}()
|
|
||||||
|
|
||||||
if finalContainerOrientation == .horizontal {
|
|
||||||
let vwrPeripheralMinWidth = vwrPeripherals.fittingSize.width + 3
|
|
||||||
Self.makeSimpleConstraint(item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual, value: vwrPeripheralMinWidth)
|
|
||||||
finalContainer.spacing = 5
|
|
||||||
} else {
|
|
||||||
finalContainer.spacing = 2
|
|
||||||
Self.makeSimpleConstraint(item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual, value: vwrPeripherals.fittingSize.width)
|
|
||||||
}
|
|
||||||
Self.addStatistics(vwrPeripherals, memo: "vwrPeripherals")
|
|
||||||
|
|
||||||
finalContainer.orientation = finalContainerOrientation
|
|
||||||
finalContainer.alignment = finalContainerOrientation == .vertical ? .leading : .centerY
|
|
||||||
finalContainer.addView(candidateContainer, in: .leading)
|
|
||||||
finalContainer.addView(vwrPeripherals, in: .leading)
|
|
||||||
Self.makeSimpleConstraint(
|
|
||||||
item: finalContainer, attribute: .width,
|
|
||||||
relation: .equal, value: finalContainer.fittingSize.width
|
|
||||||
)
|
|
||||||
Self.makeSimpleConstraint(
|
|
||||||
item: finalContainer, attribute: .height,
|
|
||||||
relation: .equal, value: finalContainer.fittingSize.height
|
|
||||||
)
|
|
||||||
Self.addStatistics(finalContainer, memo: "finalContainer")
|
|
||||||
|
|
||||||
// 更換容器內容為上文生成的新內容。
|
|
||||||
subviews.forEach { removeView($0) }
|
|
||||||
addView(finalContainer, in: .top)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Interface Components.
|
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
|
||||||
private var candidateListBackground: NSColor {
|
|
||||||
let delta = NSApplication.isDarkMode ? 0.05 : 0.99
|
|
||||||
return .init(white: delta, alpha: 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drawCellCocoa(_ theCell: CandidateCellData? = nil) -> NSView {
|
|
||||||
let theCell = theCell ?? CandidatePool.blankCell.cleanCopy
|
|
||||||
let cellLabel = VwrCandidateCell(cell: theCell)
|
|
||||||
cellLabel.target = self
|
|
||||||
Self.makeSimpleConstraint(item: cellLabel, attribute: .width, relation: .equal, value: cellLabel.fittingSize.width)
|
|
||||||
Self.makeSimpleConstraint(item: cellLabel, attribute: .height, relation: .equal, value: cellLabel.fittingSize.height)
|
|
||||||
Self.addStatistics(cellLabel, memo: "cellLabel")
|
|
||||||
let wrappedCell = NSStackView()
|
|
||||||
let padding: CGFloat = 3
|
|
||||||
wrappedCell.edgeInsets = .init(top: padding, left: padding, bottom: padding, right: padding)
|
|
||||||
wrappedCell.addView(cellLabel, in: .leading)
|
|
||||||
if theCell.isHighlighted {
|
|
||||||
wrappedCell.wantsLayer = true
|
|
||||||
wrappedCell.layer?.backgroundColor = theCell.themeColorCocoa.cgColor
|
|
||||||
wrappedCell.layer?.cornerRadius = padding * 2
|
|
||||||
}
|
|
||||||
let cellWidth = max(thePool.cellWidth(theCell).min ?? wrappedCell.fittingSize.width, wrappedCell.fittingSize.width)
|
|
||||||
let cellHeight = wrappedCell.fittingSize.height
|
|
||||||
wrappedCell.setHuggingPriority(.fittingSizeCompression, for: .horizontal)
|
|
||||||
wrappedCell.setHuggingPriority(.fittingSizeCompression, for: .vertical)
|
|
||||||
Self.makeSimpleConstraint(item: wrappedCell, attribute: .height, relation: .equal, value: cellHeight)
|
|
||||||
switch thePool.layout {
|
|
||||||
case .horizontal where thePool.maxLinesPerPage > 1:
|
|
||||||
Self.makeSimpleConstraint(item: wrappedCell, attribute: .width, relation: .equal, value: cellWidth)
|
|
||||||
default:
|
|
||||||
Self.makeSimpleConstraint(item: wrappedCell, attribute: .width, relation: .greaterThanOrEqual, value: cellWidth)
|
|
||||||
}
|
|
||||||
Self.addStatistics(wrappedCell, memo: "wrappedCell")
|
|
||||||
theCell.visualDimension = .init(width: cellWidth, height: cellHeight)
|
|
||||||
return wrappedCell
|
|
||||||
}
|
|
||||||
|
|
||||||
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.13)
|
|
||||||
default:
|
|
||||||
return .clear
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func generateLineContainer(_ theLine: inout [CandidateCellData]) -> NSStackView {
|
|
||||||
let isVerticalListing: Bool = thePool.layout == .vertical
|
|
||||||
let isMatrix = thePool.maxLinesPerPage > 1
|
|
||||||
let vwrCurrentLine = NSStackView()
|
|
||||||
vwrCurrentLine.spacing = 0
|
|
||||||
vwrCurrentLine.orientation = isVerticalListing ? .vertical : .horizontal
|
|
||||||
var cellHeight = 0.0
|
|
||||||
var lineSize: CGSize = .zero
|
|
||||||
let isCurrentLine = theLine.hasHighlightedCell
|
|
||||||
theLine.forEach { theCell in
|
|
||||||
vwrCurrentLine.addView(drawCellCocoa(theCell), in: isVerticalListing ? .top : .leading)
|
|
||||||
switch thePool.layout {
|
|
||||||
case .horizontal:
|
|
||||||
lineSize.width += theCell.visualDimension.width
|
|
||||||
lineSize.height = max(lineSize.height, theCell.visualDimension.height)
|
|
||||||
case .vertical:
|
|
||||||
lineSize.width = max(lineSize.width, theCell.visualDimension.width)
|
|
||||||
lineSize.height += theCell.visualDimension.height
|
|
||||||
}
|
|
||||||
cellHeight = max(theCell.visualDimension.height, cellHeight)
|
|
||||||
}
|
|
||||||
let lineBg = lineBackground(isCurrentLine: isCurrentLine, isMatrix: isMatrix)
|
|
||||||
vwrCurrentLine.wantsLayer = isCurrentLine && isMatrix
|
|
||||||
if vwrCurrentLine.wantsLayer {
|
|
||||||
vwrCurrentLine.layer?.backgroundColor = lineBg.cgColor
|
|
||||||
vwrCurrentLine.layer?.cornerRadius = 6
|
|
||||||
}
|
|
||||||
vwrCurrentLine.alphaValue = isCurrentLine ? 1 : 0.85
|
|
||||||
lineDimension.width = max(lineSize.width, lineDimension.width)
|
|
||||||
switch thePool.layout {
|
|
||||||
case .horizontal: lineDimension.height = max(lineSize.height, lineDimension.height)
|
|
||||||
case .vertical: lineDimension.height = cellHeight * Double(thePool.maxLineCapacity)
|
|
||||||
}
|
|
||||||
return vwrCurrentLine
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeLabel(_ attrStr: NSAttributedString) -> NSTextField {
|
|
||||||
let textField = NSTextField()
|
|
||||||
textField.isSelectable = false
|
|
||||||
textField.isEditable = false
|
|
||||||
textField.isBordered = false
|
|
||||||
textField.backgroundColor = .clear
|
|
||||||
textField.allowsEditingTextAttributes = false
|
|
||||||
textField.preferredMaxLayoutWidth = textField.frame.width
|
|
||||||
textField.attributedStringValue = attrStr
|
|
||||||
textField.sizeToFit()
|
|
||||||
return textField
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Constraint Utilities
|
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
|
||||||
static var strForConstraintStatistics = NSMutableString(string: "TDKCandidates Dimensions (Debug):\n")
|
|
||||||
|
|
||||||
static func addStatistics(_ target: NSView, memo: String = "") {
|
|
||||||
if Self.strForConstraintStatistics.length == 0 {
|
|
||||||
Self.strForConstraintStatistics.append("TDKCandidates Dimensions (Debug):\n")
|
|
||||||
}
|
|
||||||
Self.strForConstraintStatistics.append("\(target.fittingSize) \(memo)\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeSimpleConstraint(item: NSView, attribute: NSLayoutConstraint.Attribute, relation: NSLayoutConstraint.Relation, value: CGFloat) {
|
|
||||||
item.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
let widthConstraint = NSLayoutConstraint(
|
|
||||||
item: item, attribute: attribute, relatedBy: relation, toItem: nil,
|
|
||||||
attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: value
|
|
||||||
)
|
|
||||||
item.addConstraint(widthConstraint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Candidate Cell View
|
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
|
||||||
class VwrCandidateCell: NSTextField {
|
|
||||||
public var cellData: CandidateCellData
|
|
||||||
public init(cell: CandidateCellData) {
|
|
||||||
cellData = cell
|
|
||||||
super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514)))
|
super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514)))
|
||||||
isSelectable = false
|
refresh()
|
||||||
isEditable = false
|
|
||||||
isBordered = false
|
|
||||||
backgroundColor = .clear
|
|
||||||
allowsEditingTextAttributes = false
|
|
||||||
preferredMaxLayoutWidth = frame.width
|
|
||||||
attributedStringValue = cellData.attributedString()
|
|
||||||
sizeToFit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(*, unavailable)
|
@available(*, unavailable)
|
||||||
required init?(coder _: NSCoder) {
|
required init?(coder _: NSCoder) {
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
deinit {
|
// MARK: - Interface Renderer.
|
||||||
// TODO: This doesn't work at all. (#TDKError_NSMenuDeconstruction)
|
|
||||||
theMenu?.cancelTrackingWithoutAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Mouse Actions.
|
public extension VwrCandidateTDKCocoa {
|
||||||
|
func refresh() {
|
||||||
override func mouseUp(with _: NSEvent) {
|
defer {
|
||||||
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
vCLog(Self.strForConstraintStatistics.description)
|
||||||
target.didSelectCandidateAt(cellData.index)
|
Self.strForConstraintStatistics = .init()
|
||||||
}
|
}
|
||||||
|
// 用來登記全部的行容器,方便在收尾階段統一設定 constraints。
|
||||||
override func rightMouseUp(with event: NSEvent) {
|
var arrStackViewsOfLines = [NSStackView]()
|
||||||
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
// 清理兩個計數器。
|
||||||
let index = cellData.index
|
lineDimension = .zero
|
||||||
let candidateText = cellData.displayedText
|
candidateAreaDimension = .zero
|
||||||
let isEnabled: Bool = target.controller?.delegate?.isCandidateContextMenuEnabled ?? false
|
// 容器自身美化。
|
||||||
guard isEnabled, !candidateText.isEmpty, index >= 0 else { return }
|
edgeInsets = .init(top: 5, left: 5, bottom: 5, right: 5)
|
||||||
prepareMenu()
|
wantsLayer = true
|
||||||
theMenu?.popUp(positioning: nil, at: event.locationInWindow, in: target)
|
layer?.backgroundColor = candidateListBackground.cgColor
|
||||||
}
|
layer?.cornerRadius = 10
|
||||||
|
// 現在開始準備容器內容。
|
||||||
// MARK: Menu.
|
let isVerticalListing: Bool = thePool.layout == .vertical
|
||||||
|
let candidateContainer = NSStackView()
|
||||||
var theMenu: NSMenu?
|
// 這是行陳列方向,不是候選字詞陳列方向。
|
||||||
|
candidateContainer.orientation = isVerticalListing ? .horizontal : .vertical
|
||||||
private func prepareMenu() {
|
candidateContainer.alignment = isVerticalListing ? .top : .leading
|
||||||
guard let thePool = (target as? VwrCandidateTDKCocoa)?.thePool else { return }
|
candidateContainer.spacing = 0
|
||||||
let newMenu = NSMenu()
|
candidateContainer.setHuggingPriority(.fittingSizeCompression, for: .horizontal)
|
||||||
let boostMenuItem = NSMenuItem(
|
candidateContainer.setHuggingPriority(.fittingSizeCompression, for: .vertical)
|
||||||
title: "↑ \(cellData.displayedText)",
|
for lineID in thePool.lineRangeForCurrentPage {
|
||||||
action: #selector(menuActionOfBoosting(_:)),
|
var theLine = thePool.candidateLines[lineID]
|
||||||
keyEquivalent: ""
|
let vwrCurrentLine = generateLineContainer(&theLine)
|
||||||
)
|
candidateContainer.addView(vwrCurrentLine, in: isVerticalListing ? .top : .leading)
|
||||||
boostMenuItem.target = self
|
arrStackViewsOfLines.append(vwrCurrentLine)
|
||||||
newMenu.addItem(boostMenuItem)
|
}
|
||||||
|
if thePool.maxLinesPerPage - thePool.lineRangeForCurrentPage.count > 0 {
|
||||||
let nerfMenuItem = NSMenuItem(
|
thePool.lineRangeForFinalPageBlanked.enumerated().forEach { _ in
|
||||||
title: "↓ \(cellData.displayedText)",
|
var theLine = [CandidateCellData]()
|
||||||
action: #selector(menuActionOfNerfing(_:)),
|
let copied = CandidatePool.blankCell.cleanCopy
|
||||||
keyEquivalent: ""
|
for _ in 0 ..< thePool.maxLineCapacity {
|
||||||
)
|
theLine.append(copied)
|
||||||
nerfMenuItem.target = self
|
}
|
||||||
newMenu.addItem(nerfMenuItem)
|
let vwrCurrentLine = generateLineContainer(&theLine)
|
||||||
|
candidateContainer.addView(vwrCurrentLine, in: isVerticalListing ? .top : .leading)
|
||||||
if thePool.isFilterable(target: cellData.index) {
|
arrStackViewsOfLines.append(vwrCurrentLine)
|
||||||
let filterMenuItem = NSMenuItem(
|
}
|
||||||
title: "✖︎ \(cellData.displayedText)",
|
|
||||||
action: #selector(menuActionOfFiltering(_:)),
|
|
||||||
keyEquivalent: ""
|
|
||||||
)
|
|
||||||
filterMenuItem.target = self
|
|
||||||
newMenu.addItem(filterMenuItem)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
theMenu = newMenu
|
// 處理行寬或列高。
|
||||||
CtlCandidateTDK.currentMenu = newMenu
|
switch thePool.layout {
|
||||||
}
|
case .vertical:
|
||||||
|
var accumulatedWidth: CGFloat = 0
|
||||||
|
var lines = [[CandidateCellData]]()
|
||||||
|
thePool.lineRangeForCurrentPage.forEach { lines.append(thePool.candidateLines[$0]) }
|
||||||
|
arrStackViewsOfLines.enumerated().forEach { viewLineID, vwrCurrentLine in
|
||||||
|
var columnWidth: CGFloat = 0
|
||||||
|
if (0 ..< lines.count).contains(viewLineID), !lines.isEmpty {
|
||||||
|
let line = Array(lines[viewLineID])
|
||||||
|
columnWidth = line.map(\.visualDimension.width).max() ?? lineDimension.width
|
||||||
|
} else {
|
||||||
|
columnWidth = CandidatePool.blankCell.visualDimension.width
|
||||||
|
}
|
||||||
|
accumulatedWidth += columnWidth
|
||||||
|
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .width, relation: .equal, value: columnWidth)
|
||||||
|
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .height, relation: .equal, value: lineDimension.height)
|
||||||
|
Self.addStatistics(vwrCurrentLine, memo: "vwrCurrentLine")
|
||||||
|
}
|
||||||
|
candidateAreaDimension.width = accumulatedWidth
|
||||||
|
candidateAreaDimension.height = lineDimension.height
|
||||||
|
case .horizontal:
|
||||||
|
arrStackViewsOfLines.forEach { vwrCurrentLine in
|
||||||
|
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .width, relation: .equal, value: lineDimension.width)
|
||||||
|
Self.makeSimpleConstraint(item: vwrCurrentLine, attribute: .height, relation: .equal, value: lineDimension.height)
|
||||||
|
Self.addStatistics(vwrCurrentLine, memo: "vwrCurrentLine")
|
||||||
|
}
|
||||||
|
candidateAreaDimension.width = lineDimension.width
|
||||||
|
candidateAreaDimension.height = lineDimension.height * Double(thePool.maxLinesPerPage)
|
||||||
|
}
|
||||||
|
|
||||||
@objc func menuActionOfBoosting(_: Any? = nil) {
|
Self.makeSimpleConstraint(item: candidateContainer, attribute: .width, relation: .equal, value: candidateAreaDimension.width)
|
||||||
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
Self.makeSimpleConstraint(item: candidateContainer, attribute: .height, relation: .equal, value: candidateAreaDimension.height)
|
||||||
target.didRightClickCandidateAt(cellData.index, action: .toBoost)
|
Self.addStatistics(candidateContainer, memo: "candidateContainer")
|
||||||
}
|
|
||||||
|
|
||||||
@objc func menuActionOfNerfing(_: Any? = nil) {
|
let vwrPeripherals = Self.makeLabel(thePool.attributedDescriptionBottomPanes)
|
||||||
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
Self.makeSimpleConstraint(
|
||||||
target.didRightClickCandidateAt(cellData.index, action: .toNerf)
|
item: vwrPeripherals, attribute: .height, relation: .greaterThanOrEqual,
|
||||||
}
|
value: vwrPeripherals.fittingSize.height
|
||||||
|
)
|
||||||
|
Self.makeSimpleConstraint(
|
||||||
|
item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual,
|
||||||
|
value: vwrPeripherals.fittingSize.width
|
||||||
|
)
|
||||||
|
|
||||||
@objc func menuActionOfFiltering(_: Any? = nil) {
|
// 組裝。
|
||||||
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
let finalContainer = NSStackView()
|
||||||
target.didRightClickCandidateAt(cellData.index, action: .toFilter)
|
let finalContainerOrientation: NSUserInterfaceLayoutOrientation = {
|
||||||
|
if thePool.maxLinesPerPage == 1, thePool.layout == .horizontal { return .horizontal }
|
||||||
|
return .vertical
|
||||||
|
}()
|
||||||
|
|
||||||
|
if finalContainerOrientation == .horizontal {
|
||||||
|
let vwrPeripheralMinWidth = vwrPeripherals.fittingSize.width + 3
|
||||||
|
Self.makeSimpleConstraint(item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual, value: vwrPeripheralMinWidth)
|
||||||
|
finalContainer.spacing = 5
|
||||||
|
} else {
|
||||||
|
finalContainer.spacing = 2
|
||||||
|
Self.makeSimpleConstraint(item: vwrPeripherals, attribute: .width, relation: .greaterThanOrEqual, value: vwrPeripherals.fittingSize.width)
|
||||||
|
}
|
||||||
|
Self.addStatistics(vwrPeripherals, memo: "vwrPeripherals")
|
||||||
|
|
||||||
|
finalContainer.orientation = finalContainerOrientation
|
||||||
|
finalContainer.alignment = finalContainerOrientation == .vertical ? .leading : .centerY
|
||||||
|
finalContainer.addView(candidateContainer, in: .leading)
|
||||||
|
finalContainer.addView(vwrPeripherals, in: .leading)
|
||||||
|
Self.makeSimpleConstraint(
|
||||||
|
item: finalContainer, attribute: .width,
|
||||||
|
relation: .equal, value: finalContainer.fittingSize.width
|
||||||
|
)
|
||||||
|
Self.makeSimpleConstraint(
|
||||||
|
item: finalContainer, attribute: .height,
|
||||||
|
relation: .equal, value: finalContainer.fittingSize.height
|
||||||
|
)
|
||||||
|
Self.addStatistics(finalContainer, memo: "finalContainer")
|
||||||
|
|
||||||
|
// 更換容器內容為上文生成的新內容。
|
||||||
|
subviews.forEach { removeView($0) }
|
||||||
|
addView(finalContainer, in: .top)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate Methods
|
// MARK: - Interface Components.
|
||||||
|
|
||||||
private extension VwrCandidateTDKCocoa {
|
private extension VwrCandidateTDKCocoa {
|
||||||
func didSelectCandidateAt(_ pos: Int) {
|
private var candidateListBackground: NSColor {
|
||||||
controller?.delegate?.candidatePairSelectionConfirmed(at: pos)
|
let delta = NSApplication.isDarkMode ? 0.05 : 0.99
|
||||||
|
return .init(white: delta, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func drawCellCocoa(_ theCell: CandidateCellData? = nil) -> NSView {
|
||||||
|
let theCell = theCell ?? CandidatePool.blankCell.cleanCopy
|
||||||
|
let cellLabel = VwrCandidateCell(cell: theCell)
|
||||||
|
cellLabel.target = self
|
||||||
|
Self.makeSimpleConstraint(item: cellLabel, attribute: .width, relation: .equal, value: cellLabel.fittingSize.width)
|
||||||
|
Self.makeSimpleConstraint(item: cellLabel, attribute: .height, relation: .equal, value: cellLabel.fittingSize.height)
|
||||||
|
Self.addStatistics(cellLabel, memo: "cellLabel")
|
||||||
|
let wrappedCell = NSStackView()
|
||||||
|
let padding: CGFloat = 3
|
||||||
|
wrappedCell.edgeInsets = .init(top: padding, left: padding, bottom: padding, right: padding)
|
||||||
|
wrappedCell.addView(cellLabel, in: .leading)
|
||||||
|
if theCell.isHighlighted {
|
||||||
|
wrappedCell.wantsLayer = true
|
||||||
|
wrappedCell.layer?.backgroundColor = theCell.themeColorCocoa.cgColor
|
||||||
|
wrappedCell.layer?.cornerRadius = padding * 2
|
||||||
|
}
|
||||||
|
let cellWidth = max(thePool.cellWidth(theCell).min ?? wrappedCell.fittingSize.width, wrappedCell.fittingSize.width)
|
||||||
|
let cellHeight = wrappedCell.fittingSize.height
|
||||||
|
wrappedCell.setHuggingPriority(.fittingSizeCompression, for: .horizontal)
|
||||||
|
wrappedCell.setHuggingPriority(.fittingSizeCompression, for: .vertical)
|
||||||
|
Self.makeSimpleConstraint(item: wrappedCell, attribute: .height, relation: .equal, value: cellHeight)
|
||||||
|
switch thePool.layout {
|
||||||
|
case .horizontal where thePool.maxLinesPerPage > 1:
|
||||||
|
Self.makeSimpleConstraint(item: wrappedCell, attribute: .width, relation: .equal, value: cellWidth)
|
||||||
|
default:
|
||||||
|
Self.makeSimpleConstraint(item: wrappedCell, attribute: .width, relation: .greaterThanOrEqual, value: cellWidth)
|
||||||
|
}
|
||||||
|
Self.addStatistics(wrappedCell, memo: "wrappedCell")
|
||||||
|
theCell.visualDimension = .init(width: cellWidth, height: cellHeight)
|
||||||
|
return wrappedCell
|
||||||
|
}
|
||||||
|
|
||||||
|
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 func generateLineContainer(_ theLine: inout [CandidateCellData]) -> NSStackView {
|
||||||
|
let isVerticalListing: Bool = thePool.layout == .vertical
|
||||||
|
let isMatrix = thePool.maxLinesPerPage > 1
|
||||||
|
let vwrCurrentLine = NSStackView()
|
||||||
|
vwrCurrentLine.spacing = 0
|
||||||
|
vwrCurrentLine.orientation = isVerticalListing ? .vertical : .horizontal
|
||||||
|
var cellHeight = 0.0
|
||||||
|
var lineSize: CGSize = .zero
|
||||||
|
let isCurrentLine = theLine.hasHighlightedCell
|
||||||
|
theLine.forEach { theCell in
|
||||||
|
vwrCurrentLine.addView(drawCellCocoa(theCell), in: isVerticalListing ? .top : .leading)
|
||||||
|
switch thePool.layout {
|
||||||
|
case .horizontal:
|
||||||
|
lineSize.width += theCell.visualDimension.width
|
||||||
|
lineSize.height = max(lineSize.height, theCell.visualDimension.height)
|
||||||
|
case .vertical:
|
||||||
|
lineSize.width = max(lineSize.width, theCell.visualDimension.width)
|
||||||
|
lineSize.height += theCell.visualDimension.height
|
||||||
|
}
|
||||||
|
cellHeight = max(theCell.visualDimension.height, cellHeight)
|
||||||
|
}
|
||||||
|
let lineBg = lineBackground(isCurrentLine: isCurrentLine, isMatrix: isMatrix)
|
||||||
|
vwrCurrentLine.wantsLayer = isCurrentLine && isMatrix
|
||||||
|
if vwrCurrentLine.wantsLayer {
|
||||||
|
vwrCurrentLine.layer?.backgroundColor = lineBg.cgColor
|
||||||
|
vwrCurrentLine.layer?.cornerRadius = 6
|
||||||
|
}
|
||||||
|
vwrCurrentLine.alphaValue = isCurrentLine ? 1 : 0.85
|
||||||
|
lineDimension.width = max(lineSize.width, lineDimension.width)
|
||||||
|
switch thePool.layout {
|
||||||
|
case .horizontal: lineDimension.height = max(lineSize.height, lineDimension.height)
|
||||||
|
case .vertical: lineDimension.height = cellHeight * Double(thePool.maxLineCapacity)
|
||||||
|
}
|
||||||
|
return vwrCurrentLine
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeLabel(_ attrStr: NSAttributedString) -> NSTextField {
|
||||||
|
let textField = NSTextField()
|
||||||
|
textField.isSelectable = false
|
||||||
|
textField.isEditable = false
|
||||||
|
textField.isBordered = false
|
||||||
|
textField.backgroundColor = .clear
|
||||||
|
textField.allowsEditingTextAttributes = false
|
||||||
|
textField.preferredMaxLayoutWidth = textField.frame.width
|
||||||
|
textField.attributedStringValue = attrStr
|
||||||
|
textField.sizeToFit()
|
||||||
|
return textField
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) {
|
// MARK: - Constraint Utilities
|
||||||
controller?.delegate?.candidatePairRightClicked(at: pos, action: action)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Debug Module Using Swift UI.
|
private extension VwrCandidateTDKCocoa {
|
||||||
|
static var strForConstraintStatistics = NSMutableString(string: "TDKCandidates Dimensions (Debug):\n")
|
||||||
|
|
||||||
import SwiftUI
|
static func addStatistics(_ target: NSView, memo: String = "") {
|
||||||
|
if Self.strForConstraintStatistics.length == 0 {
|
||||||
|
Self.strForConstraintStatistics.append("TDKCandidates Dimensions (Debug):\n")
|
||||||
|
}
|
||||||
|
Self.strForConstraintStatistics.append("\(target.fittingSize) \(memo)\n")
|
||||||
|
}
|
||||||
|
|
||||||
@available(macOS 10.15, *)
|
static func makeSimpleConstraint(item: NSView, attribute: NSLayoutConstraint.Attribute, relation: NSLayoutConstraint.Relation, value: CGFloat) {
|
||||||
public struct VwrCandidateTDKCocoaForSwiftUI: NSViewRepresentable {
|
item.translatesAutoresizingMaskIntoConstraints = false
|
||||||
public weak var controller: CtlCandidateTDK?
|
let widthConstraint = NSLayoutConstraint(
|
||||||
public var thePool: CandidatePool
|
item: item, attribute: attribute, relatedBy: relation, toItem: nil,
|
||||||
|
attribute: NSLayoutConstraint.Attribute.notAnAttribute, multiplier: 1, constant: value
|
||||||
public func makeNSView(context _: Context) -> VwrCandidateTDKCocoa {
|
)
|
||||||
let nsView = VwrCandidateTDKCocoa(thePool: thePool)
|
item.addConstraint(widthConstraint)
|
||||||
nsView.controller = controller
|
}
|
||||||
return nsView
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateNSView(_ nsView: VwrCandidateTDKCocoa, context _: Context) {
|
// MARK: - Candidate Cell View
|
||||||
nsView.thePool = thePool
|
|
||||||
nsView.refresh()
|
private extension VwrCandidateTDKCocoa {
|
||||||
|
class VwrCandidateCell: NSTextField {
|
||||||
|
public var cellData: CandidateCellData
|
||||||
|
public init(cell: CandidateCellData) {
|
||||||
|
cellData = cell
|
||||||
|
super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514)))
|
||||||
|
isSelectable = false
|
||||||
|
isEditable = false
|
||||||
|
isBordered = false
|
||||||
|
backgroundColor = .clear
|
||||||
|
allowsEditingTextAttributes = false
|
||||||
|
preferredMaxLayoutWidth = frame.width
|
||||||
|
attributedStringValue = cellData.attributedString()
|
||||||
|
sizeToFit()
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder _: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
// TODO: This doesn't work at all. (#TDKError_NSMenuDeconstruction)
|
||||||
|
theMenu?.cancelTrackingWithoutAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Mouse Actions.
|
||||||
|
|
||||||
|
override func mouseUp(with _: NSEvent) {
|
||||||
|
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
||||||
|
target.didSelectCandidateAt(cellData.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseUp(with event: NSEvent) {
|
||||||
|
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
||||||
|
let index = cellData.index
|
||||||
|
let candidateText = cellData.displayedText
|
||||||
|
let isEnabled: Bool = target.controller?.delegate?.isCandidateContextMenuEnabled ?? false
|
||||||
|
guard isEnabled, !candidateText.isEmpty, index >= 0 else { return }
|
||||||
|
prepareMenu()
|
||||||
|
theMenu?.popUp(positioning: nil, at: event.locationInWindow, in: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Menu.
|
||||||
|
|
||||||
|
var theMenu: NSMenu?
|
||||||
|
|
||||||
|
private func prepareMenu() {
|
||||||
|
guard let thePool = (target as? VwrCandidateTDKCocoa)?.thePool else { return }
|
||||||
|
let newMenu = NSMenu()
|
||||||
|
let boostMenuItem = NSMenuItem(
|
||||||
|
title: "↑ \(cellData.displayedText)",
|
||||||
|
action: #selector(menuActionOfBoosting(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
boostMenuItem.target = self
|
||||||
|
newMenu.addItem(boostMenuItem)
|
||||||
|
|
||||||
|
let nerfMenuItem = NSMenuItem(
|
||||||
|
title: "↓ \(cellData.displayedText)",
|
||||||
|
action: #selector(menuActionOfNerfing(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
nerfMenuItem.target = self
|
||||||
|
newMenu.addItem(nerfMenuItem)
|
||||||
|
|
||||||
|
if thePool.isFilterable(target: cellData.index) {
|
||||||
|
let filterMenuItem = NSMenuItem(
|
||||||
|
title: "✖︎ \(cellData.displayedText)",
|
||||||
|
action: #selector(menuActionOfFiltering(_:)),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
filterMenuItem.target = self
|
||||||
|
newMenu.addItem(filterMenuItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
theMenu = newMenu
|
||||||
|
CtlCandidateTDK.currentMenu = newMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfBoosting(_: Any? = nil) {
|
||||||
|
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
||||||
|
target.didRightClickCandidateAt(cellData.index, action: .toBoost)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfNerfing(_: Any? = nil) {
|
||||||
|
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
||||||
|
target.didRightClickCandidateAt(cellData.index, action: .toNerf)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func menuActionOfFiltering(_: Any? = nil) {
|
||||||
|
guard let target = target as? VwrCandidateTDKCocoa else { return }
|
||||||
|
target.didRightClickCandidateAt(cellData.index, action: .toFilter)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: - Delegate Methods
|
||||||
|
|
||||||
|
private extension VwrCandidateTDKCocoa {
|
||||||
|
func didSelectCandidateAt(_ pos: Int) {
|
||||||
|
controller?.delegate?.candidatePairSelectionConfirmed(at: pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRightClickCandidateAt(_ pos: Int, action: CandidateContextMenuAction) {
|
||||||
|
controller?.delegate?.candidatePairRightClicked(at: pos, action: action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Module Using Swift UI.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@available(macOS 10.15, *)
|
||||||
|
public struct VwrCandidateTDKCocoaForSwiftUI: NSViewRepresentable {
|
||||||
|
public weak var controller: CtlCandidateTDK?
|
||||||
|
public var thePool: CandidatePool
|
||||||
|
|
||||||
|
public func makeNSView(context _: Context) -> VwrCandidateTDKCocoa {
|
||||||
|
let nsView = VwrCandidateTDKCocoa(thePool: thePool)
|
||||||
|
nsView.controller = controller
|
||||||
|
return nsView
|
||||||
|
}
|
||||||
|
|
||||||
|
public func updateNSView(_ nsView: VwrCandidateTDKCocoa, context _: Context) {
|
||||||
|
nsView.thePool = thePool
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -457,13 +457,13 @@ struct VwrCandidateTDK_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
VStack {
|
VStack {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
Text("田所選字窗 Cocoa 模式").bold().font(Font.system(.title))
|
Text("田所選字窗 CG 模式").bold().font(Font.system(.title))
|
||||||
VStack {
|
VStack {
|
||||||
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolX).fixedSize()
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolX).fixedSize()
|
||||||
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize()
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize()
|
||||||
HStack {
|
HStack {
|
||||||
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolY).fixedSize()
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolY).fixedSize()
|
||||||
VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolYS).fixedSize()
|
VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolYS).fixedSize()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -480,5 +480,32 @@ struct VwrCandidateTDK_Previews: PreviewProvider {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if USING_STACK_VIEW_IN_TDK_COCOA
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue