From 339cfb0ad4defc97e15d5788d60673ee72081ac7 Mon Sep 17 00:00:00 2001 From: ShikiSuen Date: Sun, 16 Jul 2023 17:28:10 +0800 Subject: [PATCH] CtlCandidateTDK // Rewrite. --- .../CandidateCellData_Core.swift | 31 +- .../CandidateWindow/CandidatePool.swift | 24 +- .../CandidatePool_CocoaImpl.swift | 138 +++- .../TDKCandidates/CtlCandidateTDK.swift | 13 +- .../VwrCandidateTDK_Appkit.swift | 246 ++++++ .../TDKCandidates/VwrCandidateTDK_Cocoa.swift | 762 +++++++++--------- .../VwrCandidateTDK_SwiftUI.swift | 43 +- 7 files changed, 859 insertions(+), 398 deletions(-) create mode 100644 Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Appkit.swift diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift index 29983e22..f2f226c3 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidateCellData_Core.swift @@ -8,18 +8,20 @@ import AppKit import Shared -import SwiftUI -import SwiftUIBackports // MARK: - Candidate Cell /// 用來管理選字窗內顯示的候選字的單位。用 class 型別會比較方便一些。 public class CandidateCellData: Hashable { public var visualDimension: CGSize = .zero + public var visualOrigin: CGPoint = .zero public var locale = "" 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 displayedText: String + public let displayedText: String + public private(set) var textDimension: NSSize public var spanLength: Int public var size: Double { Self.unifiedSize } public var isHighlighted: Bool = false @@ -29,7 +31,6 @@ public class CandidateCellData: Hashable { // 該候選字詞在當前行/列內的索引編號 public var subIndex: Int = 0 - public var charGlyphWidth: Double { ceil(size * 1.0125 + 7) } public var fontSizeCandidate: Double { size } public var fontSizeKey: Double { max(ceil(fontSizeCandidate * 0.6), 11) } public var fontColorCandidate: NSColor { isHighlighted ? .selectedMenuItemTextColor : .controlTextColor } @@ -64,6 +65,10 @@ public class CandidateCellData: Hashable { self.displayedText = displayedText spanLength = max(spanningLength ?? displayedText.count, 1) 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 { @@ -76,9 +81,9 @@ public class CandidateCellData: Hashable { } 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 } - return ceil(attributedStringPhrase().boundingDimension.width + charGlyphWidth) + return textDimension.width } // MARK: - Fonts and NSColors. @@ -202,6 +207,20 @@ public class CandidateCellData: Hashable { 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. diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift index f0335083..67c0829b 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift @@ -23,12 +23,33 @@ public class CandidatePool { public var reverseLookupResult: [String] = [] public private(set) var highlightedIndex: Int = 0 public private(set) var currentLineNumber = 0 + public var metrics: UIMetrics = .allZeroed private var recordedLineRangeForCurrentPage: Range? private var previouslyRecordedLineRangeForPreviousPage: Range? + 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: - 動態變數 + 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()) } @@ -130,6 +151,7 @@ public class CandidatePool { candidateLines.append(currentColumn) recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage highlight(at: 0) + updateMetrics() } } @@ -254,7 +276,7 @@ public extension CandidatePool { if layout != .vertical, maxLinesPerPage == 1 { min = max(minAccepted, cell.cellLength(isMatrix: false)) } 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) } diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift index 1d8ab923..e8b972ce 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool_CocoaImpl.swift @@ -8,7 +8,141 @@ 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 { // MARK: Candidate List with Peripherals. @@ -143,6 +277,7 @@ extension CandidatePool { let positionCounterTextSize = max(ceil(CandidateCellData.unifiedSize * 0.7), 11) let attrTooltip: [NSAttributedString.Key: AnyObject] = [ .font: Self.blankCell.phraseFontEmphasized(size: positionCounterTextSize), + .foregroundColor: NSColor.textColor, ] let tooltipText = NSAttributedString( string: " \(tooltip) ", attributes: attrTooltip @@ -154,6 +289,7 @@ extension CandidatePool { let reverseLookupTextSize = max(ceil(CandidateCellData.unifiedSize * 0.6), 9) let attrReverseLookup: [NSAttributedString.Key: AnyObject] = [ .font: Self.blankCell.phraseFont(size: reverseLookupTextSize), + .foregroundColor: NSColor.textColor, ] let attrReverseLookupSpacer: [NSAttributedString.Key: AnyObject] = [ .font: Self.blankCell.phraseFont(size: reverseLookupTextSize), diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift index b64af181..545170e3 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/CtlCandidateTDK.swift @@ -41,8 +41,15 @@ public class CtlCandidateTDK: CtlCandidate, NSWindowDelegate { ).edgesIgnoringSafeArea(.top) } - private var theViewCocoa: NSStackView { - VwrCandidateTDKCocoa(controller: self, thePool: Self.thePool) + #if USING_STACK_VIEW_IN_TDK_COCOA + /// 該視圖模式因算法陳舊而不再維護。 + 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 { @@ -120,7 +127,7 @@ public class CtlCandidateTDK: CtlCandidate, NSWindowDelegate { Self.currentView = NSHostingView(rootView: theView) break viewCheck } - Self.currentView = theViewCocoa + Self.currentView = theViewAppKit } window.contentView = Self.currentView window.setContentSize(Self.currentView.fittingSize) diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Appkit.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Appkit.swift new file mode 100644 index 00000000..fb2bca6d --- /dev/null +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Appkit.swift @@ -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 + } +} diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Cocoa.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Cocoa.swift index 41cf6004..b011f296 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Cocoa.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_Cocoa.swift @@ -6,412 +6,416 @@ // marks, or product names of Contributor, except as required to fulfill notice // requirements defined in MIT License. -import AppKit -import Shared +#if USING_STACK_VIEW_IN_TDK_COCOA -/// 田所選字窗的 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 + import AppKit + import Shared - // 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) { - self.controller = controller - thePool = pool - super.init(frame: .init(origin: .zero, size: .init(width: 114_514, height: 114_514))) - refresh() - } + // MARK: - Constructors. - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -// 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 + public init(controller: CtlCandidateTDK? = nil, thePool pool: CandidatePool) { + self.controller = controller + thePool = pool 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() + refresh() } @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: - Interface Renderer. - // 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) + 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) + } } - 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) { - guard let target = target as? VwrCandidateTDKCocoa else { return } - target.didRightClickCandidateAt(cellData.index, action: .toBoost) - } + 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") - @objc func menuActionOfNerfing(_: Any? = nil) { - guard let target = target as? VwrCandidateTDKCocoa else { return } - target.didRightClickCandidateAt(cellData.index, action: .toNerf) - } + 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 + ) - @objc func menuActionOfFiltering(_: Any? = nil) { - guard let target = target as? VwrCandidateTDKCocoa else { return } - target.didRightClickCandidateAt(cellData.index, action: .toFilter) + // 組裝。 + 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: - Delegate Methods + // MARK: - Interface Components. -private extension VwrCandidateTDKCocoa { - func didSelectCandidateAt(_ pos: Int) { - controller?.delegate?.candidatePairSelectionConfirmed(at: pos) + 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.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) { - controller?.delegate?.candidatePairRightClicked(at: pos, action: action) - } -} + // MARK: - Constraint Utilities -// 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, *) -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 + 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) + } } - public func updateNSView(_ nsView: VwrCandidateTDKCocoa, context _: Context) { - nsView.thePool = thePool - nsView.refresh() + // 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))) + 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 diff --git a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_SwiftUI.swift b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_SwiftUI.swift index ee280ede..3330abbc 100644 --- a/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_SwiftUI.swift +++ b/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/TDKCandidates/VwrCandidateTDK_SwiftUI.swift @@ -180,7 +180,7 @@ extension VwrCandidateTDK { case .horizontal where isCurrentLineInMatrix: return colorScheme == .dark ? Color.primary.opacity(0.05) : .white case .vertical where isCurrentLineInMatrix: - return absoluteBackgroundColor.opacity(0.13) + return absoluteBackgroundColor.opacity(0.9) default: return Color.clear } @@ -308,9 +308,9 @@ extension VwrCandidateTDK { var absoluteBackgroundColor: Color { if colorScheme == .dark { - return Color(white: 0) + return Color.black } else { - return Color(white: 1) + return Color.white } } @@ -457,13 +457,13 @@ struct VwrCandidateTDK_Previews: PreviewProvider { } VStack { HStack(alignment: .top) { - Text("田所選字窗 Cocoa 模式").bold().font(Font.system(.title)) + Text("田所選字窗 CG 模式").bold().font(Font.system(.title)) VStack { - VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolX).fixedSize() - VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize() + VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolX).fixedSize() + VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolXS).fixedSize() HStack { - VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolY).fixedSize() - VwrCandidateTDKCocoaForSwiftUI(controller: nil, thePool: thePoolYS).fixedSize() + VwrCandidateTDKAppKitForSwiftUI(controller: nil, thePool: thePoolY).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 } }