diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 220f975915..c94ebd7377 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1822,6 +1822,10 @@ 9FBD847B2BB3EC3300220859 /* MockAttributionOriginProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBD84792BB3EC3300220859 /* MockAttributionOriginProvider.swift */; }; 9FDA6C212B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */; }; 9FDA6C222B79A59D00E099A9 /* BookmarkFavoriteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */; }; + 9FDB93EB2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDB93EA2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift */; }; + 9FDB93EC2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDB93EA2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift */; }; + 9FDB93EE2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */; }; + 9FDB93EF2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */; }; 9FEE98652B846870002E44E8 /* AddEditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */; }; 9FEE98662B846870002E44E8 /* AddEditBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */; }; 9FEE98692B85B869002E44E8 /* BookmarksDialogViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */; }; @@ -3541,6 +3545,8 @@ 9FBD84762BB3E54200220859 /* InstallationAttributionPixelHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationAttributionPixelHandlerTests.swift; sourceTree = ""; }; 9FBD84792BB3EC3300220859 /* MockAttributionOriginProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAttributionOriginProvider.swift; sourceTree = ""; }; 9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFavoriteView.swift; sourceTree = ""; }; + 9FDB93EA2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publishers.withLatestFrom.swift; sourceTree = ""; }; + 9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPreviewEventsHandler.swift; sourceTree = ""; }; 9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkView.swift; sourceTree = ""; }; 9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDialogViewModel.swift; sourceTree = ""; }; 9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogCoordinatorViewModel.swift; sourceTree = ""; }; @@ -7466,6 +7472,7 @@ B6A9E46A2614618A0067D1B9 /* OperatingSystemVersionExtension.swift */, B637273C26CCF0C200C8CB02 /* OptionalExtension.swift */, B684592125C93BE000DC17B6 /* Publisher.asVoid.swift */, + 9FDB93EA2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift */, B68C2FB127706E6A00BF2C7D /* ProcessExtension.swift */, B684592625C93C0500DC17B6 /* Publishers.NestedObjectChanges.swift */, B6AAAC3D26048F690029438D /* RandomAccessCollectionExtension.swift */, @@ -7551,6 +7558,7 @@ children = ( AAC82C5F258B6CB5009B6B42 /* TabPreviewWindowController.swift */, AAE8B10F258A456C00E81239 /* TabPreviewViewController.swift */, + 9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */, 1DB67F272B6FE21D003DF243 /* Model */, 1DC6696E2B6CF08200AA0645 /* Services */, ); @@ -9688,6 +9696,7 @@ 3706FB0E293F65D500E42796 /* FaviconManager.swift in Sources */, 3706FB0F293F65D500E42796 /* ChromiumFaviconsReader.swift in Sources */, 4B0BD7B82A9FE6E600EF609D /* NetworkProtectionOnboardingMenu.swift in Sources */, + 9FDB93EF2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift in Sources */, 3706FB10293F65D500E42796 /* SuggestionTableRowView.swift in Sources */, 3706FB11293F65D500E42796 /* DownloadsPreferences.swift in Sources */, 3706FB12293F65D500E42796 /* PasswordManagementItemList.swift in Sources */, @@ -9697,6 +9706,7 @@ EE6666702B56EDE4001D898D /* VPNLocationsHostingViewController.swift in Sources */, 3706FB16293F65D500E42796 /* StoredPermission.swift in Sources */, 3706FB17293F65D500E42796 /* FirePopoverCollectionViewHeader.swift in Sources */, + 9FDB93EC2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift in Sources */, 85774B042A71CDD000DE0561 /* BlockMenuItem.swift in Sources */, 3706FB19293F65D500E42796 /* FireViewController.swift in Sources */, B6E3E55C2BC0041A00A41922 /* DownloadListStoreMock.swift in Sources */, @@ -10901,6 +10911,7 @@ B6106BAD26A7BF390013B453 /* PermissionState.swift in Sources */, 371C0A2927E33EDC0070591F /* FeedbackPresenter.swift in Sources */, B66260DD29AC5D4300E9E3EE /* NavigationProtectionTabExtension.swift in Sources */, + 9FDB93EE2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift in Sources */, 1D1A33492A6FEB170080ACED /* BurnerMode.swift in Sources */, 14505A08256084EF00272CC6 /* UserAgent.swift in Sources */, 987799F12999993C005D8EB6 /* LegacyBookmarkStore.swift in Sources */, @@ -11215,6 +11226,7 @@ B69A14F22B4D6FE800B9417D /* AddBookmarkFolderPopoverViewModel.swift in Sources */, 4BE65478271FCD41008D1D63 /* PasswordManagementNoteItemView.swift in Sources */, AA5C8F632591021700748EB7 /* NSApplicationExtension.swift in Sources */, + 9FDB93EB2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift in Sources */, AA9E9A5625A3AE8400D1959D /* NSWindowExtension.swift in Sources */, 7BD3AF5D2A8E7AF1006F9F56 /* KeychainType+ClientDefault.swift in Sources */, 370A34B12AB24E3700C77F7C /* SyncDebugMenu.swift in Sources */, diff --git a/DuckDuckGo/Common/Extensions/Publishers.withLatestFrom.swift b/DuckDuckGo/Common/Extensions/Publishers.withLatestFrom.swift new file mode 100644 index 0000000000..67336e1a75 --- /dev/null +++ b/DuckDuckGo/Common/Extensions/Publishers.withLatestFrom.swift @@ -0,0 +1,67 @@ +// +// Publishers.withLatestFrom.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 +import Combine + +// More Info: +// - RXMarbles: https://rxmarbles.com/#withLatestFrom +// - https://jasdev.me/notes/with-latest-from +extension Publisher { + + /// Upon an emission from self, emit the latest value from the + /// second publisher, if any exists. + /// + /// - parameter other: A second publisher source. + /// + /// - returns: A publisher containing the latest value from the second publisher, if any. + func withLatestFrom( + _ other: Other + ) -> AnyPublisher where Failure == Other.Failure { + withLatestFrom(other) { _, otherValue in otherValue } + } + + /// Merges two publishers into a single publisher by combining each value + /// from self with the latest value from the second publisher, if any. + /// + /// - parameter other: A second publisher source. + /// - parameter resultSelector: Function to invoke for each value from the self combined + /// with the latest value from the second source, if any. + /// + /// - returns: A publisher containing the result of combining each value of the self + /// with the latest value from the second publisher, if any, using the + /// specified result selector function. + func withLatestFrom( + _ other: Other, + resultSelector: @escaping (Output, Other.Output) -> Result + ) -> AnyPublisher where Other.Failure == Failure { + let upstream = share() + + return other + .map { second in + upstream.map { + resultSelector($0, second) + } + } + .switchToLatest() + .zip(upstream) // `zip`ping and discarding `\.1` allows for upstream completions to be projected down immediately. + .map(\.0) + .eraseToAnyPublisher() + } + +} diff --git a/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift b/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift index 4d0be4f564..b96ad300d2 100644 --- a/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift +++ b/DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift @@ -59,7 +59,7 @@ final class HoverTrackingArea: NSTrackingArea { private var observers: [NSKeyValueObservation]? init(owner: some Hoverable) { - super.init(rect: .zero, options: [.mouseEnteredAndExited, .activeInKeyWindow, .enabledDuringMouseDrag, .inVisibleRect], owner: owner, userInfo: nil) + super.init(rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .enabledDuringMouseDrag, .inVisibleRect], owner: owner, userInfo: nil) observers = [ owner.observe(\.backgroundColor) { [weak self] _, _ in self?.updateLayer() }, @@ -115,6 +115,10 @@ final class HoverTrackingArea: NSTrackingArea { view?.mouseExited(with: event) } + @objc func mouseMoved(_ event: NSEvent) { + view?.mouseMoved(with: event) + } + private func mouseDownDidChange() { guard let view else { return } diff --git a/DuckDuckGo/Common/View/AppKit/MouseOverView.swift b/DuckDuckGo/Common/View/AppKit/MouseOverView.swift index 6ede126b3a..75a0eb3dbf 100644 --- a/DuckDuckGo/Common/View/AppKit/MouseOverView.swift +++ b/DuckDuckGo/Common/View/AppKit/MouseOverView.swift @@ -22,6 +22,7 @@ import Combine @objc protocol MouseOverViewDelegate: AnyObject { @objc optional func mouseOverView(_ mouseOverView: MouseOverView, isMouseOver: Bool) + @objc optional func mouseOverViewIsMoving(_ mouseOverView: MouseOverView) @objc optional func mouseClickView(_ mouseClickView: MouseClickView, mouseDownEvent: NSEvent) @objc optional func mouseClickView(_ mouseClickView: MouseClickView, mouseUpEvent: NSEvent) @@ -59,6 +60,7 @@ internal class MouseOverView: NSControl, Hoverable { } } } + @objc dynamic var isMouseDown: Bool = false override class var cellClass: AnyClass? { @@ -135,6 +137,16 @@ internal class MouseOverView: NSControl, Hoverable { } } + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + + delegate?.mouseOverViewIsMoving?(self) + + if eventTypeMask.contains(.init(type: event.type)), let action { + NSApp.sendAction(action, to: target, from: self) + } + } + override func mouseDown(with event: NSEvent) { guard isMouseLocationInsideBounds(event.locationInWindow) else { return } diff --git a/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift b/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift index 5c77dfdf96..32c674da93 100644 --- a/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift +++ b/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift @@ -65,6 +65,8 @@ final class PinnedTabsViewModel: ObservableObject { } } + @Published var mouseMoving: Void = () + @Published var shouldDrawLastItemSeparator: Bool = true { didSet { updateItemsWithoutSeparator() diff --git a/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift b/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift index 7bc278f80e..ab40d5ca5d 100644 --- a/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift +++ b/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift @@ -56,9 +56,13 @@ struct PinnedTabView: View { } if controlActiveState == .key { - stack.onHover { [weak collectionModel, weak model] isHovered in - collectionModel?.hoveredItem = isHovered ? model : nil - } + stack + .onHover { [weak collectionModel, weak model] isHovered in + collectionModel?.hoveredItem = isHovered ? model : nil + } + .onMouseMoving { [weak collectionModel] in + collectionModel?.mouseMoving = () + } } else { stack } diff --git a/DuckDuckGo/TabBar/View/TabBarViewController.swift b/DuckDuckGo/TabBar/View/TabBarViewController.swift index e92f33986b..24b35f2455 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewController.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewController.swift @@ -60,6 +60,8 @@ final class TabBarViewController: NSViewController { private var mouseDownCancellable: AnyCancellable? private var cancellables = Set() + private let tabPreviewEventsHandler: TabPreviewEventsHandler + @IBOutlet weak var shadowView: TabShadowView! @IBOutlet weak var rightSideStackView: NSStackView! @@ -89,10 +91,18 @@ final class TabBarViewController: NSViewController { self.pinnedTabsViewModel = pinnedTabsViewModel self.pinnedTabsView = pinnedTabsView self.pinnedTabsHostingView = PinnedTabsHostingView(rootView: pinnedTabsView) + tabPreviewEventsHandler = TabPreviewEventsHandler( + pinnedTabHoveredIndexPublisher: pinnedTabsViewModel.$hoveredItemIndex.eraseToAnyPublisher(), + pinnedTabMouseMovingPublisher: pinnedTabsViewModel.$mouseMoving.eraseToAnyPublisher() + ) } else { self.pinnedTabsViewModel = nil self.pinnedTabsView = nil self.pinnedTabsHostingView = nil + tabPreviewEventsHandler = TabPreviewEventsHandler( + pinnedTabHoveredIndexPublisher: Just(nil).eraseToAnyPublisher(), + pinnedTabMouseMovingPublisher: Just(()).eraseToAnyPublisher() + ) } super.init(coder: coder) @@ -107,6 +117,7 @@ final class TabBarViewController: NSViewController { setupFireButton() setupPinnedTabsView() subscribeToTabModeChanges() + subscribeToTabPreviewEvents() setupAddTabButton() setupAsBurnerWindowIfNeeded() } @@ -245,13 +256,6 @@ final class TabBarViewController: NSViewController { } .store(in: &cancellables) - pinnedTabsViewModel.$hoveredItemIndex.dropFirst().removeDuplicates() - .debounce(for: 0.05, scheduler: DispatchQueue.main) - .sink { [weak self] index in - self?.pinnedTabsViewDidUpdateHoveredItem(to: index) - } - .store(in: &cancellables) - pinnedTabsViewModel.contextMenuActionPublisher .sink { [weak self] action in self?.handlePinnedTabContextMenuAction(action) @@ -278,37 +282,21 @@ final class TabBarViewController: NSViewController { .store(in: &cancellables) } - private func pinnedTabsViewDidUpdateHoveredItem(to index: Int?) { - func shouldDismissPinnedTabPreview() -> Bool { - // If the point is not within the view we can safely dismiss the preview - guard let pointWithinView = self.view.mouseLocationInsideBounds(nil) else { - return true - } - - // Calculate the rect of the standard tabs - let standardTabsTotalWidth = self.collectionView.visibleItems().reduce(into: 0.0) { partial, item in - partial += item.view.bounds.width - } - - // Create a rect with the width of pinned and non pinned tabs - var standardAndPinnedTabsContainerRect = self.pinnedTabsContainerView.frame - standardAndPinnedTabsContainerRect.size.width += standardTabsTotalWidth - - // If the point is not within the rect dismiss the preview - return !standardAndPinnedTabsContainerRect.contains(pointWithinView) - } - - if let index = index { - showPinnedTabPreview(at: index) - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - // When the mouse hover on top of the semaphore view we want to dismiss the preview to avoid the preview get stuck on screen when the popover to choose the screen size is shown. - // We don't want to dismiss the preview if the location of the mouse is within the rect that encompasses the pinned and standard tabs - if shouldDismissPinnedTabPreview() { - self.hideTabPreview(allowQuickRedisplay: true) + private func subscribeToTabPreviewEvents() { + tabPreviewEventsHandler.eventPublisher + .debounce(for: 0.05, scheduler: DispatchQueue.main) + .sink { [weak self] event in + guard let self else { return } + switch event { + case let .hide(allowQuickRedisplay, withDelay): + self.hideTabPreview(allowQuickRedisplay: allowQuickRedisplay, withDelay: withDelay) + case let .show(.pinned(index)): + self.showPinnedTabPreview(at: index) + case let .show(.unpinned(item)): + self.showTabPreview(for: item) } } - } + .store(in: &cancellables) } private func deselectTabAndSelectPinnedTab(at index: Int) { @@ -634,8 +622,8 @@ final class TabBarViewController: NSViewController { tabPreviewWindowController.show(parentWindow: window, topLeftPointInWindow: pointInWindow) } - func hideTabPreview(allowQuickRedisplay: Bool = false) { - tabPreviewWindowController.hide(allowQuickRedisplay: allowQuickRedisplay) + func hideTabPreview(allowQuickRedisplay: Bool = false, withDelay: Bool = false) { + tabPreviewWindowController.hide(allowQuickRedisplay: allowQuickRedisplay, withDelay: false) } } @@ -1041,15 +1029,17 @@ extension TabBarViewController: NSCollectionViewDelegate { extension TabBarViewController: TabBarViewItemDelegate { func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, isMouseOver: Bool) { + guard !isMouseOver else { return } - if isMouseOver { - // Show tab preview for visible tab bar items - if collectionView.visibleRect.intersects(tabBarViewItem.view.frame) { - showTabPreview(for: tabBarViewItem) - } - } else { - tabPreviewWindowController.hide(allowQuickRedisplay: true, withDelay: true) - } + // Send an event to dismiss the preview when the mouse exits the area + tabPreviewEventsHandler.unpinnedTabMouseExited() + } + + func tabBarViewItemMouseIsMoving(_ tabBarViewItem: TabBarViewItem) { + guard collectionView.visibleRect.intersects(tabBarViewItem.view.frame) else { return } + + // Send an event to show the preview when the mouse moves within the area + tabPreviewEventsHandler.unpinnedTabMouseEntered(tabBarViewItem: tabBarViewItem) } func tabBarViewItemCanBeDuplicated(_ tabBarViewItem: TabBarViewItem) -> Bool { diff --git a/DuckDuckGo/TabBar/View/TabBarViewItem.swift b/DuckDuckGo/TabBar/View/TabBarViewItem.swift index 4acca3216c..d3fb30261a 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewItem.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewItem.swift @@ -29,6 +29,7 @@ struct OtherTabBarViewItemsState { protocol TabBarViewItemDelegate: AnyObject { func tabBarViewItem(_ tabBarViewItem: TabBarViewItem, isMouseOver: Bool) + func tabBarViewItemMouseIsMoving(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemCanBeDuplicated(_ tabBarViewItem: TabBarViewItem) -> Bool func tabBarViewItemCanBePinned(_ tabBarViewItem: TabBarViewItem) -> Bool @@ -647,6 +648,10 @@ extension TabBarViewItem: MouseClickViewDelegate { view.needsLayout = true } + func mouseOverViewIsMoving(_ mouseOverView: MouseOverView) { + delegate?.tabBarViewItemMouseIsMoving(self) + } + func mouseClickView(_ mouseClickView: MouseClickView, otherMouseDownEvent: NSEvent) { // close on middle-click guard otherMouseDownEvent.buttonNumber == 2 else { return } diff --git a/DuckDuckGo/TabPreview/TabPreviewEventsHandler.swift b/DuckDuckGo/TabPreview/TabPreviewEventsHandler.swift new file mode 100644 index 0000000000..85d3d31713 --- /dev/null +++ b/DuckDuckGo/TabPreview/TabPreviewEventsHandler.swift @@ -0,0 +1,83 @@ +// +// TabPreviewEventsHandler.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 +import Combine + +final class TabPreviewEventsHandler { + private let unpinnedTabsMouseEnteredAndExitedPublisher = PassthroughSubject() + private var cancellables: Set = [] + + private let pinnedTabsMouseExitedPublisher: AnyPublisher + private let pinnedTabsMouseEnteredPublisher: AnyPublisher + + var eventPublisher: AnyPublisher { + Publishers.Merge3(pinnedTabsMouseExitedPublisher, pinnedTabsMouseEnteredPublisher, unpinnedTabsMouseEnteredAndExitedPublisher).eraseToAnyPublisher() + } + + init( + pinnedTabHoveredIndexPublisher: AnyPublisher, + pinnedTabMouseMovingPublisher: AnyPublisher + ) { + // Instead of showing the tab preview when the mouse enter the tracking area we want to show when the mouse moves within the area. + // The reason is that when the mouse is hovered on a Tab and we exit full screen, we don't want to show the preview again. + // We want to show it only if the user moves the mouse as per Safari behaviour. + + pinnedTabsMouseExitedPublisher = pinnedTabHoveredIndexPublisher + .dropFirst() + .removeDuplicates() + .filter { index in + index == nil + } + .map { _ -> TabPreviewEvent in + TabPreviewEvent.hide(allowQuickRedisplay: true, withDelay: false) + } + .eraseToAnyPublisher() + + pinnedTabsMouseEnteredPublisher = pinnedTabMouseMovingPublisher + .dropFirst() + .withLatestFrom(pinnedTabHoveredIndexPublisher) + .compactMap { index in + guard let index else { return nil } + return TabPreviewEvent.show(.pinned(index)) + } + .eraseToAnyPublisher() + } + + func unpinnedTabMouseExited() { + unpinnedTabsMouseEnteredAndExitedPublisher.send(.hide(allowQuickRedisplay: true, withDelay: true)) + } + + func unpinnedTabMouseEntered(tabBarViewItem: TabBarViewItem) { + unpinnedTabsMouseEnteredAndExitedPublisher.send(.show(.unpinned(tabBarViewItem))) + } + +} + +extension TabPreviewEventsHandler { + + enum TabPreviewEvent { + enum Tab { + case pinned(Int) + case unpinned(TabBarViewItem) + } + case show(Tab) + case hide(allowQuickRedisplay: Bool, withDelay: Bool) + } + +} diff --git a/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+MouseMoving.swift b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+MouseMoving.swift new file mode 100644 index 0000000000..f53ccd7dc7 --- /dev/null +++ b/LocalPackages/SwiftUIExtensions/Sources/SwiftUIExtensions/View+MouseMoving.swift @@ -0,0 +1,97 @@ +// +// View+MouseMoving.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 SwiftUI + +public extension View { + func onMouseMoving(perform action: @escaping () -> Void) -> some View { + modifier(MouseMovingModifier(action)) + } +} + +private struct MouseMovingModifier: ViewModifier { + let isMoving: () -> Void + + init(_ isMoving: @escaping () -> Void) { + self.isMoving = isMoving + } + + func body(content: Content) -> some View { + content.background( + GeometryReader(content: { proxy in + TrackingAreaRepresentable(isMoving: isMoving, frame: proxy.frame(in: .global)) + }) + ) + } +} + +private extension MouseMovingModifier { + + struct TrackingAreaRepresentable: NSViewRepresentable { + let isMoving: () -> Void + let frame: CGRect + + func makeCoordinator() -> Coordinator { + Coordinator(isMoving: isMoving) + } + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: frame) + + let options: NSTrackingArea.Options = [ + .mouseMoved, + .inVisibleRect, + .activeInKeyWindow + ] + + let trackingArea = NSTrackingArea( + rect: frame, + options: options, + owner: context.coordinator, + userInfo: nil + ) + + view.addTrackingArea(trackingArea) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} + + static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + nsView.trackingAreas.forEach(nsView.removeTrackingArea(_:)) + } + } + + final class Coordinator: NSResponder { + var isMoving: () -> Void + + init(isMoving: @escaping () -> Void) { + self.isMoving = isMoving + super.init() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func mouseMoved(with event: NSEvent) { + isMoving() + } + } + +}