From 784dfbcb04a06825b6c0baca298752dd7e7c5a48 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 15 Aug 2024 19:27:26 +0600 Subject: [PATCH 01/29] NSPopover-based Bookmarks menu system --- DuckDuckGo.xcodeproj/project.pbxproj | 42 +- .../xcschemes/sandbox-test-tool.xcscheme | 2 +- .../Extensions/NSOutlineViewExtensions.swift | 6 + DuckDuckGo/Bookmarks/Model/BookmarkNode.swift | 10 +- .../Model/BookmarkOutlineViewDataSource.swift | 91 +- .../Model/BookmarkSidebarTreeController.swift | 1 + .../Model/BookmarkTreeController.swift | 46 +- .../Bookmarks/Services/BookmarkStore.swift | 2 +- .../Services/BookmarkStoreMock.swift | 13 - .../Bookmarks/View/BookmarkListPopover.swift | 142 ++ .../View/BookmarkListViewController.swift | 1254 ++++++++++++----- ...okmarkManagementDetailViewController.swift | 2 + ...kmarkManagementSidebarViewController.swift | 1 + .../View/BookmarkOutlineCellView.swift | 256 +++- .../Bookmarks/View/BookmarksOutlineView.swift | 93 +- .../View/OutlineSeparatorViewCell.swift | 20 +- .../View/RoundedSelectionRowView.swift | 12 +- .../BookmarksBar/View/BookmarksBar.storyboard | 25 +- .../View/BookmarksBarCollectionViewItem.swift | 45 +- .../View/BookmarksBarCollectionViewItem.xib | 15 +- .../View/BookmarksBarViewController.swift | 121 +- .../View/BookmarksBarViewModel.swift | 85 +- .../View/HorizontallyCenteredLayout.swift | 41 +- .../View/ItemCachingCollectionView.swift | 88 ++ .../Common/Extensions/NSEventExtension.swift | 33 + .../NSLayoutConstraintExtension.swift | 13 + .../Extensions/NSPopoverExtension.swift | 31 +- .../Common/Extensions/NSViewExtension.swift | 2 +- .../Common/Extensions/NSWindowExtension.swift | 4 + .../View/AppKit/HoverTrackingArea.swift | 15 +- .../AppKit/MouseOverAnimationButton.swift | 2 +- .../Common/View/AppKit/MouseOverButton.swift | 2 +- .../Common/View/AppKit/MouseOverView.swift | 2 +- .../View/AppKit/SteppedScrollView.swift | 56 + .../FileDownload/View/DownloadsCellView.swift | 6 +- .../BookmarksBarViewModelTests.swift | 1 + 36 files changed, 1963 insertions(+), 617 deletions(-) create mode 100644 DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift create mode 100644 DuckDuckGo/BookmarksBar/View/ItemCachingCollectionView.swift create mode 100644 DuckDuckGo/Common/View/AppKit/SteppedScrollView.swift diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 76cb4792fa..ca6f837c7b 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1659,6 +1659,12 @@ 7BFF35732C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; 7BFF35742C10D75000F89673 /* IPCServiceLauncher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */; }; 7BFF850F2B0C09DA00ECACA2 /* DuckDuckGo Personal Information Removal.app in Embed Login Items */ = {isa = PBXBuildFile; fileRef = 9D9AE8D12AAA39A70026E7DC /* DuckDuckGo Personal Information Removal.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 8400DC482C6E236B006509D2 /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC472C6E236B006509D2 /* BookmarkListPopover.swift */; }; + 8400DC492C6E236B006509D2 /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC472C6E236B006509D2 /* BookmarkListPopover.swift */; }; + 8400DC4B2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */; }; + 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */; }; + 8400DC4E2C6E2770006509D2 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */; }; + 8400DC4F2C6E2770006509D2 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */; }; 84DC715A2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */; }; 84DC715B2C1C1E9000033B8C /* UserDefaultsWrapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */; }; 85012B0229133F9F003D0DCC /* NavigationBarPopovers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */; }; @@ -3583,6 +3589,9 @@ 7BEC20412B0F505F00243D3E /* AddBookmarkFolderPopoverView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddBookmarkFolderPopoverView.swift; sourceTree = ""; }; 7BF1A9D72AE054D300FCA683 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 7BFF35722C10D75000F89673 /* IPCServiceLauncher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IPCServiceLauncher.swift; sourceTree = ""; }; + 8400DC472C6E236B006509D2 /* BookmarkListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListPopover.swift; sourceTree = ""; }; + 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ItemCachingCollectionView.swift; sourceTree = ""; }; + 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SteppedScrollView.swift; sourceTree = ""; }; 84DC71582C1C1E8A00033B8C /* UserDefaultsWrapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsWrapperTests.swift; sourceTree = ""; }; 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarPopovers.swift; sourceTree = ""; }; 850E8DFA2A6FEC5E00691187 /* BookmarksBarAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksBarAppearance.swift; sourceTree = ""; }; @@ -5975,13 +5984,14 @@ isa = PBXGroup; children = ( 859F30622A72A7A900C20372 /* Prompt */, - 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */, - 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */, 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */, 4BE5336A286912D40019DBFD /* BookmarksBarCollectionViewItem.swift */, 4BE53369286912D40019DBFD /* BookmarksBarCollectionViewItem.xib */, - 4BE5336D286915A10019DBFD /* HorizontallyCenteredLayout.swift */, 85774AFE2A713D3B00DE0561 /* BookmarksBarMenuFactory.swift */, + 4BD18EFF283F0BC500058124 /* BookmarksBarViewController.swift */, + 4BE41A5D28446EAD00760399 /* BookmarksBarViewModel.swift */, + 4BE5336D286915A10019DBFD /* HorizontallyCenteredLayout.swift */, + 8400DC4A2C6E26AE006509D2 /* ItemCachingCollectionView.swift */, ); path = View; sourceTree = ""; @@ -6372,6 +6382,7 @@ B6E3E5572BBFD51400A41922 /* PreviewViewController.swift */, B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */, B693954226F04BE90015B914 /* ShadowView.swift */, + 8400DC4D2C6E2770006509D2 /* SteppedScrollView.swift */, 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */, B693954526F04BEA0015B914 /* WindowDraggingView.swift */, ); @@ -7588,14 +7599,15 @@ AAC5E4C425D6A6E8007F5990 /* AddBookmarkPopover.swift */, 7BEC20402B0F505F00243D3E /* AddBookmarkPopoverView.swift */, B69A14F92B4D705D00B9417D /* BookmarkFolderPicker.swift */, + 8400DC472C6E236B006509D2 /* BookmarkListPopover.swift */, 4B9292CC2667123700AD2C21 /* BookmarkListViewController.swift */, 4B9292CD2667123700AD2C21 /* BookmarkManagementDetailViewController.swift */, 4B9292C72667123700AD2C21 /* BookmarkManagementSidebarViewController.swift */, 4B9292C82667123700AD2C21 /* BookmarkManagementSplitViewController.swift */, - 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */, 4B92928726670D1600AD2C21 /* BookmarkOutlineCellView.swift */, 4B92928526670D1600AD2C21 /* BookmarksOutlineView.swift */, 4B92928926670D1700AD2C21 /* BookmarkTableCellView.swift */, + 4B9292C92667123700AD2C21 /* BookmarkTableRowView.swift */, 4B9292C62667123700AD2C21 /* BrowserTabSelectionDelegate.swift */, 4B92928626670D1600AD2C21 /* OutlineSeparatorViewCell.swift */, 4B0511B3262CAA5A00F6079C /* RoundedSelectionRowView.swift */, @@ -7606,25 +7618,25 @@ AAC5E4C225D6A6C7007F5990 /* Model */ = { isa = PBXGroup; children = ( + AAC5E4CD25D6A709007F5990 /* Bookmark.swift */, + 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, + AAC5E4CF25D6A709007F5990 /* BookmarkList.swift */, 4B9292D82667124B00AD2C21 /* BookmarkListTreeControllerDataSource.swift */, + BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, 4B92929926670D2A00AD2C21 /* BookmarkManagedObject.swift */, + AAC5E4CE25D6A709007F5990 /* BookmarkManager.swift */, 4B92929326670D2A00AD2C21 /* BookmarkNode.swift */, 4B92929126670D2A00AD2C21 /* BookmarkOutlineViewDataSource.swift */, + 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, 4B92929426670D2A00AD2C21 /* BookmarkSidebarTreeController.swift */, + BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, + 4B92929726670D2A00AD2C21 /* BookmarkTreeController.swift */, 4B92929526670D2A00AD2C21 /* PasteboardBookmark.swift */, 4B92929226670D2A00AD2C21 /* PasteboardFolder.swift */, 4B92929A26670D2A00AD2C21 /* PasteboardWriting.swift */, 4B92929826670D2A00AD2C21 /* PseudoFolder.swift */, 4B92929626670D2A00AD2C21 /* SpacerNode.swift */, - 4B92929726670D2A00AD2C21 /* BookmarkTreeController.swift */, - AAC5E4CD25D6A709007F5990 /* Bookmark.swift */, - AAC5E4CF25D6A709007F5990 /* BookmarkList.swift */, - AAC5E4CE25D6A709007F5990 /* BookmarkManager.swift */, - 379E877529E98729001C8BB0 /* BookmarksCleanupErrorHandling.swift */, B6F9BDDB2B45B7EE00677B33 /* WebsiteInfo.swift */, - 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, - BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, - BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, ); path = Model; sourceTree = ""; @@ -10002,6 +10014,7 @@ 3707C722294B5D2900682A9F /* WKWebViewExtension.swift in Sources */, 3706FAD9293F65D500E42796 /* FirefoxFaviconsReader.swift in Sources */, 3706FADA293F65D500E42796 /* CopyHandler.swift in Sources */, + 8400DC492C6E236B006509D2 /* BookmarkListPopover.swift in Sources */, 3706FADB293F65D500E42796 /* ContentBlockingRulesUpdateObserver.swift in Sources */, 3706FADC293F65D500E42796 /* FirefoxLoginReader.swift in Sources */, 3706FADD293F65D500E42796 /* AtbParser.swift in Sources */, @@ -10599,6 +10612,7 @@ B6DE57F72B05EA9000CD54B9 /* SheetHostingWindow.swift in Sources */, 3706FEB9293F6EFF00E42796 /* BWVault.swift in Sources */, B6B5F5852B03580A008DB58A /* RequestFilePermissionView.swift in Sources */, + 8400DC4F2C6E2770006509D2 /* SteppedScrollView.swift in Sources */, 3706FC60293F65D500E42796 /* TabBarCollectionView.swift in Sources */, 3706FC61293F65D500E42796 /* NSAlertExtension.swift in Sources */, 3706FC62293F65D500E42796 /* ThirdPartyBrowser.swift in Sources */, @@ -10698,6 +10712,7 @@ 372A0FED2B2379310033BF7F /* SyncMetricsEventsHandler.swift in Sources */, 1D0DE93F2C3BA9840037ABC2 /* AppRestarter.swift in Sources */, 3706FCA4293F65D500E42796 /* RecentlyClosedMenu.swift in Sources */, + 8400DC4C2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, 4B9DB02D2A983B24000927DB /* WaitlistKeychainStorage.swift in Sources */, EECE10E629DD77E60044D027 /* FeatureFlag.swift in Sources */, ); @@ -11764,6 +11779,7 @@ 3158B14D2B0BF74D00AF130C /* DataBrokerProtectionManager.swift in Sources */, BBFB727F2C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, 4BA1A6A5258B07DF00F6F690 /* EncryptedValueTransformer.swift in Sources */, + 8400DC4B2C6E26AE006509D2 /* ItemCachingCollectionView.swift in Sources */, B634DBE1293C8FD500C3C99E /* Tab+Dialogs.swift in Sources */, 4B92929F26670D2A00AD2C21 /* PasteboardBookmark.swift in Sources */, 37BF3F14286D8A6500BD9014 /* PinnedTabsManager.swift in Sources */, @@ -11803,6 +11819,7 @@ 1D9A37672BD8EA8800EBC58D /* DockPositionProvider.swift in Sources */, 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, F188267C2BBEB3AA00D9AC4F /* GeneralPixel.swift in Sources */, + 8400DC482C6E236B006509D2 /* BookmarkListPopover.swift in Sources */, 4B723E1026B0006700E14D75 /* CSVImporter.swift in Sources */, 37A4CEBA282E992F00D75B89 /* StartupPreferences.swift in Sources */, 4B41EDB12B168B1E001EEDF4 /* VPNFeedbackFormView.swift in Sources */, @@ -11847,6 +11864,7 @@ F118EA852BEACC7000F77634 /* NonStandardPixel.swift in Sources */, 377D8D642C105B8B001F5F6B /* NSAccessibilityProtocolExtension.swift in Sources */, 1DB9618329F67F6200CF5568 /* FaviconNullStore.swift in Sources */, + 8400DC4E2C6E2770006509D2 /* SteppedScrollView.swift in Sources */, BB5789722B2CA70F0009DFE2 /* DataBrokerProtectionSubscriptionEventHandler.swift in Sources */, B693954F26F04BEB0015B914 /* PaddedImageButton.swift in Sources */, 4BA1A6B8258B081600F6F690 /* EncryptionKeyStoring.swift in Sources */, diff --git a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme index eb7e5e26bb..41730d7069 100644 --- a/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme +++ b/DuckDuckGo.xcodeproj/xcshareddata/xcschemes/sandbox-test-tool.xcscheme @@ -1,7 +1,7 @@ + version = "1.7"> Int? { + let row = row(forItem: item) + guard row >= 0, row != NSNotFound else { return nil } + return row + } + func revealAndSelect(nodePath: BookmarkNode.Path) { let totalNodePathComponents = nodePath.components.count if totalNodePathComponents < 2 { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift index 8ef9a2ac86..7dd9647a61 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift @@ -39,7 +39,7 @@ final class BookmarkNode: Hashable { var childNodes = [BookmarkNode]() var isRoot: Bool { - return parent == nil + return representedObject is RootNode } var numberOfChildNodes: Int { @@ -79,6 +79,14 @@ final class BookmarkNode: Hashable { self.uniqueID = uniqueId } + var canBeHighlighted: Bool { + if representedObject is SpacerNode { + return false + } else { + return true + } + } + /// Creates an instance of a bookmark node. /// - Parameters: /// - representedObject: The represented object contained in the node. diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift index 09fa347374..08b48132ad 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift @@ -25,11 +25,19 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS enum ContentMode { case bookmarksAndFolders case foldersOnly + case bookmarksMenu + + var isSeparatorVisible: Bool { + switch self { + case .bookmarksAndFolders, .bookmarksMenu: true + case .foldersOnly: false + } + } } @Published var selectedFolders: [BookmarkFolder] = [] - let treeController: BookmarkTreeController + private var outlineView: NSOutlineView? private let contentMode: ContentMode private(set) var expandedNodesIDs = Set() @@ -39,14 +47,12 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS /// so we can expand the tree to the destination folder once the drop finishes. private(set) var dragDestinationFolderInSearchMode: BookmarkFolder? + private let treeController: BookmarkTreeController private let bookmarkManager: BookmarkManager private let showMenuButtonOnHover: Bool private let onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? private let presentFaviconsFetcherOnboarding: (() -> Void)? - private var favoritesPseudoFolder = PseudoFolder.favorites - private var bookmarksPseudoFolder = PseudoFolder.bookmarks - init( contentMode: ContentMode, bookmarkManager: BookmarkManager, @@ -64,28 +70,19 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS self.presentFaviconsFetcherOnboarding = presentFaviconsFetcherOnboarding super.init() - - reloadData(with: sortMode) } - func reloadData(with sortMode: BookmarksSortMode) { + func reloadData(with sortMode: BookmarksSortMode, withRootFolder rootFolder: BookmarkFolder? = nil) { isSearching = false dragDestinationFolderInSearchMode = nil - setFolderCount() - treeController.rebuild(for: sortMode) + treeController.rebuild(for: sortMode, withRootFolder: rootFolder) } - func reloadData(for searchQuery: String, and sortMode: BookmarksSortMode) { + func reloadData(for searchQuery: String, sortMode: BookmarksSortMode) { isSearching = true - setFolderCount() treeController.rebuild(for: searchQuery, sortMode: sortMode) } - private func setFolderCount() { - favoritesPseudoFolder.count = bookmarkManager.list?.favoriteBookmarks.count ?? 0 - bookmarksPseudoFolder.count = bookmarkManager.list?.totalBookmarks ?? 0 - } - // MARK: - Private private func id(from notification: Notification) -> String? { @@ -113,6 +110,9 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if self.outlineView == nil { + self.outlineView = outlineView + } return nodeForItem(item).numberOfChildNodes } @@ -121,7 +121,8 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS } func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { - return nodeForItem(item).canHaveChildNodes + // don‘t display disclosure indicator for “empty” nodes when no indentation level + return contentMode == .bookmarksMenu ? false : nodeForItem(item).canHaveChildNodes } func outlineViewSelectionDidChange(_ notification: Notification) { @@ -146,38 +147,38 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS assertionFailure("\(#file): Failed to cast item to Node") return nil } + if node.representedObject is SpacerNode { + return outlineView.makeView(withIdentifier: contentMode.isSeparatorVisible + ? OutlineSeparatorViewCell.separatorIdentifier + : OutlineSeparatorViewCell.blankIdentifier, owner: self) as? OutlineSeparatorViewCell + ?? OutlineSeparatorViewCell(isSeparatorVisible: contentMode.isSeparatorVisible) + } + let cell = outlineView.makeView(withIdentifier: .init(BookmarkOutlineCellView.className()), owner: self) as? BookmarkOutlineCellView ?? BookmarkOutlineCellView(identifier: .init(BookmarkOutlineCellView.className())) cell.shouldShowMenuButton = showMenuButtonOnHover cell.delegate = self + cell.update(from: node, isSearch: isSearching, isMenuPopover: contentMode == .bookmarksMenu) - if let bookmark = node.representedObject as? Bookmark { - cell.update(from: bookmark, isSearch: isSearching) - - if bookmark.favicon(.small) == nil { - presentFaviconsFetcherOnboarding?() - } - return cell + if let bookmark = node.representedObject as? Bookmark, bookmark.favicon(.small) == nil { + presentFaviconsFetcherOnboarding?() } - if let folder = node.representedObject as? BookmarkFolder { - cell.update(from: folder, isSearch: isSearching) - return cell - } + return cell + } - if let folder = node.representedObject as? PseudoFolder { - if folder == .bookmarks { - cell.update(from: bookmarksPseudoFolder) - } else if folder == .favorites { - cell.update(from: favoritesPseudoFolder) - } else { - assertionFailure("\(#file): Tried to update PseudoFolder cell with invalid type") - } + func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { + let view = RoundedSelectionRowView() + view.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - return cell - } + return view + } - return OutlineSeparatorViewCell(separatorVisible: contentMode == .bookmarksAndFolders) + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + if let node = item as? BookmarkNode, node.representedObject is SpacerNode { + return OutlineSeparatorViewCell.rowHeight(for: contentMode == .bookmarksMenu ? .bookmarkBarMenu : .popover) + } + return BookmarkOutlineCellView.rowHeight } func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? { @@ -185,10 +186,7 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS return entity.pasteboardWriter } - func outlineView(_ outlineView: NSOutlineView, - validateDrop info: NSDraggingInfo, - proposedItem item: Any?, - proposedChildIndex index: Int) -> NSDragOperation { + func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation { let destinationNode = nodeForItem(item) if contentMode == .foldersOnly { @@ -347,13 +345,6 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS return true } - func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { - let view = RoundedSelectionRowView() - view.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - - return view - } - // MARK: - NSTableViewDelegate func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift index 9735a5d677..fc484a2472 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift @@ -34,6 +34,7 @@ final class BookmarkSidebarTreeController: BookmarkTreeControllerDataSource { private func childNodesForRootNode(_ node: BookmarkNode) -> [BookmarkNode] { let bookmarks = PseudoFolder.bookmarks + bookmarks.count = bookmarkManager.list?.totalBookmarks ?? 0 let bookmarksNode = BookmarkNode(representedObject: bookmarks, parent: node) bookmarksNode.canHaveChildNodes = true diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift index ecf145ba66..2b03ed2608 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift @@ -30,7 +30,11 @@ protocol BookmarkTreeControllerSearchDataSource: AnyObject { final class BookmarkTreeController { - let rootNode: BookmarkNode + static let openAllInNewTabsIdentifier = "openAllInNewTabs" + static let emptyPlaceholderIdentifier = "empty" + + private(set) var rootNode: BookmarkNode + private let isBookmarksBarMenu: Bool private weak var dataSource: BookmarkTreeControllerDataSource? private weak var searchDataSource: BookmarkTreeControllerSearchDataSource? @@ -38,18 +42,35 @@ final class BookmarkTreeController { init(dataSource: BookmarkTreeControllerDataSource, sortMode: BookmarksSortMode, searchDataSource: BookmarkTreeControllerSearchDataSource? = nil, - rootNode: BookmarkNode) { + rootNode: BookmarkNode? = nil, + isBookmarksBarMenu: Bool = false) { self.dataSource = dataSource self.searchDataSource = searchDataSource - self.rootNode = rootNode + self.rootNode = rootNode ?? BookmarkNode.genericRootNode() + self.isBookmarksBarMenu = isBookmarksBarMenu rebuild(for: sortMode) } convenience init(dataSource: BookmarkTreeControllerDataSource, sortMode: BookmarksSortMode, - searchDataSource: BookmarkTreeControllerSearchDataSource? = nil) { - self.init(dataSource: dataSource, sortMode: sortMode, searchDataSource: searchDataSource, rootNode: BookmarkNode.genericRootNode()) + searchDataSource: BookmarkTreeControllerSearchDataSource? = nil, + rootFolder: BookmarkFolder?, + isBookmarksBarMenu: Bool) { + self.init(dataSource: dataSource, sortMode: sortMode, searchDataSource: searchDataSource, rootNode: rootFolder.map(Self.rootNode(from:)), isBookmarksBarMenu: isBookmarksBarMenu) + } + + private static func rootNode(from rootFolder: BookmarkFolder) -> BookmarkNode { + let genericRootNode = BookmarkNode.genericRootNode() + let bookmarksNode = BookmarkNode(representedObject: rootFolder, parent: genericRootNode) + bookmarksNode.canHaveChildNodes = true + return bookmarksNode + } + + private static func separatorNode(for parentNode: BookmarkNode) -> BookmarkNode { + let spacerObject = SpacerNode.divider + let node = BookmarkNode(representedObject: spacerObject, parent: parentNode) + return node } // MARK: - Public @@ -58,7 +79,10 @@ final class BookmarkTreeController { rootNode.childNodes = searchDataSource?.nodes(for: searchQuery, sortMode: sortMode) ?? [] } - func rebuild(for sortMode: BookmarksSortMode) { + func rebuild(for sortMode: BookmarksSortMode, withRootFolder rootFolder: BookmarkFolder? = nil) { + if let rootFolder { + self.rootNode = Self.rootNode(from: rootFolder) + } rebuildChildNodes(node: rootNode, sortMode: sortMode) } @@ -112,12 +136,16 @@ final class BookmarkTreeController { node.childNodes = childNodes } - childNodes.forEach { childNode in - if rebuildChildNodes(node: childNode, sortMode: sortMode) { - childNodesDidChange = true + if isBookmarksBarMenu { + } else { + childNodes.forEach { childNode in + if rebuildChildNodes(node: childNode, sortMode: sortMode) { + childNodesDidChange = true + } } } return childNodesDidChange } + } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift index 8c8f4a6d06..8fc3cc0aae 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStore.swift @@ -25,7 +25,7 @@ enum BookmarkStoreFetchPredicateType { case favorites } -enum ParentFolderType { +enum ParentFolderType: Equatable { case root case parent(uuid: String) } diff --git a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift index 003ba7bb59..f6096beacd 100644 --- a/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift +++ b/DuckDuckGo/Bookmarks/Services/BookmarkStoreMock.swift @@ -175,17 +175,4 @@ public final class BookmarkStoreMock: BookmarkStore { func handleFavoritesAfterDisablingSync() {} } -extension ParentFolderType: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.root, .root): - return true - case (.parent(let lhsValue), .parent(let rhsValue)): - return lhsValue == rhsValue - default: - return false - } - } -} - #endif diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift b/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift new file mode 100644 index 0000000000..2f3962254b --- /dev/null +++ b/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift @@ -0,0 +1,142 @@ +// +// BookmarkListPopover.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Foundation + +final class BookmarkListPopover: NSPopover { + + private let mode: BookmarkListViewController.Mode + private let bookmarkManager: BookmarkManager + private(set) var rootFolder: BookmarkFolder? + + private(set) var preferredEdge: NSRectEdge? + private(set) weak var positioningView: NSView? + + static let popoverInsets = NSEdgeInsets(top: 13, left: 13, bottom: 13, right: 13) + + init(mode: BookmarkListViewController.Mode = .popover, bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, rootFolder: BookmarkFolder? = nil) { + self.mode = mode + self.bookmarkManager = bookmarkManager + self.rootFolder = rootFolder + + super.init() + + if mode == .bookmarkBarMenu { + self.shouldHideAnchor = true + } + self.animates = false + self.behavior = .transient + + setupContentController() + } + + required init?(coder: NSCoder) { + fatalError("BookmarkListPopover: Bad initializer") + } + + // swiftlint:disable:next force_cast + var viewController: BookmarkListViewController { contentViewController as! BookmarkListViewController } + + private func setupContentController() { + let controller = BookmarkListViewController(mode: mode, bookmarkManager: bookmarkManager, rootFolder: rootFolder) + controller.delegate = self + contentViewController = controller + } + + func reloadData(withRootFolder rootFolder: BookmarkFolder) { + self.rootFolder = rootFolder + viewController.reloadData(withRootFolder: rootFolder) + } + + override func show(relativeTo positioningRect: NSRect, of positioningView: NSView, preferredEdge: NSRectEdge) { + Self.closeBookmarkListPopovers(shownIn: mainWindow, except: self) + + var positioningView = positioningView + var positioningRect = positioningRect + // add temporary view to bookmarks menu table to prevent popover jumping on table reloading + // showing the popover against coordinates in the table view breaks popover positioning + // the view will be removed in `close()` + if positioningView is NSTableCellView, + let tableView = positioningView.superview?.superview as? NSTableView { + let v = NSView(frame: positioningView.convert(positioningRect, to: tableView)) + positioningRect = v.bounds + positioningView = v + tableView.addSubview(v) + } + + self.positioningView = positioningView + self.preferredEdge = preferredEdge + viewController.adjustPreferredContentSize(positionedRelativeTo: positioningRect, of: positioningView, at: preferredEdge) + super.show(relativeTo: positioningRect, of: positioningView, preferredEdge: preferredEdge) + } + + /// Adjust bookmarks bar menu popover frame + @objc override func adjustFrame(_ frame: NSRect) -> NSRect { + guard case .bookmarkBarMenu = mode else { return super.adjustFrame(frame) } + guard let positioningView, let mainWindow, let screenFrame = mainWindow.screen?.visibleFrame else { return frame } + let offset = viewController.preferredContentOffset + var frame = frame + + let windowPoint = positioningView.convert(NSPoint(x: offset.x, y: (positioningView.isFlipped ? positioningView.bounds.minY : positioningView.bounds.maxY) + offset.y), to: nil) + let screenPoint = mainWindow.convertPoint(toScreen: windowPoint) + + if case .maxX = preferredEdge { // submenu + // adjust the menu popover Y 16pt above the positioning view bottom edge + frame.origin.y = min(max(screenFrame.minY, screenPoint.y - frame.size.height + 36), screenFrame.maxY) + + } else { // context menu + // align the menu popover content by the left edge of the positioning view but keeping the popover frame inside the screen bounds + frame.origin.x = min(max(screenFrame.minX, screenPoint.x - Self.popoverInsets.left), screenFrame.maxX - frame.width) + // aling the menu popover content top edge by the bottom edge of the positioning view but keeping the popover frame inside the screen bounds + frame.origin.y = min(max(screenFrame.minY, screenPoint.y - frame.size.height - Self.popoverInsets.top), screenFrame.maxY) + } + return frame + } + + /// close other BookmarkListPopover-s shown from the main window when opening a new one + static func closeBookmarkListPopovers(shownIn window: NSWindow?, except popoverToKeep: BookmarkListPopover? = nil) { + guard let window, + // ignore when opening a submenu from another BookmarkListPopover + !(window.contentViewController?.nextResponder is Self) else { return } + for case let .some(popover as Self) in (window.childWindows ?? []).map(\.contentViewController?.nextResponder) where popover !== popoverToKeep && popover.isShown { + popover.close() + } + } + + override func close() { + // remove temporary positioning view from bookmarks menu table + if let positioningView, positioningView.superview is NSTableView { + positioningView.removeFromSuperview() + } + super.close() + } + +} + +extension BookmarkListPopover: BookmarkListViewControllerDelegate { + + func popoverShouldClose(_ bookmarkListViewController: BookmarkListViewController) { + close() + } + + func popover(shouldPreventClosure: Bool) { + behavior = shouldPreventClosure ? .applicationDefined : .transient + } + +} diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift index 43a1450afb..419761de6f 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -27,49 +27,80 @@ protocol BookmarkListViewControllerDelegate: AnyObject { } final class BookmarkListViewController: NSViewController { - static let preferredContentSize = CGSize(width: 420, height: 500) + + enum Mode { case popover, bookmarkBarMenu } + let mode: Mode + + fileprivate enum Constants { + static let preferredContentSize = CGSize(width: 420, height: 500) + static let noContentMenuSize = CGSize(width: 8, height: 40) + static let maxMenuPopoverContentWidth: CGFloat = 500 - 13 * 2 + static let minVisibleRows = 4 + } weak var delegate: BookmarkListViewControllerDelegate? var currentTabWebsite: WebsiteInfo? - private lazy var titleTextField = NSTextField(string: UserText.bookmarks) + private var titleTextField: NSTextField? + + private var newBookmarkButton: MouseOverButton? + private var newFolderButton: MouseOverButton? + private var manageBookmarksButton: MouseOverButton? + private var searchBookmarksButton: MouseOverButton? + private var sortBookmarksButton: MouseOverButton? - private lazy var stackView = NSStackView() - private lazy var newBookmarkButton = MouseOverButton(image: .addBookmark, target: self, action: #selector(newBookmarkButtonClicked)) - private lazy var newFolderButton = MouseOverButton(image: .addFolder, target: self, action: #selector(newFolderButtonClicked)) - private lazy var searchBookmarksButton = MouseOverButton(image: .searchBookmarks, target: self, action: #selector(searchBookmarkButtonClicked)) - private lazy var sortBookmarksButton = MouseOverButton(image: .bookmarkSortAsc, target: self, action: #selector(sortBookmarksButtonClicked)) - private var isSearchVisible = false + private var boxDivider: NSBox? + private var boxDividerTopConstraint: NSLayoutConstraint? - private lazy var buttonsDivider = NSBox() - private lazy var manageBookmarksButton = MouseOverButton(title: UserText.bookmarksManage, target: self, action: #selector(openManagementInterface)) - private lazy var boxDivider = NSBox() + private lazy var searchBar = { + let searchBar = NSSearchField() + searchBar.translatesAutoresizingMaskIntoConstraints = false + searchBar.delegate = self + return searchBar + }() - private lazy var scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 420, height: 408)) + private lazy var scrollView = SteppedScrollView(frame: NSRect(x: 0, y: 0, width: 420, height: 408), + stepSize: BookmarkOutlineCellView.rowHeight) private lazy var outlineView = BookmarksOutlineView(frame: scrollView.frame) - private lazy var emptyState = NSView() - private lazy var emptyStateTitle = NSTextField() - private lazy var emptyStateMessage = NSTextField() - private lazy var emptyStateImageView = NSImageView(image: .bookmarksEmpty) - private lazy var importButton = NSButton(title: UserText.importBookmarksButtonTitle, target: self, action: #selector(onImportClicked)) - private lazy var searchBar = NSSearchField() - private var boxDividerTopConstraint = NSLayoutConstraint() + private var scrollDownButton: MouseOverButton? + private var scrollUpButton: MouseOverButton? + + private var emptyState: NSView? + private var emptyStateTitle: NSTextField? + private var emptyStateMessage: NSTextField? + private var emptyStateImageView: NSImageView? + private var importButton: NSButton? - private var cancellables = Set() private let bookmarkManager: BookmarkManager private let treeControllerDataSource: BookmarkListTreeControllerDataSource private let treeControllerSearchDataSource: BookmarkListTreeControllerSearchDataSource private let sortBookmarksViewModel: SortBookmarksViewModel private let bookmarkMetrics: BookmarksSearchAndSortMetrics - private lazy var treeController = BookmarkTreeController(dataSource: treeControllerDataSource, - sortMode: sortBookmarksViewModel.selectedSortMode, - searchDataSource: treeControllerSearchDataSource) + private let treeController: BookmarkTreeController + + private var bookmarkListPopover: BookmarkListPopover? + private(set) var preferredContentOffset: CGPoint = .zero + + private var isSearchVisible = false { + didSet { + switch (oldValue, isSearchVisible) { + case (false, true): + showSearchBar() + case (true, false): + hideSearchBar() + showTreeView() + default: break + } + } + } + + private var cancellables = Set() private lazy var dataSource: BookmarkOutlineViewDataSource = { BookmarkOutlineViewDataSource( - contentMode: .bookmarksAndFolders, + contentMode: mode == .bookmarkBarMenu ? .bookmarksMenu : .bookmarksAndFolders, bookmarkManager: bookmarkManager, treeController: treeController, sortMode: sortBookmarksViewModel.selectedSortMode, @@ -100,118 +131,173 @@ final class BookmarkListViewController: NSViewController { return .init(syncService: syncService, syncBookmarksAdapter: syncBookmarksAdapter) }() - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + init(mode: Mode = .popover, + bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + rootFolder: BookmarkFolder? = nil, metrics: BookmarksSearchAndSortMetrics = BookmarksSearchAndSortMetrics()) { + + self.mode = mode self.bookmarkManager = bookmarkManager - self.treeControllerDataSource = BookmarkListTreeControllerDataSource(bookmarkManager: bookmarkManager) - self.treeControllerSearchDataSource = BookmarkListTreeControllerSearchDataSource(bookmarkManager: bookmarkManager) self.bookmarkMetrics = metrics self.sortBookmarksViewModel = SortBookmarksViewModel(manager: bookmarkManager, metrics: metrics, origin: .panel) + self.treeControllerDataSource = BookmarkListTreeControllerDataSource(bookmarkManager: bookmarkManager) + self.treeControllerSearchDataSource = BookmarkListTreeControllerSearchDataSource(bookmarkManager: bookmarkManager) + self.treeController = BookmarkTreeController(dataSource: treeControllerDataSource, + sortMode: sortBookmarksViewModel.selectedSortMode, + searchDataSource: treeControllerSearchDataSource, + rootFolder: rootFolder, + isBookmarksBarMenu: mode == .bookmarkBarMenu) + super.init(nibName: nil, bundle: nil) + self.representedObject = rootFolder } required init?(coder: NSCoder) { fatalError("\(type(of: self)): Bad initializer") } + // MARK: View Lifecycle override func loadView() { - view = ColorView(frame: .zero, backgroundColor: .popoverBackground) + view = NSView() + view.autoresizesSubviews = false - view.addSubview(titleTextField) - view.addSubview(boxDivider) - view.addSubview(stackView) - view.addSubview(scrollView) - view.addSubview(emptyState) + titleTextField = (mode == .bookmarkBarMenu) ? nil : { + let titleTextField = NSTextField(string: UserText.bookmarks) - view.autoresizesSubviews = false + titleTextField.isEditable = false + titleTextField.isBordered = false + titleTextField.drawsBackground = false + titleTextField.translatesAutoresizingMaskIntoConstraints = false + titleTextField.font = .systemFont(ofSize: 17) + titleTextField.textColor = .labelColor + titleTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) + titleTextField.setContentHuggingPriority(.init(251), for: .horizontal) + + return titleTextField + }() - titleTextField.isEditable = false - titleTextField.isBordered = false - titleTextField.drawsBackground = false - titleTextField.translatesAutoresizingMaskIntoConstraints = false - titleTextField.font = .systemFont(ofSize: 17) - titleTextField.textColor = .labelColor - - boxDivider.boxType = .separator - boxDivider.setContentHuggingPriority(.defaultHigh, for: .vertical) - boxDivider.translatesAutoresizingMaskIntoConstraints = false - - stackView.orientation = .horizontal - stackView.spacing = 4 - stackView.setHuggingPriority(.defaultHigh, for: .horizontal) - stackView.setHuggingPriority(.defaultHigh, for: .vertical) - stackView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(newBookmarkButton) - stackView.addArrangedSubview(newFolderButton) - stackView.addArrangedSubview(sortBookmarksButton) - stackView.addArrangedSubview(searchBookmarksButton) - stackView.addArrangedSubview(buttonsDivider) - stackView.addArrangedSubview(manageBookmarksButton) - - newBookmarkButton.bezelStyle = .shadowlessSquare - newBookmarkButton.cornerRadius = 4 - newBookmarkButton.normalTintColor = .button - newBookmarkButton.mouseDownColor = .buttonMouseDown - newBookmarkButton.mouseOverColor = .buttonMouseOver - newBookmarkButton.translatesAutoresizingMaskIntoConstraints = false - newBookmarkButton.toolTip = UserText.newBookmarkTooltip - - newFolderButton.bezelStyle = .shadowlessSquare - newFolderButton.cornerRadius = 4 - newFolderButton.normalTintColor = .button - newFolderButton.mouseDownColor = .buttonMouseDown - newFolderButton.mouseOverColor = .buttonMouseOver - newFolderButton.translatesAutoresizingMaskIntoConstraints = false - newFolderButton.toolTip = UserText.newFolderTooltip - - searchBookmarksButton.bezelStyle = .shadowlessSquare - searchBookmarksButton.cornerRadius = 4 - searchBookmarksButton.normalTintColor = .button - searchBookmarksButton.mouseDownColor = .buttonMouseDown - searchBookmarksButton.mouseOverColor = .buttonMouseOver - searchBookmarksButton.translatesAutoresizingMaskIntoConstraints = false - searchBookmarksButton.toolTip = UserText.bookmarksSearch - - sortBookmarksButton.bezelStyle = .shadowlessSquare - sortBookmarksButton.cornerRadius = 4 - sortBookmarksButton.normalTintColor = .button - sortBookmarksButton.mouseDownColor = .buttonMouseDown - sortBookmarksButton.mouseOverColor = .buttonMouseOver - sortBookmarksButton.translatesAutoresizingMaskIntoConstraints = false - sortBookmarksButton.toolTip = UserText.bookmarksSort + boxDivider = (mode == .bookmarkBarMenu) ? nil : { + let boxDivider = NSBox() + boxDivider.boxType = .separator + boxDivider.setContentHuggingPriority(.defaultHigh, for: .vertical) + boxDivider.translatesAutoresizingMaskIntoConstraints = false + return boxDivider + }() - searchBar.translatesAutoresizingMaskIntoConstraints = false - searchBar.delegate = self + newBookmarkButton = (mode == .bookmarkBarMenu) ? nil : { + let newBookmarkButton = MouseOverButton(image: .addBookmark, target: self, + action: #selector(newBookmarkButtonClicked)) + newBookmarkButton.bezelStyle = .shadowlessSquare + newBookmarkButton.cornerRadius = 4 + newBookmarkButton.normalTintColor = .button + newBookmarkButton.mouseDownColor = .buttonMouseDown + newBookmarkButton.mouseOverColor = .buttonMouseOver + newBookmarkButton.translatesAutoresizingMaskIntoConstraints = false + newBookmarkButton.toolTip = UserText.newBookmarkTooltip + return newBookmarkButton + }() - buttonsDivider.boxType = .separator - buttonsDivider.setContentHuggingPriority(.defaultHigh, for: .horizontal) - buttonsDivider.translatesAutoresizingMaskIntoConstraints = false - - manageBookmarksButton.bezelStyle = .shadowlessSquare - manageBookmarksButton.cornerRadius = 4 - manageBookmarksButton.normalTintColor = .button - manageBookmarksButton.mouseDownColor = .buttonMouseDown - manageBookmarksButton.mouseOverColor = .buttonMouseOver - manageBookmarksButton.translatesAutoresizingMaskIntoConstraints = false - manageBookmarksButton.font = .systemFont(ofSize: 12) - manageBookmarksButton.toolTip = UserText.manageBookmarksTooltip - manageBookmarksButton.image = { - let image = NSImage.externalAppScheme - image.alignmentRect = NSRect(x: 0, y: 0, width: image.size.width + 6, height: image.size.height) - return image + newFolderButton = (mode == .bookmarkBarMenu) ? nil : { + let newFolderButton = MouseOverButton(image: .addFolder, target: self, + action: #selector(newFolderButtonClicked)) + newFolderButton.bezelStyle = .shadowlessSquare + newFolderButton.cornerRadius = 4 + newFolderButton.normalTintColor = .button + newFolderButton.mouseDownColor = .buttonMouseDown + newFolderButton.mouseOverColor = .buttonMouseOver + newFolderButton.translatesAutoresizingMaskIntoConstraints = false + newFolderButton.toolTip = UserText.newFolderTooltip + return newFolderButton + }() + + searchBookmarksButton = (mode == .bookmarkBarMenu) ? nil : { + let searchBookmarksButton = MouseOverButton(image: .searchBookmarks, target: self, + action: #selector(searchBookmarksButtonClicked)) + searchBookmarksButton.bezelStyle = .shadowlessSquare + searchBookmarksButton.cornerRadius = 4 + searchBookmarksButton.normalTintColor = .button + searchBookmarksButton.mouseDownColor = .buttonMouseDown + searchBookmarksButton.mouseOverColor = .buttonMouseOver + searchBookmarksButton.translatesAutoresizingMaskIntoConstraints = false + searchBookmarksButton.toolTip = UserText.bookmarksSearch + return searchBookmarksButton + }() + + sortBookmarksButton = (mode == .bookmarkBarMenu) ? nil : { + let sortBookmarksButton = MouseOverButton(image: .bookmarkSortAsc, target: self, action: #selector(sortBookmarksButtonClicked)) + sortBookmarksButton.bezelStyle = .shadowlessSquare + sortBookmarksButton.cornerRadius = 4 + sortBookmarksButton.normalTintColor = .button + sortBookmarksButton.mouseDownColor = .buttonMouseDown + sortBookmarksButton.mouseOverColor = .buttonMouseOver + sortBookmarksButton.translatesAutoresizingMaskIntoConstraints = false + sortBookmarksButton.toolTip = UserText.bookmarksSort + return sortBookmarksButton + }() + + let buttonsDivider = (mode == .bookmarkBarMenu) ? nil : { + let buttonsDivider = NSBox() + buttonsDivider.boxType = .separator + buttonsDivider.setContentHuggingPriority(.defaultHigh, for: .horizontal) + buttonsDivider.translatesAutoresizingMaskIntoConstraints = false + return buttonsDivider + }() + + manageBookmarksButton = (mode == .bookmarkBarMenu) ? nil : { + let manageBookmarksButton = MouseOverButton(title: UserText.bookmarksManage, target: self, + action: #selector(openManagementInterface)) + manageBookmarksButton.bezelStyle = .shadowlessSquare + manageBookmarksButton.cornerRadius = 4 + manageBookmarksButton.normalTintColor = .button + manageBookmarksButton.mouseDownColor = .buttonMouseDown + manageBookmarksButton.mouseOverColor = .buttonMouseOver + manageBookmarksButton.translatesAutoresizingMaskIntoConstraints = false + manageBookmarksButton.font = .systemFont(ofSize: 12) + manageBookmarksButton.toolTip = UserText.manageBookmarksTooltip + manageBookmarksButton.image = { + let image = NSImage.externalAppScheme + image.alignmentRect = NSRect(x: 0, y: 0, width: image.size.width + 6, height: image.size.height) + return image + }() + manageBookmarksButton.imagePosition = .imageLeading + return manageBookmarksButton + }() + + let stackView = (mode == .bookmarkBarMenu) ? nil : { + let stackView = NSStackView() + stackView.orientation = .horizontal + stackView.spacing = 4 + stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + stackView.setHuggingPriority(.defaultHigh, for: .vertical) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.addArrangedSubview(newBookmarkButton) + stackView.addArrangedSubview(newFolderButton) + stackView.addArrangedSubview(sortBookmarksButton) + stackView.addArrangedSubview(searchBookmarksButton) + stackView.addArrangedSubview(buttonsDivider) + stackView.addArrangedSubview(manageBookmarksButton) + return stackView }() - manageBookmarksButton.imagePosition = .imageLeading - manageBookmarksButton.imageHugsTitle = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = false scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.drawsBackground = false scrollView.hasHorizontalScroller = false scrollView.usesPredominantAxisScrolling = false - scrollView.autohidesScrollers = true scrollView.automaticallyAdjustsContentInsets = false - scrollView.scrollerInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) - scrollView.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 12) + if mode == .popover { + scrollView.borderType = .noBorder + scrollView.autohidesScrollers = true + scrollView.scrollerInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) + scrollView.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + } else { + scrollView.borderType = .noBorder + scrollView.scrollerInsets = NSEdgeInsetsZero + scrollView.contentInsets = NSEdgeInsetsZero + scrollView.hasVerticalScroller = false + scrollView.scrollerStyle = .overlay + scrollView.verticalScrollElasticity = .none + scrollView.horizontalScrollElasticity = .none + } let column = NSTableColumn() column.width = scrollView.frame.width - 32 @@ -224,15 +310,18 @@ final class BookmarkListViewController: NSViewController { outlineView.allowsExpansionToolTips = true outlineView.allowsMultipleSelection = false outlineView.backgroundColor = .clear - outlineView.indentationPerLevel = 13 - outlineView.rowHeight = 24 - outlineView.usesAutomaticRowHeights = true + outlineView.usesAutomaticRowHeights = false outlineView.target = self outlineView.action = #selector(handleClick) outlineView.menu = NSMenu() outlineView.menu!.delegate = self outlineView.dataSource = dataSource outlineView.delegate = dataSource + if mode == .popover { + outlineView.indentationPerLevel = 13 + } else { + outlineView.indentationPerLevel = 0 + } let clipView = NSClipView(frame: scrollView.frame) clipView.translatesAutoresizingMaskIntoConstraints = true @@ -241,154 +330,377 @@ final class BookmarkListViewController: NSViewController { clipView.drawsBackground = false scrollView.contentView = clipView - emptyState.addSubview(emptyStateImageView) - emptyState.addSubview(emptyStateTitle) - emptyState.addSubview(emptyStateMessage) - emptyState.addSubview(importButton) - - emptyState.isHidden = true - emptyState.translatesAutoresizingMaskIntoConstraints = false - - emptyStateTitle.translatesAutoresizingMaskIntoConstraints = false - emptyStateTitle.alignment = .center - emptyStateTitle.drawsBackground = false - emptyStateTitle.isBordered = false - emptyStateTitle.isEditable = false - emptyStateTitle.font = .systemFont(ofSize: 15, weight: .semibold) - emptyStateTitle.textColor = .labelColor - emptyStateTitle.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateTitle, - lineHeight: 1.14, - kern: -0.23) - - emptyStateMessage.translatesAutoresizingMaskIntoConstraints = false - emptyStateMessage.alignment = .center - emptyStateMessage.drawsBackground = false - emptyStateMessage.isBordered = false - emptyStateMessage.isEditable = false - emptyStateMessage.font = .systemFont(ofSize: 13) - emptyStateMessage.textColor = .labelColor - emptyStateMessage.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateMessage, - lineHeight: 1.05, - kern: -0.08) - - importButton.translatesAutoresizingMaskIntoConstraints = false - importButton.isHidden = true - - setupLayout() - } - - private func setupLayout() { - titleTextField.setContentHuggingPriority(.defaultHigh, for: .vertical) - titleTextField.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: 12).isActive = true - titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true - - newBookmarkButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - newBookmarkButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - newFolderButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - newFolderButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - searchBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - searchBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - sortBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - sortBookmarksButton.widthAnchor.constraint(equalToConstant: 28).isActive = true - - buttonsDivider.widthAnchor.constraint(equalToConstant: 13).isActive = true - buttonsDivider.heightAnchor.constraint(equalToConstant: 18).isActive = true - - manageBookmarksButton.heightAnchor.constraint(equalToConstant: 28).isActive = true - let titleWidth = (manageBookmarksButton.title as NSString) - .size(withAttributes: [.font: manageBookmarksButton.font as Any]).width - let buttonWidth = manageBookmarksButton.image!.size.height + titleWidth + 18 - manageBookmarksButton.widthAnchor.constraint(equalToConstant: buttonWidth).isActive = true - - stackView.centerYAnchor.constraint(equalTo: titleTextField.centerYAnchor).isActive = true - view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 20).isActive = true - - boxDividerTopConstraint = boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12) - boxDividerTopConstraint.isActive = true - boxDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - view.trailingAnchor.constraint(equalTo: boxDivider.trailingAnchor).isActive = true - - scrollView.topAnchor.constraint(equalTo: boxDivider.bottomAnchor).isActive = true - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - - emptyState.topAnchor.constraint(equalTo: boxDivider.bottomAnchor).isActive = true - emptyState.centerXAnchor.constraint(equalTo: boxDivider.centerXAnchor).isActive = true - emptyState.widthAnchor.constraint(equalToConstant: 342).isActive = true - emptyState.heightAnchor.constraint(equalToConstant: 383).isActive = true - - emptyStateImageView.translatesAutoresizingMaskIntoConstraints = false - emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateImageView.setContentHuggingPriority(.init(rawValue: 251), for: .vertical) - emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor, constant: 94.5).isActive = true - emptyStateImageView.widthAnchor.constraint(equalToConstant: 128).isActive = true - emptyStateImageView.heightAnchor.constraint(equalToConstant: 96).isActive = true - emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - - emptyStateTitle.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateTitle.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8).isActive = true - emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - emptyStateTitle.widthAnchor.constraint(equalToConstant: 192).isActive = true - - emptyStateMessage.setContentHuggingPriority(.defaultHigh, for: .vertical) - emptyStateMessage.setContentHuggingPriority(.init(rawValue: 251), for: .horizontal) - emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8).isActive = true - emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true - - emptyStateMessage.widthAnchor.constraint(equalToConstant: 192).isActive = true - - importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8).isActive = true - importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor).isActive = true + scrollUpButton = mode == .popover ? nil : { + let scrollUpButton = MouseOverButton(image: .condenseUp, target: nil, action: nil) + scrollUpButton.translatesAutoresizingMaskIntoConstraints = false + scrollUpButton.bezelStyle = .shadowlessSquare + scrollUpButton.normalTintColor = .labelColor + scrollUpButton.backgroundColor = .clear + scrollUpButton.mouseOverColor = .blackWhite10 + return scrollUpButton + }() + + scrollDownButton = mode == .popover ? nil : { + let scrollDownButton = MouseOverButton(image: .expandDown, target: nil, action: nil) + scrollDownButton.translatesAutoresizingMaskIntoConstraints = false + scrollDownButton.bezelStyle = .shadowlessSquare + scrollDownButton.normalTintColor = .labelColor + scrollDownButton.backgroundColor = .clear + scrollDownButton.mouseOverColor = .blackWhite10 + return scrollDownButton + }() + + titleTextField.map(view.addSubview) + boxDivider.map(view.addSubview) + stackView.map(view.addSubview) + view.addSubview(scrollView) + scrollUpButton.map(view.addSubview) + scrollDownButton.map(view.addSubview) + + setupEmptyStateView() + setupLayout(stackView: stackView, buttonsDivider: buttonsDivider) } - override func viewDidLoad() { - super.viewDidLoad() + private func setupEmptyStateView() { + guard mode == .popover else { return } - preferredContentSize = Self.preferredContentSize + let emptyStateImageView = { + let emptyStateImageView = NSImageView(image: .bookmarksEmpty) + emptyStateImageView.translatesAutoresizingMaskIntoConstraints = false + emptyStateImageView.setContentHuggingPriority(.init(251), for: .horizontal) + emptyStateImageView.setContentHuggingPriority(.init(251), for: .vertical) + return emptyStateImageView + }() + self.emptyStateImageView = emptyStateImageView + + let emptyStateTitle = { + let emptyStateTitle = NSTextField() + emptyStateTitle.translatesAutoresizingMaskIntoConstraints = false + emptyStateTitle.setContentHuggingPriority(.defaultHigh, for: .vertical) + emptyStateTitle.setContentHuggingPriority(.init(251), for: .horizontal) + emptyStateTitle.alignment = .center + emptyStateTitle.drawsBackground = false + emptyStateTitle.isBordered = false + emptyStateTitle.isEditable = false + emptyStateTitle.font = .systemFont(ofSize: 15, weight: .semibold) + emptyStateTitle.textColor = .labelColor + emptyStateTitle.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateTitle, + lineHeight: 1.14, + kern: -0.23) + return emptyStateTitle + }() + self.emptyStateTitle = emptyStateTitle + + let emptyStateMessage = { + let emptyStateMessage = NSTextField() + emptyStateMessage.translatesAutoresizingMaskIntoConstraints = false + emptyStateMessage.setContentHuggingPriority(.defaultHigh, for: .vertical) + emptyStateMessage.setContentHuggingPriority(.init(251), for: .horizontal) + emptyStateMessage.alignment = .center + emptyStateMessage.drawsBackground = false + emptyStateMessage.isBordered = false + emptyStateMessage.isEditable = false + emptyStateMessage.font = .systemFont(ofSize: 13) + emptyStateMessage.textColor = .labelColor + emptyStateMessage.attributedStringValue = NSAttributedString.make(UserText.bookmarksEmptyStateMessage, + lineHeight: 1.05, + kern: -0.08) + return emptyStateMessage + }() + self.emptyStateMessage = emptyStateMessage + + let importButton = { + let importButton = NSButton(title: UserText.importBookmarksButtonTitle, target: self, + action: #selector(onImportClicked)) + importButton.translatesAutoresizingMaskIntoConstraints = false + importButton.isHidden = true + return importButton + }() + self.importButton = importButton + + emptyState = { + let emptyState = NSView() + emptyState.addSubview(emptyStateImageView) + emptyState.addSubview(emptyStateTitle) + emptyState.addSubview(emptyStateMessage) + emptyState.addSubview(importButton) + + emptyState.isHidden = true + emptyState.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(emptyState) + + return emptyState + }() + setupEmptyStateLayout() + } + + private func setupLayout(stackView: NSStackView?, buttonsDivider: NSBox?) { + var constraints = [ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + .priority(900), + ] + + if let titleTextField, let boxDivider, let stackView { + let boxDividerTopConstraint = boxDivider.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 12) + self.boxDividerTopConstraint = boxDividerTopConstraint + constraints += [ + titleTextField.topAnchor.constraint(equalTo: view.topAnchor, constant: 12), + titleTextField.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + + stackView.centerYAnchor.constraint(equalTo: titleTextField.centerYAnchor), + view.trailingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: 20), + + boxDividerTopConstraint, + boxDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: boxDivider.trailingAnchor), + + scrollView.topAnchor.constraint(equalTo: boxDivider.bottomAnchor), + ] + } else { + constraints += [ + scrollView.topAnchor.constraint(equalTo: view.topAnchor) + .priority(900), + ] + } + if let scrollUpButton, let scrollDownButton { + constraints += [ + scrollUpButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollUpButton.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollUpButton.topAnchor.constraint(equalTo: view.topAnchor), + scrollUpButton.heightAnchor.constraint(equalToConstant: 16), + + scrollDownButton.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollDownButton.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollDownButton.bottomAnchor.constraint(equalTo: view.bottomAnchor), + scrollDownButton.heightAnchor.constraint(equalToConstant: 16), + + scrollView.topAnchor.constraint(equalTo: scrollUpButton.bottomAnchor) + .autoDeactivatedWhenViewIsHidden(scrollUpButton), + scrollView.bottomAnchor.constraint(equalTo: scrollDownButton.topAnchor) + .autoDeactivatedWhenViewIsHidden(scrollDownButton), + ] + } + constraints += newBookmarkButton.map { newBookmarkButton in + [ + newBookmarkButton.heightAnchor.constraint(equalToConstant: 28), + newBookmarkButton.widthAnchor.constraint(equalToConstant: 28), + ] + } ?? [] + constraints += newFolderButton.map { newFolderButton in + [ + newFolderButton.heightAnchor.constraint(equalToConstant: 28), + newFolderButton.widthAnchor.constraint(equalToConstant: 28), + ] + } ?? [] + constraints += buttonsDivider.map { buttonsDivider in + [ + buttonsDivider.widthAnchor.constraint(equalToConstant: 13), + buttonsDivider.heightAnchor.constraint(equalToConstant: 18), + ] + } ?? [] + constraints += manageBookmarksButton.map { manageBookmarksButton in + [ + manageBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + manageBookmarksButton.widthAnchor.constraint(equalToConstant: { + let titleWidth = (manageBookmarksButton.title as NSString) + .size(withAttributes: [.font: manageBookmarksButton.font as Any]).width + let buttonWidth = manageBookmarksButton.image!.size.height + titleWidth + 18 + return buttonWidth + }()), + ] + } ?? [] + constraints += searchBookmarksButton.map { searchBookmarksButton in + [ + searchBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + searchBookmarksButton.widthAnchor.constraint(equalToConstant: 28), + ] + } ?? [] + constraints += sortBookmarksButton.map { sortBookmarksButton in + [ + sortBookmarksButton.heightAnchor.constraint(equalToConstant: 28), + sortBookmarksButton.widthAnchor.constraint(equalToConstant: 28), + ] + } ?? [] + + NSLayoutConstraint.activate(constraints) + } + + private func setupEmptyStateLayout() { + guard let emptyState, let emptyStateImageView, let emptyStateTitle, + let emptyStateMessage, let importButton, let boxDivider else { return } + + NSLayoutConstraint.activate([ + emptyState.topAnchor.constraint(equalTo: boxDivider.bottomAnchor), + emptyState.centerXAnchor.constraint(equalTo: boxDivider.centerXAnchor), + emptyState.widthAnchor.constraint(equalToConstant: 342), + emptyState.heightAnchor.constraint(equalToConstant: 383), + + emptyStateImageView.topAnchor.constraint(equalTo: emptyState.topAnchor, constant: 94.5), + emptyStateImageView.widthAnchor.constraint(equalToConstant: 128), + emptyStateImageView.heightAnchor.constraint(equalToConstant: 96), + emptyStateImageView.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + + emptyStateTitle.topAnchor.constraint(equalTo: emptyStateImageView.bottomAnchor, constant: 8), + emptyStateTitle.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + emptyStateTitle.widthAnchor.constraint(equalToConstant: 192), + + emptyStateMessage.topAnchor.constraint(equalTo: emptyStateTitle.bottomAnchor, constant: 8), + emptyStateMessage.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + + emptyStateMessage.widthAnchor.constraint(equalToConstant: 192), + + importButton.topAnchor.constraint(equalTo: emptyStateMessage.bottomAnchor, constant: 8), + importButton.centerXAnchor.constraint(equalTo: emptyState.centerXAnchor), + ]) + } + + override func viewDidLoad() { + preferredContentSize = Constants.preferredContentSize outlineView.setDraggingSourceOperationMask([.move], forLocal: true) outlineView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, FolderPasteboardWriter.folderUTIInternalType]) + } - bookmarkManager.listPublisher.receive(on: DispatchQueue.main).sink { [weak self] list in - guard let self else { return } + override func viewWillAppear() { + subscribeToModelEvents() + } - self.reloadData() - let isEmpty = list?.topLevelEntities.isEmpty ?? true + override func viewWillDisappear() { + cancellables = [] + } - if isEmpty { - self.searchBookmarksButton.isHidden = true - self.showEmptyStateView(for: .noBookmarks) - } else { - self.searchBookmarksButton.isHidden = false - self.outlineView.isHidden = false + private func subscribeToModelEvents() { + bookmarkManager.listPublisher + .dropFirst() // reloadData will be called from adjustPreferredContentSize + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.reloadData() } - }.store(in: &cancellables) + .store(in: &cancellables) sortBookmarksViewModel.$selectedSortMode.sink { [weak self] newSortMode in - guard let self else { return } + self?.setupSort(mode: newSortMode) + }.store(in: &cancellables) - switch newSortMode { - case .nameDescending: - self.sortBookmarksButton.image = .bookmarkSortDesc - default: - self.sortBookmarksButton.image = .bookmarkSortAsc + if case .bookmarkBarMenu = mode { + subscribeToScrollingEvents() + subscribeToMenuPopoverEvents() + // only subscribe to click outside events in root bookmarks menu + // to close all the bookmarks menu popovers + if !(view.window?.parent?.contentViewController is Self) { + subscribeToClickOutsideEvents() } - - self.setupSort(mode: newSortMode) - }.store(in: &cancellables) + } } - override func viewWillAppear() { - super.viewWillAppear() + private func subscribeToMenuPopoverEvents() { + // show submenu for folder when dragging or hovering over it + typealias RowEvtPub = AnyPublisher<(Int?, BookmarkFolder?), Never> + // hover over bookmarks menu row + outlineView.$highlightedRow + .compactMap { [weak self] (row) -> RowEvtPub? in + guard let self else { return nil } + let bookmarkNode = row.flatMap { self.outlineView.item(atRow: $0) } as? BookmarkNode + let folder = bookmarkNode?.representedObject as? BookmarkFolder + + // prevent closing or opening another submenu when mouse is moving down+right + let isMouseMovingDownRight: Bool = { + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .mouseMoved, + currentEvent.deltaY >= 0, + currentEvent.deltaX >= currentEvent.deltaY { + true + } else { + false + } + }() + var delay: RunLoop.SchedulerTimeType.Stride + // is moving down+right to a submenu? + if isMouseMovingDownRight, + let bookmarkListPopover, bookmarkListPopover.isShown, + let expandedFolder = bookmarkListPopover.rootFolder, + let node = treeController.node(representing: expandedFolder), + let expandedRow = outlineView.rowIfValid(forItem: node) { + guard expandedRow != row else { return nil } + + // delay submenu hiding when cursor is moving right (to the menu) + // over other elements that would normally hide the submenu + delay = 0.3 + // restore the originally highlighted row for the expanded folder + outlineView.highlightedRow = expandedRow + + } else if folder != nil { + // delay folder expanding when hovering over a subfolder + delay = 0.1 + } else { + // hide submenu instantly when mouse is moved away from folder + // unless it‘s moving down+right as handled above. + delay = 0 + } + + let valuePublisher = Just((row, folder)) + if delay > 0 { + return valuePublisher + .delay(for: delay, scheduler: RunLoop.main) + .eraseToAnyPublisher() + } else { + return valuePublisher.eraseToAnyPublisher() + } + } + .switchToLatest() + .filter { [weak outlineView] (row, _) in + return outlineView?.highlightedRow == row + } + .sink { [weak self] (row, folder) in + self?.outlineViewDidHighlight(folder, atRow: row) + } + .store(in: &cancellables) + } - reloadData() + private func subscribeToClickOutsideEvents() { + Publishers.Merge( + // close bookmarks menu when main menu is clicked + NotificationCenter.default.publisher(for: NSMenu.didBeginTrackingNotification, + object: NSApp.mainMenu).asVoid(), + // close bookmarks menu on click outside of the menu or its submenus + NSEvent.publisher(forEvents: [.local, .global], + matching: [.leftMouseDown, .rightMouseDown]) + .filter { [weak self] event in + if event.type == .leftMouseDown, + event.deviceIndependentFlags == .command, + event.window == nil { + // don‘t close on Cmd+click in other app + return false + } + guard let self, + // always close on global event + let eventWindow = event.window else { return true /* close */} + // is showing submenu? + if let popover = nextResponder as? BookmarkListPopover, + let positioningView = popover.positioningView, + positioningView.isMouseLocationInsideBounds(event.locationInWindow) { + // don‘t close when the button used to open the popover is + // clicked again, it‘ll be managed by + // BookmarksBarViewController.popoverShouldClose + return false + } + // go up from the clicked window to figure out if the click is in a submenu + for window in sequence(first: eventWindow, next: \.parent) + where window === self.view.window { + // we found our window: the click was in the menu tree + return false // don‘t close + } + return true // close + }.asVoid() + ) + .sink { [weak self] _ in + self?.delegate?.popoverShouldClose(self!) // close + } + .store(in: &cancellables) } override func keyDown(with event: NSEvent) { @@ -404,23 +716,155 @@ final class BookmarkListViewController: NSViewController { } } - private func reloadData() { + func reloadData(withRootFolder rootFolder: BookmarkFolder? = nil) { + if let rootFolder { + self.representedObject = rootFolder + isSearchVisible = false + } + // this weirdness is needed to load a new `BookmarkFolder` object on any modification + // because `BookmarkManager.move(objectUUIDs:toIndex:withinParentFolder)` doesn‘t + // actually move a `Bookmark` object into actual `BookmarkFolder` object + // as well as updating a `Bookmark` doesn‘t modify it, instead + // `BookmarkManager.loadBookmarks()` is called every time recreating the whole + // bookmark hierarchy. + var rootFolder = rootFolder + if rootFolder == nil, let oldRootFolder = self.representedObject as? BookmarkFolder { + if oldRootFolder.id == PseudoFolder.bookmarks.id { + // reloadData for clipped Bookmarks will be triggered with updated rootFolder + // from BookmarksBarViewController.bookmarksBarViewModelReloadedData + return + } + rootFolder = bookmarkManager.getBookmarkFolder(withId: oldRootFolder.id) + } + if dataSource.isSearching { if let destinationFolder = dataSource.dragDestinationFolderInSearchMode { hideSearchBar() updateSearchAndExpand(destinationFolder) } else { - dataSource.reloadData(for: searchBar.stringValue, and: sortBookmarksViewModel.selectedSortMode) + dataSource.reloadData(for: searchBar.stringValue, + sortMode: sortBookmarksViewModel.selectedSortMode) outlineView.reloadData() } } else { let selectedNodes = self.selectedNodes - dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) + dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode, + withRootFolder: rootFolder ?? self.representedObject as? BookmarkFolder) outlineView.reloadData() expandAndRestore(selectedNodes: selectedNodes) } + + let isEmpty = (outlineView.numberOfRows == 0) + self.emptyState?.isHidden = !isEmpty + self.searchBookmarksButton?.isHidden = isEmpty + self.outlineView.isHidden = isEmpty + + if isEmpty { + self.showEmptyStateView(for: .noBookmarks) + } + } + + // MARK: Layout + + func adjustPreferredContentSize(positionedRelativeTo positioningRect: NSRect, + of positioningView: NSView, + at preferredEdge: NSRectEdge) { + _=view // loadViewIfNeeded() + + guard let mainWindow = positioningView.window, + let screenFrame = mainWindow.screen?.visibleFrame else { return } + + self.reloadData(withRootFolder: representedObject as? BookmarkFolder) + outlineView.highlightedRow = nil + scrollView.contentView.bounds.origin = .zero // scroll to top + + guard case .bookmarkBarMenu = mode else { + // if not menu popover + preferredContentSize = Constants.preferredContentSize + return + } + guard outlineView.numberOfRows > 0 else { + preferredContentSize = Constants.noContentMenuSize + preferredContentOffset.y = 0 + return + } + + // popover borders + let contentInsets = BookmarkListPopover.popoverInsets + // positioning rect in Screen coordinates + let windowRect = positioningView.convert(positioningRect, to: nil) + let screenPosRect = mainWindow.convertToScreen(windowRect) + // available screen space at the bottom + let availableHeightBelow = screenPosRect.minY - screenFrame.minY - contentInsets.bottom + + // calculate size to fit all the contents + var preferredContentSize = calculatePreferredContentSize() + + // menu expanding from the right edge (.maxX positioning) + // expand up if available space at the bottom is less than content size + if availableHeightBelow < preferredContentSize.height, + preferredEdge == .maxX { + // how much of the content height doesn‘t fit at the bottom? + let contentHeightToExpandUp = preferredContentSize.height - (screenPosRect.minY - screenFrame.minY) - contentInsets.top - contentInsets.bottom + // set the popover size to fit all the contents but not more than space available + preferredContentSize.height = min(screenFrame.height - contentInsets.top - contentInsets.bottom, preferredContentSize.height) + // shift the popover up from the positioining rect as much as needed + if contentHeightToExpandUp > 0 { + let availableHeightOnTop = screenFrame.maxY - screenPosRect.minY - contentInsets.top + preferredContentOffset.y = min(availableHeightOnTop, contentHeightToExpandUp) + } else { + preferredContentOffset.y = 0 + } + + // menu expanding up from the bottom edge (.minY positioning) + // if available space at the bottom is less than 4 rows + } else if Int(availableHeightBelow / BookmarkOutlineCellView.rowHeight) < Constants.minVisibleRows { + // expand the menu up from the bottom-most point + let availableHeightOnTop = screenFrame.maxY - screenPosRect.minY - contentInsets.top + // shift the popover up matching the content size but not more than space available + preferredContentOffset.y = min(availableHeightOnTop, preferredContentSize.height) - availableHeightBelow + // set the popover size to fit all the contents but not more than space available + preferredContentSize.height = min(screenFrame.height - contentInsets.top - contentInsets.bottom, preferredContentSize.height) + + } else { + // expand the menu down when space is available to fit the content + preferredContentOffset = .zero + preferredContentSize.height = min(availableHeightBelow, preferredContentSize.height) + } + + self.preferredContentSize = preferredContentSize + updateScrollButtons() + } + + func calculatePreferredContentSize() -> NSSize { + let contentInsets = BookmarkListPopover.popoverInsets + var contentSize = NSSize(width: 0, height: 20) + for row in 0.. contentSize.width { + contentSize.width = min(Constants.maxMenuPopoverContentWidth, cellWidth) + } + } + // desired row height + if node?.representedObject is SpacerNode { + contentSize.height += OutlineSeparatorViewCell.rowHeight(for: mode) + } else { + contentSize.height += BookmarkOutlineCellView.rowHeight + } + } + return contentSize + } + + override func viewDidLayout() { + super.viewDidLayout() + + updateScrollButtons() } private func updateSearchAndExpand(_ folder: BookmarkFolder) { @@ -428,9 +872,7 @@ final class BookmarkListViewController: NSViewController { expandFoldersAndScrollUntil(folder) outlineView.scrollToAdjustedPositionInOutlineView(folder) - guard let node = dataSource.treeController.node(representing: folder) else { - return - } + guard let node = treeController.node(representing: folder) else { return } outlineView.highlight(node) } @@ -446,51 +888,48 @@ final class BookmarkListViewController: NSViewController { showDialog(view: view) } - @objc func searchBookmarkButtonClicked(_ sender: NSButton) { + @objc func searchBookmarksButtonClicked(_ sender: NSButton) { isSearchVisible.toggle() - - if isSearchVisible { - showSearchBar() - } else { - hideSearchBar() - showTreeView() - } } @objc func sortBookmarksButtonClicked(_ sender: NSButton) { let menu = sortBookmarksViewModel.menu bookmarkMetrics.fireSortButtonClicked(origin: .panel) - menu.popUpAtMouseLocation(in: sortBookmarksButton) + menu.delegate = sortBookmarksViewModel + menu.popUpAtMouseLocation(in: sender) } private func showSearchBar() { + isSearchVisible = true view.addSubview(searchBar) - boxDividerTopConstraint.isActive = false + guard let titleTextField, let boxDivider else { return } + boxDividerTopConstraint?.isActive = false searchBar.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, constant: 8).isActive = true searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16).isActive = true view.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, constant: 16).isActive = true boxDivider.topAnchor.constraint(equalTo: searchBar.bottomAnchor, constant: 10).isActive = true searchBar.makeMeFirstResponder() - searchBookmarksButton.backgroundColor = .buttonMouseDown - searchBookmarksButton.mouseOverColor = .buttonMouseDown + searchBookmarksButton?.backgroundColor = .buttonMouseDown + searchBookmarksButton?.mouseOverColor = .buttonMouseDown } private func hideSearchBar() { + isSearchVisible = false searchBar.stringValue = "" searchBar.removeFromSuperview() - boxDividerTopConstraint.isActive = true - isSearchVisible = false - searchBookmarksButton.backgroundColor = .clear - searchBookmarksButton.mouseOverColor = .buttonMouseOver + boxDividerTopConstraint?.isActive = true + searchBookmarksButton?.backgroundColor = .clear + searchBookmarksButton?.mouseOverColor = .buttonMouseOver } private func setupSort(mode: BookmarksSortMode) { hideSearchBar() dataSource.reloadData(with: mode) outlineView.reloadData() - sortBookmarksButton.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear - sortBookmarksButton.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver + sortBookmarksButton?.image = (mode == .nameDescending) ? .bookmarkSortDesc : .bookmarkSortAsc + sortBookmarksButton?.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear + sortBookmarksButton?.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver } @objc func openManagementInterface(_ sender: NSButton) { @@ -501,14 +940,18 @@ final class BookmarkListViewController: NSViewController { guard sender.clickedRow != -1 else { return } let item = sender.item(atRow: sender.clickedRow) - if let node = item as? BookmarkNode, - let bookmark = node.representedObject as? Bookmark { + guard let node = item as? BookmarkNode else { return } + + switch node.representedObject { + case let bookmark as Bookmark: onBookmarkClick(bookmark) - } else if let node = item as? BookmarkNode, let folder = node.representedObject as? BookmarkFolder, dataSource.isSearching { + + case let folder as BookmarkFolder where dataSource.isSearching: bookmarkMetrics.fireSearchResultClicked(origin: .panel) hideSearchBar() updateSearchAndExpand(folder) - } else { + + default: handleItemClickWhenNotInSearchMode(item: item) } } @@ -557,19 +1000,19 @@ final class BookmarkListViewController: NSViewController { } private func showTreeView() { - emptyState.isHidden = true + emptyState?.isHidden = true outlineView.isHidden = false dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) outlineView.reloadData() } private func showEmptyStateView(for mode: BookmarksEmptyStateContent) { - emptyState.isHidden = false + emptyState?.isHidden = false outlineView.isHidden = true - emptyStateTitle.stringValue = mode.title - emptyStateMessage.stringValue = mode.description - emptyStateImageView.image = mode.image - importButton.isHidden = mode.shouldHideImportButton + emptyStateTitle?.stringValue = mode.title + emptyStateMessage?.stringValue = mode.description + emptyStateImageView?.image = mode.image + importButton?.isHidden = mode.shouldHideImportButton } @objc func onImportClicked(_ sender: NSButton) { @@ -639,6 +1082,155 @@ final class BookmarkListViewController: NSViewController { contextMenu.popUpAtMouseLocation(in: view) } + /// Show or close folder submenu on row hover + /// the method is called from `outlineView.$highlightedRow` observer after a delay as needed + func outlineViewDidHighlight(_ folder: BookmarkFolder?, atRow row: Int?) { + guard let row, let folder, + let cell = outlineView.view(atColumn: 0, row: row, makeIfNecessary: false) else { + // close submenu if shown + guard let bookmarkListPopover, bookmarkListPopover.isShown else { return } + bookmarkListPopover.close() + return + } + + let bookmarkListPopover: BookmarkListPopover + if let popover = self.bookmarkListPopover { + bookmarkListPopover = popover + if bookmarkListPopover.isShown { + if bookmarkListPopover.rootFolder?.id == folder.id { + // submenu for the folder is already shown + return + } + bookmarkListPopover.close() + } + // reuse the popover for another folder + bookmarkListPopover.reloadData(withRootFolder: folder) + } else { + bookmarkListPopover = BookmarkListPopover(mode: .bookmarkBarMenu, rootFolder: folder) + self.bookmarkListPopover = bookmarkListPopover + } + + bookmarkListPopover.show(positionedAsSubmenuAgainst: cell) + } + + // MARK: Bookmarks Menu scrolling + + private func subscribeToScrollingEvents() { + // scrollViewDidScroll + NotificationCenter.default + .publisher(for: NSView.boundsDidChangeNotification, object: scrollView.contentView).asVoid() + .compactMap { [weak scrollView=scrollView] in + scrollView?.documentVisibleRect + } + .scan((old: CGRect.zero, new: scrollView.documentVisibleRect)) { + (old: $0.new, new: $1) + } + .sink { [weak self] change in + self?.scrollViewDidScroll(old: change.old, new: change.new) + }.store(in: &cancellables) + + // Scroll Up Button hover + scrollUpButton?.publisher(for: \.isMouseOver) + .map { [weak scrollDownButton] isMouseOver in + guard isMouseOver, + NSApp.currentEvent?.type != .keyDown else { + if let scrollDownButton, scrollDownButton.isMouseOver { + // ignore mouse over change when the button appears on + // the Down key press + scrollDownButton.isMouseOver = false + } + return Empty().eraseToAnyPublisher() + } + return Timer.publish(every: 0.1, on: .main, in: .default) + .autoconnect() + .asVoid() + .eraseToAnyPublisher() + } + .switchToLatest() + .sink { [weak outlineView] in + guard let outlineView else { return } + // scroll up on scrollUpButton hover on Timed events + let newScrollOrigin = NSPoint(x: outlineView.visibleRect.origin.x, y: outlineView.visibleRect.origin.y - BookmarkOutlineCellView.rowHeight) + outlineView.scroll(newScrollOrigin) + } + .store(in: &cancellables) + + // Scroll Down Button hover + scrollDownButton?.publisher(for: \.isMouseOver) + .map { [weak scrollDownButton] isMouseOver in + guard isMouseOver, + NSApp.currentEvent?.type != .keyDown else { + if let scrollDownButton, scrollDownButton.isMouseOver { + // ignore mouse over change when the button appears on + // the Down key press + scrollDownButton.isMouseOver = false + } + return Empty().eraseToAnyPublisher() + } + return Timer.publish(every: 0.1, on: .main, in: .default) + .autoconnect() + .asVoid() + .eraseToAnyPublisher() + } + .switchToLatest() + .sink { [weak outlineView] in + guard let outlineView else { return } + // scroll down on scrollDownButton hover on Timed events + let newScrollOrigin = NSPoint(x: outlineView.visibleRect.origin.x, y: outlineView.visibleRect.origin.y + BookmarkOutlineCellView.rowHeight) + outlineView.scroll(newScrollOrigin) + } + .store(in: &cancellables) + } + + private func scrollViewDidScroll(old oldVisibleRect: NSRect, new visibleRect: NSRect) { + guard let window = view.window, let screen = window.screen else { return } + + let availableHeight = screen.visibleFrame.maxY - window.frame.maxY + let scrollDeltaY = visibleRect.minY - oldVisibleRect.minY + if scrollDeltaY > 0, availableHeight > 0 { + let contentHeight = outlineView.bounds.height + // shift bookmarks menu popover up incrementing height if screen space is available + var popoverHeightIncrement = min(availableHeight, scrollDeltaY) + if preferredContentSize.height + popoverHeightIncrement > contentHeight { + popoverHeightIncrement = contentHeight - preferredContentSize.height + } + if popoverHeightIncrement > 0 { + preferredContentOffset.y = popoverHeightIncrement + preferredContentSize.height += popoverHeightIncrement + // decrement scrolling position + if preferredContentSize.height + popoverHeightIncrement > contentHeight { + scrollView.contentView.bounds.origin.y = 0 + } else { + scrollView.contentView.bounds.origin.y -= popoverHeightIncrement + } + } + // -> will update scroll buttons on next `viewDidLayout` pass + + } else { + updateScrollButtons() + } + + if let event = NSApp.currentEvent, event.type != .keyDown { + // update current highlighted row to match cursor position + outlineView.updateHighlightedRowUnderCursor() + } + } + + private func updateScrollButtons() { + guard let scrollUpButton, let scrollDownButton else { return } + let contentHeight = outlineView.bounds.height + + var visibleRect = scrollView.documentVisibleRect + if scrollUpButton.isShown { + visibleRect.size.height += scrollUpButton.frame.height + } + if scrollDownButton.isShown { + visibleRect.size.height += scrollDownButton.frame.height + } + scrollUpButton.isShown = visibleRect.minY > 0 + scrollDownButton.isShown = visibleRect.maxY < contentHeight + } + } private extension BookmarkListViewController { @@ -845,9 +1437,7 @@ extension BookmarkListViewController: BookmarkSearchMenuItemSelectors { hideSearchBar() showTreeView() - guard let node = dataSource.treeController.node(representing: baseBookmark) else { - return - } + guard let node = treeController.node(representing: baseBookmark) else { return } expandFoldersUntil(node: node) outlineView.scrollToAdjustedPositionInOutlineView(node) @@ -874,97 +1464,69 @@ extension BookmarkListViewController: NSSearchFieldDelegate { } private func showSearch(for searchQuery: String) { - dataSource.reloadData(for: searchQuery, and: sortBookmarksViewModel.selectedSortMode) + dataSource.reloadData(for: searchQuery, sortMode: sortBookmarksViewModel.selectedSortMode) - if dataSource.treeController.rootNode.childNodes.isEmpty { + if treeController.rootNode.childNodes.isEmpty { showEmptyStateView(for: .noSearchResults) } else { - emptyState.isHidden = true + emptyState?.isHidden = true outlineView.isHidden = false outlineView.reloadData() - if let firstNode = dataSource.treeController.rootNode.childNodes.first { + if let firstNode = treeController.rootNode.childNodes.first { outlineView.scrollTo(firstNode) } } } } - -// MARK: - BookmarkListPopover - -final class BookmarkListPopover: NSPopover { - - override init() { - super.init() - - self.animates = false - self.behavior = .transient - - setupContentController() - } - - required init?(coder: NSCoder) { - fatalError("BookmarkListPopover: Bad initializer") - } - - // swiftlint:disable:next force_cast - var viewController: BookmarkListViewController { contentViewController as! BookmarkListViewController } - - private func setupContentController() { - let controller = BookmarkListViewController() - controller.delegate = self - contentViewController = controller - } - -} - -extension BookmarkListPopover: BookmarkListViewControllerDelegate { - - func popoverShouldClose(_ bookmarkListViewController: BookmarkListViewController) { - close() - } - - func popover(shouldPreventClosure: Bool) { - behavior = shouldPreventClosure ? .applicationDefined : .transient - } - -} - #if DEBUG // swiftlint:disable:next identifier_name func _mockPreviewBookmarkManager(previewEmptyState: Bool) -> BookmarkManager { - let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: previewEmptyState ? [] : [ - BookmarkFolder(id: "1", title: "Folder 1", children: [ - BookmarkFolder(id: "2", title: "Nested Folder", children: [ - Bookmark(id: "b1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "2") - ]) - ]), - BookmarkFolder(id: "3", title: "Another Folder", children: [ - BookmarkFolder(id: "4", title: "Nested Folder", children: [ - BookmarkFolder(id: "5", title: "Another Nested Folder", children: [ - Bookmark(id: "b2", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "5") + let bookmarks: [BaseBookmarkEntity] + if previewEmptyState { + bookmarks = [] + } else { + bookmarks = (1..<100).map { _ in [ + BookmarkFolder(id: "1", title: "Folder 1", children: [ + BookmarkFolder(id: "2", title: "Nested Folder", children: [ + Bookmark(id: "b1", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "2") ]) - ]) - ]), - Bookmark(id: "b3", url: URL.duckDuckGo.absoluteString, title: "Bookmark 1", isFavorite: false, parentFolderUUID: ""), - Bookmark(id: "b4", url: URL.duckDuckGo.absoluteString, title: "Bookmark 2", isFavorite: false, parentFolderUUID: ""), - Bookmark(id: "b5", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "") - ])) + ]), + BookmarkFolder(id: "3", title: "Another Folder", children: [ + BookmarkFolder(id: "4", title: "Nested Folder", children: [ + BookmarkFolder(id: "5", title: "Another Nested Folder", children: [ + Bookmark(id: "b2", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "5") + ]) + ]) + ]), + Bookmark(id: "b3", url: URL.duckDuckGo.absoluteString, title: "Bookmark 1", isFavorite: false, parentFolderUUID: ""), + Bookmark(id: "b4", url: URL.duckDuckGo.absoluteString, title: "Bookmark 2", isFavorite: false, parentFolderUUID: ""), + Bookmark(id: "b5", url: URL.duckDuckGo.absoluteString, title: "DuckDuckGo", isFavorite: false, parentFolderUUID: "") + ] }.flatMap { $0 } + } + let bkman = LocalBookmarkManager(bookmarkStore: BookmarkStoreMock(bookmarks: bookmarks)) + bkman.loadBookmarks() customAssertionFailure = { _, _, _ in } return bkman } +@available(macOS 14.0, *) +#Preview("Bookmarks Bar Menu", traits: BookmarkListViewController.Constants.preferredContentSize.fixedLayout) { + BookmarkListViewController(mode: .bookmarkBarMenu, bookmarkManager: _mockPreviewBookmarkManager(previewEmptyState: false)) + ._preview_hidingWindowControlsOnAppear() +} + @available(macOS 14.0, *) #Preview("Test Bookmark data", - traits: BookmarkListViewController.preferredContentSize.fixedLayout) { + traits: BookmarkListViewController.Constants.preferredContentSize.fixedLayout) { BookmarkListViewController(bookmarkManager: _mockPreviewBookmarkManager(previewEmptyState: false)) ._preview_hidingWindowControlsOnAppear() } @available(macOS 14.0, *) -#Preview("Empty Scope", traits: BookmarkListViewController.preferredContentSize.fixedLayout) { +#Preview("Empty Scope", traits: BookmarkListViewController.Constants.preferredContentSize.fixedLayout) { BookmarkListViewController(bookmarkManager: _mockPreviewBookmarkManager(previewEmptyState: true)) ._preview_hidingWindowControlsOnAppear() } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift index 71243f37d6..b22577f5e3 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift @@ -149,6 +149,8 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem emptyStateImageView.imageScaling = .scaleProportionallyDown scrollView.autohidesScrollers = true + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false scrollView.borderType = .noBorder scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.usesPredominantAxisScrolling = false diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift index fce1c9d2d1..8fc2b2a36d 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift @@ -102,6 +102,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { scrollView.borderType = .noBorder scrollView.drawsBackground = false scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.usesPredominantAxisScrolling = false diff --git a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift index fb79524c09..46427e2d71 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift @@ -25,16 +25,32 @@ protocol BookmarkOutlineCellViewDelegate: AnyObject { final class BookmarkOutlineCellView: NSTableCellView { + private static let sizingCellIdentifier = NSUserInterfaceItemIdentifier("sizing") + static let sizingCell = BookmarkOutlineCellView(identifier: BookmarkOutlineCellView.sizingCellIdentifier) + + static let rowHeight: CGFloat = 28 + private static let minUrlLabelWidth: CGFloat = 42 + private lazy var faviconImageView = NSImageView() private lazy var titleLabel = NSTextField(string: "Bookmark/Folder") private lazy var countLabel = NSTextField(string: "42") + private lazy var urlLabel = NSTextField(string: "URL") private lazy var menuButton = NSButton(title: "", image: .settings, target: self, action: #selector(cellMenuButtonClicked)) private lazy var favoriteImageView = NSImageView() - private lazy var trackingArea: NSTrackingArea = { - NSTrackingArea(rect: .zero, options: [.inVisibleRect, .activeAlways, .mouseEnteredAndExited], owner: self, userInfo: nil) - }() + private var leadingConstraint = NSLayoutConstraint() + var highlight = false { + didSet { + updateUI() + } + } + var isInKeyWindow = true { + didSet { + updateUI() + } + } + var shouldShowMenuButton = false weak var delegate: BookmarkOutlineCellViewDelegate? @@ -42,7 +58,6 @@ final class BookmarkOutlineCellView: NSTableCellView { init(identifier: NSUserInterfaceItemIdentifier) { super.init(frame: .zero) self.identifier = identifier - setupUI() } @@ -50,33 +65,15 @@ final class BookmarkOutlineCellView: NSTableCellView { fatalError("\(type(of: self)): Bad initializer") } - override func updateTrackingAreas() { - super.updateTrackingAreas() - - guard !trackingAreas.contains(trackingArea), shouldShowMenuButton else { return } - addTrackingArea(trackingArea) - } - - override func mouseEntered(with event: NSEvent) { - guard shouldShowMenuButton else { return } - countLabel.isHidden = true - favoriteImageView.isHidden = true - menuButton.isHidden = false - } - - override func mouseExited(with event: NSEvent) { - guard shouldShowMenuButton else { return } - menuButton.isHidden = true - countLabel.isHidden = false - favoriteImageView.isHidden = false - } - // MARK: - Private private func setupUI() { + translatesAutoresizingMaskIntoConstraints = false + addSubview(faviconImageView) addSubview(titleLabel) addSubview(countLabel) + addSubview(urlLabel) addSubview(menuButton) addSubview(favoriteImageView) @@ -106,11 +103,21 @@ final class BookmarkOutlineCellView: NSTableCellView { countLabel.textColor = .blackWhite60 countLabel.lineBreakMode = .byClipping + urlLabel.translatesAutoresizingMaskIntoConstraints = false + urlLabel.isEditable = false + urlLabel.isBordered = false + urlLabel.isSelectable = false + urlLabel.drawsBackground = false + urlLabel.font = .systemFont(ofSize: 13) + urlLabel.textColor = .secondaryLabelColor + urlLabel.lineBreakMode = .byTruncatingTail + menuButton.translatesAutoresizingMaskIntoConstraints = false menuButton.contentTintColor = .button menuButton.imagePosition = .imageTrailing menuButton.isBordered = false menuButton.isHidden = true + menuButton.sendAction(on: .leftMouseDown) favoriteImageView.translatesAutoresizingMaskIntoConstraints = false favoriteImageView.imageScaling = .scaleProportionallyDown @@ -126,37 +133,110 @@ final class BookmarkOutlineCellView: NSTableCellView { leadingConstraint, faviconImageView.centerYAnchor.constraint(equalTo: centerYAnchor), - titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, constant: 10), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor) + .priority(700), + bottomAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6), titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 6), + trailingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 0) + .priority(800), + + urlLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: urlLabel.trailingAnchor), + countLabel.leadingAnchor.constraint(greaterThanOrEqualTo: urlLabel.trailingAnchor), countLabel.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - countLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 5), trailingAnchor.constraint(equalTo: countLabel.trailingAnchor), menuButton.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), menuButton.trailingAnchor.constraint(equalTo: trailingAnchor), menuButton.topAnchor.constraint(equalTo: topAnchor), menuButton.bottomAnchor.constraint(equalTo: bottomAnchor), menuButton.widthAnchor.constraint(equalToConstant: 28), favoriteImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), - favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: 5), favoriteImageView.trailingAnchor.constraint(equalTo: menuButton.trailingAnchor), favoriteImageView.heightAnchor.constraint(equalToConstant: 15), favoriteImageView.widthAnchor.constraint(equalToConstant: 15), ]) - faviconImageView.setContentHuggingPriority(NSLayoutConstraint.Priority(rawValue: 251), for: .horizontal) - faviconImageView.setContentHuggingPriority(NSLayoutConstraint.Priority(rawValue: 251), for: .vertical) + titleLabel.leadingAnchor.constraint(equalTo: faviconImageView.trailingAnchor, + constant: 10) + .priority(900) + .autoDeactivatedWhenViewIsHidden(faviconImageView) + menuButton.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(menuButton) + countLabel.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(countLabel) + favoriteImageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 5) + .priority(900) + .autoDeactivatedWhenViewIsHidden(favoriteImageView) + urlLabel.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, + constant: 6) + .priority(900) + .autoDeactivatedWhenViewIsHidden(urlLabel) + + faviconImageView.setContentHuggingPriority(.init(251), for: .vertical) + urlLabel.setContentHuggingPriority(.init(300), for: .horizontal) + countLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + if identifier != Self.sizingCellIdentifier { + faviconImageView.setContentHuggingPriority(.init(251), for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.init(300), for: .horizontal) + titleLabel.setContentHuggingPriority(.init(301), for: .vertical) + urlLabel.setContentCompressionResistancePriority(.init(200), for: .horizontal) + countLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) - titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - titleLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) - titleLabel.setContentHuggingPriority(.init(rawValue: 200), for: .horizontal) + } else { + faviconImageView.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + urlLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + countLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + trailingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, + constant: 8) + .priority(900) + .isActive = true + } + } + + private func updateUI() { + if shouldShowMenuButton && titleLabel.isEnabled { + let isHighlighted = self.highlight && self.isInKeyWindow + countLabel.isHidden = isHighlighted || countLabel.stringValue.isEmpty + favoriteImageView.isHidden = isHighlighted || favoriteImageView.image == nil + menuButton.isShown = isHighlighted && faviconImageView.image != nil // don‘t show for custom menu item + menuButton.contentTintColor = isHighlighted ? .selectedMenuItemTextColor : .button + urlLabel.isShown = isHighlighted && !urlLabel.stringValue.isEmpty + } else { + menuButton.isHidden = true + urlLabel.isHidden = true + } + if !titleLabel.isEnabled { + titleLabel.textColor = .disabledControlTextColor + } else if highlight && isInKeyWindow { + titleLabel.textColor = .selectedMenuItemTextColor + urlLabel.textColor = .selectedMenuItemTextColor + } else { + titleLabel.textColor = .controlTextColor + urlLabel.textColor = .secondaryLabelColor + } + } + + override func layout() { + super.layout() - countLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) - countLabel.setContentHuggingPriority(.required, for: .horizontal) + // hide URL label if it can‘t fit meaningful text length + if urlLabel.isShown, urlLabel.frame.width < Self.minUrlLabelWidth { + urlLabel.stringValue = "" + urlLabel.isHidden = true + } } @objc private func cellMenuButtonClicked() { @@ -165,26 +245,76 @@ final class BookmarkOutlineCellView: NSTableCellView { // MARK: - Public - func update(from bookmark: Bookmark, isSearch: Bool = false) { + static func preferredContentWidth(for object: Any?) -> CGFloat { + guard let representedObject = (object as? BookmarkNode)?.representedObject ?? object, + representedObject is Bookmark || representedObject is BookmarkFolder + || representedObject is PseudoFolder else { return 0 } + + sizingCell.frame = .zero + sizingCell.update(from: representedObject, isMenuPopover: true) + sizingCell.layoutSubtreeIfNeeded() + + return sizingCell.frame.width + 6 + } + + func update(from object: Any, isSearch: Bool = false, isMenuPopover: Bool) { + let representedObject = (object as? BookmarkNode)?.representedObject ?? object + switch representedObject { + case let bookmark as Bookmark: + update(from: bookmark, isSearch: isSearch, showURL: identifier != Self.sizingCellIdentifier) + case let folder as BookmarkFolder: + update(from: folder, isSearch: isSearch, showChevron: isMenuPopover) + case let folder as PseudoFolder: + update(from: folder) + default: + assertionFailure("Unexpected object \(object).\(String(describing: (object as? BookmarkNode)?.representedObject))") + } + } + + func update(from bookmark: Bookmark, isSearch: Bool = false, showURL: Bool) { faviconImageView.image = bookmark.favicon(.small) ?? .bookmarkDefaultFavicon + faviconImageView.isHidden = false titleLabel.stringValue = bookmark.title + titleLabel.isEnabled = true countLabel.stringValue = "" + countLabel.isHidden = true + urlLabel.stringValue = showURL ? "– " + bookmark.url.dropping(prefix: { + if let scheme = URL(string: bookmark.url)?.navigationalScheme, + scheme.isHypertextScheme { + return scheme.separated() + } else { + return "" + } + }()) : "" + urlLabel.isHidden = urlLabel.stringValue.isEmpty + self.toolTip = bookmark.url favoriteImageView.image = bookmark.isFavorite ? .favoriteFilledBorder : nil + favoriteImageView.isHidden = favoriteImageView.image == nil + + highlight = false updateConstraints(isSearch: isSearch) } - func update(from folder: BookmarkFolder, isSearch: Bool = false) { + func update(from folder: BookmarkFolder, isSearch: Bool = false, showChevron: Bool) { faviconImageView.image = .folder + faviconImageView.isHidden = false titleLabel.stringValue = folder.title - favoriteImageView.image = nil + titleLabel.isEnabled = true + favoriteImageView.image = showChevron ? .chevronMediumRight16 : nil + favoriteImageView.isHidden = favoriteImageView.image == nil + urlLabel.stringValue = "" + self.toolTip = nil let totalChildBookmarks = folder.totalChildBookmarks - if totalChildBookmarks > 0 { + if totalChildBookmarks > 0 && !showChevron { countLabel.stringValue = String(totalChildBookmarks) + countLabel.isHidden = false } else { countLabel.stringValue = "" + countLabel.isHidden = true } + highlight = false updateConstraints(isSearch: isSearch) } @@ -194,9 +324,17 @@ final class BookmarkOutlineCellView: NSTableCellView { func update(from pseudoFolder: PseudoFolder) { faviconImageView.image = pseudoFolder.icon + faviconImageView.isHidden = false titleLabel.stringValue = pseudoFolder.name + titleLabel.isEnabled = true countLabel.stringValue = pseudoFolder.count > 0 ? String(pseudoFolder.count) : "" + countLabel.isHidden = countLabel.stringValue.isEmpty favoriteImageView.image = nil + favoriteImageView.isHidden = true + urlLabel.stringValue = "" + self.toolTip = nil + + highlight = false } } @@ -220,11 +358,17 @@ extension BookmarkOutlineCellView { translatesAutoresizingMaskIntoConstraints = true let cells = [ - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), - BookmarkOutlineCellView(identifier: .init("id")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), + BookmarkOutlineCellView(identifier: .init("")), ] let stackView = NSStackView(views: cells as [NSView]) @@ -232,13 +376,25 @@ extension BookmarkOutlineCellView { stackView.spacing = 1 addAndLayout(stackView) - cells[0].update(from: Bookmark(id: "1", url: "http://a.b", title: "DuckDuckGo", isFavorite: true)) - cells[1].update(from: BookmarkFolder(id: "2", title: "Bookmark Folder with a reasonably long name")) - cells[2].update(from: BookmarkFolder(id: "2", title: "Bookmark Folder with 42 bookmark children", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42))) + cells[0].update(from: Bookmark(id: "1", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), showURL: false) + cells[1].update(from: Bookmark(id: "2", url: "http://aurl.bu/asdfg/ss=1", title: "Some Page bookmarked", isFavorite: true), showURL: true) + cells[1].highlight = true + cells[1].wantsLayer = true + cells[1].layer!.backgroundColor = NSColor.controlAccentColor.cgColor + + let bkm2 = Bookmark(id: "3", url: "http://a.b", title: "Bookmark with longer title to test width", isFavorite: false) + cells[2].update(from: bkm2, showURL: false) + + cells[3].update(from: BookmarkFolder(id: "4", title: "Bookmark Folder with a reasonably long name"), showChevron: true) + cells[4].update(from: BookmarkFolder(id: "5", title: "Bookmark Folder with 42 bookmark children", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42)), showChevron: false) PseudoFolder.favorites.count = 64 - cells[3].update(from: PseudoFolder.favorites) + cells[5].update(from: PseudoFolder.favorites) PseudoFolder.bookmarks.count = 256 - cells[4].update(from: PseudoFolder.bookmarks) + cells[6].update(from: PseudoFolder.bookmarks) + + let sbkm = Bookmark(id: "3", url: "http://a.b", title: "Bookmark in Search mode", isFavorite: false) + cells[9].update(from: sbkm, isSearch: true, showURL: false) + cells[10].update(from: BookmarkFolder(id: "5", title: "Folder in Search mode", children: Array(repeating: Bookmark(id: "2", url: "http://a.b", title: "DuckDuckGo", isFavorite: true), count: 42)), isSearch: true, showChevron: false) widthAnchor.constraint(equalToConstant: 258).isActive = true heightAnchor.constraint(equalToConstant: CGFloat((28 + 1) * cells.count)).isActive = true diff --git a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift index 274582f719..5a89fb351d 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift @@ -26,7 +26,58 @@ extension NSDragOperation { final class BookmarksOutlineView: NSOutlineView { - var lastRow: RoundedSelectionRowView? + private var highlightedRowView: RoundedSelectionRowView? + private var highlightedCellView: BookmarkOutlineCellView? + + @PublishedAfter var highlightedRow: Int? { + didSet { + highlightedRowView?.highlight = false + highlightedCellView?.highlight = false + guard let row = highlightedRow, row < numberOfRows else { return } + if case .keyDown = NSApp.currentEvent?.type { + scrollRowToVisible(row) + } + + let item = item(atRow: row) as? BookmarkNode + let isInKeyPopover = self.isInKeyPopover + let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView + rowView?.isInKeyWindow = isInKeyPopover + rowView?.highlight = item?.canBeHighlighted ?? false + highlightedRowView = rowView + + let cellView = self.view(atColumn: 0, row: row, makeIfNecessary: false) as? BookmarkOutlineCellView + cellView?.isInKeyWindow = isInKeyPopover + cellView?.highlight = item?.canBeHighlighted ?? false + highlightedCellView = cellView + + var window = window + while let windowParent = window?.parent, + type(of: windowParent) == type(of: window!), + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self { + window = windowParent + + outlineView.highlightedRowView?.isInKeyWindow = false + outlineView.highlightedCellView?.isInKeyWindow = false + } + } + } + + private var isInKeyPopover: Bool { + if window?.childWindows?.first(where: { child in + if type(of: child) == type(of: window!), + let scrollView = child.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self, + outlineView.highlightedRow != nil { + true + } else { + false + } + }) != nil { + return false + } + return true + } override func frameOfOutlineCell(atRow row: Int) -> NSRect { let frame = super.frameOfOutlineCell(atRow: row) @@ -51,22 +102,30 @@ final class BookmarksOutlineView: NSOutlineView { } override func viewDidMoveToWindow() { + highlightedRow = nil + super.viewDidMoveToWindow() guard let scrollView = enclosingScrollView else { return } - let trackingArea = NSTrackingArea(rect: .zero, options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil) + let trackingArea = NSTrackingArea(rect: .zero, options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil) scrollView.addTrackingArea(trackingArea) } override func mouseMoved(with event: NSEvent) { - lastRow?.highlight = false - let point = convert(event.locationInWindow, to: nil) - let row = row(at: point) - guard row >= 0, let rowView = rowView(atRow: row, makeIfNecessary: false) as? RoundedSelectionRowView else { return } - let item = item(atRow: row) as? BookmarkNode - rowView.highlight = !(item?.representedObject is SpacerNode) - lastRow = rowView + updateHighlightedRowUnderCursor() + } + + override func mouseExited(with event: NSEvent) { + let windowNumber = NSWindow.windowNumber(at: NSEvent.mouseLocation, belowWindowWithWindowNumber: 0) + if let window = NSApp.window(withWindowNumber: windowNumber), + window.contentViewController?.nextResponder is NSPopover { + + highlightedRowView?.isInKeyWindow = false + highlightedCellView?.isInKeyWindow = false + } else { + highlightedRow = nil + } } func scrollTo(_ item: Any, code: ((Int) -> Void)? = nil) { @@ -101,6 +160,21 @@ final class BookmarksOutlineView: NSOutlineView { } } + func updateHighlightedRowUnderCursor() { + let point = mouseLocationInsideBounds() + let row = point.map { self.row(at: NSPoint(x: self.bounds.midX, y: $0.y)) } ?? -1 + guard row >= 0, row < NSNotFound else { + highlightedRow = nil + return + } + if highlightedRow != row { + highlightedRow = row + } else { + highlightedRowView?.isInKeyWindow = true + highlightedCellView?.isInKeyWindow = true + } + } + func isItemVisible(_ item: Any) -> Bool { let rowIndex = self.row(forItem: item) @@ -111,4 +185,5 @@ final class BookmarksOutlineView: NSOutlineView { let visibleRowsRange = self.rows(in: self.visibleRect) return visibleRowsRange.contains(rowIndex) } + } diff --git a/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift b/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift index 9413168874..61209de88a 100644 --- a/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift +++ b/DuckDuckGo/Bookmarks/View/OutlineSeparatorViewCell.swift @@ -20,6 +20,16 @@ import AppKit final class OutlineSeparatorViewCell: NSTableCellView { + static let separatorIdentifier = NSUserInterfaceItemIdentifier(className() + "_separator") + static let blankIdentifier = NSUserInterfaceItemIdentifier(className()) + + static func rowHeight(for mode: BookmarkListViewController.Mode) -> CGFloat { + switch mode { + case .bookmarkBarMenu: 11 + case .popover: 28 + } + } + private lazy var separatorView: NSBox = { let box = NSBox() box.translatesAutoresizingMaskIntoConstraints = false @@ -28,13 +38,15 @@ final class OutlineSeparatorViewCell: NSTableCellView { return box }() - init(separatorVisible: Bool = false) { + init(identifier: NSUserInterfaceItemIdentifier) { super.init(frame: .zero) + self.identifier = identifier + } - // Previous value of 20 was being ignored anyway and causes lots of console logging - self.heightAnchor.constraint(equalToConstant: 28).isActive = true + convenience init(isSeparatorVisible: Bool = false) { + self.init(identifier: isSeparatorVisible ? Self.separatorIdentifier : Self.blankIdentifier) - if separatorVisible { + if isSeparatorVisible { addSubview(separatorView) separatorView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 5).isActive = true diff --git a/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift b/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift index c66224aa4f..eea1567823 100644 --- a/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift +++ b/DuckDuckGo/Bookmarks/View/RoundedSelectionRowView.swift @@ -25,6 +25,11 @@ final class RoundedSelectionRowView: NSTableRowView { needsDisplay = true } } + var isInKeyWindow = true { + didSet { + needsDisplay = true + } + } var insets = NSEdgeInsets() @@ -65,7 +70,12 @@ final class RoundedSelectionRowView: NSTableRowView { selectionRect.size.height -= (insets.top + insets.bottom) let path = NSBezierPath(roundedRect: selectionRect, xRadius: 6, yRadius: 6) - NSColor.buttonMouseOver.setFill() + + if isInKeyWindow { + NSColor.controlAccentColor.setFill() + } else { + NSColor.buttonMouseOver.setFill() + } path.fill() } diff --git a/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard b/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard index b5d176243a..82c7fae914 100644 --- a/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard +++ b/DuckDuckGo/BookmarksBar/View/BookmarksBar.storyboard @@ -1,7 +1,7 @@ - + - + @@ -28,7 +28,7 @@ - + @@ -46,12 +46,27 @@ -