diff --git a/Evergreen.xcodeproj/project.pbxproj b/Evergreen.xcodeproj/project.pbxproj index 633bb9b75..084d7ac9d 100644 --- a/Evergreen.xcodeproj/project.pbxproj +++ b/Evergreen.xcodeproj/project.pbxproj @@ -141,6 +141,7 @@ 84DAEE301F86CAFE0058304B /* OPMLImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DAEE2F1F86CAFE0058304B /* OPMLImporter.swift */; }; 84DAEE321F870B390058304B /* DockBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DAEE311F870B390058304B /* DockBadge.swift */; }; 84E185B3203B74E500F69BFA /* SingleLineTextFieldSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */; }; + 84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */; }; 84E46C7D1F75EF7B005ECFB3 /* AppDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */; }; 84E850861FCB60CE0072EA88 /* AuthorAvatarDownloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E850851FCB60CE0072EA88 /* AuthorAvatarDownloader.swift */; }; 84E8E0DB202EC49300562D8F /* TimelineViewController+ContextualMenus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84E8E0DA202EC49300562D8F /* TimelineViewController+ContextualMenus.swift */; }; @@ -663,6 +664,7 @@ 84DAEE2F1F86CAFE0058304B /* OPMLImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPMLImporter.swift; sourceTree = ""; }; 84DAEE311F870B390058304B /* DockBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DockBadge.swift; path = Evergreen/DockBadge.swift; sourceTree = ""; }; 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleLineTextFieldSizer.swift; sourceTree = ""; }; + 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultilineTextFieldSizer.swift; sourceTree = ""; }; 84E46C7C1F75EF7B005ECFB3 /* AppDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDefaults.swift; path = Evergreen/AppDefaults.swift; sourceTree = ""; }; 84E850851FCB60CE0072EA88 /* AuthorAvatarDownloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorAvatarDownloader.swift; sourceTree = ""; }; 84E8E0DA202EC49300562D8F /* TimelineViewController+ContextualMenus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimelineViewController+ContextualMenus.swift"; sourceTree = ""; }; @@ -1011,6 +1013,7 @@ 849A97701ED9EC04007D329B /* TimelineCellAppearance.swift */, 849A97721ED9EC04007D329B /* TimelineCellLayout.swift */, 84E185B2203B74E500F69BFA /* SingleLineTextFieldSizer.swift */, + 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */, 849A97711ED9EC04007D329B /* TimelineCellData.swift */, 849A97731ED9EC04007D329B /* TimelineStringUtilities.swift */, 849A97751ED9EC04007D329B /* UnreadIndicatorView.swift */, @@ -1952,6 +1955,7 @@ 849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */, 84E8E0DB202EC49300562D8F /* TimelineViewController+ContextualMenus.swift in Sources */, 849A97791ED9EC04007D329B /* TimelineStringUtilities.swift in Sources */, + 84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */, 843A3B5620311E7700BF76EC /* FeedListOutlineView.swift in Sources */, 8472058120142E8900AD578B /* FeedInspectorViewController.swift in Sources */, 84AD1EAA2031617300BC20B7 /* FolderPasteboardWriter.swift in Sources */, diff --git a/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift b/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift new file mode 100644 index 000000000..a4ab4689d --- /dev/null +++ b/Evergreen/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift @@ -0,0 +1,128 @@ +// +// MultilineTextFieldSizer.swift +// Evergreen +// +// Created by Brent Simmons on 2/19/18. +// Copyright © 2018 Ranchero Software. All rights reserved. +// + +import AppKit + +// Get the height of an NSTextField given an NSAttributedString and a width. +// Uses a cache. Avoids actually measuring text as much as possible. +// Main thread only. + +typealias WidthHeightCache = [Int: Int] // width: height + +final class MultilineTextFieldSizer { + + private let numberOfLines: Int + private let textField:NSTextField + private var cache = [NSAttributedString: WidthHeightCache]() // Each string has a cache. + private static var sizers = [Int: MultilineTextFieldSizer]() + + private init(numberOfLines: Int) { + + self.numberOfLines = numberOfLines + self.textField = MultilineTextFieldSizer.createTextField(numberOfLines) + } + + static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> Int { + + return sizer(numberOfLines: numberOfLines).height(for: attributedString, width: width) + } + + static func emptyCache() { + + sizers = [Int: MultilineTextFieldSizer]() + } +} + +// MARK: - Private + +private extension MultilineTextFieldSizer { + + static func sizer(numberOfLines: Int) -> MultilineTextFieldSizer { + + if let cachedSizer = sizers[numberOfLines] { + return cachedSizer + } + + let newSizer = MultilineTextFieldSizer(numberOfLines: numberOfLines) + sizers[numberOfLines] = newSizer + return newSizer + } + + func height(for attributedString: NSAttributedString, width: Int) -> Int { + + if cache[attributedString] == nil { + cache[attributedString] = WidthHeightCache() + } + + if let height = cache[attributedString]![width] { + return height + } + + if let height = heightConsideringNeighbors(cache[attributedString]!, width) { + return height + } + + let height = calculateHeight(attributedString, width) + cache[attributedString]![width] = height + + return height + } + + static func createTextField(_ numberOfLines: Int) -> NSTextField { + + let textField = NSTextField(wrappingLabelWithString: "") + textField.usesSingleLineMode = false + textField.maximumNumberOfLines = numberOfLines + textField.isEditable = false + + return textField + } + + func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int { + + textField.attributedStringValue = attributedString + 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 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. + + var smallNeighbor = (width: 0, height: 0) + var largeNeighbor = (width: 0, height: 0) + + for (oneWidth, oneHeight) in heightCache { + + 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 + } +} diff --git a/Evergreen/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift b/Evergreen/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift index 99b9434c4..da8029130 100644 --- a/Evergreen/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift +++ b/Evergreen/MainWindow/Timeline/Cell/SingleLineTextFieldSizer.swift @@ -10,6 +10,7 @@ import AppKit // Get the size of an NSTextField configured with a specific font with a specific size. // Uses a cache. +// Main thready only. final class SingleLineTextFieldSizer { @@ -53,8 +54,15 @@ final class SingleLineTextFieldSizer { return newSizer } + // Use this call. It’s easiest. + static func size(for text: String, font: NSFont) -> NSSize { return sizer(for: font).size(for: text) } + + static func emptyCache() { + + sizers = [NSFont: SingleLineTextFieldSizer]() + } } diff --git a/Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift b/Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift index 01e4d8bee..ca40fb244 100644 --- a/Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift +++ b/Evergreen/MainWindow/Timeline/Cell/TimelineCellAppearance.swift @@ -23,6 +23,7 @@ struct TimelineCellAppearance: Equatable { let titleColor: NSColor let titleFont: NSFont let titleBottomMargin: CGFloat + let titleNumberOfLines = 2 let textColor: NSColor let textFont: NSFont diff --git a/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift index 0a1a2946c..1e6f410fa 100644 --- a/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Evergreen/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -40,14 +40,14 @@ struct TimelineCellLayout { var textBoxRect = TimelineCellLayout.rectForTextBox(appearance, cellData, width) - let (titleRect, titleLine1Rect) = TimelineCellLayout.rectsForTitle(textBoxRect, cellData) + let titleRect = TimelineCellLayout.rectForTitle(textBoxRect, cellData) let dateRect = TimelineCellLayout.rectForDate(textBoxRect, titleRect, appearance, cellData) let feedNameRect = TimelineCellLayout.rectForFeedName(textBoxRect, dateRect, appearance, cellData) - let unreadIndicatorRect = TimelineCellLayout.rectForUnreadIndicator(appearance, titleLine1Rect) - let starRect = TimelineCellLayout.rectForStar(appearance, unreadIndicatorRect) textBoxRect.size.height = ceil([titleRect, dateRect, feedNameRect].maxY() - textBoxRect.origin.y) let avatarImageRect = TimelineCellLayout.rectForAvatar(cellData, appearance, textBoxRect, width) + let unreadIndicatorRect = TimelineCellLayout.rectForUnreadIndicator(appearance, titleRect) + let starRect = TimelineCellLayout.rectForStar(appearance, unreadIndicatorRect) let paddingBottom = appearance.cellPadding.bottom @@ -77,18 +77,20 @@ private extension TimelineCellLayout { return textBoxRect } - static func rectsForTitle(_ textBoxRect: NSRect, _ cellData: TimelineCellData) -> (NSRect, NSRect) { + static func rectForTitle(_ textBoxRect: NSRect, _ cellData: TimelineCellData) -> NSRect { var r = textBoxRect - let renderer = RSMultiLineRenderer(attributedTitle: cellData.attributedTitle) + let height = MultilineTextFieldSizer.size(for: cellData.attributedTitle, numberOfLines: 2, width: Int(textBoxRect.width)) +// let renderer = RSMultiLineRenderer(attributedTitle: cellData.attributedTitle) +// +// let measurements = renderer.measurements(forWidth: textBoxRect.width) + r.size.height = CGFloat(height) - let measurements = renderer.measurements(forWidth: textBoxRect.width) - r.size.height = CGFloat(measurements.height) - - var rline1 = r - rline1.size.height = CGFloat(measurements.heightOfFirstLine) - - return (r, rline1) + return r +// var rline1 = r +// rline1.size.height = CGFloat(measurements.heightOfFirstLine) +// +// return (r, rline1) } static func rectForDate(_ textBoxRect: NSRect, _ titleRect: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect { @@ -122,13 +124,14 @@ private extension TimelineCellLayout { return r } - static func rectForUnreadIndicator(_ appearance: TimelineCellAppearance, _ titleLine1Rect: NSRect) -> NSRect { + static func rectForUnreadIndicator(_ appearance: TimelineCellAppearance, _ titleRect: NSRect) -> NSRect { var r = NSZeroRect r.size = NSSize(width: appearance.unreadCircleDimension, height: appearance.unreadCircleDimension) r.origin.x = appearance.cellPadding.left - r = RSRectCenteredVerticallyInRect(r, titleLine1Rect) - r.origin.y += 1 + r.origin.y = titleRect.minY + 6 +// r = RSRectCenteredVerticallyInRect(r, titleRect) +// r.origin.y += 1 return r } @@ -139,7 +142,7 @@ private extension TimelineCellLayout { r.size.width = appearance.starDimension r.size.height = appearance.starDimension r.origin.x = floor(unreadIndicatorRect.origin.x - ((appearance.starDimension - appearance.unreadCircleDimension) / 2.0)) - r.origin.y = unreadIndicatorRect.origin.y - 3.0 + r.origin.y = unreadIndicatorRect.origin.y - 4.0 return r }