mirror of
https://github.com/Ranchero-Software/NetNewsWire.git
synced 2024-12-24 16:51:19 +01:00
539 lines
13 KiB
Swift
539 lines
13 KiB
Swift
//
|
||
// SidebarViewController.swift
|
||
// Evergreen
|
||
//
|
||
// Created by Brent Simmons on 7/26/15.
|
||
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
|
||
//
|
||
|
||
import Cocoa
|
||
import RSTree
|
||
import Data
|
||
import Account
|
||
import RSCore
|
||
|
||
@objc class SidebarViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource, UndoableCommandRunner {
|
||
|
||
@IBOutlet var outlineView: SidebarOutlineView!
|
||
let treeControllerDelegate = SidebarTreeControllerDelegate()
|
||
lazy var treeController: TreeController = {
|
||
TreeController(delegate: treeControllerDelegate)
|
||
}()
|
||
var undoableCommands = [UndoableCommand]()
|
||
private var animatingChanges = false
|
||
private var sidebarCellAppearance: SidebarCellAppearance!
|
||
|
||
//MARK: NSViewController
|
||
|
||
override func viewDidLoad() {
|
||
|
||
sidebarCellAppearance = SidebarCellAppearance(theme: appDelegate.currentTheme, fontSize: AppDefaults.shared.sidebarFontSize)
|
||
|
||
outlineView.sidebarViewController = self
|
||
outlineView.setDraggingSourceOperationMask(.move, forLocal: true)
|
||
outlineView.setDraggingSourceOperationMask(.copy, forLocal: false)
|
||
|
||
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
|
||
NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .FeedSettingDidChange, object: nil)
|
||
|
||
outlineView.reloadData()
|
||
|
||
// Always expand all group items on initial display.
|
||
var row = 0
|
||
while(true) {
|
||
guard let item = outlineView.item(atRow: row) else {
|
||
break
|
||
}
|
||
let node = item as! Node
|
||
if node.isGroupItem {
|
||
outlineView.expandItem(item)
|
||
}
|
||
row += 1
|
||
}
|
||
}
|
||
|
||
//MARK: Notifications
|
||
|
||
@objc dynamic func unreadCountDidChange(_ note: Notification) {
|
||
|
||
guard let representedObject = note.object else {
|
||
return
|
||
}
|
||
configureUnreadCountForCellsForRepresentedObject(representedObject as AnyObject)
|
||
}
|
||
|
||
@objc dynamic func containerChildrenDidChange(_ note: Notification) {
|
||
|
||
rebuildTreeAndReloadDataIfNeeded()
|
||
}
|
||
|
||
@objc dynamic func batchUpdateDidPerform(_ notification: Notification) {
|
||
|
||
rebuildTreeAndReloadDataIfNeeded()
|
||
}
|
||
|
||
@objc dynamic func userDidAddFeed(_ notification: Notification) {
|
||
|
||
guard let feed = notification.userInfo?[UserInfoKey.feed] else {
|
||
return
|
||
}
|
||
revealAndSelectRepresentedObject(feed as AnyObject)
|
||
}
|
||
|
||
@objc func faviconDidBecomeAvailable(_ note: Notification) {
|
||
|
||
applyToAvailableCells(configureFavicon)
|
||
}
|
||
|
||
@objc func feedSettingDidChange(_ note: Notification) {
|
||
|
||
guard let feed = note.object as? Feed else {
|
||
return
|
||
}
|
||
configureCellsForRepresentedObject(feed)
|
||
}
|
||
|
||
// MARK: Actions
|
||
|
||
@IBAction func delete(_ sender: AnyObject?) {
|
||
|
||
if outlineView.selectionIsEmpty {
|
||
return
|
||
}
|
||
|
||
let nodesToDelete = treeController.normalizedSelectedNodes(selectedNodes)
|
||
|
||
guard let undoManager = undoManager, let deleteCommand = DeleteFromSidebarCommand(nodesToDelete: nodesToDelete, undoManager: undoManager) else {
|
||
return
|
||
}
|
||
|
||
animatingChanges = true
|
||
outlineView.beginUpdates()
|
||
|
||
let indexSetsGroupedByParent = Node.indexSetsGroupedByParent(nodesToDelete)
|
||
for (parent, indexSet) in indexSetsGroupedByParent {
|
||
outlineView.removeItems(at: indexSet, inParent: parent.isRoot ? nil : parent, withAnimation: [.slideDown])
|
||
}
|
||
|
||
outlineView.endUpdates()
|
||
|
||
runCommand(deleteCommand)
|
||
animatingChanges = false
|
||
}
|
||
|
||
@IBAction func openInBrowser(_ sender: Any?) {
|
||
|
||
guard let feed = singleSelectedFeed, let homePageURL = feed.homePageURL else {
|
||
return
|
||
}
|
||
Browser.open(homePageURL)
|
||
}
|
||
|
||
// MARK: Navigation
|
||
|
||
func canGoToNextUnread() -> Bool {
|
||
|
||
if let _ = rowContainingNextUnread() {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func goToNextUnread() {
|
||
|
||
guard let row = rowContainingNextUnread() else {
|
||
assertionFailure("goToNextUnread called before checking if there is a next unread.")
|
||
return
|
||
}
|
||
|
||
outlineView.selectRowIndexes(IndexSet([row]), byExtendingSelection: false)
|
||
}
|
||
|
||
func focus() {
|
||
|
||
guard let window = outlineView.window else {
|
||
return
|
||
}
|
||
window.makeFirstResponderUnlessDescendantIsFirstResponder(outlineView)
|
||
}
|
||
|
||
// MARK: NSOutlineViewDelegate
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
|
||
|
||
let node = item as! Node
|
||
|
||
if node.isGroupItem {
|
||
let cell = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "HeaderCell"), owner: self) as! NSTableCellView
|
||
configureGroupCell(cell, node)
|
||
return cell
|
||
}
|
||
|
||
let cell = outlineView.makeView(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "DataCell"), owner: self) as! SidebarCell
|
||
configure(cell, node)
|
||
|
||
return cell
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
|
||
|
||
let node = item as! Node
|
||
return node.isGroupItem
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet {
|
||
|
||
// Don’t allow selecting group items.
|
||
// If any index in IndexSet contains a group item,
|
||
// return the current selection (not a modified version of the proposed selection).
|
||
|
||
for index in proposedSelectionIndexes {
|
||
if let node = nodeForRow(index), node.isGroupItem {
|
||
return outlineView.selectedRowIndexes
|
||
}
|
||
}
|
||
|
||
return proposedSelectionIndexes
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool {
|
||
|
||
return !self.outlineView(outlineView, isGroupItem: item)
|
||
}
|
||
|
||
func outlineViewSelectionDidChange(_ notification: Notification) {
|
||
|
||
// TODO: support multiple selection
|
||
|
||
let selectedRow = self.outlineView.selectedRow
|
||
|
||
if selectedRow < 0 || selectedRow == NSNotFound {
|
||
postSidebarSelectionDidChangeNotification(nil)
|
||
return
|
||
}
|
||
|
||
if let selectedNode = self.outlineView.item(atRow: selectedRow) as? Node {
|
||
postSidebarSelectionDidChangeNotification([selectedNode.representedObject])
|
||
}
|
||
}
|
||
|
||
// MARK: NSOutlineViewDataSource
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int {
|
||
|
||
return nodeForItem(item as AnyObject?).numberOfChildNodes
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any {
|
||
|
||
return nodeForItem(item as AnyObject?).childNodes![index]
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool {
|
||
|
||
return nodeForItem(item as AnyObject?).canHaveChildNodes
|
||
}
|
||
|
||
func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
|
||
|
||
let node = nodeForItem(item as AnyObject?)
|
||
if let feed = node.representedObject as? Feed {
|
||
return FeedPasteboardWriter(feed: feed)
|
||
}
|
||
return nil
|
||
}
|
||
}
|
||
|
||
//MARK: - Private
|
||
|
||
private extension SidebarViewController {
|
||
|
||
var selectedNodes: [Node] {
|
||
if let nodes = outlineView.selectedItems as? [Node] {
|
||
return nodes
|
||
}
|
||
return [Node]()
|
||
}
|
||
|
||
var singleSelectedNode: Node? {
|
||
guard selectedNodes.count == 1 else {
|
||
return nil
|
||
}
|
||
return selectedNodes.first!
|
||
}
|
||
|
||
var singleSelectedFeed: Feed? {
|
||
guard let node = singleSelectedNode else {
|
||
return nil
|
||
}
|
||
return node.representedObject as? Feed
|
||
}
|
||
|
||
func rebuildTreeAndReloadDataIfNeeded() {
|
||
|
||
if !animatingChanges && !BatchUpdate.shared.isPerforming {
|
||
treeController.rebuild()
|
||
outlineView.reloadData()
|
||
}
|
||
}
|
||
|
||
func postSidebarSelectionDidChangeNotification(_ selectedObjects: [AnyObject]?) {
|
||
|
||
var userInfo = UserInfoDictionary()
|
||
if let objects = selectedObjects {
|
||
userInfo[UserInfoKey.objects] = objects
|
||
}
|
||
userInfo[UserInfoKey.view] = outlineView
|
||
|
||
NotificationCenter.default.post(name: .SidebarSelectionDidChange, object: self, userInfo: userInfo)
|
||
}
|
||
|
||
func updateUnreadCounts(for objects: [AnyObject]) {
|
||
|
||
// On selection, update unread counts for folders and feeds.
|
||
// For feeds, actually fetch from database.
|
||
|
||
for object in objects {
|
||
if let feed = object as? Feed, let account = feed.account {
|
||
account.updateUnreadCounts(for: Set([feed]))
|
||
}
|
||
else if let folder = object as? Folder, let account = folder.account {
|
||
account.updateUnreadCounts(for: folder.flattenedFeeds())
|
||
}
|
||
}
|
||
}
|
||
|
||
func nodeForItem(_ item: AnyObject?) -> Node {
|
||
|
||
if item == nil {
|
||
return treeController.rootNode
|
||
}
|
||
return item as! Node
|
||
}
|
||
|
||
func nodeForRow(_ row: Int) -> Node? {
|
||
|
||
if row < 0 || row >= outlineView.numberOfRows {
|
||
return nil
|
||
}
|
||
|
||
if let node = outlineView.item(atRow: row) as? Node {
|
||
return node
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func rowHasAtLeastOneUnreadArticle(_ row: Int) -> Bool {
|
||
|
||
if let oneNode = nodeForRow(row) {
|
||
if let unreadCountProvider = oneNode.representedObject as? UnreadCountProvider {
|
||
if unreadCountProvider.unreadCount > 0 {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func rowContainingNextUnread() -> Int? {
|
||
|
||
let selectedRow = outlineView.selectedRow
|
||
let numberOfRows = outlineView.numberOfRows
|
||
var row = selectedRow + 1
|
||
|
||
while (row < numberOfRows) {
|
||
if rowHasAtLeastOneUnreadArticle(row) {
|
||
return row
|
||
}
|
||
row += 1
|
||
}
|
||
|
||
row = 0
|
||
while (row <= selectedRow) {
|
||
if rowHasAtLeastOneUnreadArticle(row) {
|
||
return row
|
||
}
|
||
row += 1
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func configure(_ cell: SidebarCell, _ node: Node) {
|
||
|
||
cell.cellAppearance = sidebarCellAppearance
|
||
cell.objectValue = node
|
||
cell.name = nameFor(node)
|
||
configureUnreadCount(cell, node)
|
||
configureFavicon(cell, node)
|
||
cell.shouldShowImage = node.representedObject is SmallIconProvider
|
||
}
|
||
|
||
func configureUnreadCount(_ cell: SidebarCell, _ node: Node) {
|
||
|
||
cell.unreadCount = unreadCountFor(node)
|
||
}
|
||
|
||
func configureFavicon(_ cell: SidebarCell, _ node: Node) {
|
||
|
||
cell.image = imageFor(node)
|
||
}
|
||
|
||
func configureGroupCell(_ cell: NSTableCellView, _ node: Node) {
|
||
|
||
cell.objectValue = node
|
||
cell.textField?.stringValue = nameFor(node)
|
||
}
|
||
|
||
func imageFor(_ node: Node) -> NSImage? {
|
||
|
||
if let smallIconProvider = node.representedObject as? SmallIconProvider {
|
||
return smallIconProvider.smallIcon
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func nameFor(_ node: Node) -> String {
|
||
|
||
if let displayNameProvider = node.representedObject as? DisplayNameProvider {
|
||
return displayNameProvider.nameForDisplay
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func unreadCountFor(_ node: Node) -> Int {
|
||
|
||
if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
|
||
return unreadCountProvider.unreadCount
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func cellForRowView(_ rowView: NSTableRowView) -> SidebarCell? {
|
||
|
||
return rowView.view(atColumn: 0) as? SidebarCell
|
||
}
|
||
|
||
func availableSidebarCells() -> [SidebarCell] {
|
||
|
||
var cells = [SidebarCell]()
|
||
|
||
outlineView.enumerateAvailableRowViews { (rowView: NSTableRowView, _: Int) -> Void in
|
||
if let cell = cellForRowView(rowView) {
|
||
cells += [cell]
|
||
}
|
||
}
|
||
|
||
return cells
|
||
}
|
||
|
||
func configureAvailableCells() {
|
||
|
||
applyToAvailableCells(configure)
|
||
}
|
||
|
||
func applyToAvailableCells(_ callback: (SidebarCell, Node) -> Void) {
|
||
|
||
outlineView.enumerateAvailableRowViews { (rowView: NSTableRowView, row: Int) -> Void in
|
||
|
||
guard let cell = cellForRowView(rowView), let node = nodeForRow(row) else {
|
||
return
|
||
}
|
||
callback(cell, node)
|
||
}
|
||
}
|
||
|
||
func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ callback: (SidebarCell, Node) -> Void) {
|
||
|
||
applyToAvailableCells { (cell, node) in
|
||
if node.representedObject === representedObject {
|
||
callback(cell, node)
|
||
}
|
||
}
|
||
}
|
||
|
||
func configureCellsForRepresentedObject(_ representedObject: AnyObject) {
|
||
|
||
applyToCellsForRepresentedObject(representedObject, configure)
|
||
}
|
||
|
||
func configureUnreadCountForCellsForRepresentedObject(_ representedObject: AnyObject) {
|
||
|
||
applyToCellsForRepresentedObject(representedObject, configureUnreadCount)
|
||
}
|
||
|
||
@discardableResult
|
||
func revealAndSelectRepresentedObject(_ representedObject: AnyObject) -> Bool {
|
||
|
||
return outlineView.revealAndSelectRepresentedObject(representedObject, treeController)
|
||
}
|
||
|
||
func folderParentForNode(_ node: Node) -> Container? {
|
||
|
||
if let folder = node.parent?.representedObject as? Container {
|
||
return folder
|
||
}
|
||
if let feed = node.representedObject as? Feed {
|
||
return feed.account
|
||
}
|
||
if let folder = node.representedObject as? Folder {
|
||
return folder.account
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func deleteItemForNode(_ node: Node) {
|
||
|
||
// if let folder = folderParentForNode(node) {
|
||
// folder.deleteItems([node.representedObject])
|
||
// }
|
||
}
|
||
|
||
func deleteItemsForNodes(_ nodes: [Node]) {
|
||
|
||
nodes.forEach { (oneNode) in
|
||
|
||
deleteItemForNode(oneNode)
|
||
}
|
||
}
|
||
|
||
func commonParentItemForNodes(_ nodes: [Node]) -> Node? {
|
||
|
||
if nodes.isEmpty {
|
||
return nil
|
||
}
|
||
|
||
guard let parent = nodes.first!.parent else {
|
||
return nil
|
||
}
|
||
for node in nodes {
|
||
if node.parent !== parent {
|
||
return nil
|
||
}
|
||
}
|
||
return parent
|
||
}
|
||
}
|
||
|
||
extension Feed: SmallIconProvider {
|
||
|
||
var smallIcon: NSImage? {
|
||
if let image = appDelegate.faviconDownloader.favicon(for: self) {
|
||
return image
|
||
}
|
||
return appDelegate.genericFeedImage
|
||
}
|
||
}
|
||
|
||
extension Folder: SmallIconProvider {
|
||
|
||
var smallIcon: NSImage? {
|
||
return NSImage(named: NSImage.Name.folder)
|
||
}
|
||
}
|
||
|