// // OutlineContentViewController.swift // TagTunes // // Created by Kim Wittenburg on 21.01.16. // Copyright © 2016 Kim Wittenburg. All rights reserved. // import Foundation import AppKitPlus import SearchAPI class OutlineContentViewController: ContentViewController { // MARK: Types private struct OutlineViewConstants { struct ViewIdentifiers { /// The identifier used for the outline view rows representing /// `TagTunesTrack`s. static let SimpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier" /// The identifier used for the outline view rows representing /// `AlbumItem`s. static let AlbumTableCellViewIdentifier = "AlbumTableCellViewIdentifier" /// The identifier used for the outline view rows representing /// `SongItem`s. static let TrackTableCellViewIdentifier = "TrackTableCellViewIdentifier" } } // MARK: IBOutlets @IBOutlet private weak var outlineView: NSOutlineView! // MARK: Outline View Content /// All items in the outline view. internal private(set) var items = [TagTunesItem]() override func viewDidLoad() { super.viewDidLoad() outlineView.registerForDraggedTypes([TrackPboardType]) } override var selectedItems: [AnyObject] { var selectedGroups = Set() var selectedEntities = Set() var selectedTracks = Set() for row in outlineView.selectedRowIndexes { let item = outlineView.itemAtRow(row) if let album = item as? TagTunesGroupItem { selectedGroups.insert(album) } else if let track = item as? TagTunesEntityItem { selectedEntities.insert(track) } else if let track = item as? TagTunesTrack { selectedTracks.insert(track) } } for group in selectedGroups { for entity in group.children { for track in entity.associatedTracks { selectedTracks.remove(track) } selectedEntities.remove(entity) } } for entity in selectedEntities { for track in entity.associatedTracks { selectedTracks.remove(track) } } var selectedItems = [AnyObject]() selectedItems.appendContentsOf(Array(selectedGroups) as [AnyObject]) selectedItems.appendContentsOf(Array(selectedEntities) as [AnyObject]) selectedItems.appendContentsOf(Array(selectedTracks) as [AnyObject]) return selectedItems } override var clickedItems: [AnyObject] { if outlineView.clickedRow < 0 { return [] } else if outlineView.selectedRowIndexes.contains(outlineView.clickedRow) { return selectedItems } else { return [outlineView.itemAtRow(outlineView.clickedRow)!] } } override func addItem(item: TagTunesItem) { if itemForEntity(item.entity) == nil { items.append(item) outlineView.insertItemsAtIndexes(NSIndexSet(index: items.count-1), inParent: nil, withAnimation: NSTableViewAnimationOptions.EffectNone) } } override func itemForEntity(entity: SearchAPIEntity) -> TagTunesItem? { for item in items { if item.entity == entity { return item } if let group = item as? TagTunesGroupItem { for child in group.children where child.entity == entity { return child } } } return nil } override func updateItem(item: TagTunesItem) { outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(item)), columnIndexes: NSIndexSet(index: 0)) outlineView.reloadItem(item, reloadChildren: true) } override func updateAllItems() { outlineView.reloadData() } override func removeItems(objects: [AnyObject]) { outlineView.beginUpdates() for object in objects { if let item = object as? TagTunesItem { item.clearAssociatedTracks() if let index = items.indexOf({ $0 == item }) { items.removeAtIndex(index) outlineView.removeItemsAtIndexes(NSIndexSet(index: index), inParent: nil, withAnimation: .EffectNone) } } else if let track = object as? TagTunesTrack { if let entity = track.entity { entity.associatedTracks.remove(track) let row = outlineView.rowForItem(entity) outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0)) outlineView.reloadItem(entity, reloadChildren: true) } } } outlineView.endUpdates() } } // MARK: - Outline View Data Source & Delegate extension OutlineContentViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int { if item == nil { return items.count } else if let group = item as? TagTunesGroupItem { if case .Normal = group.loadingState { return group.children.count } else { return 0 } } else if let entity = item as? TagTunesEntityItem { return entity.associatedTracks.count } else { return 0 } } func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject { if item == nil { return items[index] } else if let group = item as? TagTunesGroupItem { return group.children[index] } else if let entity = item as? TagTunesEntityItem { return Array(entity.associatedTracks)[index] } else { return "" } } func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool { return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0 } func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat { if item is AlbumItem { return 39 } else if let entity = item as? TagTunesEntityItem { return (entity.parentItem != nil && entity.parentItem!.hasCommonArtist) ? 24 : 31 } else { return 17 } } func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? { if let albumItem = item as? AlbumItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView if view == nil { view = AlbumTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.AlbumTableCellViewIdentifier } let height = self.outlineView(outlineView, heightOfRowByItem: item) view?.setupForAlbumItem(albumItem, height: height) view?.errorButton?.target = self view?.errorButton.action = #selector(OutlineContentViewController.showErrorDetails(_:)) return view } if let songItem = item as? SongItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier, owner: nil) as? SongTableCellView if view == nil { view = SongTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.TrackTableCellViewIdentifier } view?.setupForSongItem(songItem) return view } if let track = item as? TagTunesTrack { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.SimpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.systemFontOfSize(0) view?.textField?.textColor = NSColor.textColor() let nameFont = NSFont.labelFontOfSize(13) if let name = track.name { view?.textField?.stringValue = name view?.textField?.textColor = NSColor.textColor() view?.textField?.font = nameFont } else { let italicFont = NSFontManager.sharedFontManager().convertFont(nameFont, toHaveTrait: .ItalicFontMask) view?.textField?.stringValue = NSLocalizedString("Unnamed Track", comment: "Default name for a track that has no name associated with it.") view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.font = italicFont } return view } return nil } // MARK: Drag and Drop func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool { if items.count > 1 { for item in items { if item is TagTunesGroupItem { return false } } } var draggedItems = [NSPasteboardItem]() let addDraggedTrack: (TagTunesTrack, row: Int) -> Void = { track, row in let item = NSPasteboardItem() item.setData(NSKeyedArchiver.archivedDataWithRootObject(track), forType: TrackPboardType) item.setData(NSKeyedArchiver.archivedDataWithRootObject(row), forType: IndexPboardType) draggedItems.append(item) } for item in items { if let draggedItem = item as? TagTunesItem { let row = outlineView.rowForItem(draggedItem) for track in draggedItem.associatedTracks { addDraggedTrack(track, row: row) } } else if let track = item as? TagTunesTrack, entity = track.entity { if !items.contains({ $0 === entity }) { let row = outlineView.rowForItem(entity) addDraggedTrack(track, row: row) } } } pasteboard.clearContents() pasteboard.writeObjects(draggedItems) return !draggedItems.isEmpty } func outlineView(outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAtPoint screenPoint: NSPoint, operation: NSDragOperation) { if let pointInWindow = view.window?.convertRectFromScreen(NSRect(origin: screenPoint, size: NSSize.zero)).origin { let pointInView = view.window!.contentView!.convertPoint(pointInWindow, fromView: nil) let targetView = view.window!.contentView!.hitTest(pointInView) if targetView?.isDescendantOf(outlineView) ?? false { return } } if let draggedItems = session.draggingPasteboard.pasteboardItems where operation != .None { outlineView.beginUpdates() let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as! TagTunesTrack) } for (row, track) in draggedTracks { let item = outlineView.itemAtRow(row) as! TagTunesItem if item is TagTunesGroupItem { item.clearAssociatedTracks() } else if let entity = item as? TagTunesEntityItem { entity.associatedTracks.remove(track) } outlineView.reloadDataForRowIndexes(NSIndexSet(index: row), columnIndexes: NSIndexSet(index: 0)) outlineView.reloadItem(item, reloadChildren: true) } outlineView.endUpdates() } } func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation { // Validate pasteboard contents guard info.draggingPasteboard().canReadItemWithDataConformingToTypes([TrackPboardType]) else { return .None } // Drop onto empty outline view if item == nil && index == NSOutlineViewDropOnItemIndex { return .None } // Drop on TagTunesTrack item or between items if index != NSOutlineViewDropOnItemIndex || item is TagTunesTrack { return .None } // Drop on loading (or failed) album if let group = item as? TagTunesGroupItem { guard case .Normal = group.loadingState else { return .None } } return .Move } func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool { guard let draggedItems = info.draggingPasteboard().pasteboardItems, targetItem = item as? TagTunesItem else { return false } outlineView.beginUpdates() let draggedTracks = draggedItems.map { ($0.dataForType(IndexPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? Int, $0.dataForType(TrackPboardType).map(NSKeyedUnarchiver.unarchiveObjectWithData) as? TagTunesTrack, $0) } if info.draggingSource() === outlineView { for (row, track, _) in draggedTracks { let item = outlineView.itemAtRow(row!) as! TagTunesItem if item is TagTunesGroupItem { item.clearAssociatedTracks() } else if let entity = item as? TagTunesEntityItem, theTrack = track { entity.associatedTracks.remove(theTrack) } outlineView.reloadDataForRowIndexes(NSIndexSet(index: row!), columnIndexes: NSIndexSet(index: 0)) outlineView.reloadItem(item, reloadChildren: true) } } var remainingItems = [NSPasteboardItem]() for (_, track, item) in draggedTracks { if let theTrack = track { if !targetItem.addAssociatedTracks([theTrack]).isEmpty { remainingItems.append(item) } } } NSApp.unsortedTracksController.returnDraggedItems(remainingItems) outlineView.reloadDataForRowIndexes(NSIndexSet(index: outlineView.rowForItem(targetItem)), columnIndexes: NSIndexSet(index: 0)) outlineView.reloadItem(targetItem, reloadChildren: true) outlineView.endUpdates() return true } } // MARK: - Error Handling extension OutlineContentViewController { /// Action that should be triggered from a view inside the outline view. If /// `sender` is not an `NSError` instance the item at the row associated with /// the `sender` (as determined by `NSOutlineView.rowForView`) should be a /// `NSError` or `Album` instance for this method to work correctly. @objc @IBAction private func showErrorDetails(sender: AnyObject?) { var error: NSError if let theError = sender as? NSError { error = theError } else if let view = sender as? NSView { let row = outlineView.rowForView(view) let item = outlineView.itemAtRow(row) if let group = item as? TagTunesGroupItem { if case let .Error(theError) = group.loadingState { error = theError as NSError } else { return } } else { return } } else { return } presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil) } override func willPresentError(error: NSError) -> NSError { let recoveryOptions = [ NSLocalizedString("OK", comment: "Button title"), NSLocalizedString("Try Again", comment: "Button title for error alerts offering the user to try again.") ] return DescriptiveError(underlyingError: error, userInfo: [NSRecoveryAttempterErrorKey: self, NSLocalizedRecoveryOptionsErrorKey: recoveryOptions]) } override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer) { let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex) delegate?.performSelector(didRecoverSelector, withObject: didRecover, withObject: contextInfo as! AnyObject) } override func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool { if recoveryOptionIndex == 0 { return true } for item in items where item is TagTunesGroupItem { let group = item as! TagTunesGroupItem if case let .Error(loadingError as NSError) = group.loadingState where error == loadingError || error.userInfo[NSUnderlyingErrorKey] === loadingError { group.beginLoadingChildren() return true } } return false } }