NetNewsWire/NetNewsWire/MainWindow/Sidebar/SidebarViewController.swift

582 lines
15 KiB
Swift
Raw Normal View History

2017-05-27 19:43:27 +02:00
//
// SidebarViewController.swift
2018-08-29 07:18:24 +02:00
// NetNewsWire
2017-05-27 19:43:27 +02:00
//
// Created by Brent Simmons on 7/26/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import AppKit
2017-05-27 19:43:27 +02:00
import RSTree
import Articles
2017-09-17 21:34:10 +02:00
import Account
2017-11-04 22:53:21 +01:00
import RSCore
2017-05-27 19:43:27 +02:00
protocol SidebarDelegate: class {
func sidebarSelectionDidChange(_: SidebarViewController, selectedObjects: [AnyObject]?)
}
@objc class SidebarViewController: NSViewController, NSOutlineViewDelegate, NSOutlineViewDataSource, NSMenuDelegate, UndoableCommandRunner {
2017-05-27 19:43:27 +02:00
@IBOutlet var outlineView: SidebarOutlineView!
weak var delegate: SidebarDelegate?
2017-10-19 22:27:59 +02:00
let treeControllerDelegate = SidebarTreeControllerDelegate()
lazy var treeController: TreeController = {
return TreeController(delegate: treeControllerDelegate)
2017-10-19 22:27:59 +02:00
}()
lazy var dataSource: SidebarOutlineDataSource = {
return SidebarOutlineDataSource(treeController: treeController)
}()
var undoableCommands = [UndoableCommand]()
2017-11-05 21:14:36 +01:00
private var animatingChanges = false
private var sidebarCellAppearance: SidebarCellAppearance!
var renameWindowController: RenameWindowController?
2018-01-21 20:35:50 +01:00
var selectedObjects: [AnyObject] {
return selectedNodes.representedObjects()
}
// MARK: - NSViewController
2017-05-27 19:43:27 +02:00
override func viewDidLoad() {
sidebarCellAppearance = SidebarCellAppearance(theme: appDelegate.currentTheme, fontSize: AppDefaults.sidebarFontSize)
outlineView.dataSource = dataSource
outlineView.setDraggingSourceOperationMask(.move, forLocal: true)
outlineView.setDraggingSourceOperationMask(.copy, forLocal: false)
outlineView.registerForDraggedTypes([FeedPasteboardWriter.feedUTIInternalType, FeedPasteboardWriter.feedUTIType, .URL, .string])
2017-05-27 19:43:27 +02:00
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
2017-10-19 22:27:59 +02:00
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
2017-10-22 00:56:01 +02:00
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)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDidRequestSidebarSelection(_:)), name: .UserDidRequestSidebarSelection, object: nil)
2017-05-27 19:43:27 +02:00
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
}
2017-05-27 19:43:27 +02:00
}
// MARK: State Restoration
private static let stateRestorationSelectedRowIndexes = "selectedRowIndexes"
override func encodeRestorableState(with coder: NSCoder) {
super.encodeRestorableState(with: coder)
coder.encode(outlineView.selectedRowIndexes, forKey: SidebarViewController.stateRestorationSelectedRowIndexes)
}
override func restoreState(with coder: NSCoder) {
super.restoreState(with: coder)
if let restoredRowIndexes = coder.decodeObject(of: [NSIndexSet.self], forKey: SidebarViewController.stateRestorationSelectedRowIndexes) as? IndexSet {
outlineView.selectRowIndexes(restoredRowIndexes, byExtendingSelection: false)
}
}
// MARK: - Notifications
2017-05-27 19:43:27 +02:00
@objc func unreadCountDidChange(_ note: Notification) {
2017-05-27 19:43:27 +02:00
guard let representedObject = note.object else {
2017-05-27 19:43:27 +02:00
return
}
configureUnreadCountForCellsForRepresentedObject(representedObject as AnyObject)
2017-05-27 19:43:27 +02:00
}
@objc func containerChildrenDidChange(_ note: Notification) {
rebuildTreeAndRestoreSelection()
2017-05-27 19:43:27 +02:00
}
@objc func batchUpdateDidPerform(_ notification: Notification) {
rebuildTreeAndRestoreSelection()
2017-05-27 19:43:27 +02:00
}
@objc func userDidAddFeed(_ notification: Notification) {
2017-05-27 19:43:27 +02:00
guard let feed = notification.userInfo?[UserInfoKey.feed] else {
2017-05-27 19:43:27 +02:00
return
}
revealAndSelectRepresentedObject(feed as AnyObject)
2017-05-27 19:43:27 +02:00
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
applyToAvailableCells(configureFavicon)
}
@objc func feedSettingDidChange(_ note: Notification) {
guard let feed = note.object as? Feed else {
return
}
configureCellsForRepresentedObject(feed)
}
@objc func displayNameDidChange(_ note: Notification) {
guard let object = note.object else {
return
}
let savedSelection = selectedNodes
rebuildTreeAndReloadDataIfNeeded()
configureCellsForRepresentedObject(object as AnyObject)
restoreSelection(to: savedSelection, sendNotificationIfChanged: true)
}
@objc func userDidRequestSidebarSelection(_ note: Notification) {
guard let feed = note.userInfo?[UserInfoKey.feed] else {
return
}
revealAndSelectRepresentedObject(feed as AnyObject)
}
// MARK: - Actions
2017-05-27 19:43:27 +02:00
@IBAction func delete(_ sender: AnyObject?) {
if outlineView.selectionIsEmpty {
return
}
deleteNodes(selectedNodes)
2017-05-27 19:43:27 +02:00
}
@IBAction func openInBrowser(_ sender: Any?) {
guard let feed = singleSelectedFeed, let homePageURL = feed.homePageURL else {
return
}
Browser.open(homePageURL)
}
@IBAction func gotoToday(_ sender: Any?) {
outlineView.revealAndSelectRepresentedObject(SmartFeedsController.shared.todayFeed, treeController)
}
@IBAction func gotoAllUnread(_ sender: Any?) {
outlineView.revealAndSelectRepresentedObject(SmartFeedsController.shared.unreadFeed, treeController)
}
@IBAction func gotoStarred(_ sender: Any?) {
outlineView.revealAndSelectRepresentedObject(SmartFeedsController.shared.starredFeed, treeController)
}
@IBAction func copy(_ sender: Any?) {
NSPasteboard.general.copyObjects(selectedObjects)
}
// MARK: - Navigation
2017-05-27 19:43:27 +02:00
func canGoToNextUnread() -> Bool {
if let _ = nextSelectableRowWithUnreadArticle() {
2017-05-27 19:43:27 +02:00
return true
}
return false
}
func goToNextUnread() {
guard let row = nextSelectableRowWithUnreadArticle() else {
2017-05-27 19:43:27 +02:00
assertionFailure("goToNextUnread called before checking if there is a next unread.")
return
}
2018-09-05 06:18:59 +02:00
NSCursor.setHiddenUntilMouseMoves(true)
2017-05-27 19:43:27 +02:00
outlineView.selectRowIndexes(IndexSet([row]), byExtendingSelection: false)
outlineView.scrollTo(row: row)
2017-05-27 19:43:27 +02:00
}
func focus() {
guard let window = outlineView.window else {
return
}
window.makeFirstResponderUnlessDescendantIsFirstResponder(outlineView)
}
// MARK: - Contextual Menu
func contextualMenuForSelectedObjects() -> NSMenu? {
return menu(for: selectedObjects)
}
func contextualMenuForClickedRows() -> NSMenu? {
let row = outlineView.clickedRow
guard row != -1, let node = nodeForRow(row) else {
return nil
}
if outlineView.selectedRowIndexes.contains(row) {
// If the clickedRow is part of the selected rows, then do a contextual menu for all the selected rows.
return contextualMenuForSelectedObjects()
}
let object = node.representedObject
return menu(for: [object])
}
// MARK: NSMenuDelegate
public func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
guard let contextualMenu = contextualMenuForClickedRows() else {
return
}
menu.takeItems(from: contextualMenu)
}
// MARK: - NSOutlineViewDelegate
2017-05-27 19:43:27 +02:00
func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? {
let node = item as! Node
2017-11-19 01:56:36 +01:00
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
2017-05-27 19:43:27 +02:00
configure(cell, node)
return cell
}
2017-11-19 01:56:36 +01:00
func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool {
let node = item as! Node
return node.isGroupItem
}
func outlineView(_ outlineView: NSOutlineView, selectionIndexesForProposedSelection proposedSelectionIndexes: IndexSet) -> IndexSet {
// Dont 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)
}
2017-05-27 19:43:27 +02:00
func outlineViewSelectionDidChange(_ notification: Notification) {
selectionDidChange(selectedObjects.isEmpty ? nil : selectedObjects)
self.invalidateRestorableState()
2017-05-27 19:43:27 +02:00
}
//MARK: - Node Manipulation
func deleteNodes(_ nodes: [Node]) {
let nodesToDelete = treeController.normalizedSelectedNodes(nodes)
guard let undoManager = undoManager, let deleteCommand = DeleteFromSidebarCommand(nodesToDelete: nodesToDelete, treeController: treeController, 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
}
// MARK: - API
func rebuildTreeAndRestoreSelection() {
let savedSelection = selectedNodes
rebuildTreeAndReloadDataIfNeeded()
restoreSelection(to: savedSelection, sendNotificationIfChanged: true)
}
2017-05-27 19:43:27 +02:00
}
// MARK: - NSUserInterfaceValidations
extension SidebarViewController: NSUserInterfaceValidations {
func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
if item.action == #selector(copy(_:)) {
return NSPasteboard.general.canCopyAtLeastOneObject(selectedObjects)
}
return true
}
}
2017-05-27 19:43:27 +02:00
//MARK: - Private
private extension SidebarViewController {
var selectedNodes: [Node] {
if let nodes = outlineView.selectedItems as? [Node] {
return nodes
2017-05-27 19:43:27 +02:00
}
return [Node]()
2017-05-27 19:43:27 +02:00
}
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
}
2017-05-27 19:43:27 +02:00
func rebuildTreeAndReloadDataIfNeeded() {
2017-11-05 21:14:36 +01:00
if !animatingChanges && !BatchUpdate.shared.isPerforming {
2017-05-27 19:43:27 +02:00
treeController.rebuild()
outlineView.reloadData()
}
}
func restoreSelection(to nodes: [Node], sendNotificationIfChanged: Bool) {
if selectedNodes == nodes { // Nothing to do?
return
}
var indexes = IndexSet()
for node in nodes {
let row = outlineView.row(forItem: node as Any)
if row > -1 {
indexes.insert(row)
}
}
outlineView.selectRowIndexes(indexes, byExtendingSelection: false)
if selectedNodes != nodes && sendNotificationIfChanged {
selectionDidChange(selectedObjects)
}
}
func selectionDidChange(_ selectedObjects: [AnyObject]?) {
delegate?.sidebarSelectionDidChange(self, selectedObjects: selectedObjects)
2017-05-27 19:43:27 +02:00
}
func updateUnreadCounts(for objects: [AnyObject]) {
2017-10-19 06:53:45 +02:00
// 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]))
}
2017-10-22 01:37:40 +02:00
else if let folder = object as? Folder, let account = folder.account {
account.updateUnreadCounts(for: folder.flattenedFeeds())
2017-10-19 06:53:45 +02:00
}
}
}
2017-05-27 19:43:27 +02:00
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 rowIsGroupItem(_ row: Int) -> Bool {
if let node = nodeForRow(row), outlineView.isGroupItem(node) {
return true
}
return false
}
func nextSelectableRowWithUnreadArticle() -> Int? {
// Skip group items, because they should never be selected.
2017-05-27 19:43:27 +02:00
let selectedRow = outlineView.selectedRow
let numberOfRows = outlineView.numberOfRows
var row = selectedRow + 1
while (row < numberOfRows) {
if rowHasAtLeastOneUnreadArticle(row) && !rowIsGroupItem(row) {
2017-05-27 19:43:27 +02:00
return row
}
row += 1
}
row = 0
while (row <= selectedRow) {
if rowHasAtLeastOneUnreadArticle(row) && !rowIsGroupItem(row) {
2017-05-27 19:43:27 +02:00
return row
}
row += 1
}
return nil
}
func configure(_ cell: SidebarCell, _ node: Node) {
cell.cellAppearance = sidebarCellAppearance
2017-05-27 19:43:27 +02:00
cell.name = nameFor(node)
configureUnreadCount(cell, node)
configureFavicon(cell, node)
cell.shouldShowImage = node.representedObject is SmallIconProvider
2017-05-27 19:43:27 +02:00
}
func configureUnreadCount(_ cell: SidebarCell, _ node: Node) {
cell.unreadCount = unreadCountFor(node)
}
func configureFavicon(_ cell: SidebarCell, _ node: Node) {
cell.image = imageFor(node)
}
2017-11-19 01:56:36 +01:00
func configureGroupCell(_ cell: NSTableCellView, _ node: Node) {
cell.textField?.stringValue = nameFor(node)
}
2017-05-27 19:43:27 +02:00
func imageFor(_ node: Node) -> NSImage? {
if let smallIconProvider = node.representedObject as? SmallIconProvider {
return smallIconProvider.smallIcon
}
return nil
2017-05-27 19:43:27 +02:00
}
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 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) {
2017-05-27 19:43:27 +02:00
applyToAvailableCells { (cell, node) in
if node.representedObject === representedObject {
callback(cell, node)
2017-05-27 19:43:27 +02:00
}
}
}
func configureCellsForRepresentedObject(_ representedObject: AnyObject) {
2017-05-27 19:43:27 +02:00
applyToCellsForRepresentedObject(representedObject, configure)
2017-05-27 19:43:27 +02:00
}
func configureUnreadCountForCellsForRepresentedObject(_ representedObject: AnyObject) {
applyToCellsForRepresentedObject(representedObject, configureUnreadCount)
}
2017-05-27 19:43:27 +02:00
@discardableResult
func revealAndSelectRepresentedObject(_ representedObject: AnyObject) -> Bool {
2017-05-27 19:43:27 +02:00
return outlineView.revealAndSelectRepresentedObject(representedObject, treeController)
}
}