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

434 lines
17 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 var candidateLines: [[CandidateCellData]] = []
public var tooltip: String = ""
public var reverseLookupResult: [String] = []
public private(set) var highlightedIndex: Int = 0
public private(set) var currentLineNumber = 0
private var recordedLineRangeForCurrentPage: Range<Int>?
private var previouslyRecordedLineRangeForPreviousPage: Range<Int>?
// MARK: -
///
///
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, selectionKeys: String = "123456789",
layout: LayoutOrientation = .vertical, locale: String = ""
) {
maxLinesPerPage = 1
self.layout = .horizontal
self.selectionKeys = "123456789"
candidateDataAll = []
// compiler
construct(candidates: candidates, lines: lines, selectionKeys: selectionKeys, layout: layout, locale: locale)
}
///
/// - Parameters:
/// - candidates:
/// - selectionKeys:
/// - direction:
/// - locale: zh-Hanszh-Hant
private func construct(
candidates: [(keyArray: [String], value: String)], lines: Int = 3, selectionKeys: String = "123456789",
layout: LayoutOrientation = .vertical, locale: String = ""
) {
self.layout = layout
maxLinesPerPage = max(1, lines)
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)
}
}
// 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()
}
}
}
///
/// - Parameter isBackward:
/// - Returns:
@discardableResult func flipPage(isBackward: Bool) -> Bool {
backupLineRangeForCurrentPage()
defer { flipLineRangeToNeighborPage(isBackward: isBackward) }
return consecutivelyFlipLines(isBackward: isBackward, count: maxLinesPerPage)
}
///
/// - 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 {
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), 90)
}
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 {}