Start work on multiline text measurement.
This commit is contained in:
parent
c9df252f9a
commit
ab6d232377
|
@ -8,33 +8,69 @@
|
|||
|
||||
import AppKit
|
||||
|
||||
// Get the height of an NSTextField given an NSAttributedString and a width.
|
||||
// Get the height of an NSTextField given a string, font, and width.
|
||||
// Uses a cache. Avoids actually measuring text as much as possible.
|
||||
// Main thread only.
|
||||
|
||||
typealias WidthHeightCache = [Int: Int] // width: height
|
||||
|
||||
private struct TextFieldSizerSpecifier: Equatable, Hashable {
|
||||
|
||||
let numberOfLines: Int
|
||||
let font: NSFont
|
||||
let hashValue: Int
|
||||
|
||||
init(numberOfLines: Int, font: NSFont) {
|
||||
self.numberOfLines = numberOfLines
|
||||
self.font = font
|
||||
self.hashValue = font.hashValue ^ numberOfLines
|
||||
}
|
||||
|
||||
static func ==(lhs : TextFieldSizerSpecifier, rhs: TextFieldSizerSpecifier) -> Bool {
|
||||
|
||||
return lhs.numberOfLines == rhs.numberOfLines && lhs.font == rhs.font
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
init(size: NSSize, numberOfLinesUsed: Int) {
|
||||
self.size = size
|
||||
self.numberOfLinesUsed = numberOfLinesUsed
|
||||
}
|
||||
}
|
||||
|
||||
final class MultilineTextFieldSizer {
|
||||
|
||||
private let numberOfLines: Int
|
||||
private let font: NSFont
|
||||
private let textField:NSTextField
|
||||
private var cache = [NSAttributedString: WidthHeightCache]() // Each string has a cache.
|
||||
private static var sizers = [Int: MultilineTextFieldSizer]()
|
||||
private let singleLineHeightEstimate: Int
|
||||
private let doubleLineHeightEstimate: Int
|
||||
private var cache = [String: WidthHeightCache]() // Each string has a cache.
|
||||
private static var sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
||||
|
||||
private init(numberOfLines: Int) {
|
||||
private init(numberOfLines: Int, font: NSFont) {
|
||||
|
||||
self.numberOfLines = numberOfLines
|
||||
self.textField = MultilineTextFieldSizer.createTextField(numberOfLines)
|
||||
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)
|
||||
}
|
||||
|
||||
static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> Int {
|
||||
static func size(for string: String, font: NSFont, numberOfLines: Int, width: Int) -> TextFieldSizeInfo {
|
||||
|
||||
return sizer(numberOfLines: numberOfLines).height(for: attributedString, width: width)
|
||||
return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width)
|
||||
}
|
||||
|
||||
static func emptyCache() {
|
||||
|
||||
sizers = [Int: MultilineTextFieldSizer]()
|
||||
sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,75 +78,101 @@ final class MultilineTextFieldSizer {
|
|||
|
||||
private extension MultilineTextFieldSizer {
|
||||
|
||||
static func sizer(numberOfLines: Int) -> MultilineTextFieldSizer {
|
||||
static func sizer(numberOfLines: Int, font: NSFont) -> MultilineTextFieldSizer {
|
||||
|
||||
if let cachedSizer = sizers[numberOfLines] {
|
||||
let specifier = TextFieldSizerSpecifier(numberOfLines: numberOfLines, font: font)
|
||||
if let cachedSizer = sizers[specifier] {
|
||||
return cachedSizer
|
||||
}
|
||||
|
||||
let newSizer = MultilineTextFieldSizer(numberOfLines: numberOfLines)
|
||||
sizers[numberOfLines] = newSizer
|
||||
let newSizer = MultilineTextFieldSizer(numberOfLines: numberOfLines, font: font)
|
||||
sizers[specifier] = newSizer
|
||||
return newSizer
|
||||
}
|
||||
|
||||
func height(for attributedString: NSAttributedString, width: Int) -> Int {
|
||||
func sizeInfo(for string: String, width: Int) -> Int {
|
||||
|
||||
if cache[attributedString] == nil {
|
||||
cache[attributedString] = WidthHeightCache()
|
||||
if cache[string] == nil {
|
||||
cache[string] = WidthHeightCache()
|
||||
}
|
||||
|
||||
if let height = cache[attributedString]![width] {
|
||||
if let height = cache[string]![width] {
|
||||
return height
|
||||
}
|
||||
|
||||
if let height = heightConsideringNeighbors(cache[attributedString]!, width) {
|
||||
if let height = heightConsideringNeighbors(cache[string]!, width) {
|
||||
return height
|
||||
}
|
||||
|
||||
let height = calculateHeight(attributedString, width)
|
||||
cache[attributedString]![width] = height
|
||||
let height = calculateHeight(string, width)
|
||||
cache[string]![width] = height
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
static func createTextField(_ numberOfLines: Int) -> NSTextField {
|
||||
static func createTextField(_ numberOfLines: Int, _ font: NSFont) -> NSTextField {
|
||||
|
||||
let textField = NSTextField(wrappingLabelWithString: "")
|
||||
textField.usesSingleLineMode = false
|
||||
textField.maximumNumberOfLines = numberOfLines
|
||||
textField.isEditable = false
|
||||
textField.font = font
|
||||
textField.allowsDefaultTighteningForTruncation = false
|
||||
|
||||
return textField
|
||||
}
|
||||
|
||||
func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int {
|
||||
func calculateHeight(_ string: String, _ width: Int) -> Int {
|
||||
|
||||
textField.attributedStringValue = attributedString
|
||||
return MultilineTextFieldSizer.calculateHeight(string, width, textField)
|
||||
}
|
||||
|
||||
static func calculateHeight(_ string: String, _ width: Int, _ textField: NSTextField) -> Int {
|
||||
|
||||
textField.stringValue = string
|
||||
textField.preferredMaxLayoutWidth = CGFloat(width)
|
||||
let size = textField.fittingSize
|
||||
return Int(ceil(size.height))
|
||||
}
|
||||
|
||||
// func widthHeightCache(for attributedString: NSAttributedString) -> WidthHeightCache {
|
||||
//
|
||||
// if let foundCache = cache[attributedString] {
|
||||
// return foundCache
|
||||
// }
|
||||
// let newCache = WidthHeightCache()
|
||||
// cache[attributedString] = newCache
|
||||
// return newCache
|
||||
// }
|
||||
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
|
||||
}
|
||||
|
||||
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.
|
||||
// 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.
|
||||
|
||||
var smallNeighbor = (width: 0, height: 0)
|
||||
var largeNeighbor = (width: 0, height: 0)
|
||||
|
||||
for (oneWidth, oneHeight) in heightCache {
|
||||
|
||||
if oneWidth < width && heightIsProbablySingleLineHeight(oneHeight) {
|
||||
return oneHeight
|
||||
}
|
||||
if numberOfLines == 2 && oneWidth > width && heightIsProbablyDoubleLineHeight(oneHeight) {
|
||||
return oneHeight
|
||||
}
|
||||
|
||||
if oneWidth < width && (oneWidth > smallNeighbor.width || smallNeighbor.width == 0) {
|
||||
smallNeighbor = (oneWidth, oneHeight)
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ private extension TimelineCellLayout {
|
|||
static func rectForTitle(_ textBoxRect: NSRect, _ cellData: TimelineCellData) -> NSRect {
|
||||
|
||||
var r = textBoxRect
|
||||
let height = MultilineTextFieldSizer.size(for: cellData.attributedTitle, numberOfLines: 2, width: Int(textBoxRect.width))
|
||||
let height = MultilineTextFieldSizer.size(for: cellData.title, font: appearance.titleFont, numberOfLines: 2, width: Int(textBoxRect.width))
|
||||
r.size.height = CGFloat(height)
|
||||
|
||||
return r
|
||||
|
|
|
@ -136,6 +136,7 @@ private extension TimelineTableCellView {
|
|||
textField.maximumNumberOfLines = 1
|
||||
textField.isEditable = false
|
||||
textField.lineBreakMode = .byTruncatingTail
|
||||
textField.allowsDefaultTighteningForTruncation = false
|
||||
return textField
|
||||
}
|
||||
|
||||
|
@ -147,6 +148,7 @@ private extension TimelineTableCellView {
|
|||
textField.isEditable = false
|
||||
textField.lineBreakMode = .byTruncatingTail
|
||||
textField.cell?.truncatesLastVisibleLine = true
|
||||
textField.allowsDefaultTighteningForTruncation = false
|
||||
return textField
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue