diff --git a/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift b/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift index a4ab4689d..85b5da751 100644 --- a/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift +++ b/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift @@ -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) } diff --git a/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift index a96c72d11..ab86a7b10 100644 --- a/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -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 diff --git a/Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift index 5a24be79f..4dae483b2 100644 --- a/Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Evergreen/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -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 }