Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Mac/MainWindow/Timeline/Cell/TimelineCellAppearance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ struct TimelineCellAppearance: Equatable {
let iconAdjustmentTop: CGFloat = 4.0
let iconCornerRadius: CGFloat = 4.0

let thumbnailHeight: CGFloat = 200.0
let thumbnailMarginTop: CGFloat = 8.0
let thumbnailMarginBottom: CGFloat = 10.0
let thumbnailCornerRadius: CGFloat = 6.0

let boxLeftMargin: CGFloat

init(showIcon: Bool, fontSize: FontSize) {
Expand Down
3 changes: 3 additions & 0 deletions Mac/MainWindow/Timeline/Cell/TimelineCellData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Articles
let showIcon: Bool // Make space even when icon is nil
let read: Bool
let starred: Bool
let thumbnailURL: URL?

init(article: Article, showFeedName: TimelineShowFeedName, feedName: String?, byline: String?, iconImage: IconImage?, showIcon: Bool) {

Expand Down Expand Up @@ -58,6 +59,7 @@ import Articles

self.read = article.status.read
self.starred = article.status.starred
self.thumbnailURL = article.firstBodyImageURL
}

init() { // Empty
Expand All @@ -72,5 +74,6 @@ import Articles
self.read = true
self.starred = false
self.attributedTitle = NSAttributedString()
self.thumbnailURL = nil
}
}
83 changes: 61 additions & 22 deletions Mac/MainWindow/Timeline/Cell/TimelineCellLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ import RSCore
let iconImageRect: NSRect
let separatorRect: NSRect
let paddingBottom: CGFloat
let thumbnailRect: NSRect

init(width: CGFloat, height: CGFloat, feedNameRect: NSRect, dateRect: NSRect, titleRect: NSRect, numberOfLinesForTitle: Int, summaryRect: NSRect, textRect: NSRect, unreadIndicatorRect: NSRect, starRect: NSRect, iconImageRect: NSRect, separatorRect: NSRect, paddingBottom: CGFloat) {
init(width: CGFloat, height: CGFloat, feedNameRect: NSRect, dateRect: NSRect, titleRect: NSRect, numberOfLinesForTitle: Int, summaryRect: NSRect, textRect: NSRect, unreadIndicatorRect: NSRect, starRect: NSRect, iconImageRect: NSRect, separatorRect: NSRect, paddingBottom: CGFloat, thumbnailRect: NSRect) {

self.width = width
self.feedNameRect = feedNameRect
Expand All @@ -39,11 +40,12 @@ import RSCore
self.iconImageRect = iconImageRect
self.separatorRect = separatorRect
self.paddingBottom = paddingBottom
self.thumbnailRect = thumbnailRect

if height > 0.1 {
self.height = height
} else {
self.height = [feedNameRect, dateRect, titleRect, summaryRect, textRect, unreadIndicatorRect, iconImageRect].maxY() + paddingBottom
self.height = [feedNameRect, dateRect, titleRect, summaryRect, textRect, unreadIndicatorRect, iconImageRect, thumbnailRect].maxY() + paddingBottom
}
}

Expand All @@ -52,32 +54,31 @@ import RSCore
// If height == 0.0, then height is calculated.

let showIcon = cellData.showIcon
var textBoxRect = TimelineCellLayout.rectForTextBox(appearance, cellData, showIcon, width)

let (titleRect, numberOfLinesForTitle) = TimelineCellLayout.rectForTitle(textBoxRect, appearance, cellData)
let summaryRect = numberOfLinesForTitle > 0 ? TimelineCellLayout.rectForSummary(textBoxRect, titleRect, numberOfLinesForTitle, appearance, cellData) : NSRect.zero
let textRect = numberOfLinesForTitle > 0 ? NSRect.zero : TimelineCellLayout.rectForText(textBoxRect, appearance, cellData)

var lastTextRect = titleRect
if numberOfLinesForTitle == 0 {
lastTextRect = textRect
} else if numberOfLinesForTitle < appearance.titleNumberOfLines {
if summaryRect.height > 0.1 {
lastTextRect = summaryRect
}
}
let dateRect = TimelineCellLayout.rectForDate(textBoxRect, lastTextRect, appearance, cellData)
let textBoxRect = TimelineCellLayout.rectForTextBox(appearance, cellData, showIcon, width)

// Date and feed name sit at the top of the cell
let dateRect = TimelineCellLayout.rectForDateAtTop(textBoxRect, appearance, cellData)
let feedNameRect = TimelineCellLayout.rectForFeedName(textBoxRect, dateRect, appearance, cellData)

textBoxRect.size.height = ceil([titleRect, summaryRect, textRect, dateRect, feedNameRect].maxY() - textBoxRect.origin.y)
// Title and summary start below the date/feedName row
let topRowMaxY = max(dateRect.maxY, feedNameRect.maxY)
var titleBoxRect = textBoxRect
titleBoxRect.origin.y = topRowMaxY + 4

let (titleRect, numberOfLinesForTitle) = TimelineCellLayout.rectForTitle(titleBoxRect, appearance, cellData)
let summaryRect = numberOfLinesForTitle > 0 ? TimelineCellLayout.rectForSummary(titleBoxRect, titleRect, numberOfLinesForTitle, appearance, cellData) : NSRect.zero
let textRect = numberOfLinesForTitle > 0 ? NSRect.zero : TimelineCellLayout.rectForText(titleBoxRect, appearance, cellData)

let iconImageRect = TimelineCellLayout.rectForIcon(cellData, appearance, showIcon, textBoxRect, width, height)
let unreadIndicatorRect = TimelineCellLayout.rectForUnreadIndicator(appearance, textBoxRect)
let starRect = TimelineCellLayout.rectForStar(appearance, unreadIndicatorRect)
let separatorRect = TimelineCellLayout.rectForSeparator(cellData, appearance, showIcon ? iconImageRect : titleRect, width, height)

let paddingBottom = appearance.cellPadding.bottom
let contentMaxY = [titleRect, summaryRect, textRect, dateRect, feedNameRect, iconImageRect].maxY()
let thumbnailRect = TimelineCellLayout.rectForThumbnail(cellData, appearance, textBoxRect, contentMaxY, showIcon)
let paddingBottom = thumbnailRect == .zero ? appearance.cellPadding.bottom : appearance.thumbnailMarginBottom

self.init(width: width, height: height, feedNameRect: feedNameRect, dateRect: dateRect, titleRect: titleRect, numberOfLinesForTitle: numberOfLinesForTitle, summaryRect: summaryRect, textRect: textRect, unreadIndicatorRect: unreadIndicatorRect, starRect: starRect, iconImageRect: iconImageRect, separatorRect: separatorRect, paddingBottom: paddingBottom)
self.init(width: width, height: height, feedNameRect: feedNameRect, dateRect: dateRect, titleRect: titleRect, numberOfLinesForTitle: numberOfLinesForTitle, summaryRect: summaryRect, textRect: textRect, unreadIndicatorRect: unreadIndicatorRect, starRect: starRect, iconImageRect: iconImageRect, separatorRect: separatorRect, paddingBottom: paddingBottom, thumbnailRect: thumbnailRect)
}

static func height(for width: CGFloat, cellData: TimelineCellData, appearance: TimelineCellAppearance) -> CGFloat {
Expand Down Expand Up @@ -156,6 +157,15 @@ import RSCore
return r
}

static func rectForDateAtTop(_ textBoxRect: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect {
let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.dateString, font: appearance.dateFont)
var r = NSRect.zero
r.size = textFieldSize
r.origin.y = textBoxRect.origin.y
r.origin.x = textBoxRect.maxX - textFieldSize.width
return r
}

static func rectForDate(_ textBoxRect: NSRect, _ rectAbove: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect {
let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.dateString, font: appearance.dateFont)

Expand All @@ -170,11 +180,18 @@ import RSCore
}

static func rectForFeedName(_ textBoxRect: NSRect, _ dateRect: NSRect, _ appearance: TimelineCellAppearance, _ cellData: TimelineCellData) -> NSRect {
if cellData.showFeedName == .none {
let name: String
switch cellData.showFeedName {
case .byline:
name = cellData.byline.isEmpty ? cellData.feedName : cellData.byline
case .feed, .none:
name = cellData.feedName
}
guard !name.isEmpty else {
return NSRect.zero
}

let textFieldSize = SingleLineTextFieldSizer.size(for: cellData.feedName, font: appearance.feedNameFont)
let textFieldSize = SingleLineTextFieldSizer.size(for: name, font: appearance.feedNameFont)
var r = NSRect.zero
r.size = textFieldSize
r.origin.y = dateRect.minY
Expand Down Expand Up @@ -222,6 +239,28 @@ import RSCore
static func rectForSeparator(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ alignmentRect: NSRect, _ width: CGFloat, _ height: CGFloat) -> NSRect {
return NSRect(x: alignmentRect.minX, y: height - 1, width: width - alignmentRect.minX, height: 1)
}

static func rectForThumbnail(_ cellData: TimelineCellData, _ appearance: TimelineCellAppearance, _ textBoxRect: NSRect, _ contentMaxY: CGFloat, _ showIcon: Bool) -> NSRect {
guard cellData.thumbnailURL != nil else {
return .zero
}
let originX: CGFloat
let thumbnailWidth: CGFloat
if showIcon {
let iconOriginX = appearance.cellPadding.left + appearance.unreadCircleDimension + appearance.unreadCircleMarginRight
originX = iconOriginX
thumbnailWidth = textBoxRect.maxX - iconOriginX
} else {
originX = textBoxRect.origin.x
thumbnailWidth = textBoxRect.width
}
return NSRect(
x: originX,
y: contentMaxY + appearance.thumbnailMarginTop,
width: thumbnailWidth,
height: appearance.thumbnailHeight
)
}
}

private extension Array where Element == NSRect {
Expand Down
47 changes: 41 additions & 6 deletions Mac/MainWindow/Timeline/Cell/TimelineTableCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ final class TimelineTableCellView: NSTableCellView {

private lazy var iconView = IconView()

private lazy var thumbnailView: NSView = {
let v = NSView(frame: .zero)
v.wantsLayer = true
v.layer?.contentsGravity = .resizeAspectFill
v.layer?.masksToBounds = true
return v
}()

private var starView = TimelineTableCellView.imageView(with: Assets.Images.timelineStarUnselected, scaling: .scaleNone)

private lazy var textFields = {
Expand All @@ -31,6 +39,7 @@ final class TimelineTableCellView: NSTableCellView {
if cellAppearance != oldValue {
updateTextFieldFonts()
iconView.layer?.cornerRadius = cellAppearance.iconCornerRadius
thumbnailView.layer?.cornerRadius = cellAppearance.thumbnailCornerRadius
needsLayout = true
}
}
Expand Down Expand Up @@ -107,6 +116,13 @@ final class TimelineTableCellView: NSTableCellView {
feedNameView.setFrame(ifNotEqualTo: layoutRects.feedNameRect)
iconView.setFrame(ifNotEqualTo: layoutRects.iconImageRect)
starView.setFrame(ifNotEqualTo: layoutRects.starRect)

if layoutRects.thumbnailRect == .zero {
hideView(thumbnailView)
} else {
showView(thumbnailView)
thumbnailView.setFrame(ifNotEqualTo: layoutRects.thumbnailRect)
}
}
}

Expand Down Expand Up @@ -188,6 +204,7 @@ private extension TimelineTableCellView {
addSubviewAtInit(feedNameView, hidden: true)
addSubviewAtInit(iconView, hidden: true)
addSubviewAtInit(starView, hidden: true)
addSubviewAtInit(thumbnailView, hidden: true)

makeTextFieldColorsNormal()
}
Expand Down Expand Up @@ -240,15 +257,18 @@ private extension TimelineTableCellView {
}

func updateFeedNameView() {
let name: String
switch cellData.showFeedName {
case .byline:
showView(feedNameView)
updateTextFieldText(feedNameView, cellData.byline)
case .feed:
showView(feedNameView)
updateTextFieldText(feedNameView, cellData.feedName)
case .none:
name = cellData.byline.isEmpty ? cellData.feedName : cellData.byline
case .feed, .none:
name = cellData.feedName
}
if name.isEmpty {
hideView(feedNameView)
} else {
showView(feedNameView)
updateTextFieldText(feedNameView, name)
}
}

Expand Down Expand Up @@ -315,5 +335,20 @@ private extension TimelineTableCellView {
updateUnreadIndicator()
updateStarView()
updateIcon()
updateThumbnail()
}

func updateThumbnail() {
guard let url = cellData?.thumbnailURL else {
thumbnailView.layer?.contents = nil
return
}
guard let data = ImageDownloader.shared.image(for: url.absoluteString),
let nsImage = NSImage(data: data),
let cgImage = nsImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
thumbnailView.layer?.contents = nil
return
}
thumbnailView.layer?.contents = cgImage
}
}
66 changes: 65 additions & 1 deletion Mac/MainWindow/Timeline/TimelineViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
return
}

rebuildThumbnailArticleIDs()

if articles.representSameArticlesInSameOrder(as: oldValue) {
// When the array is the same — same articles, same order —
// but some data in some of the articles may have changed.
Expand Down Expand Up @@ -178,6 +180,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr

private var showIcons = false
private var currentRowHeight: CGFloat = 0.0
private var thumbnailArticleIDs = Set<String>()

private var didRegisterForNotifications = false
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0)
Expand Down Expand Up @@ -238,6 +241,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(thumbnailImageDidBecomeAvailable(_:)), name: .imageDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidAddAccount, object: nil)
Expand All @@ -251,6 +255,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
didRegisterForNotifications = true
}

