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
9 changes: 9 additions & 0 deletions Mac/MainWindow/Timeline/Cell/TimelineCellData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import Images
let showIcon: Bool // Make space even when icon is nil
let read: Bool
let starred: Bool
let thumbnailURL: URL? // Article thumbnail URL

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

Expand Down Expand Up @@ -59,6 +60,13 @@ import Images

self.read = article.status.read
self.starred = article.status.starred

// Prefer the article's imageURL; fall back to the first image in its content.
if let imageURL = article.imageURL {
self.thumbnailURL = imageURL
} else {
self.thumbnailURL = article.extractFirstImageURL()
}
}

init() { // Empty
Expand All @@ -73,5 +81,6 @@ import Images
self.read = true
self.starred = false
self.attributedTitle = NSAttributedString()
self.thumbnailURL = nil
}
}
81 changes: 81 additions & 0 deletions Mac/MainWindow/Timeline/Cell/TimelineModernCellLayout.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// TimelineModernCellLayout.swift
// NetNewsWire
//
// Copyright © 2026 Ranchero Software, LLC. All rights reserved.
//

import AppKit
import RSCore

// Rects for the modern timeline cell. Table-view cells in NetNewsWire use manual
// frame layout (not Auto Layout), so this computes every subview frame.
@MainActor
struct TimelineModernCellLayout {

let feedIconRect: NSRect
let metadataRect: NSRect
let titleRect: NSRect
let summaryRect: NSRect
let thumbnailRect: NSRect
let separatorRect: NSRect
let height: CGFloat

private static let horizontalPadding: CGFloat = 16.0
private static let verticalPadding: CGFloat = 12.0
private static let thumbnailSize: CGFloat = 72.0
private static let leftRightGap: CGFloat = 12.0
private static let iconHeight: CGFloat = 20.0
private static let iconToTitleGap: CGFloat = 6.0
private static let titleToSummaryGap: CGFloat = 4.0
private static let iconToMetadataGap: CGFloat = 8.0

init(width: CGFloat, cellData: TimelineCellData) {
let titleFont = NSFont.systemFont(ofSize: 14, weight: .semibold)
let summaryFont = NSFont.systemFont(ofSize: 12, weight: .regular)

let hasThumbnail = cellData.thumbnailURL != nil
let availableWidth = width - Self.horizontalPadding * 2
let leftWidth = hasThumbnail ? (availableWidth - Self.thumbnailSize - Self.leftRightGap) : availableWidth
let leftWidthInt = max(1, Int(leftWidth))

// Coordinates run top-down (the cell view is flipped).
var y = Self.verticalPadding

self.feedIconRect = NSRect(x: Self.horizontalPadding, y: y, width: Self.iconHeight, height: Self.iconHeight)

let metadataX = self.feedIconRect.maxX + Self.iconToMetadataGap
self.metadataRect = NSRect(x: metadataX, y: y, width: width - metadataX - Self.horizontalPadding, height: Self.iconHeight)

y = self.feedIconRect.maxY + Self.iconToTitleGap

// Title (up to 2 lines); its actual line count drives the summary line count.
let titleInfo = MultilineTextFieldSizer.size(for: cellData.title, font: titleFont, numberOfLines: 2, width: leftWidthInt)
let titleLines = max(1, titleInfo.numberOfLinesUsed)
self.titleRect = NSRect(x: Self.horizontalPadding, y: y, width: leftWidth, height: titleInfo.size.height)

y = self.titleRect.maxY + Self.titleToSummaryGap

// Summary: 1 line when the title is 2 lines, otherwise 2 lines.
let summaryLines = titleLines >= 2 ? 1 : 2
let summaryHeight = MultilineTextFieldSizer.size(for: cellData.text, font: summaryFont, numberOfLines: summaryLines, width: leftWidthInt).size.height
self.summaryRect = NSRect(x: Self.horizontalPadding, y: y, width: leftWidth, height: summaryHeight)

y = self.summaryRect.maxY + Self.verticalPadding
self.height = y

if hasThumbnail {
let thumbX = Self.horizontalPadding + leftWidth + Self.leftRightGap
let thumbY = (self.height - Self.thumbnailSize) / 2.0
self.thumbnailRect = NSRect(x: thumbX, y: thumbY, width: Self.thumbnailSize, height: Self.thumbnailSize)
} else {
self.thumbnailRect = .zero
}

self.separatorRect = NSRect(x: 0, y: self.height - 0.5, width: width, height: 0.5)
}

static func height(for width: CGFloat, cellData: TimelineCellData) -> CGFloat {
return TimelineModernCellLayout(width: width, cellData: cellData).height
}
}
248 changes: 248 additions & 0 deletions Mac/MainWindow/Timeline/Cell/TimelineModernCellView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
//
// TimelineModernCellView.swift
// NetNewsWire
//
// Copyright © 2026 Ranchero Software, LLC. All rights reserved.
//

import AppKit
import RSCore

// Modern horizontal-split timeline cell (left: text / right: thumbnail) for macOS.
// Uses manual frame layout per NetNewsWire guidelines (no Auto Layout in table cells).
final class TimelineModernCellView: NSView {

// MARK: - UI Components

private let feedIconView: NSImageView = {
let imageView = NSImageView()
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.wantsLayer = true
imageView.layer?.cornerRadius = 10
imageView.layer?.masksToBounds = true
imageView.layer?.backgroundColor = NSColor.systemRed.cgColor
imageView.autoresizingMask = []
return imageView
}()

private let metadataLabel: NSTextField = {
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 11, weight: .regular)
label.textColor = .secondaryLabelColor
label.lineBreakMode = .byTruncatingTail
label.cell?.truncatesLastVisibleLine = true
label.autoresizingMask = []
return label
}()

