diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index d5c25a67d1..4de85d3857 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1443,7 +1443,6 @@ 3706FE0F293F661700E42796 /* CSVLoginExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B723E0426B0003E00E14D75 /* CSVLoginExporterTests.swift */; }; 3706FE10293F661700E42796 /* TestNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6DA06E02913AEDB00225DE2 /* TestNavigationDelegate.swift */; }; 3706FE11293F661700E42796 /* URLSuggestedFilenameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8553FF51257523760029327F /* URLSuggestedFilenameTests.swift */; }; - 3706FE12293F661700E42796 /* StringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADE11BF26D916D70032D8A7 /* StringExtensionTests.swift */; }; 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85AC3B4825DAC9BD00C7D2AA /* ConfigurationStorageTests.swift */; }; 3706FE14293F661700E42796 /* DownloadListStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B693956026F1C1BC0015B914 /* DownloadListStoreMock.swift */; }; 3706FE15293F661700E42796 /* PrivacyIconViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA91F83827076F1900771A0D /* PrivacyIconViewModelTests.swift */; }; @@ -2419,7 +2418,6 @@ AAD8078727B3F45600CF7703 /* WebsiteBreakage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD8078627B3F45600CF7703 /* WebsiteBreakage.swift */; }; AAD86E52267A0DFF005C11BE /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD86E51267A0DFF005C11BE /* UpdateController.swift */; }; AADCBF3A26F7C2CE00EF67A8 /* LottieAnimationCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADCBF3926F7C2CE00EF67A8 /* LottieAnimationCache.swift */; }; - AADE11C026D916D70032D8A7 /* StringExtensionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AADE11BF26D916D70032D8A7 /* StringExtensionTests.swift */; }; AAE246F32709EF3B00BEEAEE /* FirePopoverCollectionViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE246F12709EF3B00BEEAEE /* FirePopoverCollectionViewItem.swift */; }; AAE246F42709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib in Resources */ = {isa = PBXBuildFile; fileRef = AAE246F22709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib */; }; AAE246F6270A3D3000BEEAEE /* FirePopoverCollectionViewHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = AAE246F5270A3D3000BEEAEE /* FirePopoverCollectionViewHeader.xib */; }; @@ -2496,6 +2494,9 @@ B60C6F8B29B1CAC0007BFAA8 /* FileManagerTempDirReplacement.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8329B1BAD3007BFAA8 /* FileManagerTempDirReplacement.swift */; }; B60C6F8D29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */; }; B60C6F8E29B200AB007BFAA8 /* SavePanelAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */; }; + B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */; }; + B60D644A2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */; }; + B60D644B2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */; }; B6106BA026A7BE0B0013B453 /* PermissionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106B9F26A7BE0B0013B453 /* PermissionManagerTests.swift */; }; B6106BA726A7BECC0013B453 /* PermissionAuthorizationQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106BA526A7BEC80013B453 /* PermissionAuthorizationQuery.swift */; }; B6106BAB26A7BF1D0013B453 /* PermissionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6106BAA26A7BF1D0013B453 /* PermissionType.swift */; }; @@ -2601,6 +2602,9 @@ B662D3D92755D7AD0035D4D6 /* PixelStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */; }; B662D3DE275613BB0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; B662D3DF275616FF0035D4D6 /* EncryptionKeyStoreMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */; }; + B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; + B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; + B6676BE32AA986A700525A21 /* AddressBarTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */; }; B6685E3D29A602D90043D2EE /* ExternalAppSchemeHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B687B7CB2947A1E9001DEA6F /* ExternalAppSchemeHandler.swift */; }; B6685E3F29A606190043D2EE /* WorkspaceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */; }; B6685E4029A606190043D2EE /* WorkspaceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */; }; @@ -3747,7 +3751,6 @@ AAD86E502678D104005C11BE /* DuckDuckGoCI.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DuckDuckGoCI.entitlements; sourceTree = ""; }; AAD86E51267A0DFF005C11BE /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateController.swift; sourceTree = ""; }; AADCBF3926F7C2CE00EF67A8 /* LottieAnimationCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LottieAnimationCache.swift; sourceTree = ""; }; - AADE11BF26D916D70032D8A7 /* StringExtensionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringExtensionTests.swift; sourceTree = ""; }; AAE246F12709EF3B00BEEAEE /* FirePopoverCollectionViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirePopoverCollectionViewItem.swift; sourceTree = ""; }; AAE246F22709EF3B00BEEAEE /* FirePopoverCollectionViewItem.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FirePopoverCollectionViewItem.xib; sourceTree = ""; }; AAE246F5270A3D3000BEEAEE /* FirePopoverCollectionViewHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = FirePopoverCollectionViewHeader.xib; sourceTree = ""; }; @@ -3791,6 +3794,7 @@ B60C6F8029B1B4AD007BFAA8 /* TestRunHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestRunHelper.swift; sourceTree = ""; }; B60C6F8329B1BAD3007BFAA8 /* FileManagerTempDirReplacement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerTempDirReplacement.swift; sourceTree = ""; }; B60C6F8C29B200AB007BFAA8 /* SavePanelAccessoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavePanelAccessoryView.swift; sourceTree = ""; }; + B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTextSelectionNavigation.swift; sourceTree = ""; }; B6106B9D26A565DA0013B453 /* BundleExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleExtension.swift; sourceTree = ""; }; B6106B9F26A7BE0B0013B453 /* PermissionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManagerTests.swift; sourceTree = ""; }; B6106BA526A7BEC80013B453 /* PermissionAuthorizationQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAuthorizationQuery.swift; sourceTree = ""; }; @@ -3867,6 +3871,7 @@ B66260E529ACAE4B00E9E3EE /* NavigationHotkeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationHotkeyHandler.swift; sourceTree = ""; }; B662D3D82755D7AD0035D4D6 /* PixelStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PixelStoreTests.swift; sourceTree = ""; }; B662D3DD275613BB0035D4D6 /* EncryptionKeyStoreMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyStoreMock.swift; sourceTree = ""; }; + B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressBarTextEditor.swift; sourceTree = ""; }; B6685E3E29A606190043D2EE /* WorkspaceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceProtocol.swift; sourceTree = ""; }; B6685E4129A61C460043D2EE /* DownloadsTabExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadsTabExtension.swift; sourceTree = ""; }; B66B9C5B29A5EBAD0010E8F3 /* NavigationActionExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationActionExtension.swift; sourceTree = ""; }; @@ -6480,15 +6485,17 @@ isa = PBXGroup; children = ( AA7EB6EE27E880EA00036718 /* Animations */, - 85589E8C27BBBB870038AD11 /* NavigationBar.storyboard */, - AA68C3D22490ED62001B8783 /* NavigationBarViewController.swift */, - 14D9B8F924F7E089000D4D13 /* AddressBarViewController.swift */, - AABEE6AE24AD22B90043105B /* AddressBarTextField.swift */, - AAC5E4F525D6BF2C007F5990 /* AddressBarButtonsViewController.swift */, AAC5E4F025D6BF10007F5990 /* AddressBarButton.swift */, - AAA0CC32252F181A0079BC96 /* NavigationButtonMenuDelegate.swift */, + AAC5E4F525D6BF2C007F5990 /* AddressBarButtonsViewController.swift */, + AABEE6AE24AD22B90043105B /* AddressBarTextField.swift */, + B6676BE02AA986A700525A21 /* AddressBarTextEditor.swift */, + B60D64482AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift */, + 14D9B8F924F7E089000D4D13 /* AddressBarViewController.swift */, AAA0CC462533833C0079BC96 /* MoreOptionsMenu.swift */, + 85589E8C27BBBB870038AD11 /* NavigationBar.storyboard */, + AAA0CC32252F181A0079BC96 /* NavigationButtonMenuDelegate.swift */, 85012B0129133F9F003D0DCC /* NavigationBarPopovers.swift */, + AA68C3D22490ED62001B8783 /* NavigationBarViewController.swift */, ); path = View; sourceTree = ""; @@ -7076,7 +7083,6 @@ B67C6C462654C643006C872E /* FileManagerExtensionTests.swift */, 1DA6D0FF2A1FF9DC00540406 /* HTTPCookieTests.swift */, B6C0B24526E9CB190031CB7F /* RunLoopExtensionTests.swift */, - AADE11BF26D916D70032D8A7 /* StringExtensionTests.swift */, 85F69B3B25EDE81F00978E59 /* URLExtensionTests.swift */, 4B8AD0B027A86D9200AE44D6 /* WKWebsiteDataStoreExtensionTests.swift */, B6AA64722994B43300D99CD6 /* FutureExtensionTests.swift */, @@ -9015,6 +9021,7 @@ 3192A09C2A4C4CFF0084EA89 /* CookieConsentPopoverManager.swift in Sources */, 3192A09D2A4C4CFF0084EA89 /* PasswordManagementIdentityItemView.swift in Sources */, 3192A09E2A4C4CFF0084EA89 /* ProgressExtension.swift in Sources */, + B60D644B2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3192A09F2A4C4CFF0084EA89 /* CSVParser.swift in Sources */, 3192A0A02A4C4CFF0084EA89 /* PixelDataModel.xcdatamodeld in Sources */, 3192A0A12A4C4CFF0084EA89 /* PrivacyDashboardWebView.swift in Sources */, @@ -9226,6 +9233,7 @@ 3192A1652A4C4CFF0084EA89 /* PasswordManagementLoginModel.swift in Sources */, BB5CB0A12A7AD59D00B312D1 /* NetworkProtectionDebugUtilities.swift in Sources */, 3192A1662A4C4CFF0084EA89 /* TabViewModel.swift in Sources */, + B6676BE32AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, 3192A1672A4C4CFF0084EA89 /* TabDragAndDropManager.swift in Sources */, 3192A1682A4C4CFF0084EA89 /* NSNotificationName+Favicons.swift in Sources */, 3192A1692A4C4CFF0084EA89 /* PinningManager.swift in Sources */, @@ -9425,6 +9433,7 @@ 3706FA93293F65D500E42796 /* WKWebView+Download.swift in Sources */, 3706FA94293F65D500E42796 /* TabShadowConfig.swift in Sources */, 3706FA97293F65D500E42796 /* WindowDraggingView.swift in Sources */, + B60D644A2AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3706FA98293F65D500E42796 /* SecureVaultSorting.swift in Sources */, 3706FA99293F65D500E42796 /* PreferencesSidebarModel.swift in Sources */, 3706FA9A293F65D500E42796 /* DuckPlayerURLExtension.swift in Sources */, @@ -9578,6 +9587,7 @@ 3706FB1D293F65D500E42796 /* StatisticsLoader.swift in Sources */, 3793FDD829535EBA00A2E28F /* Assertions.swift in Sources */, 3706FB1E293F65D500E42796 /* WebsiteBreakageReporter.swift in Sources */, + B6676BE22AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, 3706FB1F293F65D500E42796 /* PrivacyPreferencesModel.swift in Sources */, 3706FB20293F65D500E42796 /* LocalUnprotectedDomains.swift in Sources */, 3707C719294B5D0F00682A9F /* HoveredLinkTabExtension.swift in Sources */, @@ -10114,7 +10124,6 @@ 3706FE10293F661700E42796 /* TestNavigationDelegate.swift in Sources */, 3706FE11293F661700E42796 /* URLSuggestedFilenameTests.swift in Sources */, B6F56569299A414300A04298 /* WKWebViewMockingExtension.swift in Sources */, - 3706FE12293F661700E42796 /* StringExtensionTests.swift in Sources */, 3706FE13293F661700E42796 /* ConfigurationStorageTests.swift in Sources */, 3706FE14293F661700E42796 /* DownloadListStoreMock.swift in Sources */, 3706FE15293F661700E42796 /* PrivacyIconViewModelTests.swift in Sources */, @@ -10537,6 +10546,7 @@ B68458CD25C7EB9000DC17B6 /* WKWebViewConfigurationExtensions.swift in Sources */, 85AC7ADD27BEB6EE00FFB69B /* HomePageDefaultBrowserModel.swift in Sources */, AAC30A26268DFEE200D2D9CD /* CrashReporter.swift in Sources */, + B60D64492AAF1B7C00B26F50 /* AddressBarTextSelectionNavigation.swift in Sources */, 3184AC6D288F29D800C35E4B /* BadgeNotificationAnimationModel.swift in Sources */, 857FFEC027D239DC00415E7A /* HyperLink.swift in Sources */, 37445F992A1566420029F789 /* SyncDataProviders.swift in Sources */, @@ -10581,6 +10591,7 @@ 85589E8027BBB8630038AD11 /* AddEditFavoriteWindow.swift in Sources */, 1D6216B229069BBF00386B2C /* BWKeyStorage.swift in Sources */, AA7E919F287872EA00AB6B62 /* VisitViewModel.swift in Sources */, + B6676BE12AA986A700525A21 /* AddressBarTextEditor.swift in Sources */, B69B503B2726A12500758A2B /* Atb.swift in Sources */, B6C0BB6A29AF1C7000AE8E3C /* BrowserTabView.swift in Sources */, B6B1E88026D5DA9B0062C350 /* DownloadsViewController.swift in Sources */, @@ -11211,7 +11222,6 @@ 56D145E829E6BB6300E3488A /* CapturingDataImportProvider.swift in Sources */, B630793526731BC400DCEE41 /* URLSuggestedFilenameTests.swift in Sources */, B603974E29C1F93600902A34 /* TabPermissionsTests.swift in Sources */, - AADE11C026D916D70032D8A7 /* StringExtensionTests.swift in Sources */, 85AC3B4925DAC9BD00C7D2AA /* ConfigurationStorageTests.swift in Sources */, B693956126F1C1BC0015B914 /* DownloadListStoreMock.swift in Sources */, AA91F83927076F1900771A0D /* PrivacyIconViewModelTests.swift in Sources */, diff --git a/DuckDuckGo/Common/Extensions/NSMenuExtension.swift b/DuckDuckGo/Common/Extensions/NSMenuExtension.swift index 2349695dd9..010ac88143 100644 --- a/DuckDuckGo/Common/Extensions/NSMenuExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSMenuExtension.swift @@ -28,13 +28,20 @@ extension NSMenu { } } - func index(ofItemWithIdentifier id: String) -> Int? { - guard let item = items.first(where: { $0.identifier?.rawValue == id }) else { return nil } - return index(of: item) + func indexOfItem(withIdentifier id: String) -> Int? { + return items.enumerated().first(where: { $0.element.identifier?.rawValue == id })?.offset } func item(with identifier: WKMenuItemIdentifier) -> NSMenuItem? { - return index(ofItemWithIdentifier: identifier.rawValue).map { self.items[$0] } + return indexOfItem(withIdentifier: identifier.rawValue).map { self.items[$0] } + } + + func indexOfItem(with action: Selector) -> Int? { + return items.enumerated().first(where: { $0.element.action == action })?.offset + } + + func item(with action: Selector) -> NSMenuItem? { + return indexOfItem(with: action).map { self.items[$0] } } func replaceItem(at index: Int, with newItem: NSMenuItem) { diff --git a/DuckDuckGo/Common/Extensions/NSPasteboardExtension.swift b/DuckDuckGo/Common/Extensions/NSPasteboardExtension.swift index f64a21da8a..838962327e 100644 --- a/DuckDuckGo/Common/Extensions/NSPasteboardExtension.swift +++ b/DuckDuckGo/Common/Extensions/NSPasteboardExtension.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import AppKit import Foundation extension NSPasteboard { diff --git a/DuckDuckGo/Common/Extensions/StringExtension.swift b/DuckDuckGo/Common/Extensions/StringExtension.swift index f7fdc5d4c2..53cd3148a2 100644 --- a/DuckDuckGo/Common/Extensions/StringExtension.swift +++ b/DuckDuckGo/Common/Extensions/StringExtension.swift @@ -24,23 +24,10 @@ extension String { // MARK: - General - func nsRange(from range: Range? = nil) -> NSRange { - if let range = range { - return NSRange(location: self[.. String { return (self.count > length) ? self.prefix(length) + trailing : self } - subscript (_ range: NSRange) -> Self { - .init(self[utf16.index(startIndex, offsetBy: range.lowerBound) ..< utf16.index(startIndex, offsetBy: range.upperBound)]) - } - func escapedJavaScriptString() -> String { self.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") diff --git a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift index 356bf07ef9..093049bc30 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarButtonsViewController.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import BrowserServicesKit import Cocoa import Combine import Common @@ -1040,7 +1041,7 @@ extension URL { var isLocalURL: Bool { if let host = self.host { for regex in Self.compiledRegexes - where regex.firstMatch(in: host, options: [], range: NSRange(location: 0, length: host.utf16.count)) != nil { + where regex.firstMatch(in: host, options: [], range: host.fullRange) != nil { return true } } diff --git a/DuckDuckGo/NavigationBar/View/AddressBarTextEditor.swift b/DuckDuckGo/NavigationBar/View/AddressBarTextEditor.swift new file mode 100644 index 0000000000..97932f4027 --- /dev/null +++ b/DuckDuckGo/NavigationBar/View/AddressBarTextEditor.swift @@ -0,0 +1,456 @@ +// +// AddressBarTextEditor.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Common +import Foundation +import QuickLookUI + +final class AddressBarTextEditor: NSTextView { + + fileprivate var addressBar: AddressBarTextField? { + guard let delegate else { return nil } + guard let addressBar = delegate as? AddressBarTextField else { + assertionFailure("AddressBarTextEditor: unexpected kind of delegate") + return nil + } + return addressBar + } + + @available(macOS 12.0, *) + override var textLayoutManager: NSTextLayoutManager? { + if let textLayoutManager = super.textLayoutManager, + !(textLayoutManager.textSelectionNavigation is AddressBarTextSelectionNavigation) { + textLayoutManager.textSelectionNavigation = AddressBarTextSelectionNavigation(dataSource: textLayoutManager) + } + + return super.textLayoutManager + } + + override var smartInsertDeleteEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticQuoteSubstitutionEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticLinkDetectionEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticDataDetectionEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticDashSubstitutionEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticTextReplacementEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticSpellingCorrectionEnabled: Bool { + get { false } + set {} + } + + override var isGrammarCheckingEnabled: Bool { + get { false } + set {} + } + + override var isAutomaticTextCompletionEnabled: Bool { + get { false } + set {} + } + + override var isContinuousSpellCheckingEnabled: Bool { + get { false } + set {} + } + + override var isIncrementalSearchingEnabled: Bool { + get { false } + set {} + } + +#if swift(>=5.9) + override var inlinePredictionType: NSTextInputTraitType { + get { .no } + set {} + } +#else + @objc var inlinePredictionType: NSTextInputTraitType { + get { .no } + set {} + } +#endif + + override var usesFindPanel: Bool { + get { false } + set {} + } + + override var usesFindBar: Bool { + get { false } + set {} + } + + override var usesRuler: Bool { + get { false } + set {} + } + + override var usesFontPanel: Bool { + get { false } + set {} + } + + override var usesInspectorBar: Bool { + get { false } + set {} + } + + override func acceptsPreviewPanelControl(_ panel: QLPreviewPanel!) -> Bool { + false + } + + override var enabledTextCheckingTypes: NSTextCheckingTypes { + get { 0 } + set {} + } + + // MARK: - Copy/Paste + + override func copy(_ sender: Any?) { + CopyHandler().copy(sender) + } + + override func paste(_ sender: Any?) { + // Fixes an issue when url-name instead of url is pasted + if let url = NSPasteboard.general.url { + super.pasteAsPlainText(url.absoluteString) + } else { + super.paste(sender) + } + } + + // MARK: - Exclude “ – Search with DuckDuckGo” suffix from selection range + + override func setSelectedRanges(_ ranges: [NSValue], affinity: NSSelectionAffinity, stillSelecting stillSelectingFlag: Bool) { + let string = self.string + let range = ranges.first.flatMap { Range($0.rangeValue, in: string) } + + let selectableRange = addressBar?.stringValueWithoutSuffixRange + let clamped = selectableRange.flatMap { range?.clamped(to: $0) } ?? range + let result = clamped.map { [NSValue(range: NSRange($0, in: string))] } ?? [] + + super.setSelectedRanges(result, affinity: affinity, stillSelecting: stillSelectingFlag) + } + + override func characterIndexForInsertion(at point: NSPoint) -> Int { + let index = super.characterIndexForInsertion(at: point) + let adjustedRange = selectionRange(forProposedRange: NSRange(location: index, length: 0), granularity: .selectByCharacter) + return adjustedRange.location + } + + override func insertText(_ string: Any, replacementRange: NSRange) { + guard let addressBar, let string = string as? String else { + super.insertText(string, replacementRange: replacementRange) + return + } + breakUndoCoalescingIfNeeded(for: InputType(string)) + + addressBar.textView(self, userTypedString: string, at: replacementRange.location == NSNotFound ? self.selectedRange() : replacementRange) { + super.insertText(string, replacementRange: replacementRange) + } + } + + func selectToTheEnd(from offset: Int? = nil) { + let string = self.string + let startIndex: String.Index + if let offset { + startIndex = string.index(string.startIndex, offsetBy: min(string.count, offset)) + } else if let selectedRange = Range(self.selectedRange(), in: string) { + startIndex = selectedRange.lowerBound + } else { + startIndex = string.startIndex + } + + let range = NSRange(startIndex..., in: string) + + self.setSelectedRanges([NSValue(range: range)], affinity: .downstream /* rtl */, stillSelecting: false) + } + + // MARK: - Moving selection by word + + @available(macOS, deprecated: 12.0, message: "Move this logic to AddressBarTextSelectionNavigation") + override func selectionRange(forProposedRange proposedCharRange: NSRange, granularity: NSSelectionGranularity) -> NSRange { + let string = self.string + guard let selectableRange = addressBar?.stringValueWithoutSuffixRange, + var clampedRange = Range(proposedCharRange, in: string)?.clamped(to: selectableRange) else { return proposedCharRange } + guard !selectableRange.isEmpty else { return NSRange(selectableRange, in: string) } + + let range: Range + switch granularity { + case .selectByParagraph: + // select all + range = selectableRange + + case .selectByWord: + // if at the end of string: adjust proposed selection to include at least the last character + if clampedRange.lowerBound == selectableRange.upperBound { + clampedRange = string.index(selectableRange.upperBound, offsetBy: -1).. Int? { + let string = self.string + + guard let selectableRange = addressBar?.stringValueWithoutSuffixRange, + let selectedRange = Range(selectedRange(), in: string)?.clamped(to: selectableRange) else { return nil } + + var index = backwards ? selectedRange.lowerBound : selectedRange.upperBound + var searchRange: Range { + backwards ? selectableRange.lowerBound.. NSTextView? { + return customEditor + } + + override var allowsUndo: Bool { + get { + !(customEditor.addressBar?.isUndoDisabled ?? false) && super.allowsUndo + } + set { + super.allowsUndo = newValue + } + } + +} + +extension AddressBarTextField { + + var editor: AddressBarTextEditor? { + guard let editor = currentEditor() else { return nil } + guard let addressBarTextEditor = editor as? AddressBarTextEditor else { + assertionFailure("AddressBarTextField: unexpected kind of editor") + return nil + } + return addressBarTextEditor + } + +} + +private extension CharacterSet { + static let urlWordCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "+_~")) + static let urlWordBoundCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")).inverted +} diff --git a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift index eff9e3688c..8c1429b694 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarTextField.swift @@ -16,22 +16,14 @@ // limitations under the License. // -import Cocoa +import AppKit +import Carbon.HIToolbox import Combine import Common import BrowserServicesKit -protocol AddressBarTextFieldDelegate: AnyObject { - - func adressBarTextField(_ addressBarTextField: AddressBarTextField, didChangeValue value: AddressBarTextField.Value) - -} - -// swiftlint:disable:next type_body_length final class AddressBarTextField: NSTextField { - weak var addressBarTextFieldDelegate: AddressBarTextFieldDelegate? - var tabCollectionViewModel: TabCollectionViewModel! { didSet { subscribeToSelectedTabViewModel() @@ -47,23 +39,30 @@ final class AddressBarTextField: NSTextField { } } - var suggestionWindowVisible: AnyPublisher { - self.publisher(for: \.suggestionWindowController?.window?.isVisible) - .map { $0 ?? false } - .eraseToAnyPublisher() + private var isHomePage: Bool { + tabCollectionViewModel.selectedTabViewModel?.tab.content == .homePage } - var isSuggestionWindowVisible: Bool { - suggestionWindowController?.window?.isVisible == true + private var isBurner: Bool { + tabCollectionViewModel.isBurner } private var suggestionResultCancellable: AnyCancellable? private var selectedSuggestionViewModelCancellable: AnyCancellable? private var selectedTabViewModelCancellable: AnyCancellable? - private var searchSuggestionsCancellable: AnyCancellable? private var addressBarStringCancellable: AnyCancellable? private var contentTypeCancellable: AnyCancellable? + private enum TextDidChangeEventType { + case none + case userAppendingTextToTheEnd + case userModifiedText + } + // flag when updating the Value from `handleTextDidChange()` + private var currentTextDidChangeEvent: TextDidChangeEventType = .none + + // MARK: - Lifecycle + override func awakeFromNib() { super.awakeFromNib() @@ -82,14 +81,7 @@ final class AddressBarTextField: NSTextField { layoutSuggestionWindow() } - func clearValue() { - value = .text("") - suggestionContainerViewModel?.clearSelection() - suggestionContainerViewModel?.clearUserStringValue() - hideSuggestionWindow() - } - - private var isHandlingUserAppendingText = false + // MARK: Observation private func subscribeToSuggestionResult() { suggestionResultCancellable = suggestionContainerViewModel?.suggestionContainer.$result @@ -103,25 +95,29 @@ final class AddressBarTextField: NSTextField { } private func subscribeToSelectedSuggestionViewModel() { - selectedSuggestionViewModelCancellable = - suggestionContainerViewModel?.$selectedSuggestionViewModel.receive(on: DispatchQueue.main).sink { [weak self] _ in + selectedSuggestionViewModelCancellable = suggestionContainerViewModel?.$selectedSuggestionViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in self?.displaySelectedSuggestionViewModel() - } + } } private func subscribeToSelectedTabViewModel() { - selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.restoreValueIfPossible() - self?.subscribeToAddressBarString() - self?.subscribeToContentType() - } + selectedTabViewModelCancellable = tabCollectionViewModel.$selectedTabViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.restoreValueIfPossible() + self?.subscribeToAddressBarString() + self?.subscribeToContentType() + } } private func subscribeToContentType() { - contentTypeCancellable = tabCollectionViewModel.selectedTabViewModel? - .tab.$content .receive(on: DispatchQueue.main).sink { [weak self] contentType in - self?.font = .systemFont(ofSize: contentType == .homePage ? 15 : 13) - } + contentTypeCancellable = tabCollectionViewModel.selectedTabViewModel?.tab.$content + .receive(on: DispatchQueue.main) + .sink { [weak self] contentType in + self?.font = .systemFont(ofSize: contentType == .homePage ? 15 : 13) + } } private func subscribeToAddressBarString() { @@ -136,31 +132,86 @@ final class AddressBarTextField: NSTextField { } } - private func updateValue() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - return + // MARK: - Value + + @Published private(set) var value: Value = .text("") { + didSet { + guard value != oldValue else { return } + + saveValue(oldValue: oldValue) + updateAttributedStringValue() + + if let editor, case .suggestion(let suggestion) = value { + let originalStringValue = suggestion.userStringValue + if value.string.lowercased().hasPrefix(originalStringValue.lowercased()) { + + editor.selectToTheEnd(from: originalStringValue.count) + } else { + // if suggestion doesn't start with the user input select whole string + editor.selectAll(nil) + } + + } else if let editor, editor.undoManager?.isRedoing == true { + // also select to the end when redo action appends a suggestion as a .text Value after current selection + let string = stringValueWithoutSuffix + if let selectedRange = Range(editor.selectedRange(), in: string), + selectedRange.isEmpty, + selectedRange.upperBound < string.endIndex { + + editor.selectToTheEnd(from: string.distance(from: string.startIndex, to: selectedRange.lowerBound)) + } + } } + } - let addressBarString = selectedTabViewModel.addressBarString - let isSearch = selectedTabViewModel.tab.content.url?.isDuckDuckGoSearch ?? false - value = Value(stringValue: addressBarString, userTyped: false, isSearch: isSearch) + private var suffix: Suffix? { + value.suffix } - private func saveValue() { - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { - return - } + private var stringValueWithoutSuffix: String { + let stringValue = currentEditor()?.string ?? stringValue + guard let suffix else { return stringValue } + return stringValue.dropping(suffix: suffix.string) + } - selectedTabViewModel.lastAddressBarTextFieldValue = value + var stringValueWithoutSuffixRange: Range { + let string = editor?.string ?? stringValue + guard let suffix = suffix?.string, + string.hasSuffix(suffix) else { return string.startIndex.. UndoManager? { + undoManager } - // MARK: - Suffixes - - var isHomePage: Bool { - tabCollectionViewModel.selectedTabViewModel?.tab.content == .homePage + func clearUndoManager() { + undoManager?.removeAllActions() } - var isBurner: Bool { - tabCollectionViewModel.isBurner - } - - func makeTextAttributes() -> [NSAttributedString.Key: Any] { - let size: CGFloat = isHomePage ? 15 : 13 - return [ - .font: NSFont.systemFont(ofSize: size, weight: .regular), - .foregroundColor: NSColor.textColor, - .kern: -0.16 - ] - } - - enum Suffix { - init?(value: Value) { - if case .text("") = value { - return nil - } + /// flag is set when the TextField undo record creation is disabled for current `controlTextDidChange` action + /// AddressBarTextEditor checks the flag and disables UndoManager while it‘s set to prevent doubling Undo action for both text change and direct Value setting + private(set) var isUndoDisabled = false - switch value { - case .text: self = Suffix.search - case .url(urlString: _, url: let url, userTyped: let userTyped): - guard userTyped, - let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) - else { return nil } - self = Suffix.visit(host: host) - case .suggestion(let suggestionViewModel): - self.init(suggestionViewModel: suggestionViewModel) - } - } - - init?(suggestionViewModel: SuggestionViewModel) { - switch suggestionViewModel.suggestion { - case .phrase: - self = Suffix.search - case .website(url: let url): - guard let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) else { - return nil - } - self = Suffix.visit(host: host) - - case .bookmark(title: _, url: let url, isFavorite: _, allowedInTopHits: _), - .historyEntry(title: _, url: let url, allowedInTopHits: _): - if let title = suggestionViewModel.title, - !title.isEmpty, - suggestionViewModel.autocompletionString != title { - self = .title(title) - } else if let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) { - self = .visit(host: host) - } else { - self = .url(url) - } - - case .unknown: - self = Suffix.search - } - } - - case search - case visit(host: String) - case url(URL) - case title(String) - - func toAttributedString(size: CGFloat, isBurner: Bool) -> NSAttributedString { - let suffixColor = isBurner ? NSColor.burnerAccentColor : NSColor.addressBarSuffixColor - let attrs = [NSAttributedString.Key.font: NSFont.systemFont(ofSize: size, weight: .light), - .foregroundColor: suffixColor] - return NSAttributedString(string: string, attributes: attrs) - } - - static let searchSuffix = " – \(UserText.searchDuckDuckGoSuffix)" - static let visitSuffix = " – \(UserText.addressBarVisitSuffix)" - - var string: String { - switch self { - case .search: - return Self.searchSuffix - case .visit(host: let host): - return "\(Self.visitSuffix) \(host)" - case .url(let url): - if url.isDuckDuckGoSearch { - return Self.searchSuffix - } else { - return " – " + url.toString(decodePunycode: false, - dropScheme: true, - dropTrailingSlash: false) - } - case .title(let title): - return " – " + title - } + func withUndoDisabled(do job: () -> R) -> R { + isUndoDisabled = true + defer { + isUndoDisabled = false } + return job() } - private var suffix: Suffix? + // MARK: - Suggestion window - private var stringValueWithoutSuffix: String { - if let suffix = suffix { - return stringValue.dropping(suffix: suffix.string) - } else { - return stringValue + private func displaySelectedSuggestionViewModel() { + guard let suggestionWindow = suggestionWindowController?.window else { + os_log("AddressBarTextField: Window not available", type: .error) + return } - } + guard suggestionWindow.isVisible else { return } - // MARK: - Cursor & Selection + guard let selectedSuggestionViewModel = suggestionContainerViewModel?.selectedSuggestionViewModel else { + if let originalStringValue = suggestionContainerViewModel?.userStringValue { + self.value = Value(stringValue: originalStringValue, userTyped: true) + } else { + clearValue() + } - private func selectToTheEnd(from offset: Int) { - guard let currentEditor = currentEditor() else { - os_log("AddressBarTextField: Current editor not available", type: .error) return } - let string = currentEditor.string - let startIndex = string.index(string.startIndex, offsetBy: string.count >= offset ? offset : 0) - let endIndex = string.index(string.endIndex, offsetBy: -(suffix?.string.count ?? 0)) - - currentEditor.selectedRange = string.nsRange(from: startIndex.. NSRange { - let suffixStart = stringValue.utf16.count - (suffix?.string.utf16.count ?? 0) - let currentSelectionEnd = range.location + range.length - guard suffixStart >= 0, - currentSelectionEnd > suffixStart - else { return range } - - let newLocation = min(range.location, suffixStart) - let newMaxLength = suffixStart - newLocation - let newLength = min(newMaxLength, currentSelectionEnd - newLocation) - return NSRange(location: newLocation, length: newLength) + self.value = Value.suggestion(selectedSuggestionViewModel) } - // MARK: - Suggestion window - enum SuggestionWindowSizes { static let padding = CGPoint(x: -20, y: 1) } @@ -587,6 +456,16 @@ final class AddressBarTextField: NSTextField { } }() + var isSuggestionWindowVisiblePublisher: AnyPublisher { + self.publisher(for: \.suggestionWindowController?.window?.isVisible) + .map { $0 ?? false } + .eraseToAnyPublisher() + } + + var isSuggestionWindowVisible: Bool { + suggestionWindowController?.window?.isVisible == true + } + private func initSuggestionWindow() { let windowController = NSStoryboard.suggestion .instantiateController(withIdentifier: "SuggestionWindowController") as? NSWindowController @@ -674,7 +553,7 @@ final class AddressBarTextField: NSTextField { // MARK: - Menu Actions - @objc private func pasteAndGo(_ menuItem: NSMenuItem) { + @objc func pasteAndGo(_ menuItem: NSMenuItem) { guard let pasteboardString = NSPasteboard.general.string(forType: .string)?.trimmingWhitespace(), let url = URL(trimmedAddressBarString: pasteboardString) else { assertionFailure("Pasteboard doesn't contain URL") @@ -684,7 +563,7 @@ final class AddressBarTextField: NSTextField { tabCollectionViewModel.selectedTabViewModel?.tab.setUrl(url, userEntered: pasteboardString) } - @objc private func pasteAndSearch(_ menuItem: NSMenuItem) { + @objc func pasteAndSearch(_ menuItem: NSMenuItem) { guard let pasteboardString = NSPasteboard.general.string(forType: .string)?.trimmingWhitespace(), let searchURL = URL.makeSearchUrl(from: pasteboardString) else { assertionFailure("Can't create search URL from pasteboard string") @@ -694,7 +573,7 @@ final class AddressBarTextField: NSTextField { tabCollectionViewModel.selectedTabViewModel?.tab.setUrl(searchURL, userEntered: pasteboardString) } - @objc private func toggleAutocomplete(_ menuItem: NSMenuItem) { + @objc func toggleAutocomplete(_ menuItem: NSMenuItem) { AppearancePreferences.shared.showAutocompleteSuggestions.toggle() let shouldShowAutocomplete = AppearancePreferences.shared.showAutocompleteSuggestions @@ -708,14 +587,17 @@ final class AddressBarTextField: NSTextField { } } - @objc private func toggleShowFullWebsiteAddress(_ menuItem: NSMenuItem) { + @objc func toggleShowFullWebsiteAddress(_ menuItem: NSMenuItem) { AppearancePreferences.shared.showFullURL.toggle() let shouldShowFullURL = AppearancePreferences.shared.showFullURL menuItem.state = shouldShowFullURL ? .on : .off } - // MARK: NSDraggingDestination +} + +// MARK: - NSDraggingDestination +extension AddressBarTextField { override func draggingEntered(_ draggingInfo: NSDraggingInfo) -> NSDragOperation { return .copy @@ -730,7 +612,8 @@ final class AddressBarTextField: NSTextField { tabCollectionViewModel.selectedTabViewModel?.tab.setUrl(url, userEntered: draggingInfo.draggingPasteboard.string(forType: .string) ?? url.absoluteString) } else if let stringValue = draggingInfo.draggingPasteboard.string(forType: .string) { - self.stringValue = stringValue + self.value = .init(stringValue: stringValue, userTyped: false) + clearUndoManager() window?.makeKeyAndOrderFront(self) NSApp.activate(ignoringOtherApps: true) @@ -745,12 +628,183 @@ final class AddressBarTextField: NSTextField { } -extension Notification.Name { +extension AddressBarTextField { - static let suggestionWindowOpen = Notification.Name("suggestionWindowOpen") + enum Value: Equatable { + case text(_ text: String) + case url(urlString: String, url: URL, userTyped: Bool) + case suggestion(_ suggestionViewModel: SuggestionViewModel) + + init(stringValue: String, userTyped: Bool, isSearch: Bool = false) { + if let url = URL(trimmedAddressBarString: stringValue), url.isValid { + var stringValue = stringValue + // display punycoded url in readable form when editing + if !userTyped, + let punycodeDecoded = url.punycodeDecodedString { + stringValue = punycodeDecoded + } + self = .url(urlString: stringValue, url: url, userTyped: userTyped) + } else { + self = .text(stringValue) + } + } + + var string: String { + switch self { + case .text(let text): + return text + case .url(urlString: let urlString, url: _, userTyped: _): + return urlString + case .suggestion(let suggestionViewModel): + let autocompletionString = suggestionViewModel.autocompletionString + if autocompletionString.lowercased() + .hasPrefix(suggestionViewModel.userStringValue.lowercased()) { + // keep user input capitalization + let suffixLength = autocompletionString.count - suggestionViewModel.userStringValue.count + return suggestionViewModel.userStringValue + autocompletionString.suffix(suffixLength) + } + return autocompletionString + } + } + + var suffix: Suffix? { + Suffix(value: self) + } + + var isEmpty: Bool { + switch self { + case .text(let text): + return text.isEmpty + case .url(urlString: let urlString, url: _, userTyped: _): + return urlString.isEmpty + case .suggestion(let suggestion): + return suggestion.string.isEmpty + } + } + + var isText: Bool { + if case .text = self { + return true + } + return false + } + + var suggestion: SuggestionViewModel? { + if case .suggestion(let suggestion) = self { + return suggestion + } + return nil + } + + var isSuggestion: Bool { + self.suggestion != nil + } + + func toAttributedString(isHomePage: Bool, isBurner: Bool) -> NSAttributedString? { + var attributes: [NSAttributedString.Key: Any] { + let size: CGFloat = isHomePage ? 15 : 13 + return [ + .font: NSFont.systemFont(ofSize: size, weight: .regular), + .foregroundColor: NSColor.textColor, + .kern: -0.16 + ] + } + + guard let suffix else { return nil } + + let attributedString = NSMutableAttributedString(string: self.string, attributes: attributes) + attributedString.append(suffix.toAttributedString(size: isHomePage ? 15 : 13, isBurner: isBurner)) + + return attributedString + } + + } + + enum Suffix { + init?(value: Value) { + if case .text("") = value { + return nil + } + + switch value { + case .text: self = Suffix.search + case .url(urlString: _, url: let url, userTyped: let userTyped): + guard userTyped, + let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) + else { return nil } + self = Suffix.visit(host: host) + case .suggestion(let suggestionViewModel): + self.init(suggestionViewModel: suggestionViewModel) + } + } + + init?(suggestionViewModel: SuggestionViewModel) { + switch suggestionViewModel.suggestion { + case .phrase: + self = Suffix.search + case .website(url: let url): + guard let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) else { + return nil + } + self = Suffix.visit(host: host) + + case .bookmark(title: _, url: let url, isFavorite: _, allowedInTopHits: _), + .historyEntry(title: _, url: let url, allowedInTopHits: _): + if let title = suggestionViewModel.title, + !title.isEmpty, + suggestionViewModel.autocompletionString != title { + self = .title(title) + } else if let host = url.root?.toString(decodePunycode: true, dropScheme: true, dropTrailingSlash: true) { + self = .visit(host: host) + } else { + self = .url(url) + } + + case .unknown: + self = Suffix.search + } + } + + case search + case visit(host: String) + case url(URL) + case title(String) + + func toAttributedString(size: CGFloat, isBurner: Bool) -> NSAttributedString { + let suffixColor = isBurner ? NSColor.burnerAccentColor : NSColor.addressBarSuffixColor + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: size, weight: .light), + .foregroundColor: suffixColor + ] + return NSAttributedString(string: string, attributes: attrs) + } + + static let searchSuffix = " – \(UserText.searchDuckDuckGoSuffix)" + static let visitSuffix = " – \(UserText.addressBarVisitSuffix)" + + var string: String { + switch self { + case .search: + return Self.searchSuffix + case .visit(host: let host): + return "\(Self.visitSuffix) \(host)" + case .url(let url): + if url.isDuckDuckGoSearch { + return Self.searchSuffix + } else { + return " – " + url.toString(decodePunycode: false, + dropScheme: true, + dropTrailingSlash: false) + } + case .title(let title): + return " – " + title + } + } + } } +// MARK: - NSTextFieldDelegate extension AddressBarTextField: NSTextFieldDelegate { func controlTextDidEndEditing(_ obj: Notification) { @@ -764,21 +818,16 @@ extension AddressBarTextField: NSTextFieldDelegate { private func handleTextDidChange() { let stringValueWithoutSuffix = self.stringValueWithoutSuffix + self.currentTextDidChangeEvent = (currentTextDidChangeEvent != .none) ? currentTextDidChangeEvent : .userModifiedText + defer { + self.currentTextDidChangeEvent = .none + } // if user continues typing letters from displayed Suggestion // don't blink and keep the Suggestion displayed - if isHandlingUserAppendingText, - case .suggestion(let suggestion) = self.value, - // disable autocompletion when user entered Space - !stringValueWithoutSuffix.contains(" "), - stringValueWithoutSuffix.hasPrefix(suggestion.userStringValue), - suggestion.autocompletionString.hasPrefix(stringValueWithoutSuffix), - let editor = currentEditor(), - editor.selectedRange.location == stringValueWithoutSuffix.utf16.count { - - self.value = .suggestion(SuggestionViewModel(isHomePage: isHomePage, suggestion: suggestion.suggestion, - userStringValue: stringValueWithoutSuffix)) - self.selectToTheEnd(from: stringValueWithoutSuffix.count) + if case .userAppendingTextToTheEnd = currentTextDidChangeEvent, + let suggestion = autocompleteSuggestionBeingTypedOverByUser(with: stringValueWithoutSuffix) { + self.value = .suggestion(SuggestionViewModel(isHomePage: isHomePage, suggestion: suggestion.suggestion, userStringValue: stringValueWithoutSuffix)) } else { suggestionContainerViewModel?.clearSelection() @@ -789,26 +838,20 @@ extension AddressBarTextField: NSTextFieldDelegate { suggestionContainerViewModel?.clearUserStringValue() hideSuggestionWindow() } else { - suggestionContainerViewModel?.setUserStringValue(stringValueWithoutSuffix, - userAppendedStringToTheEnd: isHandlingUserAppendingText) + suggestionContainerViewModel?.setUserStringValue(stringValueWithoutSuffix, userAppendedStringToTheEnd: currentTextDidChangeEvent == .userAppendingTextToTheEnd) } - - // reset user typed flag for the next didChange event - isHandlingUserAppendingText = false } - func textView(_ textView: NSTextView, userTypedString typedString: String, at range: NSRange) { - let userTypedLength = suggestionContainerViewModel?.userStringValue?.utf16.count ?? 0 - let currentValueLength = self.stringValueWithoutSuffix.utf16.count - let selectedSuggestionRange = NSRange(location: userTypedLength, length: currentValueLength - userTypedLength) - assert(selectedSuggestionRange.upperBound <= currentValueLength) + private func autocompleteSuggestionBeingTypedOverByUser(with newUserEnteredValue: String) -> SuggestionViewModel? { + if case .userAppendingTextToTheEnd = currentTextDidChangeEvent, // only when typing over + case .suggestion(let suggestion) = self.value, // current value should be an autocompletion suggestion + !newUserEnteredValue.contains(" "), // disable autocompletion when user entered Space + newUserEnteredValue.hasPrefix(suggestion.userStringValue), // newly typed value should start with a previous value + suggestion.autocompletionString.hasPrefix(newUserEnteredValue) { // new typed value should still match the selected suggestion - // if user is typing in the end of current value or replacing selected suggestion range - // or replaces the whole string - if selectedSuggestionRange == range || range.length >= currentValueLength || range.location == NSNotFound { - // we'll select the first suggested item - isHandlingUserAppendingText = true + return suggestion } + return nil } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { @@ -820,24 +863,31 @@ extension AddressBarTextField: NSTextFieldDelegate { if commandSelector == #selector(NSResponder.insertTab(_:)) { window?.makeFirstResponder(nextKeyView) return false - } - // Collision of suffix and forward deleting - if [#selector(NSResponder.deleteForward(_:)), #selector(NSResponder.deleteWordForward(_:))].contains(commandSelector) { - if let currentEditor = currentEditor(), - currentEditor.selectedRange.location == value.string.utf16.count, - currentEditor.selectedRange.length == 0 { - // Don't do delete when cursor is in the end - return true + } else if commandSelector == #selector(noop(_:)), + let event = NSApp.currentEvent, + case .keyDown = event.type, + event.keyCode == kVK_ForwardDelete, + event.modifierFlags.contains(.command) { + // Cmd + Forward Delete + if isSuggestionWindowVisible { + suggestionContainerViewModel?.clearSelection() } + + textView.deleteToEndOfLine(control) + return true } - if suggestionWindowController?.window?.isVisible ?? false { + if isSuggestionWindowVisible { switch commandSelector { case #selector(NSResponder.moveDown(_:)): - suggestionContainerViewModel?.selectNextIfPossible(); return true + suggestionContainerViewModel?.selectNextIfPossible() + return true + case #selector(NSResponder.moveUp(_:)): - suggestionContainerViewModel?.selectPreviousIfPossible(); return true + suggestionContainerViewModel?.selectPreviousIfPossible() + return true + case #selector(NSResponder.deleteBackward(_:)), #selector(NSResponder.deleteForward(_:)), #selector(NSResponder.deleteToMark(_:)), @@ -847,7 +897,10 @@ extension AddressBarTextField: NSTextFieldDelegate { #selector(NSResponder.deleteToEndOfParagraph(_:)), #selector(NSResponder.deleteToBeginningOfLine(_:)), #selector(NSResponder.deleteBackwardByDecomposingPreviousCharacter(_:)): - suggestionContainerViewModel?.clearSelection(); return false + + suggestionContainerViewModel?.clearSelection() + return false + default: return false } @@ -858,91 +911,99 @@ extension AddressBarTextField: NSTextFieldDelegate { } -extension AddressBarTextField: SuggestionViewControllerDelegate { +// MARK: - NSTextViewDelegate +extension AddressBarTextField: NSTextViewDelegate { - func suggestionViewControllerDidConfirmSelection(_ suggestionViewController: SuggestionViewController) { - let suggestion = suggestionContainerViewModel?.selectedSuggestionViewModel?.suggestion - if NSApp.isCommandPressed { - openNewTab(selected: NSApp.isShiftPressed, suggestion: suggestion) - return + func textView(_ textView: NSTextView, userTypedString typedString: String, at insertionNsRange: NSRange, callback: () -> Void) { + let oldValue = stringValueWithoutSuffix + let insertionRange = Range(insertionNsRange, in: oldValue) ?? oldValue.startIndex..= oldValue.endIndex { + // we'll select the first suggested item or update userEnteredText in currently selected suggestion + currentTextDidChangeEvent = .userAppendingTextToTheEnd + } else { + currentTextDidChangeEvent = .userModifiedText } - navigate(suggestion: suggestion) - } - func shouldCloseSuggestionWindow(forMouseEvent event: NSEvent) -> Bool { - // don't hide suggestions if clicking somewhere inside the Address Bar view - return superview?.isMouseLocationInsideBounds(event.locationInWindow) != true - } -} + // when typing over the autocomplete suggestion + if insertionRange == selectedSuggestionRange, + autocompleteSuggestionBeingTypedOverByUser(with: oldValue.replacingCharacters(in: selectedSuggestionRange, with: typedString)) != nil { + // disable TextEditor‘s built-in undo, we‘ll save the Suggestion Value to the Undo Manager instead + isUndoDisabled = true + } -extension AddressBarTextField: NSTextViewDelegate { + // call `AddressBarTextEditor:super.insertText(typedString, replacementRange: insertionRange)` + // which will call `controlTextDidChange:` with `isHandlingUserAppendingText`/`isUndoDisabled` flags set if suited + callback() + + currentTextDidChangeEvent = .none + isUndoDisabled = false + } func textView(_ textView: NSTextView, willChangeSelectionFromCharacterRange _: NSRange, toCharacterRange range: NSRange) -> NSRange { DispatchQueue.main.async { // artifacts can appear when the selection changes, especially if the size of the field has changed, this clears them textView.needsDisplay = true } - return self.filterSuffix(fromSelectionRange: range, for: textView.string) + guard let range = Range(range, in: textView.string) else { return range } + return NSRange(range.clamped(to: stringValueWithoutSuffixRange), in: textView.string) } func textView(_ view: NSTextView, menu: NSMenu, for event: NSEvent, at charIndex: Int) -> NSMenu? { - let textViewMenu = removingAttributeChangingMenuItems(from: menu) - let additionalMenuItems = [ - makeAutocompleteSuggestionsMenuItem(), - makeFullWebsiteAddressMenuItem(), - NSMenuItem.separator() - ] + removeUnwantedMenuItems(from: menu) - if let pasteMenuItemIndex = pasteMenuItemIndex(within: menu), - let pasteAndDoMenuItem = makePasteAndGoMenuItem() { - textViewMenu.insertItem(pasteAndDoMenuItem, at: pasteMenuItemIndex + 1) + if let pasteAndGoMenuItem = NSMenuItem.makePasteAndGoMenuItem() { + let pasteMenuItemIndex = menu.indexOfItem(withTarget: nil, andAction: #selector(NSText.paste)) // ?? -1 + menu.insertItem(pasteAndGoMenuItem, at: pasteMenuItemIndex + 1) } - if let insertionPoint = menuItemInsertionPoint(within: menu) { - additionalMenuItems.reversed().forEach { item in - textViewMenu.insertItem(item, at: insertionPoint) - } - } else { - additionalMenuItems.forEach { item in - textViewMenu.addItem(item) - } + if let sharingMenuItem = menu.item(with: Self.shareMenuItemAction) { + sharingMenuItem.title = UserText.shareMenuItem + sharingMenuItem.submenu = SharingMenu(title: UserText.shareMenuItem) + } + + let additionalMenuItems: [NSMenuItem] = [ + .toggleAutocompleteSuggestionsMenuItem, + .toggleFullWebsiteAddressMenuItem, + .separator() + ] + let insertionPoint = menuItemInsertionPoint(within: menu) + for (idx, item) in additionalMenuItems.enumerated() { + menu.insertItem(item, at: insertionPoint + idx) } - return textViewMenu + return menu } /// Returns the menu item after which new items should be added. /// This will be the first separator that comes after a predefined list of items: Cut, Copy, or Paste. /// /// - Returns: The preferred menu item. If none are found, nil is returned. - private func menuItemInsertionPoint(within menu: NSMenu) -> Int? { - let preferredSelectorNames = ["cut:", "copy:", "paste:"] - var foundPreferredSelector = false - - for (index, item) in menu.items.enumerated() { - if foundPreferredSelector && item.isSeparatorItem { - let indexAfterSeparator = index + 1 - return menu.items.indices.contains(indexAfterSeparator) ? indexAfterSeparator : index - } - - if let action = item.action, preferredSelectorNames.contains(action.description) { - foundPreferredSelector = true - } - } - - return nil - } + private func menuItemInsertionPoint(within menu: NSMenu) -> Int { + let cutItemIndex = max(0, menu.indexOfItem(withTarget: nil, andAction: #selector(NSText.cut)) /* ?? -1 */) + let separatorIndex = menu.items[cutItemIndex...].firstIndex(where: { $0.isSeparatorItem }) - private func pasteMenuItemIndex(within menu: NSMenu) -> Int? { - let pasteSelector = "paste:" - let index = menu.items.firstIndex { menuItem in - guard let action = menuItem.action else { return false } - return pasteSelector == action.description + if let separatorIndex { + return separatorIndex + 1 } - return index + return menu.numberOfItems } - private static var selectorsToRemove: Set = Set([ + private static var selectorsToRemove = Set([ + Selector(("_openLinkFromMenu:")), + NSSelectorFromString("invoke"), + Selector(("_openPreview")), + Selector(("runActionForDictionary:")), Selector(("_makeLinkFromMenu:")), Selector(("_searchWithGoogleFromMenu:")), #selector(NSFontManager.orderFrontFontPanel(_:)), @@ -950,32 +1011,27 @@ extension AddressBarTextField: NSTextViewDelegate { Selector(("replaceQuotesInSelection:")), #selector(NSStandardKeyBindingResponding.uppercaseWord(_:)), #selector(NSTextView.startSpeaking(_:)), - #selector(NSTextView.changeLayoutOrientation(_:)) + #selector(NSTextView.changeLayoutOrientation(_:)), + #selector(NSTextView.orderFrontSubstitutionsPanel(_:)) ]) + private static let shareMenuItemAction = Selector(("_performStandardShareMenuItem:")) - private func removingAttributeChangingMenuItems(from menu: NSMenu) -> NSMenu { - menu.items.reversed().forEach { menuItem in - if let action = menuItem.action, Self.selectorsToRemove.contains(action) { - menu.removeItem(menuItem) - } else { - if let submenu = menuItem.submenu, submenu.items.first(where: { submenuItem in - if let submenuAction = submenuItem.action, Self.selectorsToRemove.contains(submenuAction) { - return true - } else { - return false - } - }) != nil { - menu.removeItem(menuItem) - } - } + private func removeUnwantedMenuItems(from menu: NSMenu) { + // filter out menu items with action from `selectorsToRemove` or containing submenu items with action from the list + menu.items = menu.items.filter { menuItem in + menuItem.action.map { action in Self.selectorsToRemove.contains(action) } != true + && Self.selectorsToRemove.isDisjoint(with: menuItem.submenu?.items.compactMap(\.action) ?? []) } - return menu } - private func makeAutocompleteSuggestionsMenuItem() -> NSMenuItem { +} + +private extension NSMenuItem { + + static var toggleAutocompleteSuggestionsMenuItem: NSMenuItem { let menuItem = NSMenuItem( title: UserText.showAutocompleteSuggestions.localizedCapitalized, - action: #selector(toggleAutocomplete(_:)), + action: #selector(AddressBarTextField.toggleAutocomplete(_:)), keyEquivalent: "" ) menuItem.state = AppearancePreferences.shared.showAutocompleteSuggestions ? .on : .off @@ -983,10 +1039,10 @@ extension AddressBarTextField: NSTextViewDelegate { return menuItem } - private func makeFullWebsiteAddressMenuItem() -> NSMenuItem { + static var toggleFullWebsiteAddressMenuItem: NSMenuItem { let menuItem = NSMenuItem( title: UserText.showFullWebsiteAddress.localizedCapitalized, - action: #selector(toggleShowFullWebsiteAddress(_:)), + action: #selector(AddressBarTextField.toggleShowFullWebsiteAddress(_:)), keyEquivalent: "" ) menuItem.state = AppearancePreferences.shared.showFullURL ? .on : .off @@ -997,7 +1053,7 @@ extension AddressBarTextField: NSTextViewDelegate { private static var pasteAndGoMenuItem: NSMenuItem { NSMenuItem( title: UserText.pasteAndGo, - action: #selector(pasteAndGo(_:)), + action: #selector(AddressBarTextField.pasteAndGo(_:)), keyEquivalent: "" ) } @@ -1005,12 +1061,12 @@ extension AddressBarTextField: NSTextViewDelegate { private static var pasteAndSearchMenuItem: NSMenuItem { NSMenuItem( title: UserText.pasteAndSearch, - action: #selector(pasteAndSearch(_:)), + action: #selector(AddressBarTextField.pasteAndSearch(_:)), keyEquivalent: "" ) } - private func makePasteAndGoMenuItem() -> NSMenuItem? { + static func makePasteAndGoMenuItem() -> NSMenuItem? { if let trimmedPasteboardString = NSPasteboard.general.string(forType: .string)?.trimmingWhitespace(), trimmedPasteboardString.count > 0 { if URL(trimmedAddressBarString: trimmedPasteboardString) != nil { @@ -1024,103 +1080,35 @@ extension AddressBarTextField: NSTextViewDelegate { } } -final class AddressBarTextEditor: NSTextView { - - override func mouseDown(with event: NSEvent) { - super.mouseDown(with: event) - - guard let delegate = delegate as? AddressBarTextField else { - os_log("AddressBarTextEditor: unexpected kind of delegate") - return - } - - if let currentSelection = selectedRanges.first as? NSRange { - let adjustedSelection = delegate.filterSuffix(fromSelectionRange: currentSelection, for: string) - setSelectedRange(adjustedSelection) - } - } +// MARK: - SuggestionViewControllerDelegate +extension AddressBarTextField: SuggestionViewControllerDelegate { - override func paste(_ sender: Any?) { - guard let delegate = delegate as? AddressBarTextField else { - os_log("AddressBarTextEditor: unexpected kind of delegate") - super.paste(sender) + func suggestionViewControllerDidConfirmSelection(_ suggestionViewController: SuggestionViewController) { + let suggestion = suggestionContainerViewModel?.selectedSuggestionViewModel?.suggestion + if NSApp.isCommandPressed { + openNewTab(selected: NSApp.isShiftPressed, suggestion: suggestion) return } - - // Fixes an issue when url-name instead of url is pasted - if let urlString = NSPasteboard.general.string(forType: .URL) { - super.pasteAsPlainText(urlString) - delegate.handlePastedURL() - } else { - super.paste(sender) - } - } - - override func copy(_ sender: Any?) { - CopyHandler().copy(sender) - } - - override func selectionRange(forProposedRange proposedCharRange: NSRange, granularity: NSSelectionGranularity) -> NSRange { - guard let delegate = delegate as? AddressBarTextField else { - os_log("AddressBarTextEditor: unexpected kind of delegate") - return super.selectionRange(forProposedRange: proposedCharRange, granularity: granularity) - } - - let string = self.string - var range: NSRange - switch granularity { - case .selectByParagraph: - // select all and then adjust by removing suffix - range = self.string.nsRange(from: string.startIndex.. 0 { - range.location -= 1 - } - // select word and then adjust by removing suffix - range = super.selectionRange(forProposedRange: range, granularity: granularity) - - case .selectByCharacter: fallthrough - @unknown default: - // adjust caret location only - range = proposedCharRange - } - return delegate.filterSuffix(fromSelectionRange: range, for: self.string) + navigate(suggestion: suggestion) } - override func characterIndexForInsertion(at point: NSPoint) -> Int { - let index = super.characterIndexForInsertion(at: point) - let adjustedRange = selectionRange(forProposedRange: NSRange(location: index, length: 0), - granularity: .selectByCharacter) - return adjustedRange.location + func shouldCloseSuggestionWindow(forMouseEvent event: NSEvent) -> Bool { + // don't hide suggestions if clicking somewhere inside the Address Bar view + return superview?.isMouseLocationInsideBounds(event.locationInWindow) != true } - override func insertText(_ string: Any, replacementRange: NSRange) { - defer { - super.insertText(string, replacementRange: replacementRange) - } - - guard let delegate = delegate as? AddressBarTextField else { - os_log("AddressBarTextEditor: unexpected kind of delegate") - return - } - guard let string = string as? String else { return } - - delegate.textView(self, userTypedString: string, at: replacementRange.location == NSNotFound ? self.selectedRange() : replacementRange) - } } -final class AddressBarTextFieldCell: NSTextFieldCell { - lazy var customEditor = AddressBarTextEditor() - - override func fieldEditor(for controlView: NSView) -> NSTextView? { - return customEditor - } +extension Notification.Name { + static let suggestionWindowOpen = Notification.Name("suggestionWindowOpen") } fileprivate extension NSStoryboard { static let suggestion = NSStoryboard(name: "Suggestion", bundle: .main) } + +extension NSResponder { + + @objc func noop(_: Any?) {} + +} diff --git a/DuckDuckGo/NavigationBar/View/AddressBarTextSelectionNavigation.swift b/DuckDuckGo/NavigationBar/View/AddressBarTextSelectionNavigation.swift new file mode 100644 index 0000000000..4ad186fe84 --- /dev/null +++ b/DuckDuckGo/NavigationBar/View/AddressBarTextSelectionNavigation.swift @@ -0,0 +1,81 @@ +// +// AddressBarTextSelectionNavigation.swift +// +// Copyright © 2023 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppKit +import Foundation + +@available(macOS 12.0, *) +final class AddressBarTextSelectionNavigation: NSTextSelectionNavigation { + private weak var dataSource: NSTextLayoutManager? + + override init(dataSource: NSTextSelectionDataSource) { + self.dataSource = dataSource as? NSTextLayoutManager + super.init(dataSource: dataSource) + } + + // to be updated on macOS 11 drop: move logics from AddressBarTextEditor.selectionRange(forProposedRange:granularity:) + override func textSelection(for granularity: NSTextSelection.Granularity, enclosing selection: NSTextSelection) -> NSTextSelection { + guard let range = selection.textRanges.first else { return selection } + guard let dataSource, let textView = dataSource.textContainer?.textView as? AddressBarTextEditor else { return selection } + + let start = dataSource.documentRange.location + let location = dataSource.offset(from: start, to: range.location) + let length = dataSource.offset(from: range.location, to: range.endLocation) + let newRange = textView.selectionRange(forProposedRange: NSRange(location: location, length: length), granularity: NSSelectionGranularity(granularity)) + + guard let newLocation = dataSource.location(start, offsetBy: newRange.location), + let newEnd = dataSource.location(newLocation, offsetBy: newRange.length), + let selectionRange = NSTextRange(location: newLocation, end: newEnd) else { return selection } + + return NSTextSelection(range: selectionRange, affinity: selection.affinity, granularity: granularity) + } + + override func textSelections(interactingAt point: CGPoint, inContainerAt containerLocation: NSTextLocation, anchors: [NSTextSelection], modifiers: NSTextSelectionNavigation.Modifier, selecting: Bool, bounds: CGRect) -> [NSTextSelection] { + + let selections = super.textSelections(interactingAt: point, inContainerAt: containerLocation, anchors: anchors, modifiers: modifiers, selecting: selecting, bounds: bounds) + guard modifiers == .extend, + let proposedSelection = selections.first, + let anchor = anchors.first else { return selections } + + let textSelection = textSelection(for: anchor.granularity, enclosing: proposedSelection) + guard let textSelectionRange = textSelection.textRanges.first, + let anchorRange = anchor.textRanges.first else { return selections } + + let range = textSelectionRange.union(anchorRange) + let extendedSelection = NSTextSelection(range: range, affinity: anchor.affinity, granularity: anchor.granularity) + + return [extendedSelection] + } + +} + +@available(macOS 12.0, *) +extension NSSelectionGranularity { + + init(_ textSelectionGranularity: NSTextSelection.Granularity) { + switch textSelectionGranularity { + case .character: + self = .selectByCharacter + case .word: + self = .selectByWord + default: + self = .selectByParagraph + } + } + +} diff --git a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift index bef7bef8bf..00633f870c 100644 --- a/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift +++ b/DuckDuckGo/NavigationBar/View/AddressBarViewController.swift @@ -69,13 +69,11 @@ final class AddressBarViewController: NSViewController { } private var cancellables = Set() - private var passiveAddressBarStringCancellable: AnyCancellable? - private var tabContentCancellable: AnyCancellable? - private var progressCancellable: AnyCancellable? - private var loadingCancellable: AnyCancellable? + private var tabViewModelCancellables = Set() + private var eventMonitorCancellables = Set() + /// save mouse-down position to handle same-place clicks outside of the Address Bar to remove first responder private var clickPoint: NSPoint? - private var eventMonitorCancellables = Set() required init?(coder: NSCoder) { fatalError("AddressBarViewController: Bad initializer") @@ -101,9 +99,9 @@ final class AddressBarViewController: NSViewController { updateView() // only activate active text field leading constraint on its appearance to avoid constraint conflicts activeTextFieldMinXConstraint.isActive = false - addressBarTextField.addressBarTextFieldDelegate = self addressBarTextField.tabCollectionViewModel = tabCollectionViewModel subscribeToSelectedTabViewModel() + subscribeToAddressBarValue() registerForMouseEnteredAndExitedEvents() } @@ -188,38 +186,55 @@ final class AddressBarViewController: NSViewController { @IBOutlet var shadowView: ShadowView! private func subscribeToSelectedTabViewModel() { - tabCollectionViewModel.$selectedTabViewModel.receive(on: DispatchQueue.main).sink { [weak self] _ in - self?.subscribeToTabContent() - self?.subscribeToPassiveAddressBarString() - self?.subscribeToProgressEvents() - // don't resign first responder on tab switching - self?.clickPoint = nil - }.store(in: &cancellables) + tabCollectionViewModel.$selectedTabViewModel + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self else { return } + + tabViewModelCancellables.removeAll() + + subscribeToTabContent() + subscribeToPassiveAddressBarString() + subscribeToProgressEvents() + + // don't resign first responder on tab switching + clickPoint = nil + } + .store(in: &cancellables) + } + + private func subscribeToAddressBarValue() { + addressBarTextField.$value + .sink { [weak self] value in + guard let self else { return } + + updateMode(value: value) + addressBarButtonsViewController?.textFieldValue = value + updateView() + } + .store(in: &cancellables) } private func subscribeToTabContent() { - tabContentCancellable = tabCollectionViewModel.selectedTabViewModel?.tab.$content + tabCollectionViewModel.selectedTabViewModel?.tab.$content .receive(on: DispatchQueue.main) .map { $0 == .homePage } .assign(to: \.isHomePage, onWeaklyHeld: self) + .store(in: &tabViewModelCancellables) } private func subscribeToPassiveAddressBarString() { - passiveAddressBarStringCancellable = nil - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { passiveTextField.stringValue = "" return } - passiveAddressBarStringCancellable = selectedTabViewModel.$passiveAddressBarString + selectedTabViewModel.$passiveAddressBarString .receive(on: DispatchQueue.main) .assign(to: \.stringValue, onWeaklyHeld: passiveTextField) + .store(in: &tabViewModelCancellables) } private func subscribeToProgressEvents() { - progressCancellable = nil - loadingCancellable = nil - guard let selectedTabViewModel = tabCollectionViewModel.selectedTabViewModel else { progressIndicator.hide(animated: false) return @@ -231,16 +246,18 @@ final class AddressBarViewController: NSViewController { progressIndicator.hide(animated: false) } - progressCancellable = selectedTabViewModel.$progress.sink { [weak self] value in - guard selectedTabViewModel.isLoading, - let progressIndicator = self?.progressIndicator, - progressIndicator.isShown - else { return } + selectedTabViewModel.$progress + .sink { [weak self] value in + guard selectedTabViewModel.isLoading, + let progressIndicator = self?.progressIndicator, + progressIndicator.isShown + else { return } - progressIndicator.increaseProgress(to: value) - } + progressIndicator.increaseProgress(to: value) + } + .store(in: &tabViewModelCancellables) - loadingCancellable = selectedTabViewModel.$isLoading + selectedTabViewModel.$isLoading .sink { [weak self] isLoading in guard let progressIndicator = self?.progressIndicator else { return } @@ -252,7 +269,8 @@ final class AddressBarViewController: NSViewController { } else if progressIndicator.isShown { progressIndicator.finishAndHide() } - } + } + .store(in: &tabViewModelCancellables) } private func subscribeToButtonsWidth() { @@ -264,7 +282,7 @@ final class AddressBarViewController: NSViewController { } private func subscribeForShadowViewUpdates() { - addressBarTextField.suggestionWindowVisible + addressBarTextField.isSuggestionWindowVisiblePublisher .sink { [weak self] isSuggestionsWindowVisible in self?.updateShadowView(isSuggestionsWindowVisible) } @@ -485,16 +503,6 @@ extension AddressBarViewController: AddressBarButtonsViewControllerDelegate { } -extension AddressBarViewController: AddressBarTextFieldDelegate { - - func adressBarTextField(_ addressBarTextField: AddressBarTextField, didChangeValue value: AddressBarTextField.Value) { - updateMode(value: value) - addressBarButtonsViewController?.textFieldValue = value - updateView() - } - -} - fileprivate extension NSView { var shouldShowArrowCursor: Bool { diff --git a/DuckDuckGo/Permissions/View/PermissionContextMenu.swift b/DuckDuckGo/Permissions/View/PermissionContextMenu.swift index 4b2937299e..e207d95d01 100644 --- a/DuckDuckGo/Permissions/View/PermissionContextMenu.swift +++ b/DuckDuckGo/Permissions/View/PermissionContextMenu.swift @@ -346,7 +346,7 @@ private extension NSMenuItem { static func popupPermissionRequested(domain: String?) -> NSMenuItem { let title = UserText.permissionPopupTitle let attributedTitle = NSMutableAttributedString(string: title) - attributedTitle.setAttributes([.font: NSFont.systemFont(ofSize: 11.0)], range: title.nsRange()) + attributedTitle.setAttributes([.font: NSFont.systemFont(ofSize: 11.0)], range: title.fullRange) let menuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") menuItem.attributedTitle = attributedTitle @@ -366,7 +366,7 @@ private extension NSMenuItem { } let attributedTitle = NSMutableAttributedString(string: title) - attributedTitle.setAttributes([.font: NSFont.systemFont(ofSize: 11.0)], range: title.nsRange()) + attributedTitle.setAttributes([.font: NSFont.systemFont(ofSize: 11.0)], range: title.fullRange) let menuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") menuItem.attributedTitle = attributedTitle diff --git a/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift b/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift index 3fa5a93b3f..8d205d7fcc 100644 --- a/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift +++ b/DuckDuckGo/Suggestions/ViewModel/SuggestionContainerViewModel.swift @@ -124,7 +124,7 @@ final class SuggestionContainerViewModel { return } - if suggestionViewModel(at: index) !== self.selectedSuggestionViewModel { + if suggestionViewModel(at: index) != self.selectedSuggestionViewModel { selectionIndex = index } } diff --git a/DuckDuckGo/Suggestions/ViewModel/SuggestionViewModel.swift b/DuckDuckGo/Suggestions/ViewModel/SuggestionViewModel.swift index b2a6bcb327..1c56cb0682 100644 --- a/DuckDuckGo/Suggestions/ViewModel/SuggestionViewModel.swift +++ b/DuckDuckGo/Suggestions/ViewModel/SuggestionViewModel.swift @@ -19,7 +19,7 @@ import Cocoa import BrowserServicesKit -final class SuggestionViewModel { +struct SuggestionViewModel: Equatable { let isHomePage: Bool let suggestion: Suggestion @@ -39,21 +39,33 @@ final class SuggestionViewModel { return style }() - lazy var tableRowViewStandardAttributes: [NSAttributedString.Key: Any] = { - let size: CGFloat = isHomePage ? 15 : 13 - return [ - .font: NSFont.systemFont(ofSize: size, weight: .regular), - .paragraphStyle: Self.paragraphStyle - ] - }() + private static let homePageTableRowViewStandardAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 15, weight: .regular), + .paragraphStyle: Self.paragraphStyle + ] - lazy var tableRowViewBoldAttributes: [NSAttributedString.Key: Any] = { - let size: CGFloat = isHomePage ? 15 : 13 - return [ - NSAttributedString.Key.font: NSFont.systemFont(ofSize: size, weight: .bold), - .paragraphStyle: Self.paragraphStyle - ] - }() + private static let regularTableRowViewStandardAttributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 13, weight: .regular), + .paragraphStyle: Self.paragraphStyle + ] + + private static let homePageTableRowViewBoldAttributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.font: NSFont.systemFont(ofSize: 15, weight: .bold), + .paragraphStyle: Self.paragraphStyle + ] + + private static let regularTableRowViewBoldAttributes: [NSAttributedString.Key: Any] = [ + NSAttributedString.Key.font: NSFont.systemFont(ofSize: 13, weight: .bold), + .paragraphStyle: Self.paragraphStyle + ] + + var tableRowViewStandardAttributes: [NSAttributedString.Key: Any] { + isHomePage ? Self.homePageTableRowViewStandardAttributes : Self.regularTableRowViewStandardAttributes + } + + var tableRowViewBoldAttributes: [NSAttributedString.Key: Any] { + isHomePage ? Self.homePageTableRowViewBoldAttributes : Self.regularTableRowViewBoldAttributes + } var tableCellViewAttributedString: NSAttributedString { var firstPart = "" diff --git a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift index fe6f02cef9..a0bd6f4077 100644 --- a/DuckDuckGo/Tab/ViewModel/TabViewModel.swift +++ b/DuckDuckGo/Tab/ViewModel/TabViewModel.swift @@ -69,7 +69,6 @@ final class TabViewModel { @Published private(set) var addressBarString: String = "" @Published private(set) var passiveAddressBarString: String = "" var lastAddressBarTextFieldValue: AddressBarTextField.Value? - var lastHomePageTextFieldValue: AddressBarTextField.Value? @Published private(set) var title: String = UserText.tabHomeTitle @Published private(set) var favicon: NSImage? diff --git a/DuckDuckGo/UserAgent/Services/WebKitVersionProvider.swift b/DuckDuckGo/UserAgent/Services/WebKitVersionProvider.swift index b6ce301e5c..c1a020d99c 100644 --- a/DuckDuckGo/UserAgent/Services/WebKitVersionProvider.swift +++ b/DuckDuckGo/UserAgent/Services/WebKitVersionProvider.swift @@ -23,12 +23,12 @@ struct WebKitVersionProvider { static func getVersion() -> String? { guard let userAgent = WKWebView().value(forKey: "userAgent") as? String, let regularExpression = try? NSRegularExpression(pattern: #"AppleWebKit\s*\/\s*([\d.]+)"#, options: []), - let match = regularExpression.firstMatch(in: userAgent, options: [], range: userAgent.nsRange()), + let match = regularExpression.firstMatch(in: userAgent, options: [], range: userAgent.fullRange), match.numberOfRanges >= 1 else { return nil } - return userAgent[match.range(at: 1)] + return userAgent[match.range(at: 1)].map(String.init) } } diff --git a/UnitTests/Common/Extensions/StringExtensionTests.swift b/UnitTests/Common/Extensions/StringExtensionTests.swift deleted file mode 100644 index 42e00217bd..0000000000 --- a/UnitTests/Common/Extensions/StringExtensionTests.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// StringExtensionTests.swift -// -// Copyright © 2021 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 XCTest -@testable import DuckDuckGo_Privacy_Browser - -final class StringExtensionTests: XCTestCase { - - // MARK: - General - - func testWhenNsRangeIsCalledWithoutParameter_ThenFullRangeIsReturened() { - XCTAssertEqual("".nsRange(), NSRange(location: 0, length: 0)) - XCTAssertEqual("š".nsRange(), NSRange(location: 0, length: 1)) - } -} diff --git a/UnitTests/Suggestions/ViewModel/SuggestionViewModelTests.swift b/UnitTests/Suggestions/ViewModel/SuggestionViewModelTests.swift index 8171c934e4..24a6e2f329 100644 --- a/UnitTests/Suggestions/ViewModel/SuggestionViewModelTests.swift +++ b/UnitTests/Suggestions/ViewModel/SuggestionViewModelTests.swift @@ -161,7 +161,7 @@ final class SuggestionViewModelTests: XCTestCase { } extension SuggestionViewModel { - convenience init(suggestion: Suggestion, userStringValue: String) { + init(suggestion: Suggestion, userStringValue: String) { self.init(isHomePage: false, suggestion: suggestion, userStringValue: userStringValue) } }