Skip to content

Commit

Permalink
Refactor logic to present Tab Preview when mouse moving on Tabs and n…
Browse files Browse the repository at this point in the history
…ot on hovering
  • Loading branch information
alessandroboron committed May 23, 2024
1 parent 630bb49 commit b280ef9
Show file tree
Hide file tree
Showing 10 changed files with 326 additions and 50 deletions.
12 changes: 12 additions & 0 deletions DuckDuckGo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3541,6 +3545,8 @@
9FBD84762BB3E54200220859 /* InstallationAttributionPixelHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstallationAttributionPixelHandlerTests.swift; sourceTree = "<group>"; };
9FBD84792BB3EC3300220859 /* MockAttributionOriginProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAttributionOriginProvider.swift; sourceTree = "<group>"; };
9FDA6C202B79A59D00E099A9 /* BookmarkFavoriteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkFavoriteView.swift; sourceTree = "<group>"; };
9FDB93EA2BFEF71600AC50F6 /* Publishers.withLatestFrom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publishers.withLatestFrom.swift; sourceTree = "<group>"; };
9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabPreviewEventsHandler.swift; sourceTree = "<group>"; };
9FEE98642B846870002E44E8 /* AddEditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkView.swift; sourceTree = "<group>"; };
9FEE98682B85B869002E44E8 /* BookmarksDialogViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDialogViewModel.swift; sourceTree = "<group>"; };
9FEE986C2B85BA17002E44E8 /* AddEditBookmarkDialogCoordinatorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditBookmarkDialogCoordinatorViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -7551,6 +7558,7 @@
children = (
AAC82C5F258B6CB5009B6B42 /* TabPreviewWindowController.swift */,
AAE8B10F258A456C00E81239 /* TabPreviewViewController.swift */,
9FDB93ED2BFEF84E00AC50F6 /* TabPreviewEventsHandler.swift */,
1DB67F272B6FE21D003DF243 /* Model */,
1DC6696E2B6CF08200AA0645 /* Services */,
);
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
67 changes: 67 additions & 0 deletions DuckDuckGo/Common/Extensions/Publishers.withLatestFrom.swift
Original file line number Diff line number Diff line change
@@ -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: Publisher>(
_ other: Other
) -> AnyPublisher<Other.Output, Other.Failure> 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: Publisher, Result>(
_ other: Other,
resultSelector: @escaping (Output, Other.Output) -> Result
) -> AnyPublisher<Result, Failure> 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()
}

}
6 changes: 5 additions & 1 deletion DuckDuckGo/Common/View/AppKit/HoverTrackingArea.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand Down Expand Up @@ -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 }

Expand Down
12 changes: 12 additions & 0 deletions DuckDuckGo/Common/View/AppKit/MouseOverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -59,6 +60,7 @@ internal class MouseOverView: NSControl, Hoverable {
}
}
}

@objc dynamic var isMouseDown: Bool = false

override class var cellClass: AnyClass? {
Expand Down Expand Up @@ -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 }

Expand Down
2 changes: 2 additions & 0 deletions DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ final class PinnedTabsViewModel: ObservableObject {
}
}

@Published var mouseMoving: Void = ()

@Published var shouldDrawLastItemSeparator: Bool = true {
didSet {
updateItemsWithoutSeparator()
Expand Down
10 changes: 7 additions & 3 deletions DuckDuckGo/PinnedTabs/View/PinnedTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
82 changes: 36 additions & 46 deletions DuckDuckGo/TabBar/View/TabBarViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ final class TabBarViewController: NSViewController {
private var mouseDownCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()

private let tabPreviewEventsHandler: TabPreviewEventsHandler

@IBOutlet weak var shadowView: TabShadowView!

@IBOutlet weak var rightSideStackView: NSStackView!
Expand Down Expand Up @@ -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)
Expand All @@ -107,6 +117,7 @@ final class TabBarViewController: NSViewController {
setupFireButton()
setupPinnedTabsView()
subscribeToTabModeChanges()
subscribeToTabPreviewEvents()
setupAddTabButton()
setupAsBurnerWindowIfNeeded()
}
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}

}
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions DuckDuckGo/TabBar/View/TabBarViewItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
Loading

0 comments on commit b280ef9

Please sign in to comment.