From c555646fb2c5df6e649b6a98143c5705feea0f24 Mon Sep 17 00:00:00 2001 From: Nate Weaver Date: Thu, 30 Apr 2020 02:36:32 -0500 Subject: [PATCH] Add attributed title support in the timeline --- .../Cell/MultilineTextFieldSizer.swift | 47 +++++++++++++++++++ .../Timeline/Cell/TimelineCellData.swift | 3 ++ .../Timeline/Cell/TimelineCellLayout.swift | 3 +- .../Timeline/Cell/TimelineTableCellView.swift | 14 ++++++ .../Article Rendering/ArticleRenderer.swift | 2 +- .../Extensions/ArticleStringFormatter.swift | 17 +++++-- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift b/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift index d5b979d25..de75fc46e 100644 --- a/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift +++ b/Mac/MainWindow/Timeline/Cell/MultilineTextFieldSizer.swift @@ -34,6 +34,7 @@ final class MultilineTextFieldSizer { private let singleLineHeightEstimate: Int private let doubleLineHeightEstimate: Int private var cache = [String: WidthHeightCache]() // Each string has a cache. + private var attributedCache = [NSAttributedString: WidthHeightCache]() private static var sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]() private init(numberOfLines: Int, font: NSFont) { @@ -51,6 +52,14 @@ final class MultilineTextFieldSizer { return sizer(numberOfLines: numberOfLines, font: font).sizeInfo(for: string, width: width) } + static func size(for attributedString: NSAttributedString, numberOfLines: Int, width: Int) -> TextFieldSizeInfo { + + // 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) + } + static func emptyCache() { sizers = [TextFieldSizerSpecifier: MultilineTextFieldSizer]() @@ -83,6 +92,16 @@ private extension MultilineTextFieldSizer { return sizeInfo } + 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 + } + func height(for string: String, width: Int) -> Int { if cache[string] == nil { @@ -103,6 +122,26 @@ private extension MultilineTextFieldSizer { return height } + 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 + } + static func createTextField(_ numberOfLines: Int, _ font: NSFont) -> NSTextField { let textField = NSTextField(wrappingLabelWithString: "") @@ -120,6 +159,14 @@ private extension MultilineTextFieldSizer { return MultilineTextFieldSizer.calculateHeight(string, width, textField) } + func calculateHeight(_ attributedString: NSAttributedString, _ width: Int) -> Int { + + textField.attributedStringValue = attributedString + textField.preferredMaxLayoutWidth = CGFloat(width) + let size = textField.fittingSize + return Int(ceil(size.height)) + } + static func calculateHeight(_ string: String, _ width: Int, _ textField: NSTextField) -> Int { textField.stringValue = string diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift index b6ca525bc..1e92a7714 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellData.swift @@ -12,6 +12,7 @@ import Articles struct TimelineCellData { let title: String + let attributedTitle: NSAttributedString let text: String let dateString: String let feedName: String @@ -26,6 +27,7 @@ struct TimelineCellData { init(article: Article, showFeedName: TimelineShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool, featuredImage: NSImage?) { self.title = ArticleStringFormatter.truncatedTitle(article) + self.attributedTitle = ArticleStringFormatter.attributedTruncatedTitle(article) self.text = ArticleStringFormatter.truncatedSummary(article) self.dateString = ArticleStringFormatter.dateString(article.logicalDatePublished) @@ -64,5 +66,6 @@ struct TimelineCellData { self.featuredImage = nil self.read = true self.starred = false + self.attributedTitle = NSAttributedString() } } diff --git a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift index 07bb08c9b..df4fb42b2 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift @@ -115,7 +115,8 @@ private extension TimelineCellLayout { return (r, 0) } - let sizeInfo = MultilineTextFieldSizer.size(for: cellData.title, font: appearance.titleFont, numberOfLines: appearance.titleNumberOfLines, width: Int(textBoxRect.width)) + let attributedTitle = cellData.attributedTitle.adding(font: appearance.titleFont) + let sizeInfo = MultilineTextFieldSizer.size(for: attributedTitle, numberOfLines: appearance.titleNumberOfLines, width: Int(textBoxRect.width)) r.size.height = sizeInfo.size.height if sizeInfo.numberOfLinesUsed < 1 { r.size.height = 0 diff --git a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift index eb1e8a79e..3151a7d3c 100644 --- a/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift +++ b/Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift @@ -222,6 +222,7 @@ private extension TimelineTableCellView { func updateTitleView() { updateTextFieldText(titleView, cellData?.title) + updateTextFieldAttributedText(titleView, cellData?.attributedTitle) } func updateSummaryView() { @@ -247,6 +248,19 @@ private extension TimelineTableCellView { } } + func updateTextFieldAttributedText(_ textField: NSTextField, _ text: NSAttributedString?) { + var s = text ?? NSAttributedString(string: "") + + if let fieldFont = textField.font, let color = textField.textColor { + s = s.adding(font: fieldFont, color: color) + } + + if textField.attributedStringValue != s { + textField.attributedStringValue = s + needsLayout = true + } + } + func updateFeedNameView() { switch cellData.showFeedName { case .byline: diff --git a/Shared/Article Rendering/ArticleRenderer.swift b/Shared/Article Rendering/ArticleRenderer.swift index ef7a25a52..3588615bd 100644 --- a/Shared/Article Rendering/ArticleRenderer.swift +++ b/Shared/Article Rendering/ArticleRenderer.swift @@ -46,7 +46,7 @@ struct ArticleRenderer { self.article = article self.extractedArticle = extractedArticle self.articleStyle = style - self.title = article?.title ?? "" + self.title = article?.sanitizedTitle() ?? "" if let content = extractedArticle?.content { self.body = content self.baseURL = extractedArticle?.url diff --git a/Shared/Extensions/ArticleStringFormatter.swift b/Shared/Extensions/ArticleStringFormatter.swift index 4fb9249ae..1ca644232 100644 --- a/Shared/Extensions/ArticleStringFormatter.swift +++ b/Shared/Extensions/ArticleStringFormatter.swift @@ -52,8 +52,8 @@ struct ArticleStringFormatter { return s } - static func truncatedTitle(_ article: Article) -> String { - guard let title = article.title else { + static func truncatedTitle(_ article: Article, forHTML: Bool = false) -> String { + guard let title = article.sanitizedTitle(forHTML: forHTML) else { return "" } @@ -64,7 +64,11 @@ struct ArticleStringFormatter { var s = title.replacingOccurrences(of: "\n", with: "") s = s.replacingOccurrences(of: "\r", with: "") s = s.replacingOccurrences(of: "\t", with: "") - s = s.rsparser_stringByDecodingHTMLEntities() + + if !forHTML { + s = s.rsparser_stringByDecodingHTMLEntities() + } + s = s.trimmingWhitespace s = s.collapsingWhitespace @@ -79,6 +83,13 @@ struct ArticleStringFormatter { return s } + static func attributedTruncatedTitle(_ article: Article) -> NSAttributedString { + let title = truncatedTitle(article, forHTML: true) + let data = title.data(using: .utf8)! + let attributed = NSAttributedString(html: data, documentAttributes: nil)! + return attributed + } + static func truncatedSummary(_ article: Article) -> String { guard let body = article.body else { return ""