private let titleLabel: NSTextField = {
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 14, weight: .semibold)
label.textColor = .labelColor
label.maximumNumberOfLines = 2
label.lineBreakMode = .byWordWrapping
label.cell?.wraps = true
label.cell?.truncatesLastVisibleLine = true
label.autoresizingMask = []
return label
}()

private let summaryLabel: NSTextField = {
let label = NSTextField(labelWithString: "")
label.font = .systemFont(ofSize: 12, weight: .regular)
label.textColor = .secondaryLabelColor
label.maximumNumberOfLines = 2
label.lineBreakMode = .byWordWrapping
label.cell?.wraps = true
label.cell?.truncatesLastVisibleLine = true
label.autoresizingMask = []
return label
}()

private let thumbnailImageView: NSImageView = {
let imageView = NSImageView()
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.wantsLayer = true
imageView.layer?.masksToBounds = true
imageView.layer?.cornerRadius = 6
imageView.layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor
imageView.imageAlignment = .alignCenter
imageView.autoresizingMask = []
return imageView
}()

private let separatorView: NSView = {
let view = NSView()
view.wantsLayer = true
view.layer?.backgroundColor = NSColor.separatorColor.cgColor
view.autoresizingMask = []
return view
}()

// MARK: - Properties

var cellData: TimelineCellData! {
didSet {
configureWithCellData()
needsLayout = true
}
}

private var imageLoadTask: URLSessionDataTask?

// Flipped so layout coordinates run top-to-bottom.
override var isFlipped: Bool {
return true
}

// MARK: - Initialization

override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setupViews()
}

required init?(coder: NSCoder) {
super.init(coder: coder)
setupViews()
}

deinit {
imageLoadTask?.cancel()
}

// MARK: - Setup

private func setupViews() {
addSubview(feedIconView)
addSubview(metadataLabel)
addSubview(titleLabel)
addSubview(summaryLabel)
addSubview(thumbnailImageView)
addSubview(separatorView)
}

override func layout() {
super.layout()

guard let cellData, bounds.width > 0 else {
return
}

let layout = TimelineModernCellLayout(width: bounds.width, cellData: cellData)
feedIconView.frame = layout.feedIconRect
metadataLabel.frame = layout.metadataRect
titleLabel.frame = layout.titleRect
summaryLabel.frame = layout.summaryRect
thumbnailImageView.frame = layout.thumbnailRect
thumbnailImageView.isHidden = layout.thumbnailRect == .zero
separatorView.frame = layout.separatorRect
}

// MARK: - Configuration

private func configureWithCellData() {
guard let cellData else {
return
}

// NSTableView reuse has no prepareForReuse callback, so cancel any in-flight
// load and clear the image to avoid flashing the previous article's thumbnail.
imageLoadTask?.cancel()
imageLoadTask = nil
thumbnailImageView.image = nil

if let iconImage = cellData.iconImage {
feedIconView.image = iconImage.image
feedIconView.layer?.backgroundColor = NSColor.clear.cgColor
} else {
feedIconView.image = nil
feedIconView.layer?.backgroundColor = NSColor.systemRed.cgColor
}

metadataLabel.stringValue = "\(cellData.feedName) • \(cellData.dateString)"

titleLabel.stringValue = cellData.title
titleLabel.textColor = cellData.read ? .secondaryLabelColor : .labelColor

summaryLabel.stringValue = cellData.text

if let url = cellData.thumbnailURL {
loadImage(from: url)
}
}

private func loadImage(from url: URL) {
imageLoadTask?.cancel()

if let cachedImage = MacImageCache.shared.image(for: url) {
thumbnailImageView.image = cachedImage
return
}

imageLoadTask = URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self,
let data = data,
let image = NSImage(data: data),
error == nil else {
return
}

// Cropping is CPU work; stay off the main thread.
let croppedImage = image.cropToSquare()

// MacImageCache is MainActor-isolated.
DispatchQueue.main.async { [weak self] in
MacImageCache.shared.storeImage(croppedImage, for: url)
self?.thumbnailImageView.image = croppedImage
}
}

imageLoadTask?.resume()
}
}

// MARK: - Image Cache for macOS

@MainActor
final class MacImageCache {
static let shared = MacImageCache()

private let cache = NSCache<NSURL, NSImage>()

private init() {
cache.countLimit = 100
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
}

func image(for url: URL) -> NSImage? {
return cache.object(forKey: url as NSURL)
}

func storeImage(_ image: NSImage, for url: URL) {
cache.setObject(image, forKey: url as NSURL)
}
}

// MARK: - NSImage Extension for Square Cropping

extension NSImage {
/// Crops the image to a centered square.
func cropToSquare() -> NSImage {
guard let cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return self
}

let width = cgImage.width
let height = cgImage.height
let minDimension = min(width, height)
let x = (width - minDimension) / 2
let y = (height - minDimension) / 2

if let croppedCGImage = cgImage.cropping(to: CGRect(x: x, y: y, width: minDimension, height: minDimension)) {
return NSImage(cgImage: croppedCGImage, size: NSSize(width: minDimension, height: minDimension))
}

return self
}
}
Loading