diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 76cb4792fa..edb2749820 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1659,6 +1659,18 @@ 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, ); }; }; + 841211362C4631E900088FBC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841211352C4631E900088FBC /* BookmarkListPopover.swift */; }; + 841211372C4631E900088FBC /* BookmarkListPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 841211352C4631E900088FBC /* BookmarkListPopover.swift */; }; + 844683082C537F5200A15F93 /* MenuItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844683072C537F5200A15F93 /* MenuItemNode.swift */; }; + 844683092C537F5200A15F93 /* MenuItemNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 844683072C537F5200A15F93 /* MenuItemNode.swift */; }; + 8446830B2C59015200A15F93 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8446830A2C59015200A15F93 /* SteppedScrollView.swift */; }; + 8446830C2C59015200A15F93 /* SteppedScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8446830A2C59015200A15F93 /* SteppedScrollView.swift */; }; + 84CB98002C636A8F0063F1D2 /* BookmarkDragDropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB97FF2C636A8F0063F1D2 /* BookmarkDragDropManager.swift */; }; + 84CB98012C636A8F0063F1D2 /* BookmarkDragDropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB97FF2C636A8F0063F1D2 /* BookmarkDragDropManager.swift */; }; + 84CB98032C648BA00063F1D2 /* NSDragOperationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB98022C648BA00063F1D2 /* NSDragOperationExtension.swift */; }; + 84CB98042C648BA00063F1D2 /* NSDragOperationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB98022C648BA00063F1D2 /* NSDragOperationExtension.swift */; }; + 84CB98062C64D9580063F1D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB98052C64D9580063F1D2 /* ItemCachingCollectionView.swift */; }; + 84CB98072C64D9580063F1D2 /* ItemCachingCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84CB98052C64D9580063F1D2 /* ItemCachingCollectionView.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 +3595,12 @@ 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 = ""; }; + 841211352C4631E900088FBC /* BookmarkListPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkListPopover.swift; sourceTree = ""; }; + 844683072C537F5200A15F93 /* MenuItemNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItemNode.swift; sourceTree = ""; }; + 8446830A2C59015200A15F93 /* SteppedScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedScrollView.swift; sourceTree = ""; }; + 84CB97FF2C636A8F0063F1D2 /* BookmarkDragDropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkDragDropManager.swift; sourceTree = ""; }; + 84CB98022C648BA00063F1D2 /* NSDragOperationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSDragOperationExtension.swift; sourceTree = ""; }; + 84CB98052C64D9580063F1D2 /* ItemCachingCollectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemCachingCollectionView.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 = ""; }; @@ -5974,14 +5992,15 @@ 4BD18F02283F0F1000058124 /* View */ = { 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 */, + 84CB98052C64D9580063F1D2 /* ItemCachingCollectionView.swift */, + 859F30622A72A7A900C20372 /* Prompt */, ); path = View; sourceTree = ""; @@ -6374,6 +6393,7 @@ B693954226F04BE90015B914 /* ShadowView.swift */, 4BDFA4AD27BF19E500648192 /* ToggleableScrollView.swift */, B693954526F04BEA0015B914 /* WindowDraggingView.swift */, + 8446830A2C59015200A15F93 /* SteppedScrollView.swift */, ); path = AppKit; sourceTree = ""; @@ -7588,14 +7608,15 @@ AAC5E4C425D6A6E8007F5990 /* AddBookmarkPopover.swift */, 7BEC20402B0F505F00243D3E /* AddBookmarkPopoverView.swift */, B69A14F92B4D705D00B9417D /* BookmarkFolderPicker.swift */, + 841211352C4631E900088FBC /* 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 */, @@ -7616,6 +7637,7 @@ 4B92929A26670D2A00AD2C21 /* PasteboardWriting.swift */, 4B92929826670D2A00AD2C21 /* PseudoFolder.swift */, 4B92929626670D2A00AD2C21 /* SpacerNode.swift */, + 844683072C537F5200A15F93 /* MenuItemNode.swift */, 4B92929726670D2A00AD2C21 /* BookmarkTreeController.swift */, AAC5E4CD25D6A709007F5990 /* Bookmark.swift */, AAC5E4CF25D6A709007F5990 /* BookmarkList.swift */, @@ -7625,6 +7647,7 @@ 9F872DA22B90920F00138637 /* BookmarkFolderInfo.swift */, BBB881872C4029BA001247C6 /* BookmarkListTreeControllerSearchDataSource.swift */, BB7B5F972C4ED73800BA4AF8 /* BookmarksSearchAndSortMetrics.swift */, + 84CB97FF2C636A8F0063F1D2 /* BookmarkDragDropManager.swift */, ); path = Model; sourceTree = ""; @@ -7740,8 +7763,8 @@ B6DB3CF826A00E2D00D459B7 /* AVCaptureDevice+SwizzledAuthState.swift */, B6106B9D26A565DA0013B453 /* BundleExtension.swift */, B626A75F2992407C00053070 /* CancellableExtension.swift */, - 4BA1A6C1258B0A1300F6F690 /* ContiguousBytesExtension.swift */, B603FD9D2A02712E00F3FCA9 /* CIImageExtension.swift */, + 4BA1A6C1258B0A1300F6F690 /* ContiguousBytesExtension.swift */, 85AC3AF625D5DBFD00C7D2AA /* DataExtension.swift */, B6040855274B830F00680351 /* DictionaryExtension.swift */, B63D467025BFA6C100874977 /* DispatchQueueExtensions.swift */, @@ -7750,7 +7773,6 @@ B634DBE6293C98C500C3C99E /* FutureExtension.swift */, 1DA6D0FC2A1FF9A100540406 /* HTTPCookie.swift */, AAECA41F24EEA4AC00EFA63A /* IndexPathExtension.swift */, - EEE50C282C38249C003DD7FF /* OptionalExtension.swift */, 0230C0A2272080090018F728 /* KeyedCodingExtension.swift */, 4B8D9061276D1D880078DB17 /* LocaleExtension.swift */, B66B9C5B29A5EBAD0010E8F3 /* NavigationActionExtension.swift */, @@ -7762,6 +7784,7 @@ B65E6B9F26D9F10600095F96 /* NSBezierPathExtension.swift */, B63D467925BFC3E100874977 /* NSCoderExtensions.swift */, F41D174025CB131900472416 /* NSColorExtension.swift */, + 84CB98022C648BA00063F1D2 /* NSDragOperationExtension.swift */, 31B4AF522901A4F20013585E /* NSEventExtension.swift */, B657841825FA484B00D8DB33 /* NSException+Catch.h */, B657841925FA484B00D8DB33 /* NSException+Catch.m */, @@ -7789,9 +7812,10 @@ AA9E9A5525A3AE8400D1959D /* NSWindowExtension.swift */, B643BF1327ABF772000BACEC /* NSWorkspaceExtension.swift */, B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */, + EEE50C282C38249C003DD7FF /* OptionalExtension.swift */, B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */, - B684592125C93BE000DC17B6 /* Publisher.asVoid.swift */, B68C2FB127706E6A00BF2C7D /* ProcessExtension.swift */, + B684592125C93BE000DC17B6 /* Publisher.asVoid.swift */, B684592625C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift */, 4BB88B4925B7B690006F6B06 /* SequenceExtensions.swift */, 1DFAB51C2A8982A600A0F7F6 /* SetExtension.swift */, @@ -7807,11 +7831,11 @@ B65C7DFA2B886CF0001E2D5C /* WKPDFHUDViewWrapper.swift */, B645D8F529FA95440024461F /* WKProcessPoolExtension.swift */, AAA0CC69253CC43C0079BC96 /* WKUserContentControllerExtension.swift */, + 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */, B63D466725BEB6C200874977 /* WKWebView+Private.h */, B63D466825BEB6C200874977 /* WKWebView+SessionState.swift */, B68458CC25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift */, AA92127625ADA07900600CD4 /* WKWebViewExtension.swift */, - 4B7A60A0273E0BE400BBDFEB /* WKWebsiteDataStoreExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -9953,6 +9977,7 @@ 85393C872A6FF1B600F11EB3 /* BookmarksBarAppearance.swift in Sources */, 3706FAB2293F65D500E42796 /* TabInstrumentation.swift in Sources */, 3706FAB5293F65D500E42796 /* ConfigurationManager.swift in Sources */, + 844683092C537F5200A15F93 /* MenuItemNode.swift in Sources */, 3706FAB6293F65D500E42796 /* YoutubePlayerUserScript.swift in Sources */, 1D8057C92A83CB3C00F4FED6 /* SupportedOsChecker.swift in Sources */, 373D9B4929EEAC1B00381FDD /* SyncMetadataDatabase.swift in Sources */, @@ -9972,6 +9997,7 @@ 3706FAC0293F65D500E42796 /* DataTaskProviding.swift in Sources */, 3706FAC1293F65D500E42796 /* FeedbackViewController.swift in Sources */, 3706FAC2293F65D500E42796 /* FaviconSelector.swift in Sources */, + 84CB98072C64D9580063F1D2 /* ItemCachingCollectionView.swift in Sources */, B696AFFC2AC5924800C93203 /* FileLineError.swift in Sources */, F1D43AEF2B98D8DF00BAB743 /* MainMenuActions+VanillaBrowser.swift in Sources */, 56BA1E8B2BB1CB5B001CF69F /* CertificateTrustEvaluator.swift in Sources */, @@ -10063,6 +10089,7 @@ 3706FAFE293F65D500E42796 /* SpacerNode.swift in Sources */, 3706FB00293F65D500E42796 /* PasswordManagementCreditCardModel.swift in Sources */, 3706FB01293F65D500E42796 /* NSEventExtension.swift in Sources */, + 84CB98012C636A8F0063F1D2 /* BookmarkDragDropManager.swift in Sources */, 3706FB02293F65D500E42796 /* Onboarding.swift in Sources */, 3706FEB8293F6EFB00E42796 /* ConnectBitwardenView.swift in Sources */, 1DFAB51E2A8982A600A0F7F6 /* SetExtension.swift in Sources */, @@ -10288,6 +10315,7 @@ 1D36F4252A3B85C50052B527 /* TabCleanupPreparer.swift in Sources */, 3706FB97293F65D500E42796 /* ActionSpeech.swift in Sources */, B6AFE6BD29A5D621002FF962 /* HTTPSUpgradeTabExtension.swift in Sources */, + 8446830C2C59015200A15F93 /* SteppedScrollView.swift in Sources */, 3706FB9A293F65D500E42796 /* FireproofDomainsStore.swift in Sources */, 3706FB9B293F65D500E42796 /* PrivacyDashboardPermissionHandler.swift in Sources */, 3706FB9C293F65D500E42796 /* TabCollectionViewModel.swift in Sources */, @@ -10403,6 +10431,7 @@ EEC4A6722B2C90AB00F7C0AA /* VPNLocationPreferenceItem.swift in Sources */, 3706FBE0293F65D500E42796 /* NSException+Catch.m in Sources */, 3706FBE1293F65D500E42796 /* AppStateRestorationManager.swift in Sources */, + 841211372C4631E900088FBC /* BookmarkListPopover.swift in Sources */, 9FA173EC2B7B232200EE4E6E /* AddEditBookmarkDialogView.swift in Sources */, 3706FBE2293F65D500E42796 /* ClickToLoadUserScript.swift in Sources */, 3706FBE3293F65D500E42796 /* WindowControllersManager.swift in Sources */, @@ -10536,6 +10565,7 @@ 3706FC37293F65D500E42796 /* CoreDataStore.swift in Sources */, 3706FC38293F65D500E42796 /* BundleExtension.swift in Sources */, 4B9DB04B2A983B24000927DB /* NotificationService.swift in Sources */, + 84CB98042C648BA00063F1D2 /* NSDragOperationExtension.swift in Sources */, 3706FC3A293F65D500E42796 /* NSOpenPanelExtensions.swift in Sources */, F118EA862BEACC7000F77634 /* NonStandardPixel.swift in Sources */, 3706FC3B293F65D500E42796 /* FirePopover.swift in Sources */, @@ -11491,6 +11521,7 @@ 37AFCE9227DB8CAD00471A10 /* PreferencesAboutView.swift in Sources */, F1D042A12BFBB4DD00A31506 /* DataBrokerProtectionSettings+Environment.swift in Sources */, 4B2F565C2B38F93E001214C0 /* NetworkProtectionSubscriptionEventHandler.swift in Sources */, + 8446830B2C59015200A15F93 /* SteppedScrollView.swift in Sources */, 9826B0A02747DF3D0092F683 /* ContentBlocking.swift in Sources */, 4B379C2227BDBA29008A968E /* LocalAuthenticationService.swift in Sources */, 37CEFCA92A6737A2001EF741 /* CredentialsCleanupErrorHandling.swift in Sources */, @@ -11541,6 +11572,7 @@ 4B4032842AAAC24400CCA602 /* WaitlistActivationDateStore.swift in Sources */, 1D220BFC2B87AACF00F8BBC6 /* PrivacyProtectionStatus.swift in Sources */, AA512D1424D99D9800230283 /* FaviconManager.swift in Sources */, + 84CB98062C64D9580063F1D2 /* ItemCachingCollectionView.swift in Sources */, 7BB108592A43375D000AB95F /* PFMoveApplication.m in Sources */, 7BB4BC632C5BC13D00E06FC8 /* SiteTroubleshootingInfoPublisher.swift in Sources */, 4B0AACAC28BC63ED001038AC /* ChromiumFaviconsReader.swift in Sources */, @@ -11760,6 +11792,7 @@ 3154FD1428E6011A00909769 /* TabShadowView.swift in Sources */, 1D43EB3C292B664A0065E5D6 /* BWMessageIdGenerator.swift in Sources */, B6E6B9E32BA1F5F1008AA7E1 /* FilePresenter.swift in Sources */, + 841211362C4631E900088FBC /* BookmarkListPopover.swift in Sources */, B6CC266C2BAD9CD800F53F8D /* FileProgressPresenter.swift in Sources */, 3158B14D2B0BF74D00AF130C /* DataBrokerProtectionManager.swift in Sources */, BBFB727F2C48047C0088884C /* SortBookmarksViewModel.swift in Sources */, @@ -11800,6 +11833,7 @@ 1D69C553291302F200B75945 /* BWVault.swift in Sources */, AA6FFB4424DC33320028F4D0 /* NSViewExtension.swift in Sources */, B6C0B23E26E8BF1F0031CB7F /* DownloadListViewModel.swift in Sources */, + 84CB98032C648BA00063F1D2 /* NSDragOperationExtension.swift in Sources */, 1D9A37672BD8EA8800EBC58D /* DockPositionProvider.swift in Sources */, 4B9292D52667123700AD2C21 /* BookmarkManagementDetailViewController.swift in Sources */, F188267C2BBEB3AA00D9AC4F /* GeneralPixel.swift in Sources */, @@ -11900,6 +11934,7 @@ B6F1B02A2BCE675C005E863C /* NetworkProtectionControllerTabExtension.swift in Sources */, B6C8CAA72AD010DD0060E1CD /* YandexDataImporter.swift in Sources */, EE66666F2B56EDE4001D898D /* VPNLocationsHostingViewController.swift in Sources */, + 84CB98002C636A8F0063F1D2 /* BookmarkDragDropManager.swift in Sources */, 37CC53EC27E8A4D10028713D /* PreferencesDataClearingView.swift in Sources */, 4B0135CE2729F1AA00D54834 /* NSPasteboardExtension.swift in Sources */, 85707F31276A7DCA00DC0649 /* OnboardingViewModel.swift in Sources */, @@ -11998,6 +12033,7 @@ 37BF3F21286F0A7A00BD9014 /* PinnedTabsViewModel.swift in Sources */, EEC4A6692B2C87D300F7C0AA /* VPNLocationView.swift in Sources */, AAC5E4D225D6A709007F5990 /* BookmarkList.swift in Sources */, + 844683082C537F5200A15F93 /* MenuItemNode.swift in Sources */, B602E81D2A1E25B1006D261F /* NEOnDemandRuleExtension.swift in Sources */, 56A0543E2C215FB3007D8FAB /* OnboardingUserScript.swift in Sources */, C1372EF42BBC5BAD003F8793 /* SecureTextField.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/BookmarkDragDropManager.swift b/DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift new file mode 100644 index 0000000000..ff0f77cf31 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/BookmarkDragDropManager.swift @@ -0,0 +1,198 @@ +// +// BookmarkDragDropManager.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 Common +import Foundation + +final class BookmarkDragDropManager { + + static let shared = BookmarkDragDropManager() + + static let draggedTypes: [NSPasteboard.PasteboardType] = [ + .string, + .URL, + BookmarkPasteboardWriter.bookmarkUTIInternalType, + FolderPasteboardWriter.folderUTIInternalType + ] + + private let bookmarkManager: BookmarkManager + + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + self.bookmarkManager = bookmarkManager + } + + func validateDrop(_ info: NSDraggingInfo, to destination: Any) -> NSDragOperation { + let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: info.draggingPasteboard.pasteboardItems) + let folders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard.pasteboardItems) + + let bookmarksDragOperation = bookmarks.flatMap { validateMove(for: $0, destination: destination) } + let foldersDragOperation = folders.flatMap { validateMove(for: $0, destination: destination) } + + switch (bookmarksDragOperation, foldersDragOperation) { + // If the dragged values contain both folders and bookmarks, only validate the move if all objects can be moved. + case (true, true), (true, nil), (nil, true): + return .move + default: + return .none + } + } + + private func validateMove(for draggedBookmarks: Set, destination: Any) -> Bool? { + guard !draggedBookmarks.isEmpty else { return nil } + guard destination is BookmarkFolder || destination is PseudoFolder else { return false } + + return true + } + + private func validateMove(for draggedFolders: Set, destination: Any) -> Bool? { + guard !draggedFolders.isEmpty else { return nil } + + guard let destinationFolder = destination as? BookmarkFolder else { + if destination as? PseudoFolder == .bookmarks { + return true + } + return false + } + + // Folders cannot be dragged onto themselves or any of their descendants: + return draggedFolders.allSatisfy { folder in + bookmarkManager.canMoveObjectWithUUID(objectUUID: folder.id, to: destinationFolder) + } + } + + @discardableResult + @MainActor + func acceptDrop(_ info: NSDraggingInfo, to destination: Any, at index: Int) -> Bool { + defer { + // prevent other drop targets accepting the dragged items twice + info.draggingPasteboard.clearContents() + } + guard let draggedObjectIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), !draggedObjectIdentifiers.isEmpty else { + return createBookmarks(from: info.draggingPasteboard.pasteboardItems ?? [], in: destination, at: index, window: info.draggingDestinationWindow) + } + + switch destination { + case let folder as BookmarkFolder: + if folder.id == PseudoFolder.bookmarks.id { fallthrough } + + let index = (index == -1 || index == NSNotFound) ? 0 : index + let parent: ParentFolderType = (folder.id == PseudoFolder.bookmarks.id) ? .root : .parent(uuid: folder.id) + bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: parent) { error in + if let error = error { + os_log("Failed to accept existing parent drop via outline view: %s", error.localizedDescription) + } + } + + case is PseudoFolder where (destination as? PseudoFolder) == .bookmarks: + if index == -1 || index == NSNotFound { + bookmarkManager.add(objectsWithUUIDs: draggedObjectIdentifiers, to: nil) { error in + if let error = error { + os_log("Failed to accept nil parent drop via outline view: %s", error.localizedDescription) + } + } + } else { + bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: .root) { error in + if let error = error { + os_log("Failed to accept nil parent drop via outline view: %s", error.localizedDescription) + } + } + } + + case let pseudoFolder as PseudoFolder where pseudoFolder == .favorites: + if index == -1 || index == NSNotFound { + bookmarkManager.update(objectsWithUUIDs: draggedObjectIdentifiers, update: { entity in + let bookmark = entity as? Bookmark + bookmark?.isFavorite = true + }, completion: { error in + if let error = error { + os_log("Failed to update entities during drop via outline view: %s", error.localizedDescription) + } + }) + } else { + bookmarkManager.moveFavorites(with: draggedObjectIdentifiers, toIndex: index) { error in + if let error = error { + os_log("Failed to update entities during drop via outline view: %s", error.localizedDescription) + } + } + } + + default: + assertionFailure("Unknown destination: \(destination)") + return false + } + + return true + } + + @MainActor + private func createBookmarks(from pasteboardItems: [NSPasteboardItem], in destination: Any, at index: Int, window: NSWindow?) -> Bool { + var parent: BookmarkFolder? + var isFavorite = false + + switch destination { + case let pseudoFolder as PseudoFolder where pseudoFolder == .favorites: + isFavorite = true + case let pseudoFolder as PseudoFolder where pseudoFolder == .bookmarks: + isFavorite = false + + case let folder as BookmarkFolder: + parent = folder + + default: + assertionFailure("Unknown destination: \(destination)") + return false + } + + var currentIndex = index + for item in pasteboardItems { + let url: URL + let title: String + func titleFromUrlDroppingSchemeIfNeeded(_ url: URL) -> String { + let title = url.absoluteString + // drop `http[s]://` from bookmark URL used as its title + if let scheme = url.navigationalScheme, scheme.isHypertextScheme { + return title.dropping(prefix: scheme.separated()) + } + return title + } + if let webViewItem = item.draggedWebViewValues() { + url = webViewItem.url + title = webViewItem.title ?? self.title(forTabWith: webViewItem.url, in: window) ?? titleFromUrlDroppingSchemeIfNeeded(url) + } else if let draggedString = item.string(forType: .string), + let draggedURL = URL(string: draggedString) { + url = draggedURL + title = self.title(forTabWith: draggedURL, in: window) ?? titleFromUrlDroppingSchemeIfNeeded(url) + } else { + continue + } + + self.bookmarkManager.makeBookmark(for: url, title: title, isFavorite: isFavorite, index: currentIndex, parent: parent) + currentIndex += 1 + } + + return currentIndex > index + } + + @MainActor + private func title(forTabWith url: URL, in window: NSWindow?) -> String? { + guard let mainViewController = window?.contentViewController as? MainViewController else { return nil } + return mainViewController.tabCollectionViewModel.title(forTabWithURL: url) + } + +} diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift index c7f9afbcd1..d249aa59fd 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkListTreeControllerSearchDataSource.swift @@ -25,7 +25,7 @@ final class BookmarkListTreeControllerSearchDataSource: BookmarkTreeControllerSe self.bookmarkManager = bookmarkManager } - func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { + func nodes(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] { let searchResults = bookmarkManager.search(by: searchQuery) return rebuildChildNodes(for: searchResults.sorted(by: sortMode)) diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift b/DuckDuckGo/Bookmarks/Model/BookmarkNode.swift index 8ef9a2ac86..ff06e23f91 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 { @@ -67,6 +67,16 @@ final class BookmarkNode: Hashable { return 0 } + var canBeHighlighted: Bool { + if representedObject is SpacerNode { + return false + } else if let menuItem = representedObject as? MenuItemNode { + return menuItem.isEnabled + } else { + return true + } + } + /// Creates an instance of a bookmark node. /// - Parameters: /// - representedObject: The represented object contained in the node. @@ -144,11 +154,7 @@ final class BookmarkNode: Hashable { } func childNodeRepresenting(object: AnyObject) -> BookmarkNode? { - return findNodeRepresenting(object: object, recursively: false) - } - - func descendantNodeRepresenting(object: AnyObject) -> BookmarkNode? { - return findNodeRepresenting(object: object, recursively: true) + return childNodes.first { $0.representedObjectEquals(object) } } func isAncestor(of node: BookmarkNode) -> Bool { @@ -169,20 +175,6 @@ final class BookmarkNode: Hashable { } } - func findNodeRepresenting(object: AnyObject, recursively: Bool = false) -> BookmarkNode? { - for childNode in childNodes { - if childNode.representedObjectEquals(object) { - return childNode - } - - if recursively, let foundNode = childNode.descendantNodeRepresenting(object: object) { - return foundNode - } - } - - return nil - } - // MARK: - Hashable func hash(into hasher: inout Hasher) { diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift index 09fa347374..989a244281 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkOutlineViewDataSource.swift @@ -25,32 +25,60 @@ 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() private(set) var isSearching = false /// When a drag and drop to a folder happens while in search, we need to stor the destination folder /// so we can expand the tree to the destination folder once the drop finishes. - private(set) var dragDestinationFolderInSearchMode: BookmarkFolder? + @Published private(set) var dragDestinationFolder: BookmarkFolder? + + /// Represents currently highlighted drag&drop target row + @PublishedAfter var targetRowForDropOperation: Int? { + didSet { + guard let outlineView else { return } + // unhighlight old highlighted row + if let oldValue, oldValue != targetRowForDropOperation, + oldValue < outlineView.numberOfRows, + let oldTargetRowViewForDropOperation = outlineView.rowView(atRow: oldValue, makeIfNecessary: false), + oldTargetRowViewForDropOperation.isTargetForDropOperation { + oldTargetRowViewForDropOperation.isTargetForDropOperation = false + } + // highlight newly highlighted row on value change from outside + if let targetRowForDropOperation, + targetRowForDropOperation < outlineView.numberOfRows, + let targetRowViewForDropOperation = outlineView.rowView(atRow: targetRowForDropOperation, makeIfNecessary: false), + !targetRowViewForDropOperation.isTargetForDropOperation { + targetRowViewForDropOperation.isTargetForDropOperation = true + } + } + } + private let treeController: BookmarkTreeController private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager 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, treeController: BookmarkTreeController, + dragDropManager: BookmarkDragDropManager = .shared, sortMode: BookmarksSortMode, showMenuButtonOnHover: Bool = true, onMenuRequestedAction: ((BookmarkOutlineCellView) -> Void)? = nil, @@ -58,32 +86,24 @@ final class BookmarkOutlineViewDataSource: NSObject, NSOutlineViewDataSource, NS ) { self.contentMode = contentMode self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager self.treeController = treeController self.showMenuButtonOnHover = showMenuButtonOnHover self.onMenuRequestedAction = onMenuRequestedAction 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) + dragDestinationFolder = nil + treeController.rebuild(for: sortMode, withRootFolder: rootFolder) } - func reloadData(for searchQuery: String, and sortMode: BookmarksSortMode) { + func reloadData(forSearchQuery 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 + treeController.rebuild(forSearchQuery: searchQuery, sortMode: sortMode) } // MARK: - Private @@ -113,6 +133,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 +144,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 + contentMode == .bookmarksMenu ? false : nodeForItem(item).canHaveChildNodes } func outlineViewSelectionDidChange(_ notification: Notification) { @@ -146,38 +170,52 @@ 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 row = outlineView.row(forItem: item) + let rowView = RoundedSelectionRowView() + rowView.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + // observe row drag&drop target highlight state and update `targetRowForDropOperation` + let cancellable = rowView.publisher(for: \.isTargetForDropOperation).sink { [weak self] isTargetForDropOperation in + guard let self else { return } + if isTargetForDropOperation { + if self.targetRowForDropOperation != row { + self.targetRowForDropOperation = row + } + } else if self.targetRowForDropOperation == row { + self.targetRowForDropOperation = nil } - - return cell } + rowView.onDeinit { + withExtendedLifetime(cancellable) {} + } + return rowView + } - 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,173 +223,50 @@ 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 { - // when in folders sidebar mode only allow moving a folder to another folder (or root) - if destinationNode.representedObject is BookmarkFolder - || (destinationNode.representedObject as? PseudoFolder == .bookmarks) { - return .move - } - return .none - } - - if isSearching { - if let destinationFolder = destinationNode.representedObject as? BookmarkFolder { - self.dragDestinationFolderInSearchMode = destinationFolder - return .move - } - - return .none - } - - let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: info.draggingPasteboard.pasteboardItems) - let folders = PasteboardFolder.pasteboardFolders(with: info.draggingPasteboard.pasteboardItems) - - if let bookmarks = bookmarks, let folders = folders { - let canMoveBookmarks = validateDrop(for: bookmarks, destination: destinationNode) == .move - let canMoveFolders = validateDrop(for: folders, destination: destinationNode) == .move - - // If the dragged values contain both folders and bookmarks, only validate the move if all objects can be moved. - if canMoveBookmarks, canMoveFolders { - return .move - } else { - return .none - } - } - - if let bookmarks = bookmarks { - return validateDrop(for: bookmarks, destination: destinationNode) - } - - if let folders = folders { - return validateDrop(for: folders, destination: destinationNode) - } - - return .none - } - - func validateDrop(for draggedBookmarks: Set, destination: BookmarkNode) -> NSDragOperation { - guard destination.representedObject is BookmarkFolder || destination.representedObject is PseudoFolder || destination.isRoot else { + if contentMode == .foldersOnly, destinationNode.isRoot { + // disable dropping at Root in Bookmark Manager tree view return .none } - return .move - } - - func validateDrop(for draggedFolders: Set, destination: BookmarkNode) -> NSDragOperation { - if destination.isRoot { - return .move - } - - if let pseudoFolder = destination.representedObject as? PseudoFolder, pseudoFolder == .bookmarks { - return .move - } - - guard let destinationFolder = destination.representedObject as? BookmarkFolder else { - return .none - } - - // Folders cannot be dragged onto themselves: - - let containsDestination = draggedFolders.contains { draggedFolder in - return draggedFolder.id == destinationFolder.id - } - - if containsDestination { - return .none - } - - // Folders cannot be dragged onto any of their descendants: - - let containsDescendantOfDestination = draggedFolders.contains { draggedFolder in - let folder = BookmarkFolder(id: draggedFolder.id, title: draggedFolder.name, parentFolderUUID: draggedFolder.parentFolderUUID, children: draggedFolder.children) + let operation = dragDropManager.validateDrop(info, to: destinationNode.representedObject) + self.dragDestinationFolder = (operation == .none || item == nil) ? nil : destinationNode.representedObject as? BookmarkFolder - guard let draggedNode = treeController.findNodeWithId(representing: folder) else { - return false - } - - let descendant = draggedNode.descendantNodeRepresenting(object: destination.representedObject) - - return descendant != nil - } - - if containsDescendantOfDestination { - return .none - } - - return .move + return operation } func outlineView(_ outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: Any?, childIndex index: Int) -> Bool { - guard let draggedObjectIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), - !draggedObjectIdentifiers.isEmpty else { - return false - } - - let representedObject = (item as? BookmarkNode)?.representedObject - - // Handle the nil destination case: - - if contentMode == .bookmarksAndFolders, - let pseudoFolder = representedObject as? PseudoFolder { - if pseudoFolder == .favorites { - bookmarkManager.update(objectsWithUUIDs: draggedObjectIdentifiers, update: { entity in - let bookmark = entity as? Bookmark - bookmark?.isFavorite = true - }, completion: { error in - if let error = error { - os_log("Failed to update entities during drop via outline view: %s", error.localizedDescription) - } - }) - } else if pseudoFolder == .bookmarks { - bookmarkManager.add(objectsWithUUIDs: draggedObjectIdentifiers, to: nil) { error in - if let error = error { - os_log("Failed to accept nil parent drop via outline view: %s", error.localizedDescription) - } - } - } - - return true - } - - // Handle the existing destination case: - - var index = index - // for folders-only calculate new real index based on the nearest folder index - if contentMode == .foldersOnly, - index > -1, - // get folder before the insertion point (or the first one) - let nearestObject = (outlineView.child(max(0, index - 1), ofItem: item) as? BookmarkNode)?.representedObject as? BookmarkFolder, - // get all the children of a new parent folder - let siblings = (representedObject as? BookmarkFolder)?.children ?? bookmarkManager.list?.topLevelEntities { - - // insert after the nearest item (or in place of the nearest item for index == 0) - index = (siblings.firstIndex(of: nearestObject) ?? 0) + (index == 0 ? 0 : 1) - } else if index == -1 { - // drop onto folder - index = 0 + var representedObject = (item as? BookmarkNode)?.representedObject ?? (treeController.rootNode.isRoot ? nil : treeController.rootNode.representedObject) + if (representedObject as? BookmarkFolder)?.id == PseudoFolder.bookmarks.id { + // BookmarkFolder with id == PseudoFolder.bookmarks.id is used for Clipped Items menu + // use the PseudoFolder.bookmarks root as the destination and calculate drop index based on nearest items indices. + representedObject = PseudoFolder.bookmarks } - - let parent: ParentFolderType = (representedObject as? BookmarkFolder).map { .parent(uuid: $0.id) } ?? .root - bookmarkManager.move(objectUUIDs: draggedObjectIdentifiers, toIndex: index, withinParentFolder: parent) { error in - if let error = error { - os_log("Failed to accept existing parent drop via outline view: %s", error.localizedDescription) + let index = { + // for folders-only calculate new real index based on the nearest folder index + if contentMode == .foldersOnly || representedObject is PseudoFolder, + index > -1, + // get folder before the insertion point (or the first one) + let nearestObject = (outlineView.child(max(0, index - 1), ofItem: item) as? BookmarkNode)?.representedObject as? BookmarkFolder, + // get all the children of a new parent folder (take actual bookmark list for the root) + let siblings = ((representedObject is PseudoFolder ? nil : representedObject) as? BookmarkFolder)?.children ?? bookmarkManager.list?.topLevelEntities { + + // insert after the nearest item (or in place of the nearest item for index == 0) + return (siblings.firstIndex(of: nearestObject) ?? 0) + (index == 0 ? 0 : 1) + } else if index == -1 { + // drop onto folder + return 0 } - } + return index + }() - return true + return dragDropManager.acceptDrop(info, to: representedObject ?? PseudoFolder.bookmarks, at: index) } - func outlineView(_ outlineView: NSOutlineView, rowViewForItem item: Any) -> NSTableRowView? { - let view = RoundedSelectionRowView() - view.insets = NSEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) - - return view + func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + dragDestinationFolder = nil } // MARK: - NSTableViewDelegate diff --git a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift index 9735a5d677..62124e1d24 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkSidebarTreeController.swift @@ -34,6 +34,8 @@ 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..5bcf776d9b 100644 --- a/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift +++ b/DuckDuckGo/Bookmarks/Model/BookmarkTreeController.swift @@ -19,46 +19,73 @@ import Foundation protocol BookmarkTreeControllerDataSource: AnyObject { - func treeController(childNodesFor: BookmarkNode, sortMode: BookmarksSortMode) -> [BookmarkNode] } protocol BookmarkTreeControllerSearchDataSource: AnyObject { - - func nodes(for searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] + func nodes(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) -> [BookmarkNode] } 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? 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 + } + + private static func menuItemNode(withIdentifier identifier: String, title: String, isEnabled: Bool = true, for parentNode: BookmarkNode) -> BookmarkNode { + let spacerObject = MenuItemNode(identifier: identifier, title: title, isEnabled: isEnabled) + let node = BookmarkNode(representedObject: spacerObject, parent: parentNode) + return node } // MARK: - Public - func rebuild(for searchQuery: String, sortMode: BookmarksSortMode) { - rootNode.childNodes = searchDataSource?.nodes(for: searchQuery, sortMode: sortMode) ?? [] + func rebuild(forSearchQuery searchQuery: String, sortMode: BookmarksSortMode) { + rootNode.childNodes = searchDataSource?.nodes(forSearchQuery: 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) } @@ -101,9 +128,7 @@ final class BookmarkTreeController { @discardableResult private func rebuildChildNodes(node: BookmarkNode, sortMode: BookmarksSortMode = .manual) -> Bool { - guard node.canHaveChildNodes else { - return false - } + guard node.canHaveChildNodes else { return false } let childNodes: [BookmarkNode] = dataSource?.treeController(childNodesFor: node, sortMode: sortMode) ?? [] var childNodesDidChange = childNodes != node.childNodes @@ -112,12 +137,32 @@ final class BookmarkTreeController { node.childNodes = childNodes } - childNodes.forEach { childNode in - if rebuildChildNodes(node: childNode, sortMode: sortMode) { - childNodesDidChange = true + if isBookmarksBarMenu { + // count up to 2 bookmarks in the node – add “Open all in new tabs” item if 2 bookmarks and more + var bookmarksCount = 0 + for childNode in childNodes where childNode.representedObject is Bookmark { + bookmarksCount += 1 + if bookmarksCount > 1 { + break + } + } + if bookmarksCount > 1 { + node.childNodes.append(Self.separatorNode(for: node)) + node.childNodes.append(Self.menuItemNode(withIdentifier: Self.openAllInNewTabsIdentifier, title: UserText.bookmarksOpenInNewTabs, for: node)) + + } else if childNodes.isEmpty { + node.childNodes.append(Self.menuItemNode(withIdentifier: Self.emptyPlaceholderIdentifier, title: UserText.bookmarksBarFolderEmpty, isEnabled: false, for: node)) + } + + } else { + childNodes.forEach { childNode in + if rebuildChildNodes(node: childNode, sortMode: sortMode) { + childNodesDidChange = true + } } } return childNodesDidChange } + } diff --git a/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift b/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift new file mode 100644 index 0000000000..fbe69b2d57 --- /dev/null +++ b/DuckDuckGo/Bookmarks/Model/MenuItemNode.swift @@ -0,0 +1,37 @@ +// +// MenuItemNode.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 Foundation + +final class MenuItemNode: Equatable { + + let identifier: String + let title: String + let isEnabled: Bool + + init(identifier: String, title: String, isEnabled: Bool) { + self.identifier = identifier + self.title = title + self.isEnabled = isEnabled + } + + static func == (lhs: MenuItemNode, rhs: MenuItemNode) -> Bool { + return lhs.identifier == rhs.identifier + } + +} 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/Services/MenuItemSelectors.swift b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift index 9f54209b45..02ddc919b8 100644 --- a/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift +++ b/DuckDuckGo/Bookmarks/Services/MenuItemSelectors.swift @@ -40,7 +40,7 @@ import AppKit func editFolder(_ sender: NSMenuItem) func deleteFolder(_ sender: NSMenuItem) - func openInNewTabs(_ sender: NSMenuItem) + func openInNewTabs(_ sender: Any) func openAllInNewWindow(_ sender: NSMenuItem) } diff --git a/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift b/DuckDuckGo/Bookmarks/View/BookmarkListPopover.swift new file mode 100644 index 0000000000..a83057929a --- /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 closeBookmarksPopovers(_ sender: 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..26e77d223b 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkListViewController.swift @@ -17,61 +17,95 @@ // import AppKit +import Carbon import Combine protocol BookmarkListViewControllerDelegate: AnyObject { - func popoverShouldClose(_ bookmarkListViewController: BookmarkListViewController) + func closeBookmarksPopovers(_ sender: BookmarkListViewController) func popover(shouldPreventClosure: Bool) } 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 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 newBookmarkButton: MouseOverButton? + private var newFolderButton: MouseOverButton? + private var manageBookmarksButton: MouseOverButton? + private var searchBookmarksButton: MouseOverButton? + private var sortBookmarksButton: MouseOverButton? - private lazy var buttonsDivider = NSBox() - private lazy var manageBookmarksButton = MouseOverButton(title: UserText.bookmarksManage, target: self, action: #selector(openManagementInterface)) - private lazy var boxDivider = NSBox() + private var boxDivider: NSBox? + private var boxDividerTopConstraint: NSLayoutConstraint? + + 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 dragDropManager: BookmarkDragDropManager 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, + dragDropManager: dragDropManager, sortMode: sortBookmarksViewModel.selectedSortMode, onMenuRequestedAction: { [weak self] cell in self?.showContextMenu(for: cell) @@ -100,118 +134,176 @@ final class BookmarkListViewController: NSViewController { return .init(syncService: syncService, syncBookmarksAdapter: syncBookmarksAdapter) }() - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + init(mode: Mode = .popover, + bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared, + rootFolder: BookmarkFolder? = nil, metrics: BookmarksSearchAndSortMetrics = BookmarksSearchAndSortMetrics()) { + + self.mode = mode self.bookmarkManager = bookmarkManager - self.treeControllerDataSource = BookmarkListTreeControllerDataSource(bookmarkManager: bookmarkManager) - self.treeControllerSearchDataSource = BookmarkListTreeControllerSearchDataSource(bookmarkManager: bookmarkManager) + self.dragDropManager = dragDropManager 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 +316,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,256 +336,778 @@ 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 + scrollUpButton.delegate = self + 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 + scrollDownButton.delegate = self + 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 } + + 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() + } - preferredContentSize = Self.preferredContentSize + 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]) + outlineView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) + // allow scroll buttons to scroll when dragging bookmark over + scrollDownButton?.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) + scrollUpButton?.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) + } - 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() + subscribeToDragDropEvents() + // 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 + enum HighlightEvent { case hover, dragging } + typealias RowEvtPub = AnyPublisher<(Int?, BookmarkFolder?, HighlightEvent), Never> + Publishers.Merge( + // hover over bookmarks menu row + outlineView.$highlightedRow.map { ($0, HighlightEvent.hover) }, + // dragging over folder + dataSource.$dragDestinationFolder.map { [weak self] folder in + guard let self, let folder, + let node = treeController.findNodeWithId(representing: folder), + let row = outlineView.rowIfValid(forItem: node) else { + return (nil, .dragging) + } + return (row, .dragging) + } + ) + .compactMap { [weak self] (row, event) -> 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 event == .hover, 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 event == .dragging { + // delay folder expanding when dragging over a subfolder + delay = 0.2 + } 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 + } - reloadData() + let valuePublisher = Just((row, folder, event)) + if delay > 0 { + return valuePublisher + .delay(for: delay, scheduler: RunLoop.main) + .eraseToAnyPublisher() + } else { + return valuePublisher.eraseToAnyPublisher() + } + } + .switchToLatest() + .filter { [weak dataSource, weak outlineView] (row, folder, event) in + // following is valid only when dragging + guard event == .dragging else { + return outlineView?.highlightedRow == row + } + // don‘t hide subfolder menu or switch to another folder if + // mouse cursor is outside of the view + guard outlineView?.isMouseLocationInsideBounds() == true else { return false } + if let folder { + // only show submenu if cursor is still pointing to the folder + return folder == dataSource?.dragDestinationFolder + } else { + // hide submenu if mouse cursor moved away from the folder + return true + } + } + .sink { [weak self] (row, folder, _) in + self?.outlineViewDidHighlight(folder, atRow: row) + } + .store(in: &cancellables) } - override func keyDown(with event: NSEvent) { - let commandKeyDown = event.modifierFlags.contains(.command) - if commandKeyDown && event.keyCode == 3 { // CMD + F - if isSearchVisible { - searchBar.makeMeFirstResponder() - } else { - showSearchBar() + private func subscribeToDragDropEvents() { + // restore drag&drop target folder highlight on mouse out + // when the submenu is shown + enum DropRowEvent { + case dropRow(Int?) + case highlightedRow(Int?) + } + Publishers.Merge( + dataSource.$targetRowForDropOperation.map(DropRowEvent.dropRow), + outlineView.$highlightedRow.map(DropRowEvent.highlightedRow) + ) + .sink { [weak self] event in + guard let self else { return } + switch event { + case .dropRow(let row): + guard row == nil else { break } + if let bookmarkListPopover, bookmarkListPopover.isShown, + let expandedFolder = bookmarkListPopover.rootFolder, + let node = treeController.node(representing: expandedFolder), + let expandedRow = outlineView.rowIfValid(forItem: node), + expandedRow != outlineView.highlightedRow { + // restore expanded subfolder drop target row highlight + dataSource.targetRowForDropOperation = expandedRow + } + case .highlightedRow: + // hide drop destination row highlight when drag operation ends + if dataSource.targetRowForDropOperation != nil { + dataSource.targetRowForDropOperation = nil + } } - } else { - super.keyDown(with: event) } + .store(in: &cancellables) } - private func 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.closeBookmarksPopovers + 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?.closeBookmarksPopovers(self!) // close + } + .store(in: &cancellables) + } + + func reloadData(withRootFolder rootFolder: BookmarkFolder? = nil) { + let isChangingRootFolder = if let rootFolder, let currentFolder = representedObject as? BookmarkFolder { + currentFolder.id != rootFolder.id + } else { + false + } + 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 { + if let destinationFolder = dataSource.dragDestinationFolder { hideSearchBar() updateSearchAndExpand(destinationFolder) } else { - dataSource.reloadData(for: searchBar.stringValue, and: sortBookmarksViewModel.selectedSortMode) + dataSource.reloadData(forSearchQuery: 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) + let oldContentSize = outlineView.bounds.size outlineView.reloadData() - expandAndRestore(selectedNodes: selectedNodes) + switch mode { + case .popover: + expandAndRestore(selectedNodes: selectedNodes) + case .bookmarkBarMenu: + guard !isChangingRootFolder, + let popover = nextResponder as? BookmarkListPopover, + popover.isShown, + let preferredEdge = popover.preferredEdge else { break } + + updatePositionAndContentSize(oldContentSize: oldContentSize, + popoverPositioningEdge: preferredEdge) + } + } + + let isEmpty = (outlineView.numberOfRows == 0) + self.emptyState?.isHidden = !isEmpty + self.searchBookmarksButton?.isHidden = isEmpty + self.outlineView.isHidden = isEmpty + + if isEmpty { + self.showEmptyStateView(for: .noBookmarks) + + } else { + DispatchQueue.main.async { [outlineView, weak self] in + if outlineView.isMouseLocationInsideBounds() { + outlineView.updateHighlightedRowUnderCursor() + } else if !isChangingRootFolder, + let highlightedRow = outlineView.highlightedRow, + outlineView.numberOfRows > highlightedRow { + // restore current highlight on a highlighted row + outlineView.highlightedRow = highlightedRow + } else if !isChangingRootFolder, + let bookmarkListPopover = self?.bookmarkListPopover, + bookmarkListPopover.isShown, + let expandedFolder = bookmarkListPopover.rootFolder, + let node = self?.treeController.findNodeWithId(representing: expandedFolder), + let expandedRow = outlineView.rowIfValid(forItem: node) { + // restore current highlight on a expanded folder row + outlineView.highlightedRow = expandedRow + } + } } } - private func updateSearchAndExpand(_ folder: BookmarkFolder) { - showTreeView() - expandFoldersAndScrollUntil(folder) - outlineView.scrollToAdjustedPositionInOutlineView(folder) + private func setupSort(mode: BookmarksSortMode) { + hideSearchBar() + dataSource.reloadData(with: mode) + outlineView.reloadData() + sortBookmarksButton?.image = (mode == .nameDescending) ? .bookmarkSortDesc : .bookmarkSortAsc + sortBookmarksButton?.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear + sortBookmarksButton?.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver + } + + private func showDialog(_ view: any ModalView) { + delegate?.popover(shouldPreventClosure: true) + + view.show(in: parent?.view.window) { [weak delegate] in + delegate?.popover(shouldPreventClosure: false) + } + } + + // MARK: Layout + + func adjustPreferredContentSize(positionedRelativeTo positioningRect: NSRect, + of positioningView: NSView, + at preferredEdge: NSRectEdge) { + _=view // loadViewIfNeeded() - guard let node = dataSource.treeController.node(representing: folder) else { + 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 } - outlineView.highlight(node) - } + // 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 + } - @objc func newBookmarkButtonClicked(_ sender: AnyObject) { - let view = BookmarksDialogViewFactory.makeAddBookmarkView(currentTab: currentTabWebsite) - showDialog(view: view) + // 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() } - @objc func newFolderButtonClicked(_ sender: AnyObject) { - let parentFolder = sender.representedObject as? BookmarkFolder - let view = BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parentFolder) - showDialog(view: view) + private 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 } - @objc func searchBookmarkButtonClicked(_ sender: NSButton) { - isSearchVisible.toggle() + private func updatePositionAndContentSize(oldContentSize: NSSize, popoverPositioningEdge: NSRectEdge) { + + guard let window = view.window, + let screenFrame = window.screen?.visibleFrame else { return } + + var contentSize = calculatePreferredContentSize() + guard contentSize != oldContentSize else { return } + + let heightChange = contentSize.height - oldContentSize.height + let availableHeightBelow = window.frame.minY - screenFrame.minY/* - contentInsets.bottom*/ + let availableHeightOnTop = screenFrame.maxY - window.frame.maxY/* - contentInsets.top*/ - if isSearchVisible { - showSearchBar() + // growing + if heightChange > 0 { + // expand popover down as much as available screen space allows + contentSize.height = preferredContentSize.height + min(availableHeightBelow, heightChange) + // shift popover upwards if not enough space at the bottom + if availableHeightBelow < heightChange { + preferredContentOffset.y += min(availableHeightOnTop, heightChange - availableHeightBelow) + } + + // collapsing + } else if /* heightChange < 0 && */ contentSize.height < scrollView.frame.height { + // reduce the offset of the popover upwards relative to the presenting view + preferredContentOffset.y = max(0, preferredContentOffset.y + heightChange) + // contentSize.height = contentSize.height } else { - hideSearchBar() - showTreeView() + // don‘t reduce the popover size if the content size still needs scrolling + contentSize.height = preferredContentSize.height } + + preferredContentSize = contentSize + updateScrollButtons() } - @objc func sortBookmarksButtonClicked(_ sender: NSButton) { - let menu = sortBookmarksViewModel.menu - bookmarkMetrics.fireSortButtonClicked(origin: .panel) - menu.popUpAtMouseLocation(in: sortBookmarksButton) + override func viewDidLayout() { + super.viewDidLayout() + + updateScrollButtons() + } + + private func updateSearchAndExpand(_ folder: BookmarkFolder) { + showTreeView() + expandFoldersAndScrollUntil(folder) + outlineView.scrollToAdjustedPositionInOutlineView(folder) + + guard let node = treeController.node(representing: folder) else { return } + + outlineView.highlight(node) } private func showSearchBar() { + isSearchVisible = true view.addSubview(searchBar) - 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 + guard let titleTextField, let boxDivider else { return } + boxDividerTopConstraint?.isActive = false + NSLayoutConstraint.activate([ + searchBar.topAnchor.constraint(equalTo: titleTextField.bottomAnchor, + constant: 8), + searchBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, + constant: 16), + view.trailingAnchor.constraint(equalTo: searchBar.trailingAnchor, + constant: 16), + boxDivider.topAnchor.constraint(equalTo: searchBar.bottomAnchor, + constant: 10), + ]) + 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) + private func showTreeView() { + emptyState?.isHidden = true + outlineView.isHidden = false + dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) outlineView.reloadData() - sortBookmarksButton.backgroundColor = mode.shouldHighlightButton ? .buttonMouseDown : .clear - sortBookmarksButton.mouseOverColor = mode.shouldHighlightButton ? .buttonMouseDown : .buttonMouseOver + } + + private func showEmptyStateView(for mode: BookmarksEmptyStateContent) { + emptyState?.isHidden = false + outlineView.isHidden = true + emptyStateTitle?.stringValue = mode.title + emptyStateMessage?.stringValue = mode.description + emptyStateImageView?.image = mode.image + importButton?.isHidden = mode.shouldHideImportButton + } + + // MARK: Actions + + override func keyDown(with event: NSEvent) { + let commandKeyDown = event.modifierFlags.contains(.command) + if commandKeyDown && event.keyCode == 3 { // CMD + F + if isSearchVisible { + searchBar.makeMeFirstResponder() + } else { + showSearchBar() + } + } else { + super.keyDown(with: event) + } + } + + @objc func newBookmarkButtonClicked(_ sender: AnyObject) { + let view = BookmarksDialogViewFactory.makeAddBookmarkView(currentTab: currentTabWebsite) + showDialog(view) + } + + @objc func newFolderButtonClicked(_ sender: AnyObject) { + let parentFolder = sender.representedObject as? BookmarkFolder + let view = BookmarksDialogViewFactory.makeAddBookmarkFolderView(parentFolder: parentFolder) + showDialog(view) + } + + @objc func searchBookmarksButtonClicked(_ sender: NSButton) { + isSearchVisible.toggle() + } + + @objc func sortBookmarksButtonClicked(_ sender: NSButton) { + let menu = sortBookmarksViewModel.menu + bookmarkMetrics.fireSortButtonClicked(origin: .panel) + menu.delegate = sortBookmarksViewModel + menu.popUpAtMouseLocation(in: sender) } @objc func openManagementInterface(_ sender: NSButton) { @@ -501,14 +1118,26 @@ 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 { + + case let menuItem as MenuItemNode: + if menuItem.identifier == BookmarkTreeController.openAllInNewTabsIdentifier { + self.openInNewTabs(sender) + } else { + assertionFailure("Unsupported menu item action \(menuItem.identifier)") + } + delegate?.closeBookmarksPopovers(self) + + default: handleItemClickWhenNotInSearchMode(item: item) } } @@ -519,7 +1148,7 @@ final class BookmarkListViewController: NSViewController { } WindowControllersManager.shared.open(bookmark: bookmark) - delegate?.popoverShouldClose(self) + delegate?.closeBookmarksPopovers(self) } private func handleItemClickWhenNotInSearchMode(item: Any?) { @@ -530,52 +1159,15 @@ final class BookmarkListViewController: NSViewController { } } - private func expandFoldersAndScrollUntil(_ folder: BookmarkFolder) { - guard let folderNode = treeController.findNodeWithId(representing: folder) else { - return - } - - expandFoldersUntil(node: folderNode) - outlineView.scrollToAdjustedPositionInOutlineView(folderNode) - } - - private func expandFoldersUntil(node: BookmarkNode?) { - var nodes: [BookmarkNode?] = [] - var parent = node?.parent - nodes.append(node) - - while parent != nil { - nodes.append(parent) - parent = parent?.parent - } - - while !nodes.isEmpty { - if let current = nodes.removeLast() { - outlineView.animator().expandItem(current) - } - } - } - - private func showTreeView() { - emptyState.isHidden = true - outlineView.isHidden = false - dataSource.reloadData(with: sortBookmarksViewModel.selectedSortMode) - outlineView.reloadData() - } - - private func showEmptyStateView(for mode: BookmarksEmptyStateContent) { - emptyState.isHidden = false - outlineView.isHidden = true - emptyStateTitle.stringValue = mode.title - emptyStateMessage.stringValue = mode.description - emptyStateImageView.image = mode.image - importButton.isHidden = mode.shouldHideImportButton - } - @objc func onImportClicked(_ sender: NSButton) { DataImportView().show() } + private func showManageBookmarks() { + WindowControllersManager.shared.showBookmarksTab() + delegate?.closeBookmarksPopovers(self) + } + // MARK: NSOutlineView Configuration private func expandAndRestore(selectedNodes: [BookmarkNode]) { @@ -627,6 +1219,20 @@ final class BookmarkListViewController: NSViewController { outlineView.selectRowIndexes(indexes, byExtendingSelection: false) } + private func expandFoldersAndScrollUntil(_ folder: BookmarkFolder) { + guard let folderNode = treeController.findNodeWithId(representing: folder) else { return } + + expandFoldersUntil(node: folderNode) + outlineView.scrollToAdjustedPositionInOutlineView(folderNode) + } + + private func expandFoldersUntil(node: BookmarkNode?) { + guard let folderParent = node?.parent else { return } + for parent in sequence(first: folderParent, next: \.parent).reversed() { + outlineView.animator().expandItem(parent) + } + } + private func showContextMenu(for cell: BookmarkOutlineCellView) { let row = outlineView.row(for: cell) guard @@ -639,27 +1245,167 @@ 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() + // unhighlight drop destination row if highlighted + dataSource.targetRowForDropOperation = nil + return + } -private extension BookmarkListViewController { + 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 + } - func showDialog(view: any ModalView) { - delegate?.popover(shouldPreventClosure: true) + bookmarkListPopover.show(positionedAsSubmenuAgainst: cell) + if let currentEvent = NSApp.currentEvent, + currentEvent.type == .keyDown, currentEvent.keyCode == kVK_RightArrow, + bookmarkListPopover.viewController.outlineView.numberOfRows > 0 { + DispatchQueue.main.async { + // TODO: highlight first highlightable + bookmarkListPopover.viewController.outlineView.highlightedRow = 0 + } + } + } - view.show(in: parent?.view.window) { [weak delegate] in - delegate?.popover(shouldPreventClosure: false) + // 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() } } - func showManageBookmarks() { - WindowControllersManager.shared.showBookmarksTab() - delegate?.popoverShouldClose(self) + 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 } } - // MARK: - Menu Item Selectors - extension BookmarkListViewController: NSMenuDelegate { func contextualMenuForClickedRows() -> NSMenu? { @@ -695,7 +1441,7 @@ extension BookmarkListViewController: NSMenuDelegate { } } - +// MARK: - BookmarkMenuItemSelectors extension BookmarkListViewController: BookmarkMenuItemSelectors { func openBookmarkInNewTab(_ sender: NSMenuItem) { @@ -735,7 +1481,7 @@ extension BookmarkListViewController: BookmarkMenuItemSelectors { } let view = BookmarksDialogViewFactory.makeEditBookmarkView(bookmark: bookmark) - showDialog(view: view) + showDialog(view) } func copyBookmark(_ sender: NSMenuItem) { @@ -779,7 +1525,7 @@ extension BookmarkListViewController: BookmarkMenuItemSelectors { } } - +// MARK: - FolderMenuItemSelectors extension BookmarkListViewController: FolderMenuItemSelectors { func newFolder(_ sender: NSMenuItem) { @@ -795,7 +1541,7 @@ extension BookmarkListViewController: FolderMenuItemSelectors { } let view = BookmarksDialogViewFactory.makeEditBookmarkFolderView(folder: folder, parentFolder: bookmarkEntityInfo.parent) - showDialog(view: view) + showDialog(view) } func deleteFolder(_ sender: NSMenuItem) { @@ -807,13 +1553,14 @@ extension BookmarkListViewController: FolderMenuItemSelectors { bookmarkManager.remove(folder: folder) } - func openInNewTabs(_ sender: NSMenuItem) { + func openInNewTabs(_ sender: Any) { guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, - let folder = sender.representedObject as? BookmarkFolder + let folder = ((sender as? NSMenuItem)?.representedObject ?? self.treeController.rootNode.representedObject) as? BookmarkFolder else { assertionFailure("Cannot open all in new tabs") return } + delegate?.closeBookmarksPopovers(self) let tabs = Tab.withContentOfBookmark(folder: folder, burnerMode: tabCollection.burnerMode) tabCollection.append(tabs: tabs) @@ -834,7 +1581,7 @@ extension BookmarkListViewController: FolderMenuItemSelectors { } } - +// MARK: - BookmarkSearchMenuItemSelectors extension BookmarkListViewController: BookmarkSearchMenuItemSelectors { func showInFolder(_ sender: NSMenuItem) { guard let baseBookmark = sender.representedObject as? BaseBookmarkEntity else { @@ -845,18 +1592,14 @@ 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) outlineView.highlight(node) } } - // MARK: - Search field delegate - extension BookmarkListViewController: NSSearchFieldDelegate { func controlTextDidChange(_ obj: Notification) { @@ -866,105 +1609,98 @@ extension BookmarkListViewController: NSSearchFieldDelegate { if searchQuery.isBlank { showTreeView() } else { - showSearch(for: searchQuery) + showSearch(forSearchQuery: searchQuery) } bookmarkMetrics.fireSearchExecuted(origin: .panel) } } - private func showSearch(for searchQuery: String) { - dataSource.reloadData(for: searchQuery, and: sortBookmarksViewModel.selectedSortMode) + private func showSearch(forSearchQuery searchQuery: String) { + dataSource.reloadData(forSearchQuery: 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: - MouseOverButtonDelegate +extension BookmarkListViewController: MouseOverButtonDelegate { -// MARK: - BookmarkListPopover - -final class BookmarkListPopover: NSPopover { - - override init() { - super.init() - - self.animates = false - self.behavior = .transient - - setupContentController() - } + // scroll bookmarks menu up/down on scroll buttons mouse over + func mouseOverButton(_ sender: MouseOverButton, draggingEntered info: any NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation { - required init?(coder: NSCoder) { - fatalError("BookmarkListPopover: Bad initializer") + assert(sender === scrollUpButton || sender === scrollDownButton) + isMouseOver.pointee = true + return .none } - // 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 + // scroll bookmarks menu up/down on dragging over scroll buttons + func mouseOverButton(_ sender: MouseOverButton, draggingUpdatedWith info: any NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation { + assert(sender === scrollUpButton || sender === scrollDownButton) + isMouseOver.pointee = true + return .none } } +// MARK: - Preview #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") + ]) + ]), + 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: "") - ])) + ]), + 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..e8285d987d 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementDetailViewController.swift @@ -59,6 +59,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem private let managementDetailViewModel: BookmarkManagementDetailViewModel private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager private let sortBookmarksViewModel: SortBookmarksViewModel private var selectionState: BookmarkManagementSidebarViewController.SelectionState = .empty { didSet { @@ -78,8 +79,10 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem self.selectionState = selectionState } - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared) { self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager let metrics = BookmarksSearchAndSortMetrics() let sortViewModel = SortBookmarksViewModel(manager: bookmarkManager, metrics: metrics, origin: .manager) self.sortBookmarksViewModel = sortViewModel @@ -149,6 +152,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 @@ -245,8 +250,7 @@ final class BookmarkManagementDetailViewController: NSViewController, NSMenuItem super.viewDidLoad() tableView.setDraggingSourceOperationMask([.move], forLocal: true) - tableView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, - FolderPasteboardWriter.folderUTIInternalType]) + tableView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) reloadData() @@ -454,39 +458,35 @@ extension BookmarkManagementDetailViewController: NSTableViewDelegate, NSTableVi return entity.pasteboardWriter } + private func destination(for dropOperation: NSTableView.DropOperation, at row: Int) -> Any { + switch dropOperation { + case .on: + if let entity = fetchEntity(at: row) { + return entity + } + case .above: + if let folder = selectionState.folder { + return folder + } + @unknown default: preconditionFailure() + } + return selectionState == .favorites ? PseudoFolder.favorites : PseudoFolder.bookmarks + } + func tableView(_ tableView: NSTableView, validateDrop info: NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { - return managementDetailViewModel.validateDrop(pasteboardItems: info.draggingPasteboard.pasteboardItems, - proposedRow: row, - proposedDropOperation: dropOperation) + let destination = destination(for: dropOperation, at: row) + return dragDropManager.validateDrop(info, to: destination) } func tableView(_ tableView: NSTableView, acceptDrop info: NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { - guard let draggedItemIdentifiers = info.draggingPasteboard.pasteboardItems?.compactMap(\.bookmarkEntityUUID), - !draggedItemIdentifiers.isEmpty else { - return false - } - - if let parent = fetchEntity(at: row) as? BookmarkFolder, dropOperation == .on { - bookmarkManager.add(objectsWithUUIDs: draggedItemIdentifiers, to: parent) { _ in } - return true - } else if let currentFolderUUID = selectionState.selectedFolderUUID { - bookmarkManager.move(objectUUIDs: draggedItemIdentifiers, - toIndex: row, - withinParentFolder: .parent(uuid: currentFolderUUID)) { _ in } - return true - } else { - if selectionState == .favorites { - bookmarkManager.moveFavorites(with: draggedItemIdentifiers, toIndex: row) { _ in } - } else { - bookmarkManager.move(objectUUIDs: draggedItemIdentifiers, - toIndex: row, - withinParentFolder: .root) { _ in } - } - return true - } + + let destination = destination(for: dropOperation, at: row) + let index = dropOperation == .above ? row : -1 + + return dragDropManager.acceptDrop(info, to: destination, at: index) } private func fetchEntity(at row: Int) -> BaseBookmarkEntity? { @@ -697,11 +697,11 @@ extension BookmarkManagementDetailViewController: FolderMenuItemSelectors { bookmarkManager.move(objectUUIDs: [bookmarkEntity.entityId], toIndex: nil, withinParentFolder: parentFolderType) { _ in } } - func openInNewTabs(_ sender: NSMenuItem) { - if let children = (sender.representedObject as? BookmarkFolder)?.children { + func openInNewTabs(_ sender: Any) { + if let children = ((sender as? NSMenuItem)?.representedObject as? BookmarkFolder)?.children { let bookmarks = children.compactMap { $0 as? Bookmark } openBookmarksInNewTabs(bookmarks) - } else if let bookmarks = sender.representedObject as? [Bookmark] { + } else if let bookmarks = (sender as? NSMenuItem)?.representedObject as? [Bookmark] { openBookmarksInNewTabs(bookmarks) } else { assertionFailure("Failed to open entity in new tabs") diff --git a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift index fce1c9d2d1..84d8a5723c 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarkManagementSidebarViewController.swift @@ -44,6 +44,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { } private let bookmarkManager: BookmarkManager + private let dragDropManager: BookmarkDragDropManager private let treeControllerDataSource: BookmarkSidebarTreeController private lazy var tabSwitcherButton = NSPopUpButton() @@ -54,6 +55,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { private lazy var dataSource = BookmarkOutlineViewDataSource(contentMode: .foldersOnly, bookmarkManager: bookmarkManager, treeController: treeController, + dragDropManager: dragDropManager, sortMode: selectedSortMode, showMenuButtonOnHover: false) @@ -69,8 +71,10 @@ final class BookmarkManagementSidebarViewController: NSViewController { return [BookmarkNode]() } - init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared) { + init(bookmarkManager: BookmarkManager = LocalBookmarkManager.shared, + dragDropManager: BookmarkDragDropManager = BookmarkDragDropManager.shared) { self.bookmarkManager = bookmarkManager + self.dragDropManager = dragDropManager self.selectedSortMode = bookmarkManager.sortMode treeControllerDataSource = .init(bookmarkManager: bookmarkManager) super.init(nibName: nil, bundle: nil) @@ -102,6 +106,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { scrollView.borderType = .noBorder scrollView.drawsBackground = false scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = false scrollView.usesPredominantAxisScrolling = false @@ -150,8 +155,7 @@ final class BookmarkManagementSidebarViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() outlineView.setDraggingSourceOperationMask([.move], forLocal: true) - outlineView.registerForDraggedTypes([BookmarkPasteboardWriter.bookmarkUTIInternalType, - FolderPasteboardWriter.folderUTIInternalType]) + outlineView.registerForDraggedTypes(BookmarkDragDropManager.draggedTypes) dataSource.$selectedFolders.sink { [weak self] selectedFolders in guard let self else { return } @@ -364,9 +368,9 @@ extension BookmarkManagementSidebarViewController: FolderMenuItemSelectors { bookmarkManager.move(objectUUIDs: [bookmarkEntity.entityId], toIndex: nil, withinParentFolder: parentFolderType) { _ in } } - func openInNewTabs(_ sender: NSMenuItem) { + func openInNewTabs(_ sender: Any) { guard let tabCollection = WindowControllersManager.shared.lastKeyMainWindowController?.mainViewController.tabCollectionViewModel, - let folder = sender.representedObject as? BookmarkFolder + let folder = (sender as? NSMenuItem)?.representedObject as? BookmarkFolder else { assertionFailure("Cannot open all in new tabs") return diff --git a/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift b/DuckDuckGo/Bookmarks/View/BookmarkOutlineCellView.swift index fb79524c09..c1923c44a3 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 + } + } - countLabel.setContentHuggingPriority(.defaultHigh, for: .vertical) - countLabel.setContentHuggingPriority(.required, for: .horizontal) + 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() + + // 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,78 @@ 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 || representedObject is MenuItemNode 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) + case let menuItem as MenuItemNode: + update(from: menuItem) + 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 +326,32 @@ 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 + } + + func update(from menuItem: MenuItemNode) { + faviconImageView.image = nil + faviconImageView.isHidden = true + titleLabel.stringValue = menuItem.title + titleLabel.isEnabled = menuItem.isEnabled + countLabel.stringValue = "" favoriteImageView.image = nil + favoriteImageView.isHidden = true + urlLabel.stringValue = "" + urlLabel.isHidden = true + self.toolTip = nil + + highlight = false } } @@ -220,11 +375,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 +393,31 @@ 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 node = BookmarkNode(representedObject: MenuItemNode(identifier: "", title: UserText.bookmarksOpenInNewTabs, isEnabled: true), parent: BookmarkNode.genericRootNode()) + cells[7].update(from: node, isMenuPopover: true) + + let emptyNode = BookmarkNode(representedObject: MenuItemNode(identifier: "", title: UserText.bookmarksBarFolderEmpty, isEnabled: false), parent: BookmarkNode.genericRootNode()) + cells[8].update(from: emptyNode, isMenuPopover: true) + + 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..8b4a321ccd 100644 --- a/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift +++ b/DuckDuckGo/Bookmarks/View/BookmarksOutlineView.swift @@ -17,16 +17,75 @@ // import AppKit +import Carbon +import Combine -extension NSDragOperation { +final class BookmarksOutlineView: NSOutlineView { - static let none = NSDragOperation([]) + 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) + } -final class BookmarksOutlineView: NSOutlineView { + let item = item(atRow: row) as? BookmarkNode - var lastRow: RoundedSelectionRowView? + 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 var clickedRow: Int { + let clickedRow = super.clickedRow + // on Enter/Space key down: click event is sent to the OutlineView target with highlightedRow + if [-1, NSNotFound].contains(clickedRow), let highlightedRow, + NSApp.currentEvent?.type == .keyDown { + + return highlightedRow + } + return clickedRow + } override func frameOfOutlineCell(atRow row: Int) -> NSRect { let frame = super.frameOfOutlineCell(atRow: row) @@ -51,22 +110,161 @@ 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 + } + } + + override func keyDown(with event: NSEvent) { + // TODO: arrow up/down -> skip items that aren‘t `canBeHighlighted`; don‘t highlight in child menu if no such items + switch Int(event.keyCode) { + case kVK_DownArrow: + if let highlightedRow { + guard highlightedRow < numberOfRows - 1 else { return } + if event.modifierFlags.contains(.command) || event.modifierFlags.contains(.option) { + self.highlightedRow = numberOfRows - 1 + } else { + self.highlightedRow = highlightedRow + 1 + } + + } else if numberOfRows > 0 /* && highlightedRow == nil */ { + if let window, let windowParent = window.parent, + type(of: windowParent) == type(of: window) /* _NSPopoverWindow */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self { + + // when no highlighted row in child menu popover: send event to parent menu + outlineView.keyDown(with: event) + + } else if event.modifierFlags.contains(.option) { + self.highlightedRow = numberOfRows - 1 + } else { + self.highlightedRow = 0 + } + } + case kVK_UpArrow: // TODO: pgUp/Down, modifiers + if let highlightedRow { + guard highlightedRow > 0 else { return } + if event.modifierFlags.contains(.command) || event.modifierFlags.contains(.option) { + self.highlightedRow = 0 + } else { + self.highlightedRow = highlightedRow - 1 + } + + } else if numberOfRows > 0 /* && highlightedRow == nil */ { + if let window, let windowParent = window.parent, + type(of: windowParent) == type(of: window) /* _NSPopoverWindow */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self { + + // when no highlighted row in child menu popover: send event to parent menu + outlineView.keyDown(with: event) + + } else if event.modifierFlags.contains(.option) { + self.highlightedRow = 0 + } else { + self.highlightedRow = numberOfRows - 1 + } + } + case kVK_RightArrow: + if let window, let windowParent = window.parent, + type(of: windowParent) == type(of: window) /* _NSPopoverWindow */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self { + outlineView.highlightedRowView?.isInKeyWindow = false + outlineView.highlightedCellView?.isInKeyWindow = false + if highlightedRow == nil, numberOfRows > 0 { + highlightedRow = 0 + break + } + } + if let highlightedRow, let item = self.item(atRow: highlightedRow), + isExpandable(item) { + if !isItemExpanded(item) { + animator().expandItem(item) + } + } else if numberOfRows > 0 { + self.highlightedRow = highlightedRow + } + + // TODO: when in root: open next menu, left arrow: prev menu + case kVK_LeftArrow: + if let window, let windowParent = window.parent, + type(of: windowParent) == type(of: window) /* _NSPopoverWindow */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self, + let popover = window.contentViewController?.nextResponder as? NSPopover { + + // close child menu + popover.close() + outlineView.highlightedRowView?.isInKeyWindow = true + outlineView.highlightedCellView?.isInKeyWindow = true + + } else if let highlightedRow, + let item = self.item(atRow: highlightedRow), + isExpandable(item), isItemExpanded(item) { + animator().collapseItem(item) + } else { +// super.keyDown(with: event) + } + + case kVK_Return, kVK_ANSI_KeypadEnter, kVK_Space: + if highlightedRow != nil { + guard let action else { + assertionFailure("BookmarksOutlineView.action not set") + return + } + // select highlighted item + NSApp.sendAction(action, to: target, from: self) + + } else if let window, let windowParent = window.parent, + type(of: windowParent) == type(of: window) /* _NSPopoverWindow */, + let scrollView = windowParent.contentView?.subviews.first(where: { $0 is NSScrollView }) as? NSScrollView, + let outlineView = scrollView.documentView as? Self, + numberOfRows > 0 { + + // when in child menu popover without selection: highlight first row + highlightedRow = 0 + + outlineView.highlightedRowView?.isInKeyWindow = false + outlineView.highlightedCellView?.isInKeyWindow = false + } + + case kVK_Escape: // TODO: hide search when active first + var window = window + while let windowParent = window?.parent, + type(of: windowParent) == type(of: window!) { + window = windowParent + } + // close root popover on Esc + if let popover = window?.contentViewController?.nextResponder as? NSPopover { + popover.close() + } + + default: + super.keyDown(with: event) + } } func scrollTo(_ item: Any, code: ((Int) -> Void)? = nil) { @@ -101,6 +299,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 +324,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/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift b/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift index a8d4de24b6..d865fe399c 100644 --- a/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift +++ b/DuckDuckGo/Bookmarks/ViewModel/BookmarkManagementDetailViewModel.swift @@ -96,59 +96,6 @@ final class BookmarkManagementDetailViewModel { return bookmarkManager.getBookmarkFolder(withId: parentID) } - // MARK: - Drag and drop - - func validateDrop(pasteboardItems: [NSPasteboardItem]?, - proposedRow row: Int, - proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { - if let proposedDestination = fetchEntity(at: row), proposedDestination.isFolder { - if let bookmarks = PasteboardBookmark.pasteboardBookmarks(with: pasteboardItems) { - return validateDrop(for: bookmarks, destination: proposedDestination) - } - - if let folders = PasteboardFolder.pasteboardFolders(with: pasteboardItems) { - return validateDrop(for: folders, destination: proposedDestination) - } - - return .none - } else { - // We only want to allow dropping in the same level when not searching - if dropOperation == .above && searchQuery.isBlank { - return .move - } else { - return .none - } - } - } - - private func validateDrop(for draggedBookmarks: Set, destination: BaseBookmarkEntity) -> NSDragOperation { - guard destination is BookmarkFolder else { - return .none - } - - return .move - } - - private func validateDrop(for draggedFolders: Set, destination: BaseBookmarkEntity) -> NSDragOperation { - guard let destinationFolder = destination as? BookmarkFolder else { - return .none - } - - for folderID in draggedFolders.map(\.id) where !bookmarkManager.canMoveObjectWithUUID(objectUUID: folderID, to: destinationFolder) { - return .none - } - - let tryingToDragOntoSameFolder = draggedFolders.contains { folder in - return folder.id == destination.id - } - - if tryingToDragOntoSameFolder { - return .none - } - - return .move - } - // MARK: - Metrics func onSortButtonTapped() { 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 @@ -