vChewing-macOS/Packages/vChewing_CandidateWindow/Sources/CandidateWindow/CandidatePool.swift

498 lines
20 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

// (c) 2022 and onwards The vChewing Project (MIT-NTL License).
// ====================
// This code is released under the MIT license (SPDX-License-Identifier: MIT)
// ... with NTL restriction stating that:
// No trademark license is granted to use the trade names, trademarks, service
// marks, or product names of Contributor, except as required to fulfill notice
// requirements defined in MIT License.
import Foundation
import Shared
///
public class CandidatePool {
// cell
public static let shitCell = CandidateCellData(key: " ", displayedText: "💩", isSelected: false)
public static let blankCell = CandidateCellData(key: " ", displayedText: " ", isSelected: false)
public private(set) var _maxLinesPerPage: Int
public private(set) var layout: LayoutOrientation
public private(set) var selectionKeys: String
public private(set) var candidateDataAll: [CandidateCellData]
public private(set) var candidateLines: [[CandidateCellData]] = []
public private(set) var highlightedIndex: Int = 0
public private(set) var currentLineNumber = 0
public private(set) var isExpanded: Bool = false
public var metrics: UIMetrics = .allZeroed
public var tooltip: String = ""
public var reverseLookupResult: [String] = []
private var recordedLineRangeForCurrentPage: 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: -
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 maxLinesPerPage: Int { isExpanded ? _maxLinesPerPage : 1 }
/// /
public var isMatrix: Bool { maxLinesPerPage > 1 }
/// /
public var isExpandable: Bool { _maxLinesPerPage > 1 }
///
///
public var maxRowWidth: Double { ceil(Double(maxLineCapacity) * Self.blankCell.cellLength()) }
///
public var currentPositionLabelText: String {
(highlightedIndex + 1).description + "/" + candidateDataAll.count.description
}
///
public var currentCandidate: CandidateCellData? {
(0 ..< candidateDataAll.count).contains(highlightedIndex) ? candidateDataAll[highlightedIndex] : nil
}
///
public var currentSelectedCandidateText: String? { currentCandidate?.displayedText ?? nil }
/// /
public var maxLineCapacity: Int { selectionKeys.count }
///
public var dummyCellsRequiredForCurrentLine: Int {
maxLineCapacity - candidateLines[currentLineNumber].count
}
///
public var lineRangeForFinalPageBlanked: Range<Int> {
0 ..< (maxLinesPerPage - lineRangeForCurrentPage.count)
}
///
public var lineRangeForCurrentPage: Range<Int> {
recordedLineRangeForCurrentPage ?? fallbackedLineRangeForCurrentPage
}
/// 退
public var fallbackedLineRangeForCurrentPage: Range<Int> {
currentLineNumber ..< min(candidateLines.count, currentLineNumber + maxLinesPerPage)
}
// MARK: - Constructors
///
/// - Parameters:
/// - candidates:
/// - selectionKeys:
/// - direction:
/// - locale: zh-Hanszh-Hant
public init(
candidates: [(keyArray: [String], value: String)], lines: Int = 3, isExpanded expanded: Bool = true, selectionKeys: String = "123456789",
layout: LayoutOrientation = .vertical, locale: String = ""
) {
_maxLinesPerPage = max(1, lines)
isExpanded = expanded
self.layout = .horizontal
self.selectionKeys = "123456789"
candidateDataAll = []
// compiler
construct(candidates: candidates, selectionKeys: selectionKeys, layout: layout, locale: locale)
}
///
/// - Parameters:
/// - candidates:
/// - selectionKeys:
/// - direction:
/// - locale: zh-Hanszh-Hant
private func construct(
candidates: [(keyArray: [String], value: String)], selectionKeys: String = "123456789",
layout: LayoutOrientation = .vertical, locale: String = ""
) {
self.layout = layout
Self.blankCell.locale = locale
self.selectionKeys = selectionKeys.isEmpty ? "123456789" : selectionKeys
var allCandidates = candidates.map {
CandidateCellData(key: " ", displayedText: $0.value, spanLength: $0.keyArray.count)
}
if allCandidates.isEmpty { allCandidates.append(Self.blankCell) }
candidateDataAll = allCandidates
candidateLines.removeAll()
var currentColumn: [CandidateCellData] = []
for (i, candidate) in candidateDataAll.enumerated() {
candidate.index = i
candidate.whichLine = candidateLines.count
var isOverflown: Bool = (currentColumn.count == maxLineCapacity) && !currentColumn.isEmpty
if layout == .horizontal {
isOverflown = isOverflown
|| currentColumn.map { $0.cellLength() }.reduce(0, +) > maxRowWidth - candidate.cellLength()
}
if isOverflown {
candidateLines.append(currentColumn)
currentColumn.removeAll()
candidate.whichLine += 1
}
candidate.subIndex = currentColumn.count
candidate.locale = locale
currentColumn.append(candidate)
}
candidateLines.append(currentColumn)
recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage
highlight(at: 0)
updateMetrics()
}
}
// MARK: - Public Functions (for all OS)
public extension CandidatePool {
///
enum LayoutOrientation {
case horizontal
case vertical
}
func update() {
if #available(macOS 10.15, *) {
DispatchQueue.main.async {
self.objectWillChange.send()
}
}
}
func expandIfNeeded(isBackward: Bool) {
guard !candidateLines.isEmpty, !isExpanded, isExpandable else { return }
let candidatesShown: [CandidateCellData] = candidateLines[lineRangeForCurrentPage].flatMap { $0 }
guard !candidatesShown.filter(\.isHighlighted).isEmpty else { return }
isExpanded = true
if candidateLines.count <= _maxLinesPerPage {
recordedLineRangeForCurrentPage = lineRangeForFirstPage
} else {
switch isBackward {
case true:
if lineRangeForFirstPage.contains(currentLineNumber) {
recordedLineRangeForCurrentPage = lineRangeForFirstPage
} else {
recordedLineRangeForCurrentPage = max(0, currentLineNumber - _maxLinesPerPage + 1) ..< currentLineNumber + 1
}
case false:
if lineRangeForFinalPage.contains(currentLineNumber) {
recordedLineRangeForCurrentPage = lineRangeForFinalPage
} else {
recordedLineRangeForCurrentPage = currentLineNumber ..< min(candidateLines.count, currentLineNumber + _maxLinesPerPage)
}
}
}
updateMetrics()
}
///
/// - Parameter isBackward:
/// - Returns:
@discardableResult func flipPage(isBackward: Bool) -> Bool {
if !isExpanded, isExpandable {
expandIfNeeded(isBackward: isBackward)
return true
}
backupLineRangeForCurrentPage()
defer { flipLineRangeToNeighborPage(isBackward: isBackward) }
var theCount = maxLinesPerPage
let rareConditionA: Bool = isBackward && currentLineNumber == 0
let rareConditionB: Bool = !isBackward && currentLineNumber == candidateLines.count - 1
if rareConditionA || rareConditionB { theCount = 1 }
return consecutivelyFlipLines(isBackward: isBackward, count: theCount)
}
///
/// - Parameter subIndex:
/// - Returns: nil
func calculateCandidateIndex(subIndex: Int) -> Int? {
let arrCurrentLine = candidateLines[currentLineNumber]
if !(0 ..< arrCurrentLine.count).contains(subIndex) { return nil }
return arrCurrentLine[subIndex].index
}
///
/// - Parameters:
/// - isBackward:
/// - count:
/// - Returns:
@discardableResult func consecutivelyFlipLines(isBackward: Bool, count: Int) -> Bool {
expandIfNeeded(isBackward: isBackward)
switch isBackward {
case false where currentLineNumber == candidateLines.count - 1:
return highlightNeighborCandidate(isBackward: false)
case true where currentLineNumber == 0:
return highlightNeighborCandidate(isBackward: true)
default:
if count <= 0 { return false }
for _ in 0 ..< min(maxLinesPerPage, count) {
selectNewNeighborLine(isBackward: isBackward)
}
return true
}
}
///
/// - Parameter isBackward:
/// - Returns:
@discardableResult func highlightNeighborCandidate(isBackward: Bool) -> Bool {
switch isBackward {
case false where highlightedIndex >= candidateDataAll.count - 1:
highlight(at: 0)
return false
case true where highlightedIndex <= 0:
highlight(at: candidateDataAll.count - 1)
return false
default:
highlight(at: highlightedIndex + (isBackward ? -1 : 1))
return true
}
}
///
/// - Parameter indexSpecified:
func highlight(at indexSpecified: Int) {
var indexSpecified = indexSpecified
let isBackward: Bool = indexSpecified > highlightedIndex
highlightedIndex = indexSpecified
if !(0 ..< candidateDataAll.count).contains(highlightedIndex) {
switch highlightedIndex {
case candidateDataAll.count...:
currentLineNumber = candidateLines.count - 1
highlightedIndex = max(0, candidateDataAll.count - 1)
indexSpecified = highlightedIndex
case ..<0:
highlightedIndex = 0
currentLineNumber = 0
indexSpecified = highlightedIndex
default: break
}
}
for (i, candidate) in candidateDataAll.enumerated() {
candidate.isHighlighted = (indexSpecified == i)
if candidate.isHighlighted { currentLineNumber = candidate.whichLine }
}
for (i, candidateColumn) in candidateLines.enumerated() {
if i != currentLineNumber {
candidateColumn.forEach {
$0.selectionKey = " "
}
} else {
for (i, neta) in candidateColumn.enumerated() {
if neta.selectionKey.isEmpty { continue }
neta.selectionKey = selectionKeys.map(\.description)[i]
}
}
}
if highlightedIndex != 0, indexSpecified == 0 {
recordedLineRangeForCurrentPage = fallbackedLineRangeForCurrentPage
} else {
fixLineRange(isBackward: isBackward)
}
}
func cellWidth(_ cell: CandidateCellData) -> (min: CGFloat?, max: CGFloat?) {
let minAccepted = ceil(Self.shitCell.cellLength(isMatrix: false))
let defaultMin: CGFloat = cell.cellLength(isMatrix: maxLinesPerPage != 1)
var min: CGFloat = defaultMin
if layout != .vertical, maxLinesPerPage == 1 {
min = max(minAccepted, cell.cellLength(isMatrix: false))
} else if layout == .vertical, maxLinesPerPage == 1 {
min = max(Double(CandidateCellData.unifiedSize * 6), ceil(cell.size * 5.6))
}
return (min, nil)
}
func isFilterable(target index: Int) -> Bool {
let spanLength = candidateDataAll[index].spanLength
guard spanLength == 1 else { return true }
return cellsOf(spanLength: spanLength).count > 1
}
func cellsOf(spanLength: Int) -> [CandidateCellData] {
candidateDataAll.filter { $0.spanLength == spanLength }
}
}
// MARK: - Privates.
private extension CandidatePool {
enum VerticalDirection {
case up
case down
}
enum HorizontalDirection {
case left
case right
}
///
var lineRangeForFirstPage: Range<Int> {
0 ..< min(maxLinesPerPage, candidateLines.count)
}
///
var lineRangeForFinalPage: Range<Int> {
max(0, candidateLines.count - maxLinesPerPage) ..< candidateLines.count
}
func selectNewNeighborLine(isBackward: Bool) {
switch layout {
case .horizontal: selectNewNeighborRow(direction: isBackward ? .up : .down)
case .vertical: selectNewNeighborColumn(direction: isBackward ? .left : .right)
}
}
func fixLineRange(isBackward: Bool = false) {
if !lineRangeForCurrentPage.contains(currentLineNumber) {
switch isBackward {
case false:
let theMin = currentLineNumber
let theMax = min(theMin + maxLinesPerPage, candidateLines.count)
recordedLineRangeForCurrentPage = theMin ..< theMax
case true:
let theMax = currentLineNumber + 1
let theMin = max(0, theMax - maxLinesPerPage)
recordedLineRangeForCurrentPage = theMin ..< theMax
}
}
}
func backupLineRangeForCurrentPage() {
previouslyRecordedLineRangeForPreviousPage = lineRangeForCurrentPage
}
func flipLineRangeToNeighborPage(isBackward: Bool = false) {
guard let prevRange = previouslyRecordedLineRangeForPreviousPage else { return }
var lowerBound = prevRange.lowerBound
var upperBound = prevRange.upperBound
//
lowerBound += maxLinesPerPage * (isBackward ? -1 : 1)
upperBound += maxLinesPerPage * (isBackward ? -1 : 1)
//
branch1: switch isBackward {
case false:
if upperBound < candidateLines.count { break branch1 }
if lowerBound < lineRangeForFinalPage.lowerBound { break branch1 }
let isOverFlipped = !lineRangeForFinalPage.contains(currentLineNumber)
recordedLineRangeForCurrentPage = isOverFlipped ? lineRangeForFirstPage : lineRangeForFinalPage
return
case true:
if lowerBound > 0 { break branch1 }
if upperBound > lineRangeForFirstPage.upperBound { break branch1 }
let isOverFlipped = !lineRangeForFirstPage.contains(currentLineNumber)
recordedLineRangeForCurrentPage = isOverFlipped ? lineRangeForFinalPage : lineRangeForFirstPage
return
}
let result = lowerBound ..< upperBound
if result.contains(currentLineNumber) {
recordedLineRangeForCurrentPage = result
return
}
//
}
func selectNewNeighborRow(direction: VerticalDirection) {
let currentSubIndex = candidateDataAll[highlightedIndex].subIndex
var result = currentSubIndex
branch: switch direction {
case .up:
if currentLineNumber <= 0 {
if candidateLines.isEmpty { break }
let firstRow = candidateLines[0]
let newSubIndex = min(currentSubIndex, firstRow.count - 1)
highlight(at: firstRow[newSubIndex].index)
fixLineRange(isBackward: false)
break branch
}
if currentLineNumber >= candidateLines.count - 1 { currentLineNumber = candidateLines.count - 1 }
result = currentSubIndex
//
// if candidateLines[currentLineNumber].count != candidateLines[currentLineNumber - 1].count {
// let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateLines[currentLineNumber].count))
// result = max(Int(floor(Double(candidateLines[currentLineNumber - 1].count) * ratio)), result)
// }
let targetRow = candidateLines[currentLineNumber - 1]
let newSubIndex = min(result, targetRow.count - 1)
highlight(at: targetRow[newSubIndex].index)
fixLineRange(isBackward: true)
case .down:
if currentLineNumber >= candidateLines.count - 1 {
if candidateLines.isEmpty { break }
let finalRow = candidateLines[candidateLines.count - 1]
let newSubIndex = min(currentSubIndex, finalRow.count - 1)
highlight(at: finalRow[newSubIndex].index)
fixLineRange(isBackward: true)
break branch
}
result = currentSubIndex
//
if candidateLines[currentLineNumber].count != candidateLines[currentLineNumber + 1].count {
let ratio: Double = min(1, Double(currentSubIndex) / Double(candidateLines[currentLineNumber].count))
result = max(Int(floor(Double(candidateLines[currentLineNumber + 1].count) * ratio)), result)
}
let targetRow = candidateLines[currentLineNumber + 1]
let newSubIndex = min(result, targetRow.count - 1)
highlight(at: targetRow[newSubIndex].index)
fixLineRange(isBackward: false)
}
}
func selectNewNeighborColumn(direction: HorizontalDirection) {
let currentSubIndex = candidateDataAll[highlightedIndex].subIndex
switch direction {
case .left:
if currentLineNumber <= 0 {
if candidateLines.isEmpty { break }
let firstColumn = candidateLines[0]
let newSubIndex = min(currentSubIndex, firstColumn.count - 1)
highlight(at: firstColumn[newSubIndex].index)
break
}
if currentLineNumber >= candidateLines.count - 1 { currentLineNumber = candidateLines.count - 1 }
let targetColumn = candidateLines[currentLineNumber - 1]
let newSubIndex = min(currentSubIndex, targetColumn.count - 1)
highlight(at: targetColumn[newSubIndex].index)
fixLineRange(isBackward: true)
case .right:
if currentLineNumber >= candidateLines.count - 1 {
if candidateLines.isEmpty { break }
let finalColumn = candidateLines[candidateLines.count - 1]
let newSubIndex = min(currentSubIndex, finalColumn.count - 1)
highlight(at: finalColumn[newSubIndex].index)
break
}
let targetColumn = candidateLines[currentLineNumber + 1]
let newSubIndex = min(currentSubIndex, targetColumn.count - 1)
highlight(at: targetColumn[newSubIndex].index)
fixLineRange(isBackward: false)
}
}
}
// MARK: - Turn CandidatePool into an ObservableObject.
@available(macOS 10.15, *)
extension CandidatePool: ObservableObject {}