if let clipView = tableView.enclosingScrollView?.contentView {
NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidScroll(_:)), name: NSView.boundsDidChangeNotification, object: clipView)
}

sharingServicePickerDelegate = SharingServicePickerDelegate(self.view.window)
}

Expand Down Expand Up @@ -698,6 +706,25 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
}
}

@objc func thumbnailImageDidBecomeAvailable(_ note: Notification) {
guard let url = note.userInfo?[UserInfoKey.url] as? String else {
return
}
let indexesToReload = tableView.indexesOfAvailableRowsPassingTest { [weak self] row -> Bool in
guard let self, let article = articles.articleAtRow(row) else {
return false
}
return article.firstBodyImageURL?.absoluteString == url
}
if let indexesToReload {
reloadCells(for: indexesToReload)
}
}

@objc func scrollViewDidScroll(_ note: Notification) {
prefetchThumbnailsAroundVisibleRows()
}

@objc func accountDidDownloadArticles(_ note: Notification) {
guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set<Feed> else {
return
Expand Down Expand Up @@ -886,7 +913,16 @@ extension TimelineViewController: NSTableViewDataSource {
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
// Keeping -[NSTableViewDelegate tableView:heightOfRow:] implemented fixes
// an issue that the bottom inset of NSTableView disappears on macOS Monterey.
return tableView.rowHeight
guard let article = articles.articleAtRow(row),
thumbnailArticleIDs.contains(article.articleID) else {
return currentRowHeight
}
let width = tableView.bounds.width > 0 ? tableView.bounds.width : 400
guard let appearance = showIcons ? cellAppearanceWithIcon : cellAppearance else {
return currentRowHeight
}
let cellData = TimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.feed?.nameForDisplay, byline: article.byline(), iconImage: nil, showIcon: showIcons)
return TimelineCellLayout.height(for: width, cellData: cellData, appearance: appearance)
}
}

Expand Down Expand Up @@ -1056,6 +1092,34 @@ private extension TimelineViewController {
tableView.rowHeight = currentRowHeight
}

func rebuildThumbnailArticleIDs() {
thumbnailArticleIDs = Set(articles.compactMap { $0.firstBodyImageURL != nil ? $0.articleID : nil })
}

func prefetchThumbnailsAroundVisibleRows() {
guard let scrollView = tableView.enclosingScrollView else {
return
}
let visibleRect = scrollView.contentView.bounds
let visibleRows = tableView.rows(in: visibleRect)
guard visibleRows.length > 0 else {
return
}
let prefetchCount = 5
let start = max(0, visibleRows.location - prefetchCount)
let end = min(articles.count - 1, visibleRows.location + visibleRows.length - 1 + prefetchCount)
guard start <= end else {
return
}
for row in start...end where !visibleRows.contains(row) {
guard let article = articles.articleAtRow(row),
let url = article.firstBodyImageURL else {
continue
}
ImageDownloader.shared.image(for: url.absoluteString)
}
}

func updateShowIcons() {
if showFeedNames == .feed {
self.showIcons = true
Expand Down
Loading