diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index b2c539dfb1..c357b3fdd6 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -789,7 +789,6 @@ 3706FCCD293F65D500E42796 /* shield-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396B2754D4E300B241FA /* shield-dot.json */; }; 3706FCD0293F65D500E42796 /* BookmarksBarCollectionViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4BE53369286912D40019DBFD /* BookmarksBarCollectionViewItem.xib */; }; 3706FCD2293F65D500E42796 /* shield.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396A2754D4E200B241FA /* shield.json */; }; - 3706FCD4293F65D500E42796 /* TabBarViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA7412B124D0B3AC00D22FE0 /* TabBarViewItem.xib */; }; 3706FCD6293F65D500E42796 /* httpsMobileV2FalsePositives.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B67742A255DBEB800025BD8 /* httpsMobileV2FalsePositives.json */; }; 3706FCD8293F65D500E42796 /* BookmarksBar.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 4BD18F04283F151F00058124 /* BookmarksBar.storyboard */; }; 3706FCD9293F65D500E42796 /* trackers-1.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439732754D55100B241FA /* trackers-1.json */; }; @@ -809,7 +808,6 @@ 3706FCEE293F65D500E42796 /* trackers-3.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439752754D55100B241FA /* trackers-3.json */; }; 3706FCEF293F65D500E42796 /* macos-config.json in Resources */ = {isa = PBXBuildFile; fileRef = 026ADE1326C3010C002518EE /* macos-config.json */; }; 3706FCF0293F65D500E42796 /* httpsMobileV2BloomSpec.json in Resources */ = {isa = PBXBuildFile; fileRef = 4B677427255DBEB800025BD8 /* httpsMobileV2BloomSpec.json */; }; - 3706FCF1293F65D500E42796 /* TabBarFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */; }; 3706FCF3293F65D500E42796 /* FirePopoverCollectionViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AAE246F22709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib */; }; 3706FCF5293F65D500E42796 /* dark-shield-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396E2754D4E900B241FA /* dark-shield-dot.json */; }; 3706FCF6293F65D500E42796 /* trackers-2.json in Resources */ = {isa = PBXBuildFile; fileRef = AA3439742754D55100B241FA /* trackers-2.json */; }; @@ -2074,7 +2072,6 @@ AA0F3DB7261A566C0077F2D9 /* SuggestionLoadingMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0F3DB6261A566C0077F2D9 /* SuggestionLoadingMock.swift */; }; AA13DCB4271480B0006D48D3 /* FirePopoverViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA13DCB3271480B0006D48D3 /* FirePopoverViewModel.swift */; }; AA222CB92760F74E00321475 /* FaviconReferenceCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA222CB82760F74E00321475 /* FaviconReferenceCache.swift */; }; - AA2CB12D2587BB5600AA6FBE /* TabBarFooter.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */; }; AA2CB1352587C29500AA6FBE /* TabBarFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2CB1342587C29500AA6FBE /* TabBarFooter.swift */; }; AA34396C2754D4E300B241FA /* shield.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396A2754D4E200B241FA /* shield.json */; }; AA34396D2754D4E300B241FA /* shield-dot.json in Resources */ = {isa = PBXBuildFile; fileRef = AA34396B2754D4E300B241FA /* shield-dot.json */; }; @@ -2132,7 +2129,6 @@ AA6FFB4624DC3B5A0028F4D0 /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA6FFB4524DC3B5A0028F4D0 /* WebView.swift */; }; AA72D5FE25FFF94E00C77619 /* NSMenuItemExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA72D5FD25FFF94E00C77619 /* NSMenuItemExtension.swift */; }; AA7412B224D0B3AC00D22FE0 /* TabBarViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7412B024D0B3AC00D22FE0 /* TabBarViewItem.swift */; }; - AA7412B324D0B3AC00D22FE0 /* TabBarViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AA7412B124D0B3AC00D22FE0 /* TabBarViewItem.xib */; }; AA7412B524D1536B00D22FE0 /* MainWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7412B424D1536B00D22FE0 /* MainWindowController.swift */; }; AA7412B724D1687000D22FE0 /* TabBarScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7412B624D1687000D22FE0 /* TabBarScrollView.swift */; }; AA7412BD24D2BEEE00D22FE0 /* MainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7412BC24D2BEEE00D22FE0 /* MainWindow.swift */; }; @@ -4061,7 +4057,6 @@ AA0F3DB6261A566C0077F2D9 /* SuggestionLoadingMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionLoadingMock.swift; sourceTree = ""; }; AA13DCB3271480B0006D48D3 /* FirePopoverViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirePopoverViewModel.swift; sourceTree = ""; }; AA222CB82760F74E00321475 /* FaviconReferenceCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconReferenceCache.swift; sourceTree = ""; }; - AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TabBarFooter.xib; sourceTree = ""; }; AA2CB1342587C29500AA6FBE /* TabBarFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarFooter.swift; sourceTree = ""; }; AA34396A2754D4E200B241FA /* shield.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = shield.json; sourceTree = ""; }; AA34396B2754D4E300B241FA /* shield-dot.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "shield-dot.json"; sourceTree = ""; }; @@ -4124,7 +4119,6 @@ AA6FFB4524DC3B5A0028F4D0 /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; AA72D5FD25FFF94E00C77619 /* NSMenuItemExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSMenuItemExtension.swift; sourceTree = ""; }; AA7412B024D0B3AC00D22FE0 /* TabBarViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarViewItem.swift; sourceTree = ""; }; - AA7412B124D0B3AC00D22FE0 /* TabBarViewItem.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = TabBarViewItem.xib; sourceTree = ""; }; AA7412B424D1536B00D22FE0 /* MainWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindowController.swift; sourceTree = ""; }; AA7412B624D1687000D22FE0 /* TabBarScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollView.swift; sourceTree = ""; }; AA7412BC24D2BEEE00D22FE0 /* MainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainWindow.swift; sourceTree = ""; }; @@ -7765,9 +7759,7 @@ 1456D6E024EFCBC300775049 /* TabBarCollectionView.swift */, AA7412B624D1687000D22FE0 /* TabBarScrollView.swift */, AA7412B024D0B3AC00D22FE0 /* TabBarViewItem.swift */, - AA7412B124D0B3AC00D22FE0 /* TabBarViewItem.xib */, AA2CB1342587C29500AA6FBE /* TabBarFooter.swift */, - AA2CB12C2587BB5600AA6FBE /* TabBarFooter.xib */, AA9E9A5D25A4867200D1959D /* TabDragAndDropManager.swift */, 3154FD1328E6011A00909769 /* TabShadowView.swift */, 311B262628E73E0A00FD181A /* TabShadowConfig.swift */, @@ -9961,7 +9953,6 @@ 3706FCCD293F65D500E42796 /* shield-dot.json in Resources */, 3706FCD0293F65D500E42796 /* BookmarksBarCollectionViewItem.xib in Resources */, 3706FCD2293F65D500E42796 /* shield.json in Resources */, - 3706FCD4293F65D500E42796 /* TabBarViewItem.xib in Resources */, 7B5A23762C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */, 3706FCD6293F65D500E42796 /* httpsMobileV2FalsePositives.json in Resources */, 3706FCD8293F65D500E42796 /* BookmarksBar.storyboard in Resources */, @@ -9986,7 +9977,6 @@ 3706FCEE293F65D500E42796 /* trackers-3.json in Resources */, 3706FCEF293F65D500E42796 /* macos-config.json in Resources */, 3706FCF0293F65D500E42796 /* httpsMobileV2BloomSpec.json in Resources */, - 3706FCF1293F65D500E42796 /* TabBarFooter.xib in Resources */, 3706FCF3293F65D500E42796 /* FirePopoverCollectionViewItem.xib in Resources */, 3706FCF5293F65D500E42796 /* dark-shield-dot.json in Resources */, 3706FCF6293F65D500E42796 /* trackers-2.json in Resources */, @@ -10156,7 +10146,6 @@ AA34396D2754D4E300B241FA /* shield-dot.json in Resources */, 4BE5336B286912D40019DBFD /* BookmarksBarCollectionViewItem.xib in Resources */, AA34396C2754D4E300B241FA /* shield.json in Resources */, - AA7412B324D0B3AC00D22FE0 /* TabBarViewItem.xib in Resources */, 7B5A23752C46A4A8007213AC /* ExcludedDomains.storyboard in Resources */, 4B677435255DBEB800025BD8 /* httpsMobileV2FalsePositives.json in Resources */, 4BD18F05283F151F00058124 /* BookmarksBar.storyboard in Resources */, @@ -10181,7 +10170,6 @@ AA34397B2754D55100B241FA /* trackers-3.json in Resources */, 026ADE1426C3010C002518EE /* macos-config.json in Resources */, 4B677432255DBEB800025BD8 /* httpsMobileV2BloomSpec.json in Resources */, - AA2CB12D2587BB5600AA6FBE /* TabBarFooter.xib in Resources */, AAE246F42709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib in Resources */, AA3439702754D4E900B241FA /* dark-shield-dot.json in Resources */, AA34397A2754D55100B241FA /* trackers-2.json in Resources */, diff --git a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 13d135a403..c36f051fa8 100644 --- a/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DuckDuckGo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -75,7 +75,7 @@ { "identity" : "lottie-spm", "kind" : "remoteSourceControl", - "location" : "https://github.com/airbnb/lottie-spm.git", + "location" : "https://github.com/airbnb/lottie-spm", "state" : { "revision" : "1d29eccc24cc8b75bff9f6804155112c0ffc9605", "version" : "4.4.3" diff --git a/DuckDuckGo/Assets.xcassets/Images/Audio-Mute.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Audio-Mute.imageset/Contents.json index b609317961..8a0a0480cf 100644 --- a/DuckDuckGo/Assets.xcassets/Images/Audio-Mute.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/Audio-Mute.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/DuckDuckGo/Assets.xcassets/Images/Audio.imageset/Contents.json b/DuckDuckGo/Assets.xcassets/Images/Audio.imageset/Contents.json index 35d4dda319..63d99e3a45 100644 --- a/DuckDuckGo/Assets.xcassets/Images/Audio.imageset/Contents.json +++ b/DuckDuckGo/Assets.xcassets/Images/Audio.imageset/Contents.json @@ -8,5 +8,8 @@ "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/DuckDuckGo/Common/Extensions/NSViewControllerExtension.swift b/DuckDuckGo/Common/Extensions/NSViewControllerExtension.swift index b8a3eeb09e..cfe7953fdf 100644 --- a/DuckDuckGo/Common/Extensions/NSViewControllerExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSViewControllerExtension.swift @@ -92,13 +92,6 @@ extension NSViewController { view.removeFromSuperview() } - func withoutAnimation(_ closure: () -> Void) { - CATransaction.begin() - CATransaction.setDisableActions(true) - closure() - CATransaction.commit() - } - /// #Preview helper to hide Window controls on View Controller appearance func _preview_hidingWindowControlsOnAppear() -> Self { // swiftlint:disable:this identifier_name Preview_ViewControllerWindowObserver().attach(to: self) @@ -107,6 +100,13 @@ extension NSViewController { } +func withoutAnimation(_ closure: () -> Void) { + CATransaction.begin() + CATransaction.setDisableActions(true) + closure() + CATransaction.commit() +} + /// #Preview helper to hide Window controls on View Controller appearance final class Preview_ViewControllerWindowObserver: NSObject { func attach(to viewController: NSViewController) { diff --git a/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift b/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift index 605660013c..d5b1aa2065 100644 --- a/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift +++ b/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift @@ -82,7 +82,7 @@ final class HoverTrackingArea: NSTrackingArea { view?.backgroundLayer(createIfNeeded: createIfNeeded) } - func updateLayer(animated: Bool = true) { + func updateLayer(animated: Bool = !CATransaction.disableActions()) { let color = currentBackgroundColor ?? .clear guard let view, let layer = layer(createIfNeeded: color != .clear) else { return } diff --git a/DuckDuckGo/Common/View/AppKit/MouseOverView.swift b/DuckDuckGo/Common/View/AppKit/MouseOverView.swift index 6f59c5e3da..912cf40b82 100644 --- a/DuckDuckGo/Common/View/AppKit/MouseOverView.swift +++ b/DuckDuckGo/Common/View/AppKit/MouseOverView.swift @@ -21,18 +21,18 @@ import Combine @objc protocol MouseOverViewDelegate: AnyObject { - @objc optional func mouseOverView(_ mouseOverView: MouseOverView, isMouseOver: Bool) + @objc @MainActor optional func mouseOverView(_ mouseOverView: MouseOverView, isMouseOver: Bool) - @objc optional func mouseClickView(_ mouseClickView: MouseClickView, mouseDownEvent: NSEvent) - @objc optional func mouseClickView(_ mouseClickView: MouseClickView, mouseUpEvent: NSEvent) - @objc optional func mouseClickView(_ mouseClickView: MouseClickView, rightMouseDownEvent: NSEvent) - @objc optional func mouseClickView(_ mouseClickView: MouseClickView, otherMouseDownEvent: NSEvent) + @objc @MainActor optional func mouseClickView(_ mouseClickView: MouseClickView, mouseDownEvent: NSEvent) + @objc @MainActor optional func mouseClickView(_ mouseClickView: MouseClickView, mouseUpEvent: NSEvent) + @objc @MainActor optional func mouseClickView(_ mouseClickView: MouseClickView, rightMouseDownEvent: NSEvent) + @objc @MainActor optional func mouseClickView(_ mouseClickView: MouseClickView, otherMouseDownEvent: NSEvent) - @objc optional func mouseOverView(_ sender: MouseOverView, draggingEntered info: NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation - @objc optional func mouseOverView(_ sender: MouseOverView, draggingUpdatedWith info: NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation - @objc optional func mouseOverView(_ sender: MouseOverView, performDragOperation info: NSDraggingInfo) -> Bool - @objc optional func mouseOverView(_ sender: MouseOverView, draggingEndedWith info: NSDraggingInfo) - @objc optional func mouseOverView(_ sender: MouseOverView, draggingExitedWith info: NSDraggingInfo?) + @objc @MainActor optional func mouseOverView(_ sender: MouseOverView, draggingEntered info: NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation + @objc @MainActor optional func mouseOverView(_ sender: MouseOverView, draggingUpdatedWith info: NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation + @objc @MainActor optional func mouseOverView(_ sender: MouseOverView, performDragOperation info: NSDraggingInfo) -> Bool + @objc @MainActor optional func mouseOverView(_ sender: MouseOverView, draggingEndedWith info: NSDraggingInfo) + @objc @MainActor optional func mouseOverView(_ sender: MouseOverView, draggingExitedWith info: NSDraggingInfo?) } typealias MouseClickViewDelegate = MouseOverViewDelegate @@ -52,6 +52,15 @@ internal class MouseOverView: NSControl, Hoverable { @IBInspectable dynamic var backgroundColor: NSColor? @IBInspectable dynamic var cornerRadius: CGFloat = 0.0 + var maskedCorners: CACornerMask { + get { + backgroundLayer(createIfNeeded: true)?.maskedCorners ?? [] + } + set { + backgroundLayer(createIfNeeded: true)?.maskedCorners = newValue + } + } + @IBInspectable dynamic var backgroundInset: NSPoint = .zero @IBInspectable dynamic var mouseDownColor: NSColor? diff --git a/DuckDuckGo/TabBar/View/Base.lproj/TabBar.storyboard b/DuckDuckGo/TabBar/View/Base.lproj/TabBar.storyboard index 807f51be5c..bc008f86b7 100644 --- a/DuckDuckGo/TabBar/View/Base.lproj/TabBar.storyboard +++ b/DuckDuckGo/TabBar/View/Base.lproj/TabBar.storyboard @@ -1,7 +1,7 @@ - + - + @@ -149,13 +149,14 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DuckDuckGo/TabBar/View/TabBarViewController.swift b/DuckDuckGo/TabBar/View/TabBarViewController.swift index d825123cbe..04aa47b833 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewController.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewController.swift @@ -49,11 +49,18 @@ final class TabBarViewController: NSViewController { @IBOutlet weak var addTabButton: MouseOverButton! - var footerAddButton: MouseOverButton? + private var addNewTabButtonFooter: TabBarFooter? { + guard let indexPath = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: NSCollectionView.elementKindSectionFooter).first, + let footerView = collectionView.supplementaryView(forElementKind: NSCollectionView.elementKindSectionFooter, at: indexPath) else { return nil } + return footerView as? TabBarFooter ?? { + assertionFailure("Unexpected \(footerView), expected TabBarFooter") + return nil + }() + } let tabCollectionViewModel: TabCollectionViewModel var isInteractionPrevented: Bool = false { didSet { - footerAddButton?.isEnabled = !isInteractionPrevented + addNewTabButtonFooter?.isEnabled = !isInteractionPrevented } } @@ -450,7 +457,7 @@ final class TabBarViewController: NSViewController { let tabsWidth = scrollView.bounds.width let newMode: TabMode - if max(0, (items - 1)) * TabBarViewItem.Width.minimum.rawValue + TabBarViewItem.Width.minimumSelected.rawValue < tabsWidth { + if max(0, (items - 1)) * TabBarViewItem.Width.minimum + TabBarViewItem.Width.minimumSelected < tabsWidth { newMode = .divided } else { newMode = .overflow @@ -495,15 +502,15 @@ final class TabBarViewController: NSViewController { } let tabsWidth = scrollView.bounds.width - footerCurrentWidthDimension - let minimumWidth = selected ? TabBarViewItem.Width.minimumSelected.rawValue : TabBarViewItem.Width.minimum.rawValue + let minimumWidth = selected ? TabBarViewItem.Width.minimumSelected : TabBarViewItem.Width.minimum if tabMode == .divided { var dividedWidth = tabsWidth / numberOfItems // If tabs are shorter than minimumSelected, then the selected tab takes more space - if dividedWidth < TabBarViewItem.Width.minimumSelected.rawValue { - dividedWidth = (tabsWidth - TabBarViewItem.Width.minimumSelected.rawValue) / (numberOfItems - 1) + if dividedWidth < TabBarViewItem.Width.minimumSelected { + dividedWidth = (tabsWidth - TabBarViewItem.Width.minimumSelected) / (numberOfItems - 1) } - return min(TabBarViewItem.Width.maximum.rawValue, max(minimumWidth, dividedWidth)).rounded() + return min(TabBarViewItem.Width.maximum, max(minimumWidth, dividedWidth)).rounded() } else { return minimumWidth } @@ -630,7 +637,7 @@ final class TabBarViewController: NSViewController { extension TabBarViewController: MouseOverButtonDelegate { func mouseOverButton(_ sender: MouseOverButton, draggingEntered info: any NSDraggingInfo, isMouseOver: UnsafeMutablePointer) -> NSDragOperation { - assert(sender === addTabButton || sender === footerAddButton) + assert(sender === addTabButton || sender === addNewTabButtonFooter?.addButton) let pasteboard = info.draggingPasteboard if let types = pasteboard.types, types.contains(.string) { @@ -640,7 +647,7 @@ extension TabBarViewController: MouseOverButtonDelegate { } func mouseOverButton(_ sender: MouseOverButton, performDragOperation info: any NSDraggingInfo) -> Bool { - assert(sender === addTabButton || sender === footerAddButton) + assert(sender === addTabButton || sender === addNewTabButtonFooter?.addButton) if let string = info.draggingPasteboard.string(forType: .string), let url = URL.makeURL(from: string) { tabCollectionViewModel.insertOrAppendNewTab(.url(url, credential: nil, source: .appOpenUrl)) return true @@ -860,7 +867,7 @@ extension TabBarViewController: NSCollectionViewDelegateFlowLayout { func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { let isItemSelected = tabCollectionViewModel.selectionIndex == .unpinned(indexPath.item) - return NSSize(width: self.currentTabWidth(selected: isItemSelected), height: TabBarViewItem.Height.standard.rawValue) + return NSSize(width: self.currentTabWidth(selected: isItemSelected), height: TabBarViewItem.Height.standard) } } @@ -891,36 +898,22 @@ extension TabBarViewController: NSCollectionViewDataSource { tabBarViewItem.delegate = self tabBarViewItem.isBurner = tabCollectionViewModel.isBurner - tabBarViewItem.subscribe(to: tabViewModel, tabCollectionViewModel: tabCollectionViewModel) + tabBarViewItem.subscribe(to: tabViewModel) return tabBarViewItem } - func collectionView(_ collectionView: NSCollectionView, - viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, - at indexPath: IndexPath) -> NSView { - - let view = collectionView.makeSupplementaryView(ofKind: kind, - withIdentifier: TabBarFooter.identifier, for: indexPath) - if let footer = view as? TabBarFooter { - footer.addButton?.target = self - footer.addButton?.action = #selector(addButtonAction(_:)) - footer.toolTip = UserText.newTabTooltip - self.footerAddButton = footer.addButton - self.footerAddButton?.delegate = self - self.footerAddButton?.registerForDraggedTypes([.string]) - } - + func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind, at indexPath: IndexPath) -> NSView { + // swiftlint:disable:next force_cast + let view = collectionView.makeSupplementaryView(ofKind: kind, withIdentifier: TabBarFooter.identifier, for: indexPath) as! TabBarFooter + view.target = self return view } - func collectionView( - _ collectionView: NSCollectionView, - didEndDisplaying item: NSCollectionViewItem, - forRepresentedObjectAt indexPath: IndexPath) { - + func collectionView(_ collectionView: NSCollectionView, didEndDisplaying item: NSCollectionViewItem, forRepresentedObjectAt indexPath: IndexPath) { (item as? TabBarViewItem)?.clear() } + } // MARK: - NSCollectionViewDelegate @@ -1249,13 +1242,6 @@ extension TabBarViewController: TabBarViewItemDelegate { removeFireproofing(from: tab) } - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? { - guard let indexPath = collectionView.indexPath(for: tabBarViewItem), - let tab = tabCollectionViewModel.tabCollection.tabs[safe: indexPath.item] else { return nil } - - return tab.audioState - } - func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, replaceContentWithDroppedStringValue stringValue: String) { guard let indexPath = collectionView.indexPath(for: tabBarViewItem), let tab = tabCollectionViewModel.tabCollection.tabs[safe: indexPath.item] else { return } diff --git a/DuckDuckGo/TabBar/View/TabBarViewItem.swift b/DuckDuckGo/TabBar/View/TabBarViewItem.swift index e0fadfa30f..44998bf824 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewItem.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewItem.swift @@ -26,55 +26,338 @@ struct OtherTabBarViewItemsState { } +protocol TabBarViewModel { + var titlePublisher: Published.Publisher { get } + var faviconPublisher: Published.Publisher { get } + var tabContentPublisher: AnyPublisher { get } + var usedPermissionsPublisher: Published.Publisher { get } + var mutedStatePublisher: Published.Publisher { get } +} +extension TabViewModel: TabBarViewModel { + var titlePublisher: Published.Publisher { $title } + var faviconPublisher: Published.Publisher { $favicon } + var tabContentPublisher: AnyPublisher { tab.$content.eraseToAnyPublisher() } + var usedPermissionsPublisher: Published.Publisher { $usedPermissions } + var mutedStatePublisher: Published.Publisher { tab.$audioState } +} + protocol TabBarViewItemDelegate: AnyObject { - func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, isMouseOver: Bool) - - func tabBarViewItemCanBeDuplicated(_ tabBarViewItem: TabBarViewItem) -> Bool - func tabBarViewItemCanBePinned(_ tabBarViewItem: TabBarViewItem) -> Bool - func tabBarViewItemCanBeBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool - func tabBarViewItemIsAlreadyBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool - func tabBarViewAllItemsCanBeBookmarked(_ tabBarViewItem: TabBarViewItem) -> Bool - - func tabBarViewItemCloseAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemTogglePermissionAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemCloseOtherAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemCloseToTheLeftAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemCloseToTheRightAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemDuplicateAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemPinAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemBookmarkThisPageAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemRemoveBookmarkAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemBookmarkAllOpenTabsAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemMoveToNewWindowAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemMoveToNewBurnerWindowAction(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemFireproofSite(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemMuteUnmuteSite(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemRemoveFireproofing(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? - func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, replaceContentWithDroppedStringValue: String) - - func otherTabBarViewItemsState(for tabBarViewItem: TabBarViewItem) -> OtherTabBarViewItemsState + @MainActor func tabBarViewItem(_: TabBarViewItem, isMouseOver: Bool) + + @MainActor func tabBarViewItemCanBeDuplicated(_: TabBarViewItem) -> Bool + @MainActor func tabBarViewItemCanBePinned(_: TabBarViewItem) -> Bool + @MainActor func tabBarViewItemCanBeBookmarked(_: TabBarViewItem) -> Bool + @MainActor func tabBarViewItemIsAlreadyBookmarked(_: TabBarViewItem) -> Bool + @MainActor func tabBarViewAllItemsCanBeBookmarked(_: TabBarViewItem) -> Bool + + @MainActor func tabBarViewItemCloseAction(_: TabBarViewItem) + @MainActor func tabBarViewItemTogglePermissionAction(_: TabBarViewItem) + @MainActor func tabBarViewItemCloseOtherAction(_: TabBarViewItem) + @MainActor func tabBarViewItemCloseToTheLeftAction(_: TabBarViewItem) + @MainActor func tabBarViewItemCloseToTheRightAction(_: TabBarViewItem) + @MainActor func tabBarViewItemDuplicateAction(_: TabBarViewItem) + @MainActor func tabBarViewItemPinAction(_: TabBarViewItem) + @MainActor func tabBarViewItemBookmarkThisPageAction(_: TabBarViewItem) + @MainActor func tabBarViewItemRemoveBookmarkAction(_: TabBarViewItem) + @MainActor func tabBarViewItemBookmarkAllOpenTabsAction(_: TabBarViewItem) + @MainActor func tabBarViewItemMoveToNewWindowAction(_: TabBarViewItem) + @MainActor func tabBarViewItemMoveToNewBurnerWindowAction(_: TabBarViewItem) + @MainActor func tabBarViewItemFireproofSite(_: TabBarViewItem) + @MainActor func tabBarViewItemMuteUnmuteSite(_: TabBarViewItem) + @MainActor func tabBarViewItemRemoveFireproofing(_: TabBarViewItem) + @MainActor func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, replaceContentWithDroppedStringValue: String) + + @MainActor func otherTabBarViewItemsState(for tabBarViewItem: TabBarViewItem) -> OtherTabBarViewItemsState + +} +final class TabBarItemCellView: NSView { + + enum WidthStage { + case full + case withoutCloseButton + case withoutTitle + + var isTitleHidden: Bool { self == .withoutTitle } + var isCloseButtonHidden: Bool { self != .full } + var isFaviconCentered: Bool { !isTitleHidden } + + init(width: CGFloat) { + switch width { + case 0..<61: self = .withoutTitle + case 61..<120: self = .withoutCloseButton + default: self = .full + } + } + } + + var widthStage: WidthStage = .full { + didSet { + if widthStage != oldValue { + needsLayout = true + } + } + } + + private enum TextFieldMaskGradientSize { + static let width: CGFloat = 6 + static let trailingSpace: CGFloat = 0 + static let trailingSpaceWithButton: CGFloat = 20 + static let trailingSpaceWithPermissionAndButton: CGFloat = 40 + } + + fileprivate let faviconImageView = { + let faviconImageView = NSImageView() + faviconImageView.imageScaling = .scaleProportionallyDown + faviconImageView.applyFaviconStyle() + return faviconImageView + }() + + fileprivate let mutedTabIcon = { + let mutedTabIcon = NSImageView(image: .audioMute) + mutedTabIcon.contentTintColor = .mutedTabIcon + mutedTabIcon.imageScaling = .scaleNone + return mutedTabIcon + }() + + fileprivate let titleTextField = { + let titleTextField = NSTextField() + titleTextField.wantsLayer = true + titleTextField.isEditable = false + titleTextField.alignment = .left + titleTextField.drawsBackground = false + titleTextField.isBordered = false + titleTextField.font = NSFont.systemFont(ofSize: 13) + titleTextField.textColor = .labelColor + titleTextField.lineBreakMode = .byClipping + return titleTextField + }() + + fileprivate lazy var permissionButton = { + let permissionButton = MouseOverButton(title: "", target: nil, action: #selector(TabBarViewItem.permissionButtonAction)) + permissionButton.bezelStyle = .shadowlessSquare + permissionButton.cornerRadius = 2 + permissionButton.normalTintColor = .button + permissionButton.mouseDownColor = .buttonMouseDown + permissionButton.mouseOverColor = .buttonMouseOver + permissionButton.imagePosition = .imageOnly + permissionButton.imageScaling = .scaleNone + return permissionButton + }() + + fileprivate lazy var closeButton = { + let closeButton = MouseOverButton(image: .close, target: nil, action: #selector(TabBarViewItem.closeButtonAction)) + closeButton.bezelStyle = .shadowlessSquare + closeButton.cornerRadius = 2 + closeButton.normalTintColor = .button + closeButton.mouseDownColor = .buttonMouseDown + closeButton.mouseOverColor = .buttonMouseOver + closeButton.imagePosition = .imageOnly + closeButton.imageScaling = .scaleNone + return closeButton + }() + + var target: AnyObject? { + get { + closeButton.target + } + set { + closeButton.target = newValue + permissionButton.target = newValue + } + } + + fileprivate let mouseOverView = { + let mouseOverView = MouseOverView() + mouseOverView.mouseOverColor = .tabMouseOver + return mouseOverView + }() + + fileprivate let rightSeparatorView = ColorView(frame: .zero, backgroundColor: .separator) + + fileprivate lazy var borderLayer: CALayer = { + let layer = CALayer() + layer.borderWidth = TabShadowConfig.dividerSize + layer.opacity = TabShadowConfig.alpha + layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner] + layer.cornerRadius = 11 + layer.mask = layerMask + return layer + }() + + private lazy var layerMask: CALayer = { + let layer = CALayer() + layer.addSublayer(leftPixelMask) + layer.addSublayer(rightPixelMask) + layer.addSublayer(topContentLineMask) + return layer + }() + + private let leftPixelMask: CALayer = { + let layer = CALayer() + layer.backgroundColor = NSColor.white.cgColor + return layer + }() + + private let rightPixelMask: CALayer = { + let layer = CALayer() + layer.backgroundColor = NSColor.white.cgColor + return layer + }() + + private let topContentLineMask: CALayer = { + let layer = CALayer() + layer.backgroundColor = NSColor.white.cgColor + return layer + }() + + convenience init() { + self.init(frame: .zero) + } + + override init(frame: NSRect) { + super.init(frame: frame) + translatesAutoresizingMaskIntoConstraints = false + + clipsToBounds = true + + mouseOverView.cornerRadius = 11 + mouseOverView.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + mouseOverView.layer?.addSublayer(borderLayer) + + addSubview(mouseOverView) + addSubview(faviconImageView) + addSubview(mutedTabIcon) + addSubview(titleTextField) + addSubview(permissionButton) + addSubview(closeButton) + addSubview(rightSeparatorView) + } + + required init?(coder: NSCoder) { + fatalError("TabBarItemCellView: Bad initializer") + } + + override func layout() { + super.layout() + mouseOverView.frame = bounds + + withoutAnimation { + borderLayer.frame = bounds + leftPixelMask.frame = CGRect(x: 0, y: 0, width: TabShadowConfig.dividerSize, height: TabShadowConfig.dividerSize) + rightPixelMask.frame = CGRect(x: borderLayer.bounds.width - TabShadowConfig.dividerSize, y: 0, width: TabShadowConfig.dividerSize, height: TabShadowConfig.dividerSize) + topContentLineMask.frame = CGRect(x: 0, y: TabShadowConfig.dividerSize, width: borderLayer.bounds.width, height: borderLayer.bounds.height - TabShadowConfig.dividerSize) + } + + switch widthStage { + case .full, .withoutCloseButton: + layoutForNormalMode() + case .withoutTitle: + layoutForCompactMode() + } + + rightSeparatorView.frame = NSRect(x: bounds.maxX.rounded() - 1, y: bounds.midY - 10, width: 1, height: 20) + } + + private func layoutForNormalMode() { + var minX: CGFloat = 9 + if faviconImageView.isShown { + faviconImageView.frame = NSRect(x: minX, y: bounds.midY - 8, width: 16, height: 16) + minX = faviconImageView.frame.maxX + 4 + } + if mutedTabIcon.isShown { + mutedTabIcon.frame = NSRect(x: minX, y: bounds.midY - 8, width: 16, height: 16) + minX = mutedTabIcon.frame.maxX + } + var maxX = bounds.maxX - 9 + if closeButton.isShown { + closeButton.frame = NSRect(x: maxX - 16, y: bounds.midY - 8, width: 16, height: 16) + maxX = closeButton.frame.minX - 4 + } else { + maxX = max(maxX - 1 /* 28 title offset with favicon */, 12 /* without favicon */) + } + if permissionButton.isShown { + permissionButton.frame = NSRect(x: maxX - 20, y: bounds.midY - 12, width: 24, height: 24) + } + + titleTextField.frame = NSRect(x: minX, y: bounds.midY - 8, width: bounds.maxX - minX - 8, height: 16) + updateTitleTextFieldMask() + } + + private func updateTitleTextFieldMask() { + let gradientPadding: CGFloat + switch (closeButton.isHidden, permissionButton.isHidden) { + case (true, true): + gradientPadding = TextFieldMaskGradientSize.trailingSpace + case (false, true), (true, false): + gradientPadding = TextFieldMaskGradientSize.trailingSpaceWithButton + case (false, false): + gradientPadding = TextFieldMaskGradientSize.trailingSpaceWithPermissionAndButton + } + titleTextField.gradient(width: TextFieldMaskGradientSize.width, trailingPadding: gradientPadding) + } + + private func layoutForCompactMode() { + let numberOfElements: CGFloat = (faviconImageView.isShown ? 1 : 0) + (mutedTabIcon.isShown ? 1 : 0) + (permissionButton.isShown ? 1 : 0) + (closeButton.isShown ? 1 : 0) + (titleTextField.isShown ? 1 : 0) + let elementWidth: CGFloat = 16 + var totalWidth = numberOfElements * elementWidth + // tighten elements to fit all + let spacing = min(4, bounds.width - 4 - totalWidth) + totalWidth += (numberOfElements - 1) * spacing + // shift all shown elements from center + var x = (bounds.width - totalWidth) / 2 + if faviconImageView.isShown { + assert(closeButton.isHidden) + faviconImageView.frame = NSRect(x: x.rounded(), y: bounds.midY - 8, width: 16, height: 16) + x = faviconImageView.frame.maxX + spacing + } else if titleTextField.isShown { + assert(closeButton.isHidden) + titleTextField.frame = NSRect(x: 4, y: bounds.midY - 8, width: bounds.maxX - 8, height: 16) + updateTitleTextFieldMask() + } + if mutedTabIcon.isShown { + mutedTabIcon.frame = NSRect(x: x.rounded(), y: bounds.midY - 8, width: 16, height: 16) + x = mutedTabIcon.frame.maxX + spacing + } + if permissionButton.isShown { + // make permission button from 16 to 24pt wide depending of available space + permissionButton.frame = NSRect(x: x.rounded() - spacing.rounded(), y: bounds.midY - 12, width: 16 + spacing.rounded() * 2, height: 24) + } + } + + override func updateLayer() { + NSAppearance.withAppAppearance { + borderLayer.borderColor = NSColor.tabShadowLine.cgColor + } + } } +@MainActor final class TabBarViewItem: NSCollectionViewItem { - enum Constants { - static let textFieldPadding: CGFloat = 28 - static let textFieldPaddingNoFavicon: CGFloat = 12 + static let identifier = NSUserInterfaceItemIdentifier(rawValue: "TabBarViewItem") + + enum Height { + static let standard: CGFloat = 34 + } + enum Width { + static let minimum: CGFloat = 52 + static let minimumSelected: CGFloat = 120 + static let maximum: CGFloat = 240 } - var widthStage: WidthStage { + private var widthStage: TabBarItemCellView.WidthStage { if isSelected || isDragged { return .full } else { - return WidthStage(width: view.bounds.size.width) + return .init(width: view.bounds.size.width) } } - static let identifier = NSUserInterfaceItemIdentifier(rawValue: "TabBarViewItem") - private var eventMonitor: Any? { didSet { if let oldValue = oldValue { @@ -95,50 +378,44 @@ final class TabBarViewItem: NSCollectionViewItem { } } - @IBOutlet weak var faviconImageView: NSImageView! { - didSet { - faviconImageView.applyFaviconStyle() - } - } - @IBOutlet weak var permissionButton: NSButton! - - @IBOutlet weak var titleTextField: NSTextField! - @IBOutlet weak var titleTextFieldLeadingConstraint: NSLayoutConstraint! - @IBOutlet weak var titleTextFieldLeadingMuteConstraint: NSLayoutConstraint! - @IBOutlet weak var closeButton: MouseOverButton! - @IBOutlet weak var rightSeparatorView: ColorView! - @IBOutlet weak var mouseOverView: MouseOverView! - @IBOutlet weak var faviconWrapperView: NSView! - @IBOutlet weak var faviconWrapperViewCenterConstraint: NSLayoutConstraint! - @IBOutlet weak var faviconWrapperViewLeadingConstraint: NSLayoutConstraint! - @IBOutlet var permissionCloseButtonTrailingConstraint: NSLayoutConstraint! - @IBOutlet var tabLoadingPermissionLeadingConstraint: NSLayoutConstraint! - @IBOutlet var closeButtonTrailingConstraint: NSLayoutConstraint! - @IBOutlet weak var mutedTabIcon: NSImageView! - private let titleTextFieldMaskLayer = CAGradientLayer() - private var currentURL: URL? private var cancellables = Set() weak var delegate: TabBarViewItemDelegate? - var isMouseOver = false + private(set) var isMouseOver = false + + private var cell: TabBarItemCellView { + view as! TabBarItemCellView // swiftlint:disable:this force_cast + } + + override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("TabBarViewItem: Bad initializer") + } + + override func loadView() { + view = TabBarItemCellView() + + } override func viewDidLoad() { super.viewDidLoad() - setupView() + cell.target = self + cell.mouseOverView.delegate = self + cell.mouseOverView.registerForDraggedTypes([.string]) + updateSubviews() setupMenu() - updateTitleTextFieldMask() - closeButton.isHidden = true } - override func viewDidLayout() { - super.viewDidLayout() - + override func viewWillLayout() { + cell.widthStage = widthStage updateSubviews() - updateTitleTextFieldMask() } override func viewWillDisappear() { @@ -147,7 +424,7 @@ final class TabBarViewItem: NSCollectionViewItem { } deinit { - if let eventMonitor = eventMonitor { + if let eventMonitor { NSEvent.removeMonitor(eventMonitor) } } @@ -159,7 +436,6 @@ final class TabBarViewItem: NSCollectionViewItem { } updateSubviews() updateUsedPermissions() - updateTitleTextFieldMask() } } @@ -177,42 +453,41 @@ final class TabBarViewItem: NSCollectionViewItem { super.mouseDown(with: event) } - @objc func duplicateAction(_ sender: NSButton) { + @objc private func duplicateAction(_ sender: NSButton) { delegate?.tabBarViewItemDuplicateAction(self) } - @objc func pinAction(_ sender: NSButton) { + @objc private func pinAction(_ sender: NSButton) { delegate?.tabBarViewItemPinAction(self) } - @objc func fireproofSiteAction(_ sender: NSButton) { + @objc private func fireproofSiteAction(_ sender: NSButton) { delegate?.tabBarViewItemFireproofSite(self) } - @objc func muteUnmuteSiteAction(_ sender: NSButton) { + @objc private func muteUnmuteSiteAction(_ sender: NSButton) { delegate?.tabBarViewItemMuteUnmuteSite(self) - setupMuteOrUnmutedIcon() } - @objc func removeFireproofingAction(_ sender: NSButton) { + @objc private func removeFireproofingAction(_ sender: NSButton) { delegate?.tabBarViewItemRemoveFireproofing(self) } - @objc func bookmarkThisPageAction(_ sender: Any) { + @objc private func bookmarkThisPageAction(_ sender: Any) { delegate?.tabBarViewItemBookmarkThisPageAction(self) } - @objc func removeFromBookmarksAction(_ sender: Any) { + @objc private func removeFromBookmarksAction(_ sender: Any) { delegate?.tabBarViewItemRemoveBookmarkAction(self) } - @objc func bookmarkAllOpenTabsAction(_ sender: Any) { + @objc private func bookmarkAllOpenTabsAction(_ sender: Any) { delegate?.tabBarViewItemBookmarkAllOpenTabsAction(self) } private var lastKnownIndexPath: IndexPath? - @IBAction func closeButtonAction(_ sender: Any) { + @objc fileprivate func closeButtonAction(_ sender: Any) { // due to async nature of NSCollectionView views removal // leaving window._lastLeftHit set to the button will prevent // continuous clicks on the Close button @@ -235,53 +510,60 @@ final class TabBarViewItem: NSCollectionViewItem { delegate?.tabBarViewItemCloseAction(self) } - @IBAction func permissionButtonAction(_ sender: NSButton) { + @objc fileprivate func permissionButtonAction(_ sender: NSButton) { delegate?.tabBarViewItemTogglePermissionAction(self) } - @objc func closeOtherAction(_ sender: NSMenuItem) { + @objc private func closeOtherAction(_ sender: NSMenuItem) { delegate?.tabBarViewItemCloseOtherAction(self) } - @objc func closeToTheLeftAction(_ sender: NSMenuItem) { + @objc private func closeToTheLeftAction(_ sender: NSMenuItem) { delegate?.tabBarViewItemCloseToTheLeftAction(self) } - @objc func closeToTheRightAction(_ sender: NSMenuItem) { + @objc private func closeToTheRightAction(_ sender: NSMenuItem) { delegate?.tabBarViewItemCloseToTheRightAction(self) } - @objc func moveToNewWindowAction(_ sender: NSMenuItem) { + @objc private func moveToNewWindowAction(_ sender: NSMenuItem) { delegate?.tabBarViewItemMoveToNewWindowAction(self) } - @objc func moveToNewBurnerWindowAction(_ sender: NSMenuItem) { + @objc private func moveToNewBurnerWindowAction(_ sender: NSMenuItem) { delegate?.tabBarViewItemMoveToNewBurnerWindowAction(self) } - func subscribe(to tabViewModel: TabViewModel, tabCollectionViewModel: TabCollectionViewModel) { + func subscribe(to tabViewModel: TabBarViewModel) { clearSubscriptions() - tabViewModel.$title.sink { [weak self] title in - self?.titleTextField.stringValue = title + representedObject = tabViewModel + tabViewModel.titlePublisher.sink { [weak self] title in + self?.cell.titleTextField.stringValue = title }.store(in: &cancellables) - tabViewModel.$favicon.sink { [weak self] favicon in + tabViewModel.faviconPublisher.sink { [weak self] favicon in self?.updateFavicon(favicon) }.store(in: &cancellables) - tabViewModel.tab.$content.sink { [weak self] content in + tabViewModel.tabContentPublisher.sink { [weak self] content in self?.currentURL = content.userEditableUrl }.store(in: &cancellables) - tabViewModel.$usedPermissions.assign(to: \.usedPermissions, onWeaklyHeld: self).store(in: &cancellables) + tabViewModel.usedPermissionsPublisher + .assign(to: \.usedPermissions, onWeaklyHeld: self) + .store(in: &cancellables) + + tabViewModel.mutedStatePublisher.sink { [weak self] mutedState in + self?.updateAudioMutedState(mutedState) + }.store(in: &cancellables) } func clear() { clearSubscriptions() usedPermissions = Permissions() - faviconImageView.image = nil - titleTextField.stringValue = "" + cell.faviconImageView.image = nil + cell.titleTextField.stringValue = "" } private var isDragged = false { @@ -290,143 +572,63 @@ final class TabBarViewItem: NSCollectionViewItem { } } - private lazy var borderLayer: CALayer = { - let layer = CALayer() - layer.borderWidth = TabShadowConfig.dividerSize - layer.opacity = TabShadowConfig.alpha - layer.maskedCorners = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner] - layer.cornerRadius = 11 - layer.mask = layerMask - return layer - }() - - private lazy var layerMask: CALayer = { - let layer = CALayer() - layer.addSublayer(leftPixelMask) - layer.addSublayer(rightPixelMask) - layer.addSublayer(topContentLineMask) - return layer - }() - - private lazy var leftPixelMask: CALayer = { - let layer = CALayer() - layer.backgroundColor = NSColor.white.cgColor - return layer - }() - - private lazy var rightPixelMask: CALayer = { - let layer = CALayer() - layer.backgroundColor = NSColor.white.cgColor - return layer - }() - - private lazy var topContentLineMask: CALayer = { - let layer = CALayer() - layer.backgroundColor = NSColor.white.cgColor - return layer - }() - - override func viewWillLayout() { - super.viewWillLayout() - - withoutAnimation { - borderLayer.frame = self.view.bounds - leftPixelMask.frame = CGRect(x: 0, y: 0, width: TabShadowConfig.dividerSize, height: TabShadowConfig.dividerSize) - rightPixelMask.frame = CGRect(x: borderLayer.bounds.width - TabShadowConfig.dividerSize, y: 0, width: TabShadowConfig.dividerSize, height: TabShadowConfig.dividerSize) - topContentLineMask.frame = CGRect(x: 0, y: TabShadowConfig.dividerSize, width: borderLayer.bounds.width, height: borderLayer.bounds.height - TabShadowConfig.dividerSize) - } - } - - private func updateBorderLayerColor() { - NSAppearance.withAppAppearance { - withoutAnimation { - borderLayer.borderColor = NSColor.tabShadowLine.cgColor - } - } - } - - private func setupView() { - view.wantsLayer = true - view.layer?.cornerRadius = 11 - view.layer?.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] - view.layer?.masksToBounds = true - view.layer?.addSublayer(borderLayer) - } - private func clearSubscriptions() { cancellables.removeAll() + representedObject = nil } private func updateSubviews() { - NSAppearance.withAppAppearance { - let backgroundColor: NSColor = isSelected || isDragged ? .navigationBarBackground : .clear - view.layer?.backgroundColor = backgroundColor.cgColor - mouseOverView.mouseOverColor = isSelected || isDragged ? .clear : .tabMouseOver + withoutAnimation { + if isSelected || isDragged { + cell.mouseOverView.mouseOverColor = nil + cell.mouseOverView.backgroundColor = .navigationBarBackground + } else { + cell.mouseOverView.mouseOverColor = .tabMouseOver + cell.mouseOverView.backgroundColor = nil + } + cell.borderLayer.isHidden = !isSelected } let showCloseButton = (isMouseOver && !widthStage.isCloseButtonHidden) || isSelected - closeButton.isHidden = !showCloseButton + cell.closeButton.isShown = showCloseButton + cell.faviconImageView.isShown = (cell.faviconImageView.image != nil) && (widthStage != .withoutTitle || !showCloseButton) updateSeparatorView() - permissionCloseButtonTrailingConstraint.isActive = !closeButton.isHidden - titleTextField.isHidden = widthStage.isTitleHidden && faviconImageView.image != nil - setupMuteOrUnmutedIcon() - - if mutedTabIcon.isHidden { - faviconWrapperViewCenterConstraint.priority = titleTextField.isHidden ? .defaultHigh : .defaultLow - faviconWrapperViewLeadingConstraint.priority = titleTextField.isHidden ? .defaultLow : .defaultHigh - } else { - // When the mute icon is visible and the tab is compressed we need to center both - faviconWrapperViewCenterConstraint.priority = .defaultLow - faviconWrapperViewLeadingConstraint.priority = .defaultHigh - } - - updateBorderLayerColor() - - if isSelected { - borderLayer.isHidden = false - } else { - borderLayer.isHidden = true - } + cell.titleTextField.isShown = !widthStage.isTitleHidden || (cell.faviconImageView.image == nil && !showCloseButton) // Adjust colors for burner window - if isBurner && faviconImageView.image === TabViewModel.Favicon.burnerHome { - faviconImageView.contentTintColor = .textColor + if isBurner && cell.faviconImageView.image === TabViewModel.Favicon.burnerHome { + cell.faviconImageView.contentTintColor = .textColor } else { - faviconImageView.contentTintColor = nil + cell.faviconImageView.contentTintColor = nil } - - mouseOverView.registerForDraggedTypes([.string]) - mouseOverView.delegate = self } private var usedPermissions = Permissions() { didSet { updateUsedPermissions() - updateTitleTextFieldMask() } } private func updateUsedPermissions() { + cell.needsLayout = true if usedPermissions.camera.isActive { - permissionButton.image = .cameraTabActive + cell.permissionButton.image = .cameraTabActive } else if usedPermissions.microphone.isActive { - permissionButton.image = .microphoneActive + cell.permissionButton.image = .microphoneActive } else if usedPermissions.camera.isPaused { - permissionButton.image = .cameraTabBlocked + cell.permissionButton.image = .cameraTabBlocked } else if usedPermissions.microphone.isPaused { - permissionButton.image = .microphoneIcon + cell.permissionButton.image = .microphoneIcon } else { - permissionButton.isHidden = true - tabLoadingPermissionLeadingConstraint.isActive = false + cell.permissionButton.isHidden = true return } - permissionButton.isHidden = false - tabLoadingPermissionLeadingConstraint.isActive = true + cell.permissionButton.isHidden = false } private func updateSeparatorView() { let newIsHidden = isSelected || isDragged || isLeftToSelected - if rightSeparatorView.isHidden != newIsHidden { - rightSeparatorView.isHidden = newIsHidden + if cell.rightSeparatorView.isHidden != newIsHidden { + cell.rightSeparatorView.isHidden = newIsHidden } } @@ -437,61 +639,22 @@ final class TabBarViewItem: NSCollectionViewItem { view.menu = menu } - private func updateTitleTextFieldMask() { - let gradientPadding: CGFloat - switch (closeButton.isHidden, permissionButton.isHidden) { - case (true, true): - gradientPadding = TextFieldMaskGradientSize.trailingSpace - case (false, true), (true, false): - gradientPadding = TextFieldMaskGradientSize.trailingSpaceWithButton - case (false, false): - gradientPadding = TextFieldMaskGradientSize.trailingSpaceWithPermissionAndButton - } - titleTextField.gradient(width: TextFieldMaskGradientSize.width, trailingPadding: gradientPadding) - } - private func updateFavicon(_ favicon: NSImage?) { - faviconWrapperView.isHidden = favicon == nil - titleTextFieldLeadingConstraint.constant = faviconWrapperView.isHidden ? Constants.textFieldPaddingNoFavicon : Constants.textFieldPadding - faviconImageView.image = favicon - faviconImageView.imageScaling = .scaleProportionallyDown + cell.needsLayout = true + cell.faviconImageView.isHidden = (favicon == nil) + cell.faviconImageView.image = favicon } - private func setupMuteOrUnmutedIcon() { - setupMutedTabIconVisibility() - setupMutedTabIconColor() - setupMutedTabIconPosition() - } - - private func setupMutedTabIconVisibility() { - switch delegate?.tabBarViewItemAudioState(self) { + private func updateAudioMutedState(_ mutedState: WKWebView.AudioState?) { + cell.needsLayout = true + switch mutedState { case .muted: - mutedTabIcon.isHidden = false + cell.mutedTabIcon.isHidden = false case .unmuted, .none: - mutedTabIcon.isHidden = true + cell.mutedTabIcon.isHidden = true } } - private func setupMutedTabIconColor() { - mutedTabIcon.image?.isTemplate = true - mutedTabIcon.contentTintColor = .mutedTabIcon - } - - private func setupMutedTabIconPosition() { - if mutedTabIcon.isHidden { - titleTextFieldLeadingConstraint.priority = .defaultHigh - titleTextFieldLeadingMuteConstraint.priority = .defaultLow - titleTextFieldLeadingConstraint.constant = faviconWrapperView.isHidden ? Constants.textFieldPaddingNoFavicon : Constants.textFieldPadding - } else { - if titleTextField.isHidden { - titleTextFieldLeadingMuteConstraint.priority = .defaultLow - titleTextFieldLeadingConstraint.priority = .defaultLow - } else { - titleTextFieldLeadingMuteConstraint.priority = .required - titleTextFieldLeadingConstraint.priority = .defaultLow - } - } - } } extension TabBarViewItem: NSMenuDelegate { @@ -504,13 +667,13 @@ extension TabBarViewItem: NSMenuDelegate { let areThereOtherTabs = otherItemsState.hasItemsToTheLeft || otherItemsState.hasItemsToTheRight // Menu Items - // Section 1 + // Duplicate, Pin, Mute Section addDuplicateMenuItem(to: menu) addPinMenuItem(to: menu) addMuteUnmuteMenuItem(to: menu) menu.addItem(.separator()) - // Section 2 + // Bookmark/Fireproof Section addFireproofMenuItem(to: menu) if let delegate, delegate.tabBarViewItemIsAlreadyBookmarked(self) { removeBookmarkMenuItem(to: menu) @@ -519,11 +682,11 @@ extension TabBarViewItem: NSMenuDelegate { } menu.addItem(.separator()) - // Section 3 + // Bookmark All Section addBookmarkAllTabsMenuItem(to: menu) menu.addItem(.separator()) - // Section 4 + // Close Section addCloseMenuItem(to: menu) addCloseOtherSubmenu(to: menu, tabBarItemState: otherItemsState) if !isBurner { @@ -580,9 +743,8 @@ extension TabBarViewItem: NSMenuDelegate { } private func addMuteUnmuteMenuItem(to menu: NSMenu) { - guard let audioState = delegate?.tabBarViewItemAudioState(self) else { return } - - let menuItemTitle = audioState == .muted ? UserText.unmuteTab : UserText.muteTab + let isMuted = cell.mutedTabIcon.isShown + let menuItemTitle = isMuted ? UserText.unmuteTab : UserText.muteTab let muteUnmuteMenuItem = NSMenuItem(title: menuItemTitle, action: #selector(muteUnmuteSiteAction(_:)), keyEquivalent: "") muteUnmuteMenuItem.target = self menu.addItem(muteUnmuteMenuItem) @@ -684,43 +846,268 @@ extension TabBarViewItem: MouseClickViewDelegate { } } +// MARK: - Preview +#if DEBUG +@available(macOS 14.0, *) +#Preview("Normal", traits: .fixedLayout(width: 736, height: 450)) { + TabBarViewItem.PreviewViewController(sections: [ + [ + .init(width: TabBarViewItem.Width.maximum, title: "", favicon: nil, selected: true), + .init(width: TabBarViewItem.Width.maximum, title: "about:blank", favicon: nil, selected: false), + .init(width: TabBarViewItem.Width.maximum, title: "about:blank", favicon: nil, selected: true), + ], + [ + .init(width: TabBarViewItem.Width.maximum, title: "DuckDuckGo", favicon: .homeFavicon, selected: false), + .init(width: TabBarViewItem.Width.maximum, title: "Appearance", favicon: .appearance, selected: true), + .init(width: TabBarViewItem.Width.maximum, title: "Bookmarks", favicon: .bookmarksFolder, selected: false), + ], + [ + .init(width: TabBarViewItem.Width.maximum, title: "Something in the tab title to get shrunk", favicon: .aDark, selected: true), + .init(width: TabBarViewItem.Width.maximum, title: "Somewhere all we go now to get totally drunk", favicon: nil), + .init(width: TabBarViewItem.Width.maximum, title: "Long Previewable Title with Permissions", favicon: .h, usedPermissions: [ + .camera: .paused, + ], mutedState: .muted), + ], + [ + .init(width: TabBarViewItem.Width.maximum, title: "Something in the tab title to be shrunk", favicon: .aDark, usedPermissions: [ + .camera: .active + ], mutedState: .muted, selected: true), + .init(width: TabBarViewItem.Width.maximum, title: "Test 1", favicon: .homeFavicon, usedPermissions: [ + .camera: .disabled(systemWide: true), + ], mutedState: .muted), + .init(width: TabBarViewItem.Width.maximum, title: "Test 2", favicon: .homeFavicon, usedPermissions: [ + .camera: .paused, + ], mutedState: .muted), + ], + [ + .init(width: TabBarViewItem.mediumWidth, title: "", favicon: nil, selected: true), + .init(width: TabBarViewItem.mediumWidth, title: "about:blank", favicon: nil, selected: false), + .init(width: TabBarViewItem.Width.maximum, title: "about:blank", favicon: nil, selected: true), + .init(width: TabBarViewItem.mediumWidth, title: "", favicon: nil, usedPermissions: [ + .microphone: .active + ], selected: false), + ], + [ + .init(width: TabBarViewItem.mediumWidth, title: "DuckDuckGo", favicon: .homeFavicon, selected: false), + .init(width: TabBarViewItem.Width.maximum, title: "Appearance", favicon: .appearance, selected: true), + .init(width: TabBarViewItem.mediumWidth, title: "Bookmarks", favicon: .bookmarksFolder, selected: false), + .init(width: TabBarViewItem.mediumWidth, title: "Appearance", favicon: .appearance, usedPermissions: [ + .microphone: .active + ]), + ], + [ + .init(width: TabBarViewItem.Width.maximum, title: "Something in the tab title to get shrunk", favicon: .aDark, selected: true), + .init(width: TabBarViewItem.mediumWidth, title: "Somewhere all we go now to get totally drunk", favicon: nil), + .init(width: TabBarViewItem.mediumWidth, title: "Long Previewable Title with Permissions", favicon: .b, usedPermissions: [ + .camera: .paused, + ], mutedState: .muted), + .init(width: TabBarViewItem.mediumWidth, title: "Long Previewable Title with Permissions", favicon: .h, usedPermissions: [ + .camera: .active, + ]), + ], + [ + .init(width: TabBarViewItem.Width.maximum, title: "Something in the tab title to be shrunk", favicon: .aDark, usedPermissions: [ + .camera: .active + ], mutedState: .muted, selected: true), + .init(width: TabBarViewItem.mediumWidth, title: "Test 1", favicon: .homeFavicon, usedPermissions: [ + .camera: .disabled(systemWide: true), + ], mutedState: .unmuted), + .init(width: TabBarViewItem.mediumWidth, title: "Test 2", favicon: nil, usedPermissions: [ + .microphone: .active, + ], mutedState: .muted), + .init(width: TabBarViewItem.mediumWidth, title: "Test 2", favicon: .homeFavicon, mutedState: .unmuted), + ], + + [ + .init(width: TabBarViewItem.Width.minimum, title: "Test 9", favicon: .a, usedPermissions: [ + .microphone: .active, + ], mutedState: nil), + .init(width: TabBarViewItem.Width.maximum, title: "Test 10", favicon: .error, usedPermissions: [ + .camera: .paused, + ], mutedState: .unmuted, selected: true), + .init(width: TabBarViewItem.Width.minimum, title: "Test 11", favicon: .b, usedPermissions: [ + .camera: .active, + ], mutedState: .unmuted), + .init(width: TabBarViewItem.Width.minimum, title: "Test 12", favicon: .c, usedPermissions: [ + .microphone: .active, + ], mutedState: .muted), + .init(width: TabBarViewItem.Width.minimum, title: "Test 13", favicon: .d), + .init(width: TabBarViewItem.Width.minimum, title: "Test 14", favicon: .e, usedPermissions: [ + .camera: .paused, + ], mutedState: .unmuted), + .init(width: TabBarViewItem.Width.minimum, title: "Test 16", favicon: nil, usedPermissions: [ + .microphone: .active, + ], mutedState: .muted), + .init(width: TabBarViewItem.Width.minimum, title: "Test 17", favicon: nil), + .init(width: TabBarViewItem.Width.minimum, title: "Test 18", favicon: nil, usedPermissions: [ + .camera: .paused, + ], mutedState: .unmuted), + .init(width: TabBarViewItem.Width.minimum, title: "Test 19", favicon: nil, mutedState: .muted), + + ] + ])._preview_hidingWindowControlsOnAppear() +} + extension TabBarViewItem { + static let mediumWidth = (TabBarViewItem.Width.maximum + TabBarViewItem.Width.minimum) / 2 + @MainActor + final class PreviewViewController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout, TabBarViewItemDelegate { + + final class TabBarViewModelMock: TabBarViewModel { + var width: CGFloat + var isSelected: Bool + @Published var title: String = "" + var titlePublisher: Published.Publisher { $title } + @Published var favicon: NSImage? + var faviconPublisher: Published.Publisher { $favicon } + @Published var tabContent: Tab.TabContent = .none + var tabContentPublisher: AnyPublisher { $tabContent.eraseToAnyPublisher() } + @Published var usedPermissions = Permissions() + var usedPermissionsPublisher: Published.Publisher { $usedPermissions } + @Published var mutedState: WKWebView.AudioState? + var mutedStatePublisher: Published.Publisher { + $mutedState + } + init(width: CGFloat, title: String = "Test Title", favicon: NSImage? = .aDark, tabContent: Tab.TabContent = .none, usedPermissions: Permissions = Permissions(), mutedState: WKWebView.AudioState? = nil, selected: Bool = false) { + self.width = width + self.title = title + self.favicon = favicon + self.tabContent = tabContent + self.usedPermissions = usedPermissions + self.mutedState = mutedState + self.isSelected = selected + } + } - enum Height: CGFloat { - case standard = 34 - } + let sections: [[TabBarViewModelMock]] + var collectionViews = [NSCollectionView]() - enum Width: CGFloat { - case minimum = 52 - case minimumSelected = 120 - case maximum = 240 - } + init(sections: [[TabBarViewModelMock]]) { + self.sections = sections + super.init(nibName: nil, bundle: nil) + } - enum WidthStage { - case full - case withoutCloseButton - case withoutTitle + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - init(width: CGFloat) { - switch width { - case 0..<61: self = .withoutTitle - case 61..<120: self = .withoutCloseButton - default: self = .full + override func loadView() { + view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + + var constraints = [NSLayoutConstraint]() + for (section, items) in sections.enumerated() { + let collectionView = NSCollectionView() + collectionViews.append(collectionView) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.dataSource = self + collectionView.delegate = self + let layout = NSCollectionViewFlowLayout() + layout.minimumInteritemSpacing = 0 + layout.minimumLineSpacing = 0 + layout.scrollDirection = .horizontal + collectionView.collectionViewLayout = layout + collectionView.backgroundColors = [.clear] + + let selectedItems = items.indices.filter { + items[$0].isSelected + }.map { IndexPath(item: $0, section: 0) } + + view.addSubview(collectionView) + collectionView.selectItems(at: Set(selectedItems), scrollPosition: .top) + + constraints.append(contentsOf: [ + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 8), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -8), + collectionView.topAnchor.constraint(equalTo: view.topAnchor, constant: 8 + CGFloat(section) * 48), + collectionView.heightAnchor.constraint(equalToConstant: 38), + ]) + + let separator = ColorView(frame: .zero, backgroundColor: .navigationBarBackground, borderColor: .separator, borderWidth: 1) + view.addSubview(separator) + constraints.append(contentsOf: [ + separator.topAnchor.constraint(equalTo: collectionView.topAnchor, constant: 34), + separator.heightAnchor.constraint(equalToConstant: 5), + separator.leadingAnchor.constraint(equalTo: view.leadingAnchor), + separator.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) } + NSLayoutConstraint.activate(constraints) } - var isTitleHidden: Bool { self == .withoutTitle } - var isCloseButtonHidden: Bool { self != .full } - var isFaviconCentered: Bool { !isTitleHidden } - } + func numberOfSections(in _: NSCollectionView) -> Int { 1 } -} + func collectionView(_ cv: NSCollectionView, numberOfItemsInSection section: Int) -> Int { + let section = collectionViews.firstIndex(where: { $0 === cv })! + return sections[section].count + } -private extension TabBarViewItem { - enum TextFieldMaskGradientSize { - static let width: CGFloat = 6 - static let trailingSpace: CGFloat = 0 - static let trailingSpaceWithButton: CGFloat = 20 - static let trailingSpaceWithPermissionAndButton: CGFloat = 40 + func collectionView(_ cv: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem { + let section = collectionViews.firstIndex(where: { $0 === cv })! + let item = TabBarViewItem() + item.subscribe(to: sections[section][indexPath.item]) + item.isSelected = cv.selectionIndexPaths.contains(indexPath) + item.isLeftToSelected = cv.selectionIndexPaths.contains(IndexPath(item: indexPath.item + 1, section: 0)) + item.view.toolTip = sections[section][indexPath.item].title + item.delegate = self + return item + } + + func collectionView(_ cv: NSCollectionView, layout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize { + let section = collectionViews.firstIndex(where: { $0 === cv })! + let item = sections[section][indexPath.item] + return NSSize(width: item.width, height: TabBarViewItem.Height.standard) + } + + func tabBarViewItem(_: TabBarViewItem, isMouseOver: Bool) {} + func tabBarViewItemCanBeDuplicated(_: TabBarViewItem) -> Bool { false } + func tabBarViewItemCanBePinned(_: TabBarViewItem) -> Bool { false } + func tabBarViewItemCanBeBookmarked(_: TabBarViewItem) -> Bool { false } + func tabBarViewItemIsAlreadyBookmarked(_: TabBarViewItem) -> Bool { false } + func tabBarViewAllItemsCanBeBookmarked(_: TabBarViewItem) -> Bool { false } + func tabBarViewItemCloseAction(_: TabBarViewItem) {} + func tabBarViewItemTogglePermissionAction(_ item: TabBarViewItem) { + // swiftlint:disable:next force_cast + let item = item.representedObject as! TabBarViewModelMock + for (key, value) in item.usedPermissions { + switch value { + case .disabled(systemWide: false): item.usedPermissions[key] = .disabled(systemWide: true) + case .disabled(systemWide: true): item.usedPermissions[key] = .requested(.init(.init(url: nil, domain: "", permissions: [])) { _ in }) + case .requested: item.usedPermissions[key] = .inactive + case .inactive: item.usedPermissions[key] = .active + case .active: item.usedPermissions[key] = .paused + case .paused: item.usedPermissions[key] = .revoking + case .revoking: item.usedPermissions[key] = .denied + case .denied: item.usedPermissions[key] = .revoking + case .reloading: item.usedPermissions[key] = .denied + } + } + } + func tabBarViewItemCloseOtherAction(_: TabBarViewItem) {} + func tabBarViewItemCloseToTheLeftAction(_: TabBarViewItem) {} + func tabBarViewItemCloseToTheRightAction(_: TabBarViewItem) {} + func tabBarViewItemDuplicateAction(_: TabBarViewItem) {} + func tabBarViewItemPinAction(_: TabBarViewItem) {} + func tabBarViewItemBookmarkThisPageAction(_: TabBarViewItem) {} + func tabBarViewItemRemoveBookmarkAction(_: TabBarViewItem) {} + func tabBarViewItemBookmarkAllOpenTabsAction(_: TabBarViewItem) {} + func tabBarViewItemMoveToNewWindowAction(_: TabBarViewItem) {} + func tabBarViewItemMoveToNewBurnerWindowAction(_: TabBarViewItem) {} + func tabBarViewItemFireproofSite(_: TabBarViewItem) {} + func tabBarViewItemMuteUnmuteSite(_ item: TabBarViewItem) { + // swiftlint:disable:next force_cast + let item = item.representedObject as! TabBarViewModelMock + switch item.mutedState { + case .muted: item.mutedState = .unmuted + case .unmuted: item.mutedState = .none + case .none: item.mutedState = .muted + } + } + func tabBarViewItemRemoveFireproofing(_: TabBarViewItem) {} + func tabBarViewItem(_: TabBarViewItem, replaceContentWithDroppedStringValue: String) {} + func otherTabBarViewItemsState(for: TabBarViewItem) -> OtherTabBarViewItemsState { + .init(hasItemsToTheLeft: false, hasItemsToTheRight: false) + } } } +#endif diff --git a/DuckDuckGo/TabBar/View/TabBarViewItem.xib b/DuckDuckGo/TabBar/View/TabBarViewItem.xib deleted file mode 100644 index 3bcb22a67b..0000000000 --- a/DuckDuckGo/TabBar/View/TabBarViewItem.xib +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/UnitTests/DataImport/CSVParserTests.swift b/UnitTests/DataImport/CSVParserTests.swift index a346959788..20499a5dbf 100644 --- a/UnitTests/DataImport/CSVParserTests.swift +++ b/UnitTests/DataImport/CSVParserTests.swift @@ -119,6 +119,22 @@ final class CSVParserTests: XCTestCase { ]) } + func testWhenParsingRowsWithEscapedQuotesAndLineBreaksQuotesUnescapedAndLinebreaksParsed() throws { + let string = #""" + Title,Url,Username,Password,OTPAuth,Notes + "А",,"",,,"It's\", + B,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact + information.", + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["Title", "Url", "Username", "Password", "OTPAuth", "Notes"], + ["А", "", "", "", "", "It's\",\nB,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact\ninformation.", ""] + ]) + } + func testWhenParsingQuotedRowsContainingCommasThenTheyAreTreatedAsOneColumnEntry() throws { let string = """ "url","username","password,with,commas" diff --git a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift index 5d5afab33c..40c22f9f8d 100644 --- a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift +++ b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift @@ -113,10 +113,6 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? { - return audioState - } - func tabBarViewItemMuteUnmuteSite(_ tabBarViewItem: TabBarViewItem) { } diff --git a/UnitTests/TabBar/View/TabBarViewItemTests.swift b/UnitTests/TabBar/View/TabBarViewItemTests.swift index 6544cba7a9..0b234d9a8d 100644 --- a/UnitTests/TabBar/View/TabBarViewItemTests.swift +++ b/UnitTests/TabBar/View/TabBarViewItemTests.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Combine import XCTest @testable import Subscription @testable import DuckDuckGo_Privacy_Browser @@ -26,6 +27,7 @@ final class TabBarViewItemTests: XCTestCase { var menu: NSMenu! var tabBarViewItem: TabBarViewItem! + @MainActor override func setUp() { delegate = MockTabViewItemDelegate() menu = NSMenu() @@ -37,22 +39,27 @@ final class TabBarViewItemTests: XCTestCase { delegate.clear() } + @MainActor func testThatAllExpectedItemsAreShown() { + let tabBarViewModel = TabBarViewModelMock(mutedState: .unmuted) + tabBarViewItem.subscribe(to: tabBarViewModel) tabBarViewItem.menuNeedsUpdate(menu) XCTAssertEqual(menu.item(at: 0)?.title, UserText.duplicateTab) XCTAssertEqual(menu.item(at: 1)?.title, UserText.pinTab) - XCTAssertTrue(menu.item(at: 2)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 3)?.title, UserText.fireproofSite) - XCTAssertTrue(menu.item(at: 5)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 6)?.title, UserText.bookmarkAllTabs) - XCTAssertTrue(menu.item(at: 7)?.isSeparatorItem ?? false) - XCTAssertEqual(menu.item(at: 8)?.title, UserText.closeTab) - XCTAssertEqual(menu.item(at: 9)?.title, UserText.closeOtherTabs) - XCTAssertEqual(menu.item(at: 10)?.title, UserText.moveTabToNewWindow) + XCTAssertEqual(menu.item(at: 2)?.title, UserText.muteTab) + XCTAssertTrue(menu.item(at: 3)?.isSeparatorItem ?? false) + XCTAssertEqual(menu.item(at: 4)?.title, UserText.fireproofSite) + XCTAssertEqual(menu.item(at: 5)?.title, UserText.bookmarkThisPage) + XCTAssertTrue(menu.item(at: 6)?.isSeparatorItem ?? false) + XCTAssertEqual(menu.item(at: 7)?.title, UserText.bookmarkAllTabs) + XCTAssertTrue(menu.item(at: 8)?.isSeparatorItem ?? false) + XCTAssertEqual(menu.item(at: 9)?.title, UserText.closeTab) + XCTAssertEqual(menu.item(at: 10)?.title, UserText.closeOtherTabs) + XCTAssertEqual(menu.item(at: 11)?.title, UserText.moveTabToNewWindow) // Check "Close Other Tabs" submenu - guard let submenu = menu.item(at: 9)?.submenu else { + guard let submenu = menu.item(at: 10)?.submenu else { XCTFail("\"Close Other Tabs\" menu item should have a submenu") return } @@ -61,8 +68,10 @@ final class TabBarViewItemTests: XCTestCase { XCTAssertEqual(submenu.item(at: 2)?.title, UserText.closeAllOtherTabs) } + @MainActor func testThatMuteIsShownWhenCurrentAudioStateIsUnmuted() { - delegate.audioState = .unmuted + let tabBarViewModel = TabBarViewModelMock(mutedState: .unmuted) + tabBarViewItem.subscribe(to: tabBarViewModel) tabBarViewItem.menuNeedsUpdate(menu) XCTAssertFalse(menu.item(at: 1)?.isSeparatorItem ?? true) @@ -87,7 +96,8 @@ final class TabBarViewItemTests: XCTestCase { tabBarViewItem.menuNeedsUpdate(menu) // THEN - XCTAssertEqual(menu.item(at: 4)?.title, UserText.bookmarkThisPage) + let bookmarkItem = menu.item(withTitle: UserText.deleteBookmark) ?? menu.item(withTitle: UserText.bookmarkThisPage) + XCTAssertEqual(bookmarkItem?.title, UserText.bookmarkThisPage) } func testWhenURLIsBookmarkedThenDeleteBookmarkIsShown() { @@ -98,7 +108,8 @@ final class TabBarViewItemTests: XCTestCase { tabBarViewItem.menuNeedsUpdate(menu) // THEN - XCTAssertEqual(menu.item(at: 4)?.title, UserText.deleteBookmark) + let bookmarkItem = menu.item(withTitle: UserText.deleteBookmark) ?? menu.item(withTitle: UserText.bookmarkThisPage) + XCTAssertEqual(bookmarkItem?.title, UserText.deleteBookmark) } func testWhenOneTabCloseThenOtherTabsItemIsDisabled() { @@ -176,26 +187,12 @@ final class TabBarViewItemTests: XCTestCase { @MainActor func testWhenFireproofableThenUrlFireProofSiteItemIsDisabled() { - // Set up fake views for the TabBarViewItems - let textField = NSTextField() - let imageView = NSImageView() - let constraints = NSLayoutConstraint() - let button = NSButton() - let mouseButton = MouseOverButton() - tabBarViewItem.titleTextField = textField - tabBarViewItem.faviconImageView = imageView - tabBarViewItem.faviconWrapperView = imageView - tabBarViewItem.titleTextFieldLeadingConstraint = constraints - tabBarViewItem.permissionButton = button - tabBarViewItem.tabLoadingPermissionLeadingConstraint = constraints - tabBarViewItem.closeButton = mouseButton - // Update url let tab = Tab() tab.url = URL(string: "https://www.apple.com")! delegate.mockedCurrentTab = tab let vm = TabViewModel(tab: tab) - tabBarViewItem.subscribe(to: vm, tabCollectionViewModel: TabCollectionViewModel()) + tabBarViewItem.subscribe(to: vm) // update menu tabBarViewItem.menuNeedsUpdate(menu) let item = menu.items .first { $0.title == UserText.fireproofSite } @@ -213,26 +210,12 @@ final class TabBarViewItemTests: XCTestCase { @MainActor func testSubscriptionTabDisabledItems() { - // Set up fake views for the TabBarViewItems - let textField = NSTextField() - let imageView = NSImageView() - let constraints = NSLayoutConstraint() - let button = NSButton() - let mouseButton = MouseOverButton() - tabBarViewItem.titleTextField = textField - tabBarViewItem.faviconImageView = imageView - tabBarViewItem.faviconWrapperView = imageView - tabBarViewItem.titleTextFieldLeadingConstraint = constraints - tabBarViewItem.permissionButton = button - tabBarViewItem.tabLoadingPermissionLeadingConstraint = constraints - tabBarViewItem.closeButton = mouseButton - // Update url let url = SubscriptionURL.purchase.subscriptionURL(environment: .production) let tab = Tab(content: .subscription(url)) delegate.mockedCurrentTab = tab let vm = TabViewModel(tab: tab) - tabBarViewItem.subscribe(to: vm, tabCollectionViewModel: TabCollectionViewModel()) + tabBarViewItem.subscribe(to: vm) // update menu tabBarViewItem.menuNeedsUpdate(menu) @@ -310,3 +293,29 @@ final class TabBarViewItemTests: XCTestCase { } } + +private class TabBarViewModelMock: TabBarViewModel { + var width: CGFloat + var isSelected: Bool + @Published var title: String = "" + var titlePublisher: Published.Publisher { $title } + @Published var favicon: NSImage? + var faviconPublisher: Published.Publisher { $favicon } + @Published var tabContent: Tab.TabContent = .none + var tabContentPublisher: AnyPublisher { $tabContent.eraseToAnyPublisher() } + @Published var usedPermissions = Permissions() + var usedPermissionsPublisher: Published.Publisher { $usedPermissions } + @Published var mutedState: WKWebView.AudioState? + var mutedStatePublisher: Published.Publisher { + $mutedState + } + init(width: CGFloat = 0, title: String = "Test Title", favicon: NSImage? = .aDark, tabContent: Tab.TabContent = .none, usedPermissions: Permissions = Permissions(), mutedState: WKWebView.AudioState? = nil, selected: Bool = false) { + self.width = width + self.title = title + self.favicon = favicon + self.tabContent = tabContent + self.usedPermissions = usedPermissions + self.mutedState = mutedState + self.isSelected = selected + } +}