From 3d509a94d4cdcb531f26943da5f61127ad8bcca9 Mon Sep 17 00:00:00 2001 From: Nate Weaver Date: Tue, 7 Apr 2020 16:05:13 -0500 Subject: [PATCH] Add attributed title support in the timeline --- .../Cell/MultilineTextFieldSizer.swift | 47 +++++++++++++ .../Cell/NSAttributedString+NetNewsWire.swift | 68 +++++++++++++++++++ .../Timeline/Cell/TimelineCellData.swift | 3 + .../Timeline/Cell/TimelineCellLayout.swift | 3 +- .../Timeline/Cell/TimelineTableCellView.swift | 14 ++++ NetNewsWire.xcodeproj/project.pbxproj | 6 ++ .../Article Rendering/ArticleRenderer.swift | 2 +- .../Extensions/ArticleStringFormatter.swift | 17 ++++- 8 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 Mac/MainWindow/Timeline/Cell/NSAttributedString+NetNewsWire.swift 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/NSAttributedString+NetNewsWire.swift b/Mac/MainWindow/Timeline/Cell/NSAttributedString+NetNewsWire.swift new file mode 100644 index 000000000..60869c98c --- /dev/null +++ b/Mac/MainWindow/Timeline/Cell/NSAttributedString+NetNewsWire.swift @@ -0,0 +1,68 @@ +// +// NSAttributedString+NetNewsWire.swift +// NetNewsWire +// +// Created by Nate Weaver on 2020-04-07. +// Copyright © 2020 Ranchero Software. All rights reserved. +// + +import AppKit + +extension NSAttributedString { + + func adding(font baseFont: NSFont, color: NSColor? = nil) -> NSAttributedString { + let mutable = self.mutableCopy() as! NSMutableAttributedString + let fullRange = NSRange(location: 0, length: mutable.length) + + if let color = color { + mutable.addAttribute(.foregroundColor, value: color as Any, range: fullRange) + } + + let size = baseFont.pointSize + let baseDescriptor = baseFont.fontDescriptor + let traits = baseDescriptor.symbolicTraits + + mutable.enumerateAttribute(.font, in: fullRange, options: []) { (font: Any?, range: NSRange, stop: UnsafeMutablePointer) in + guard let font = font as? NSFont else { return } + + var newTraits = traits + + if font.fontDescriptor.symbolicTraits.contains(.italic) { + newTraits.insert(.italic) + } + + var descriptor = baseDescriptor.withSymbolicTraits(newTraits) + + if font.fontDescriptor.symbolicTraits.contains(.bold) { + // This currently assumes we're modifying the title field, which is + // already semibold. + let traits: [NSFontDescriptor.TraitKey: Any] = [.weight: NSFont.Weight.heavy] + let attributes: [NSFontDescriptor.AttributeName: Any] = [.traits: traits] + descriptor = descriptor.addingAttributes(attributes) + } + + let newFont = NSFont(descriptor: descriptor, size: size) + + mutable.addAttribute(.font, value: newFont as Any, range: range) + } + + // make sup/sub smaller + mutable.enumerateAttributes(in: fullRange, options: []) { (attributes: [Key : Any], range: NSRange, stop: UnsafeMutablePointer) in + guard let superscript = attributes[.superscript] as? Int else { + return + } + + if superscript != 0 { + let font = mutable.attribute(.font, at: range.location, effectiveRange: nil) as! NSFont + let size = font.pointSize * 0.6 + + let newFont = NSFont(descriptor: font.fontDescriptor, size: size) + mutable.addAttribute(.font, value: newFont as Any, range: range) + } + + } + + return mutable.copy() as! NSAttributedString + } + +} 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/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 75846af92..4f38ea23b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -725,6 +725,8 @@ 84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; }; B27EEBF9244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; }; B27EEBFA244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; }; + B26B9572243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */; }; + B26B9573243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */; }; B27EEBFB244D15F3000932E6 /* shared.css in Resources */ = {isa = PBXBuildFile; fileRef = B27EEBDF244D15F2000932E6 /* shared.css */; }; B2B8075E239C49D300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; @@ -1784,6 +1786,7 @@ B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B27EEBDF244D15F2000932E6 /* shared.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = shared.css; sourceTree = ""; }; + B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = ""; }; @@ -2626,6 +2629,7 @@ 84E185C2203BB12600F69BFA /* MultilineTextFieldSizer.swift */, 849A97711ED9EC04007D329B /* TimelineCellData.swift */, 849A97751ED9EC04007D329B /* UnreadIndicatorView.swift */, + B26B9571243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift */, ); path = Cell; sourceTree = ""; @@ -4206,6 +4210,7 @@ 65ED4023235DEF6C0081F399 /* Folder+Scriptability.swift in Sources */, 65ED4024235DEF6C0081F399 /* TimelineCellLayout.swift in Sources */, 65ED4025235DEF6C0081F399 /* DetailWebView.swift in Sources */, + B26B9573243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */, B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */, 65ED4026235DEF6C0081F399 /* TimelineTableRowView.swift in Sources */, 65ED4027235DEF6C0081F399 /* UnreadIndicatorView.swift in Sources */, @@ -4541,6 +4546,7 @@ 8426119E1FCB6ED40086A189 /* HTMLMetadataDownloader.swift in Sources */, 849A976E1ED9EBC8007D329B /* TimelineViewController.swift in Sources */, 3B826DCC2385C84800FC1ADB /* AccountsFeedWranglerWindowController.swift in Sources */, + B26B9572243D176B0053EEF5 /* NSAttributedString+NetNewsWire.swift in Sources */, 5154368B229404D1005E1CDF /* FaviconGenerator.swift in Sources */, 5183CCE6226F4E110010922C /* RefreshInterval.swift in Sources */, 849A97771ED9EC04007D329B /* TimelineCellData.swift in Sources */, 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 ""