2018-02-20 05:28:00 +01:00
|
|
|
|
//
|
|
|
|
|
// MultilineTextFieldSizer.swift
|
2018-08-29 07:18:24 +02:00
|
|
|
|
// NetNewsWire
|
2018-02-20 05:28:00 +01:00
|
|
|
|
//
|
|
|
|
|
// Created by Brent Simmons on 2/19/18.
|
|
|
|
|
// Copyright © 2018 Ranchero Software. All rights reserved.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import AppKit
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
// Get the height of an NSTextField given a string, font, and width.
|
2018-02-20 05:28:00 +01:00
|
|
|
|
// Uses a cache. Avoids actually measuring text as much as possible.
|
|
|
|
|
// Main thread only.
|
|
|
|
|
|
|
|
|
|
typealias WidthHeightCache = [Int: Int] // width: height
|
|
|
|
|
|
2018-08-25 23:44:11 +02:00
|
|
|
|
private struct TextFieldSizerSpecifier: Hashable {
|
2018-02-21 07:32:14 +01:00
|
|
|
|
|
|
|
|
|
let numberOfLines: Int
|
|
|
|
|
let font: NSFont
|
2024-05-26 20:53:36 +02:00
|
|
|
|
|
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
|
|
|
hasher.combine(numberOfLines)
|
|
|
|
|
hasher.combine(font.pointSize)
|
|
|
|
|
}
|
2018-02-21 07:32:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct TextFieldSizeInfo {
|
|
|
|
|
|
|
|
|
|
let size: NSSize // Integral size (ceiled)
|
|
|
|
|
let numberOfLinesUsed: Int // A two-line text field may only use one line, for instance. This would equal 1, then.
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-20 07:05:30 +01:00
|
|
|
|
@MainActor final class MultilineTextFieldSizer {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
private let numberOfLines: Int
|
2018-02-21 07:32:14 +01:00
|
|
|
|
private let font: NSFont
|
2018-02-20 05:28:00 +01:00
|
|
|
|
private let textField:NSTextField
|
2018-02-21 07:32:14 +01:00
|
|
|
|
private let singleLineHeightEstimate: Int
|
|
|
|
|
private let doubleLineHeightEstimate: Int
|
|
|
|
|
private var cache = [String: WidthHeightCache]() // Each string has a cache.
|
2020-04-30 09:36:32 +02:00
|
|
|
|
private var attributedCache = [NSAttributedString: WidthHeightCache]()
|
2024-03-20 07:05:30 +01:00
|
|
|
|
@MainActor private static var sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
private init(numberOfLines: Int, font: NSFont) {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
self.numberOfLines = numberOfLines
|
2018-02-21 07:32:14 +01:00
|
|
|
|
self.font = font
|
|
|
|
|
self.textField = MultilineTextFieldSizer.createTextField(numberOfLines, font)
|
|
|
|
|
|
|
|
|
|
self.singleLineHeightEstimate = MultilineTextFieldSizer.calculateHeight("AqLjJ0/y", 200, self.textField)
|
|
|
|
|
self.doubleLineHeightEstimate = MultilineTextFieldSizer.calculateHeight("AqLjJ0/y\nAqLjJ0/y", 200, self.textField)
|
2018-02-20 05:28:00 +01:00
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
static func size(for string: String, font: NSFont, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
|
2018-02-20 05:28:00 +01:00
|
|
|
|
}
|
|
|
|
|
|
2020-04-30 09:36:32 +02:00
|
|
|
|
static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
|
2020-11-07 02:03:20 +01:00
|
|
|
|
guard attributedString.length > 0 else {
|
|
|
|
|
return TextFieldSizeInfo(size: NSSize.zero, numberOfLinesUsed: 0)
|
|
|
|
|
}
|
|
|
|
|
|
2020-04-30 09:36:32 +02:00
|
|
|
|
// Assumes the same font family/size for the whole string
|
|
|
|
|
let font = attributedString.attribute(.font, at: 0, effectiveRange: nil) as! NSFont
|
|
|
|
|
|
|
|
|
|
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: attributedString, width: width)
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-20 05:28:00 +01:00
|
|
|
|
static func emptyCache() {
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
2018-02-20 05:28:00 +01:00
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Private
|
|
|
|
|
|
|
|
|
|
private extension MultilineTextFieldSizer {
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
static func sizer(numberOfLines: Int, font: NSFont) -> MultilineTextFieldSizer {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
let specifier = TextFieldSizerSpecifier(numberOfLines: numberOfLines, font: font)
|
|
|
|
|
if let cachedSizer = sizers[specifier] {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
return cachedSizer
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
let newSizer = MultilineTextFieldSizer(numberOfLines: numberOfLines, font: font)
|
|
|
|
|
sizers[specifier] = newSizer
|
2018-02-20 05:28:00 +01:00
|
|
|
|
return newSizer
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-22 07:48:34 +01:00
|
|
|
|
func sizeInfo(for string: String, width: Int) -> TextFieldSizeInfo {
|
|
|
|
|
|
|
|
|
|
let textFieldHeight = height(for: string, width: width)
|
|
|
|
|
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
|
|
|
|
|
|
|
|
|
|
let size = NSSize(width: width, height: textFieldHeight)
|
|
|
|
|
let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
|
|
|
|
|
return sizeInfo
|
|
|
|
|
}
|
|
|
|
|
|
2020-04-30 09:36:32 +02:00
|
|
|
|
func sizeInfo(for attributedString: NSAttributedString, width: Int) -> TextFieldSizeInfo {
|
|
|
|
|
|
|
|
|
|
let textFieldHeight = height(for: attributedString, width: width)
|
|
|
|
|
let numberOfLinesUsed = numberOfLines(for: textFieldHeight)
|
|
|
|
|
|
|
|
|
|
let size = NSSize(width: width, height: textFieldHeight)
|
|
|
|
|
let sizeInfo = TextFieldSizeInfo(size: size, numberOfLinesUsed: numberOfLinesUsed)
|
|
|
|
|
return sizeInfo
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-22 07:48:34 +01:00
|
|
|
|
func height(for string: String, width: Int) -> Int {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
if cache[string] == nil {
|
|
|
|
|
cache[string] = WidthHeightCache()
|
2018-02-20 05:28:00 +01:00
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
if let height = cache[string]![width] {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
if let height = heightConsideringNeighbors(cache[string]!, width) {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
let height = calculateHeight(string, width)
|
|
|
|
|
cache[string]![width] = height
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
2020-04-30 09:36:32 +02:00
|
|
|
|
func height(for attribtuedString: NSAttributedString, width: Int) -> Int {
|
|
|
|
|
|
|
|
|
|
if attributedCache[attribtuedString] == nil {
|
|
|
|
|
attributedCache[attribtuedString] = WidthHeightCache()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let height = attributedCache[attribtuedString]![width] {
|
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if let height = heightConsideringNeighbors(attributedCache[attribtuedString]!, width) {
|
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let height = calculateHeight(attribtuedString, width)
|
|
|
|
|
attributedCache[attribtuedString]![width] = height
|
|
|
|
|
|
|
|
|
|
return height
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
static func createTextField(_ numberOfLines: Int, _ font: NSFont) -> NSTextField {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
let textField = NSTextField(wrappingLabelWithString: "")
|
|
|
|
|
textField.usesSingleLineMode = false
|
|
|
|
|
textField.maximumNumberOfLines = numberOfLines
|
|
|
|
|
textField.isEditable = false
|
2018-02-21 07:32:14 +01:00
|
|
|
|
textField.font = font
|
|
|
|
|
textField.allowsDefaultTighteningForTruncation = false
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
return textField
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
func calculateHeight(_ string: String, _ width: Int) -> Int {
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
return MultilineTextFieldSizer.calculateHeight(string, width, textField)
|
|
|
|
|
}
|
|
|
|
|
|
2020-04-30 09:36:32 +02:00
|
|
|
|
func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int {
|
|
|
|
|
|
|
|
|
|
textField.attributedStringValue = attributedString
|
|
|
|
|
textField.preferredMaxLayoutWidth = CGFloat(width)
|
|
|
|
|
let size = textField.fittingSize
|
|
|
|
|
return Int(ceil(size.height))
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
static func calculateHeight(_ string: String, _ width: Int, _ textField: NSTextField) -> Int {
|
|
|
|
|
|
|
|
|
|
textField.stringValue = string
|
2018-02-20 05:28:00 +01:00
|
|
|
|
textField.preferredMaxLayoutWidth = CGFloat(width)
|
|
|
|
|
let size = textField.fittingSize
|
|
|
|
|
return Int(ceil(size.height))
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-22 07:48:34 +01:00
|
|
|
|
func numberOfLines(for height: Int) -> Int {
|
|
|
|
|
|
|
|
|
|
// We’ll have to see if this really works reliably.
|
|
|
|
|
|
|
|
|
|
let averageHeight = CGFloat(doubleLineHeightEstimate) / 2.0
|
|
|
|
|
let lines = Int(round(CGFloat(height) / averageHeight))
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
func heightIsProbablySingleLineHeight(_ height: Int) -> Bool {
|
|
|
|
|
|
|
|
|
|
return heightIsProbablyEqualToEstimate(height, singleLineHeightEstimate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func heightIsProbablyDoubleLineHeight(_ height: Int) -> Bool {
|
|
|
|
|
|
|
|
|
|
return heightIsProbablyEqualToEstimate(height, doubleLineHeightEstimate)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func heightIsProbablyEqualToEstimate(_ height: Int, _ estimate: Int) -> Bool {
|
|
|
|
|
|
|
|
|
|
let slop = 4
|
|
|
|
|
let minimum = estimate - slop
|
|
|
|
|
let maximum = estimate + slop
|
|
|
|
|
return height >= minimum && height <= maximum
|
|
|
|
|
}
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
func heightConsideringNeighbors(_ heightCache: WidthHeightCache, _ width: Int) -> Int? {
|
|
|
|
|
|
|
|
|
|
// Given width, if the height at width - something and width + something is equal,
|
|
|
|
|
// then that height must be correct for the given width.
|
2018-02-21 07:32:14 +01:00
|
|
|
|
// Also:
|
|
|
|
|
// If a narrower neighbor’s height is single line height, then this wider width must also be single-line height.
|
|
|
|
|
// If a wider neighbor’s height is double line height, and numberOfLines == 2, then this narrower width must able be double-line height.
|
2018-02-20 05:28:00 +01:00
|
|
|
|
|
|
|
|
|
var smallNeighbor = (width: 0, height: 0)
|
|
|
|
|
var largeNeighbor = (width: 0, height: 0)
|
|
|
|
|
|
|
|
|
|
for (oneWidth, oneHeight) in heightCache {
|
|
|
|
|
|
2018-02-21 07:32:14 +01:00
|
|
|
|
if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
|
|
|
|
|
return oneHeight
|
|
|
|
|
}
|
|
|
|
|
if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
|
|
|
|
|
return oneHeight
|
|
|
|
|
}
|
|
|
|
|
|
2018-02-20 05:28:00 +01:00
|
|
|
|
if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
|
|
|
|
|
smallNeighbor = (oneWidth, oneHeight)
|
|
|
|
|
}
|
|
|
|
|
else if oneWidth > width && (oneWidth < largeNeighbor.width || largeNeighbor.width == 0) {
|
|
|
|
|
largeNeighbor = (oneWidth, oneHeight)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if smallNeighbor.width != 0 && smallNeighbor.height == largeNeighbor.height {
|
|
|
|
|
return smallNeighbor.height
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|