diff --git a/.gitignore b/.gitignore
new file mode 100755
index 0000000..e3d789b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,166 @@
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## Apple Owned files
+*.dmg
+*.dmg.signature
+.DS_Store
+.swiftpm/*
+
+## Build generated
+build/
+DerivedData/
+
+## Various settings
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+xcuserdata/
+
+## Other
+*.moved-aside
+*.xccheckout
+*.xcscmblueprint
+
+## Obj-C/Swift specific
+*.hmap
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+# Pods/
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
+# screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+
+# Xcode
+#
+# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
+
+## User settings
+xcuserdata/
+
+## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
+*.xcscmblueprint
+*.xccheckout
+
+## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
+build/
+DerivedData/
+*.moved-aside
+*.pbxuser
+!default.pbxuser
+*.mode1v3
+!default.mode1v3
+*.mode2v3
+!default.mode2v3
+*.perspectivev3
+!default.perspectivev3
+
+## Obj-C/Swift specific
+*.hmap
+
+## App packaging
+*.ipa
+*.dSYM.zip
+*.dSYM
+
+## Playgrounds
+timeline.xctimeline
+playground.xcworkspace
+
+# Swift Package Manager
+#
+# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
+# Packages/
+# Package.pins
+# Package.resolved
+# *.xcodeproj
+#
+# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
+# hence it is not needed unless you have added a package configuration file to your project
+# .swiftpm
+
+.build/
+
+# CocoaPods
+#
+# We recommend against adding the Pods directory to your .gitignore. However
+# you should judge for yourself, the pros and cons are mentioned at:
+# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
+#
+# Pods/
+#
+# Add this line if you want to avoid checking in source code from the Xcode workspace
+# *.xcworkspace
+
+# Carthage
+#
+# Add this line if you want to avoid checking in source code from Carthage dependencies.
+# Carthage/Checkouts
+
+Carthage/Build/
+
+# Accio dependency management
+Dependencies/
+.accio/
+
+# fastlane
+#
+# It is recommended to not store the screenshots in the git repo.
+# Instead, use fastlane to re-generate the screenshots whenever they are needed.
+# For more information about the recommended setup visit:
+# https://docs.fastlane.tools/best-practices/source-control/#source-control
+
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots/**/*.png
+fastlane/test_output
+
+# Code Injection
+#
+# After new code Injection tools there's a generated folder /iOSInjectionProject
+# https://github.com/johnno1962/injectionforxcode
+
+iOSInjectionProject/
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..153d416
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
\ No newline at end of file
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..d1f9bc7
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,31 @@
+// swift-tools-version:5.3
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "SuggestionPopup",
+ platforms: [
+ .macOS(.v10_12)
+ ],
+ products: [
+ // Products define the executables and libraries a package produces, and make them visible to other packages.
+ .library(
+ name: "SuggestionPopup",
+ targets: ["SuggestionPopup"]),
+ ],
+ dependencies: [
+ // Dependencies declare other packages that this package depends on.
+ // .package(url: /* package url */, from: "1.0.0"),
+ ],
+ targets: [
+ // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+ // Targets can depend on other targets in this package, and on products in packages this package depends on.
+ .target(
+ name: "SuggestionPopup",
+ dependencies: []),
+ .testTarget(
+ name: "SuggestionPopupTests",
+ dependencies: ["SuggestionPopup"]),
+ ]
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7f3e7a8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,70 @@
+# SuggestionPopup
+
+[](https://opensource.org/licenses/lgpl-3.0)
+
+This is a suggestion popup implementation similar to the one used by the `Maps.app` on macOS 10.15. It is provided under the GNU Lesser General Public License v3.0. I only tested it on macOS 10.15. MacOS 10.13-11.0 could work. I will test this in the future and make it compatible, if required. This software is still in beta.
+
+
+
+Usage:
+If you just one to have a simple location search, things are easy:
+
+``` Swift
+// Keep a reference to the search completer in memory.
+var searchCompleter: LocationSearchCompleter!
+...
+// Somewhere in your constructor create a LocationSearchCompleter with
+// your textField. You can still use the textField delegate !
+self.searchCompleter = LocationSearchCompleter(searchField: searchField)
+```
+
+If you want a custom search things are a little bit more difficult.
+
+``` Swift
+// Create or implement a new class based on NSObject which conforms
+// to the `Suggestion` protocol. A simple new class could look like
+// this:
+class SimpleSuggestion: NSObject, Suggestion {
+ init(title: String = "", subtitle: String = "", image: NSImage? = nil) {
+ self.title = title
+ self.subtitle = subtitle
+ self.image = image
+ }
+
+ var title: String = ""
+ var subtitle: String = ""
+ var image: NSImage?
+ var highlightedTitleRanges: [Range] = []
+ var highlightedSubtitleRanges: [Range] = []
+}
+// Most of the times it might be easier to just extend your existing class.
+// Take a look at the `LocationSearchCompleter` to see a simple example.
+
+
+// Create a new subclass of the SearchCompleter class
+class SimpleSearchCompleter: SearchCompleter {
+
+ // This is called on `init`. It is just for your convenience.
+ // Place all initial setup code here.
+ override func setup() {
+
+ }
+
+ // Override this function to prepare your search. If your search
+ // is compute intensive, use a background thread here and call
+ // `setSuggestions` on completion. You might show a progress spinner
+ // in this case. For a simple search, just place your code here
+ // and end the function with a `setSuggestions` call.
+ override func prepareSuggestions(for searchString: String) {
+ //self.showSpinner()
+ super. prepareSuggestions(for: searchString)
+ }
+
+ // Call this function to show the search result. You might override
+ // it to hide the progress spinner.
+ override func setSuggestions(_ suggestions: [Suggestion]) {
+ //self.hideSpinner()
+ super.setSuggestions(suggestions)
+ }
+}
+```
diff --git a/Sources/SuggestionPopup/KeyCodes.swift b/Sources/SuggestionPopup/KeyCodes.swift
new file mode 100644
index 0000000..bdaba8d
--- /dev/null
+++ b/Sources/SuggestionPopup/KeyCodes.swift
@@ -0,0 +1,22 @@
+//
+// KeyCodes.swift
+// Popup
+//
+// Created by David Klopp on 27.12.20.
+//
+
+import AppKit
+
+enum KeyCodes: UInt16 {
+ case `return` = 36
+ case tab = 48
+ case arrowLeft = 123
+ case arrowRight = 124
+ case arrowDown = 125
+ case arrowUp = 126
+}
+
+/// Define a class to be able to handle key events.
+protocol KeyResponder {
+ func processKeys(with theEvent: NSEvent) -> NSEvent?
+}
diff --git a/Sources/SuggestionPopup/SearchCompleter/LocationSearchCompleter.swift b/Sources/SuggestionPopup/SearchCompleter/LocationSearchCompleter.swift
new file mode 100644
index 0000000..f36172b
--- /dev/null
+++ b/Sources/SuggestionPopup/SearchCompleter/LocationSearchCompleter.swift
@@ -0,0 +1,69 @@
+//
+// LocationSearchController.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+import MapKit
+
+
+extension MKLocalSearchCompletion: Suggestion {
+ // Highlight the matched string inside the title.
+ public var highlightedTitleRanges: [Range] {
+ return self.titleHighlightRanges.compactMap { Range($0.rangeValue) }
+ }
+
+ // Highlight the matched string inside the subtitle.
+ public var highlightedSubtitleRanges: [Range] {
+ return self.subtitleHighlightRanges.compactMap { Range($0.rangeValue) }
+ }
+
+ // We don't show any Image.
+ public var image: NSImage? {
+ return nil
+ }
+
+}
+
+/// A simple search completer which searches for locations.
+public final class LocationSearchCompleter: SearchCompleter {
+ /// Search completer to find a location based on a string.
+ private var searchCompleter = MKLocalSearchCompleter()
+
+ // Setup the search completer.
+ public override func setup() {
+ if #available(OSX 10.15, *) {
+ self.searchCompleter.resultTypes = .address
+ } else {
+ self.searchCompleter.filterType = .locationsOnly
+ }
+ self.searchCompleter.delegate = self
+ }
+
+ // Prepare the search results and show the spinner.
+ public override func prepareSuggestions(for searchString: String) {
+ // Show a progress spinner.
+ self.showSpinner()
+ // Cancel any running search request.
+ if self.searchCompleter.isSearching {
+ self.searchCompleter.cancel()
+ }
+ // Start a search.
+ self.searchCompleter.queryFragment = searchString
+ }
+
+ // Show the results and hide the spinner.
+ public override func setSuggestions(_ suggestions: [Suggestion]) {
+ self.hideSpinner()
+ super.setSuggestions(suggestions)
+ }
+}
+
+extension LocationSearchCompleter: MKLocalSearchCompleterDelegate {
+ /// Called when the searchCompleter finished loading the search results.
+ public func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) {
+ self.setSuggestions(self.searchCompleter.results)
+ }
+}
diff --git a/Sources/SuggestionPopup/SearchCompleter/SearchCompleter.swift b/Sources/SuggestionPopup/SearchCompleter/SearchCompleter.swift
new file mode 100644
index 0000000..0b40897
--- /dev/null
+++ b/Sources/SuggestionPopup/SearchCompleter/SearchCompleter.swift
@@ -0,0 +1,227 @@
+//
+// AutocompleteSearchController.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+// TODO:
+// Bug 2: Sometimes the text entry is broken when removing characters
+// Bug 3: Hovering over a cell while deleting breaks the deleting mechanism. (This is the same as bug 2)
+
+import AppKit
+
+open class SearchCompleter: NSObject, KeyResponder {
+ /// The main searchField instance.
+ public weak var searchField: NSTextField!
+
+ /// Window events.
+ public var onShow: SuggestionShowAction?
+ public var onHide: SuggestionHideAction?
+ public var onSelect: SuggestionSelectAction?
+ public var onHighlight: SuggestionHighlightAction? {
+ get { return self.windowController.onHighlight }
+ set { self.windowController.onHighlight = newValue }
+ }
+
+ /// The main window controller.
+ var windowController: SuggestionWindowController!
+
+ /// A reference to the textDidChange observer.
+ private var textDidChangeObserver: NSObjectProtocol?
+
+ /// A reference to the window didResignKey observer.
+ private var lostFocusObserver: Any?
+
+ /// The internal monitor to capture key events.
+ private var localKeyEventMonitor: Any?
+
+ /// The internal monitor to capture mouse events.
+ private var localMouseDownEventMonitor: Any?
+
+ // MARK: - Constructor
+
+ public init(searchField: NSTextField) {
+ super.init()
+ self.searchField = searchField
+ self.windowController = SuggestionWindowController(searchField: searchField)
+ self.setup()
+
+ // Listen for text changes inside the textField.
+ self.registerTextFieldNotifications()
+ self.registerFocusNotifications()
+
+ // Add and remove the key and mouse events depending on whether the window is visible.
+ self.windowController.onShow = { [weak self] in
+ self?.registerKeyEvents()
+ self?.registerMouseEvents()
+ self?.onShow?()
+ }
+ self.windowController.onHide = { [weak self] in
+ self?.unregisterKeyEvents()
+ self?.unregisterMouseEvents()
+ self?.onHide?()
+ }
+ self.windowController.onSelect = { [weak self] in
+ self?.windowController.hide()
+ self?.onSelect?($0, $1)
+ }
+ }
+
+ // MARK: - Destructor
+
+ deinit {
+ self.unregisterTextFieldNotifications()
+ self.unregisterFocusNotifications()
+ }
+
+ // MARK: - KeyEvents
+
+ /// Handle key up and down events.
+ private func registerKeyEvents() {
+ self.localKeyEventMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.keyDown, .keyUp]) { [weak self] (event) -> NSEvent? in
+ // If the current searchField is the first responder, we capture the event.
+ guard let firstResponder = self?.searchField?.window?.firstResponder else { return event }
+ if firstResponder == self?.searchField.currentEditor() {
+ return self?.processKeys(with: event)
+ }
+ return event
+ }
+ }
+
+ /// Remove the key event monitor.
+ private func unregisterKeyEvents() {
+ if let eventMonitor = self.localKeyEventMonitor {
+ NSEvent.removeMonitor(eventMonitor)
+ }
+ self.localKeyEventMonitor = nil
+ }
+
+ /// Return the event, to allow other classes to handle the event or nil to capture it.
+ func processKeys(with theEvent: NSEvent) -> NSEvent? {
+ // Check if this controller can handle the event.
+ if let keyEvent = KeyCodes(rawValue: theEvent.keyCode) {
+ switch keyEvent {
+ case .return:
+ // Hide the window.
+ self.windowController.hide()
+ case .tab:
+ // Do not capture the tab event. We still want to be able to change the focus.
+ self.windowController.hide()
+ default:
+ break
+ }
+ }
+ // Check if the window controller can handle the event.
+ return self.windowController != nil ? self.windowController?.processKeys(with: theEvent) : theEvent
+ }
+
+ // MARK: - Mouse Events
+
+ /// Handle mouse clickes inside and outside the window.
+ private func registerMouseEvents() {
+ self.localMouseDownEventMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] (event) -> NSEvent? in
+ // Make sure the event has a window.
+ guard let eventWindow = event.window, let window = self?.windowController.window else { return event }
+ let isSuggestionWindow = eventWindow == window
+ let clickedInsideContentView = eventWindow.contentView?.hitTest(event.locationInWindow) != nil
+ let clickedInsideTextField = self?.searchField.hitTest(event.locationInWindow) != nil
+
+ // If the event window was clicked outside its toolbar then dismiss the popup.
+ if !isSuggestionWindow && clickedInsideContentView && !clickedInsideTextField {
+ self?.windowController.hide()
+ }
+ return event
+ }
+ }
+
+ /// Remove the mouse click monitor.
+ private func unregisterMouseEvents() {
+ if let eventMonitor = self.localMouseDownEventMonitor {
+ NSEvent.removeMonitor(eventMonitor)
+ }
+ self.localMouseDownEventMonitor = nil
+ }
+
+ // MARK: - TextField
+
+ private func registerTextFieldNotifications() {
+ self.textDidChangeObserver = NotificationCenter.default.addObserver(
+ forName: NSTextField.textDidChangeNotification,
+ object: self.searchField, queue: .main) { [weak self] _ in
+
+ let text = self?.searchField.stringValue ?? ""
+ if text.isEmpty {
+ // Hide window
+ self?.windowController.hide()
+ } else {
+ self?.prepareSuggestions(for: text)
+ // Show the autocomplete window and start a progress spinner.
+ self?.windowController.show()
+ }
+ }
+ }
+
+ private func unregisterTextFieldNotifications() {
+ if let observer = self.textDidChangeObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ self.textDidChangeObserver = nil
+ }
+
+ // MARK: - Focus
+
+ private func registerFocusNotifications() {
+ // If the suggestion window looses focus we dismiss it.
+ guard let window = self.windowController.window else { return }
+ self.lostFocusObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification,
+ object: window,
+ queue: nil) { [weak self] _ in
+ self?.windowController.hide()
+ }
+ }
+
+ private func unregisterFocusNotifications() {
+ if let observer = self.lostFocusObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ self.lostFocusObserver = nil
+ }
+
+ // MARK: - Public Methods
+
+ /// Show the spinner to indicate work.
+ public func showSpinner() {
+ let window = self.windowController.window as? SuggestionWindow
+ window?.showSpinner()
+ }
+
+ /// Hide the spinner to indicate the work is finished.
+ public func hideSpinner() {
+ let window = self.windowController.window as? SuggestionWindow
+ window?.hideSpinner()
+ }
+
+ // MARK: - Override
+
+ /// Override this function to perform initial setup.
+ open func setup() {
+
+ }
+
+ /// This function is called when the textField text changes. Prepare your search results here.
+ open func prepareSuggestions(for searchString: String) {
+
+ }
+
+ /// Use this function to update the search results.
+ open func setSuggestions(_ suggestions: [Suggestion]) {
+ self.windowController.setSuggestions(suggestions)
+ // If we don't have any suggestions hide the window.
+ if suggestions.isEmpty {
+ self.windowController.hide()
+ }
+ }
+}
diff --git a/Sources/SuggestionPopup/SearchCompleter/Suggestion.swift b/Sources/SuggestionPopup/SearchCompleter/Suggestion.swift
new file mode 100644
index 0000000..ef3eb99
--- /dev/null
+++ b/Sources/SuggestionPopup/SearchCompleter/Suggestion.swift
@@ -0,0 +1,27 @@
+//
+// AutocompleteMatch.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+
+public typealias SuggestionSelectAction = ((String, Suggestion) -> Void)
+public typealias SuggestionHighlightAction = ((String, Suggestion?) -> Void)
+public typealias SuggestionShowAction = (() -> Void)
+public typealias SuggestionHideAction = (() -> Void)
+
+/// Your class must conform to this protocol to be displayed in the suggestion list.
+public protocol Suggestion: NSObject {
+ /// The main title.
+ var title: String { get }
+ /// The subtitle below the title.
+ var subtitle: String { get }
+ /// The image to the left.
+ var image: NSImage? { get }
+ /// Optional range to highlight inside the title.
+ var highlightedTitleRanges: [Range] { get }
+ /// Optional range to highlight inside the subtitle.
+ var highlightedSubtitleRanges: [Range] { get }
+}
diff --git a/Sources/SuggestionPopup/ViewController/SuggestionListView.swift b/Sources/SuggestionPopup/ViewController/SuggestionListView.swift
new file mode 100644
index 0000000..54485a3
--- /dev/null
+++ b/Sources/SuggestionPopup/ViewController/SuggestionListView.swift
@@ -0,0 +1,75 @@
+//
+// AutocompleteContentView.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+
+class SuggestionListView: NSScrollView {
+ /// The main table view.
+ var tableView: NSTableView!
+ /// The main table view column.
+ var column: NSTableColumn!
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+
+ // Setup the tableView.
+ self.tableView = NSTableView(frame: .zero)
+ if #available(OSX 11.0, *) {
+ self.tableView.style = .fullWidth
+ }
+ self.tableView.selectionHighlightStyle = NSTableView.SelectionHighlightStyle.regular
+ self.tableView.backgroundColor = .clear
+ self.tableView.rowSizeStyle = NSTableView.RowSizeStyle.custom
+ self.tableView.rowHeight = 36.0
+ self.tableView.intercellSpacing = NSSize(width: 5.0, height: 0.0)
+ self.tableView.headerView = nil
+
+ // Add a table column.
+ self.column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: "text"))
+ self.column.isEditable = false
+ self.tableView.addTableColumn(self.column)
+
+ // Setup the scrollView.
+ self.drawsBackground = false
+ self.documentView = self.tableView
+ self.hasVerticalScroller = true
+ self.hasHorizontalScroller = false
+ self.automaticallyAdjustsContentInsets = false
+ self.contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("InitWithCoder not supported.")
+ }
+
+ // MARK: - Layout
+
+ override func layout() {
+ super.layout()
+ self.tableView.frame.size.width = self.frame.width
+ self.column.width = self.tableView.frame.width
+ }
+
+ // MARK: - Helper
+
+ func selectPreviousRow() {
+ let row = self.tableView.selectedRow
+ if row > 0 {
+ self.tableView.selectRowIndexes([row-1], byExtendingSelection: false)
+ self.tableView.scrollRowToVisible(row-1)
+ } else {
+ self.tableView.selectRowIndexes([], byExtendingSelection: false)
+ }
+ }
+
+ func selectNextRow() {
+ let row = self.tableView.selectedRow
+ guard row < self.tableView.numberOfRows-1 else { return }
+ self.tableView.selectRowIndexes([row+1], byExtendingSelection: false)
+ self.tableView.scrollRowToVisible(row+1)
+ }
+}
diff --git a/Sources/SuggestionPopup/ViewController/SuggestionListViewController.swift b/Sources/SuggestionPopup/ViewController/SuggestionListViewController.swift
new file mode 100644
index 0000000..f6408aa
--- /dev/null
+++ b/Sources/SuggestionPopup/ViewController/SuggestionListViewController.swift
@@ -0,0 +1,181 @@
+//
+// AutocompleteContentViewController.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+
+let kMaxResults = 5
+let kCellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "AutocompleteCell")
+
+class SuggestionListViewController: NSViewController, KeyResponder {
+ /// The main tableView with all search results.
+ var contentView: SuggestionListView!
+ /// The list with all suggestions.
+ var suggestions: [Suggestion] = []
+ /// The progress spinner when loading results.
+ private var spinner: NSProgressIndicator!
+ /// The target and action to perform when a cell is selected.
+ var target: AnyObject?
+ var action: Selector?
+
+ /// Override loadView to load our custom content view.
+ override func loadView() {
+ // Create a container view that contains the effect view and the content view.
+ let containerView = NSView()
+
+ // Add the effect view.
+ let effectView = NSVisualEffectView(frame: containerView.bounds)
+ effectView.autoresizingMask = [.height, .width]
+ effectView.isEmphasized = false
+ effectView.state = .active
+ if #available(OSX 10.14, *) {
+ effectView.material = .underWindowBackground
+ } else {
+ effectView.material = .titlebar
+ }
+ effectView.blendingMode = .behindWindow
+ containerView.addSubview(effectView)
+
+ // Add the content view.
+ self.contentView = SuggestionListView(frame: containerView.bounds)
+ self.contentView.autoresizingMask = [.height, .width]
+ self.contentView.isHidden = false
+ self.contentView.tableView.dataSource = self
+ self.contentView.tableView.delegate = self
+ containerView.addSubview(contentView)
+
+ // Handle the tableView click events.
+ self.contentView.tableView.target = self
+ self.contentView.tableView.action = #selector(performActionForSelectedCell(_:))
+
+ // Add the progress spinner.
+ self.spinner = NSProgressIndicator(frame: .zero)
+ self.spinner.style = .spinning
+ self.spinner.isHidden = true
+ containerView.addSubview(self.spinner)
+
+ // Apply a corner radius to the view.
+ containerView.wantsLayer = true
+ containerView.layer?.cornerRadius = 5.0
+
+ self.view = containerView
+ }
+
+ override func viewDidLayout() {
+ super.viewDidLayout()
+ // Update the spinner. Autoresizing is not powerfull enough.
+ let pad: CGFloat = 8.0
+ let size = self.view.bounds.height - pad*2
+ self.spinner.frame = CGRect(x: pad, y: pad, width: size, height: size)
+ }
+
+ // MARK: - Spinner
+
+ func showSpinner() {
+ self.spinner.startAnimation(nil)
+ self.spinner.isHidden = false
+ self.contentView.isHidden = true
+ }
+
+ func hideSpinner() {
+ self.spinner.stopAnimation(nil)
+ self.spinner.isHidden = true
+ self.contentView.isHidden = false
+ }
+
+ // MARK: - Results
+
+ func setSuggestions(_ suggestions: [Suggestion]) {
+ self.suggestions = suggestions
+ self.contentView.tableView.reloadData()
+ }
+
+ // MARK: - Helper
+
+ /// Get the current suggested content size.
+ func getSuggestedWindowSize() -> CGSize {
+ guard let tableView = self.contentView.tableView else { return .zero }
+
+ let numberOfRows = min(tableView.numberOfRows, kMaxResults)
+ let rowHeight = tableView.rowHeight
+ let spacing = tableView.intercellSpacing
+ var frame = self.view.frame
+ frame.size.height = (rowHeight + spacing.height) * CGFloat(numberOfRows)
+ return frame.size
+ }
+
+ // MARK: - Key events
+
+ func processKeys(with theEvent: NSEvent) -> NSEvent? {
+ let keyUp: Bool = theEvent.type == .keyUp
+
+ if let keyEvent = KeyCodes(rawValue: theEvent.keyCode) {
+ switch keyEvent {
+ case .arrowUp:
+ if !keyUp {
+ self.contentView.selectPreviousRow()
+ }
+ // Capture this event.
+ return nil
+ case .arrowDown:
+ if !keyUp {
+ self.contentView.selectNextRow()
+ }
+ // Capture this event.
+ return nil
+ case .return:
+ // Perform the action for the currently selected cell.
+ self.performActionForSelectedCell()
+ return nil
+ default:
+ break
+ }
+ }
+
+ return theEvent
+ }
+
+ // MARK: - Click
+
+ @objc func performActionForSelectedCell(_ sender: AnyObject? = nil) {
+ let selectedRow = self.contentView.tableView.selectedRow
+ if selectedRow >= 0 && selectedRow < self.suggestions.count {
+ let suggestion = self.suggestions[selectedRow]
+ _ = self.target?.perform(self.action, with: suggestion)
+ }
+ }
+}
+
+
+extension SuggestionListViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return self.suggestions.count
+ }
+}
+
+extension SuggestionListViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? {
+ return SuggestionTableRowView(tableView: tableView, row: row)
+ }
+
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ var cellView = tableView.makeView(withIdentifier: kCellIdentifier, owner: self) as? SuggestionTableCellView
+ if cellView == nil {
+ let cellFrame = CGRect(x: 0, y: 0, width: tableView.frame.width, height: tableView.rowHeight)
+ cellView = SuggestionTableCellView(frame: cellFrame)
+ cellView?.identifier = kCellIdentifier
+ }
+ // Assign the new values and update the cell.
+ cellView?.image = self.suggestions[row].image
+ cellView?.title = self.suggestions[row].title
+ cellView?.subtitle = self.suggestions[row].subtitle
+ cellView?.highlightedTitleRanges = self.suggestions[row].highlightedTitleRanges
+ cellView?.highlightedSubtitleRanges = self.suggestions[row].highlightedSubtitleRanges
+ cellView?.update()
+
+ return cellView
+ }
+}
diff --git a/Sources/SuggestionPopup/ViewController/SuggestionTableCellView.swift b/Sources/SuggestionPopup/ViewController/SuggestionTableCellView.swift
new file mode 100644
index 0000000..2bd3b11
--- /dev/null
+++ b/Sources/SuggestionPopup/ViewController/SuggestionTableCellView.swift
@@ -0,0 +1,137 @@
+//
+// AutocompleteTableCellView.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+
+class SuggestionTableCellView: NSTableCellView {
+ /// The cells title.
+ var title: String = ""
+
+ /// The cells subtitle.
+ var subtitle: String = ""
+
+ /// The cells imageView.
+ var image: NSImage? {
+ get { self.imageView?.image }
+ set { self.imageView?.image = newValue }
+ }
+
+ /// The parts of the title to highlight.
+ var highlightedTitleRanges: [Range] = []
+
+ /// The parts of the subtitle to highlight.
+ var highlightedSubtitleRanges: [Range] = []
+
+ /// A reference to the enclosing row view.
+ var isHighlighted: Bool = false {
+ didSet { self.update() }
+ }
+
+ // MARK: - Constructor
+
+ private func setup() {
+ // Add the textField
+ let textField = NSTextField(frame: .zero)
+ textField.isBezeled = false
+ textField.drawsBackground = false
+ textField.isEditable = false
+ textField.isSelectable = false
+ textField.maximumNumberOfLines = 2
+ self.textField = textField
+
+ // Add the imageViw
+ let imageView = NSImageView()
+ self.imageView = imageView
+
+ self.addSubview(imageView)
+ self.addSubview(textField)
+ }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ self.setup()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ self.setup()
+ }
+
+ // MARK: - Reuse
+
+ override func prepareForReuse() {
+ super.prepareForReuse()
+ self.image = nil
+ self.isHighlighted = false
+ }
+
+ // MARK: - Layout
+
+ override func layout() {
+ super.layout()
+ self.update()
+ }
+
+ /// Update the title and subtitle text and color.
+ func update() {
+ // Create a concatenated string with title and subtitle.
+ let str = self.title + (self.subtitle.isEmpty ? "" : ("\n" + self.subtitle))
+ let mutableAttriStr = NSMutableAttributedString(string: str)
+
+ // The range of the title and subtitle string.
+ let titleRange = NSRange(location: 0, length: self.title.count)
+ let subtitleRange = NSRange(location: self.title.count + 1, length: self.subtitle.count)
+ // The paragraph style to use.
+ let paragraphStyle = NSMutableParagraphStyle()
+ paragraphStyle.lineBreakMode = .byTruncatingTail
+ // The text color to use.
+ let titleFontSize = NSFont.systemFontSize
+ let titleColor: NSColor = self.isHighlighted ? .white : .labelColor
+ let subtitleFontSize = NSFont.labelFontSize
+ let subtitleColor: NSColor = self.isHighlighted ? .white : .secondaryLabelColor
+
+ // Update the attributes string.
+ mutableAttriStr.addAttributes([.paragraphStyle: paragraphStyle,
+ .font: NSFont.systemFont(ofSize: titleFontSize),
+ .foregroundColor: titleColor], range: titleRange)
+ mutableAttriStr.addAttributes([.paragraphStyle: paragraphStyle,
+ .font: NSFont.systemFont(ofSize: subtitleFontSize),
+ .foregroundColor: subtitleColor], range: subtitleRange)
+
+ // Update the title and subtitle highlight.
+ self.highlightedTitleRanges.forEach {
+ let range = NSMakeRange($0.startIndex, $0.count)
+ mutableAttriStr.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: titleFontSize), range: range)
+ }
+ self.highlightedSubtitleRanges.forEach {
+ let range = NSMakeRange(subtitleRange.location + $0.startIndex, $0.count)
+ mutableAttriStr.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: subtitleFontSize), range: range)
+ }
+
+ // Layout the subviews.
+ let pad: CGFloat = 3
+ var remainingWidth = self.frame.width
+ var frame: CGRect = .zero
+
+ // If the imageView needs to be visible.
+ if self.image != nil {
+ let size = self.frame.height - pad*2
+ frame = CGRect(x: pad, y: pad, width: size, height: size)
+ remainingWidth -= frame.maxX
+ self.imageView?.frame = frame
+ }
+
+ // Center the textField vertically inside the cell
+ let textHeight = mutableAttriStr.size().height
+ frame = CGRect(x: 0, y: 0, width: remainingWidth, height: textHeight)
+ frame.origin.y = (self.frame.height-textHeight)/2.0
+ frame.origin.x = self.frame.size.width - remainingWidth + pad
+ self.textField?.frame = frame
+ // Update the string.
+ self.textField?.attributedStringValue = mutableAttriStr
+ }
+}
diff --git a/Sources/SuggestionPopup/ViewController/SuggestionTableRowView.swift b/Sources/SuggestionPopup/ViewController/SuggestionTableRowView.swift
new file mode 100644
index 0000000..9025937
--- /dev/null
+++ b/Sources/SuggestionPopup/ViewController/SuggestionTableRowView.swift
@@ -0,0 +1,66 @@
+//
+// AutoCompleteTableRowView.swift
+// Popup
+//
+// Created by David Klopp on 27.12.20.
+//
+
+import AppKit
+
+class SuggestionTableRowView: NSTableRowView {
+ /// Reference to the parent table view.
+ weak var tableView: NSTableView?
+ /// The row number.
+ var row: Int
+
+ /// Inform the cell if it should be highlighted.
+ override var isSelected: Bool {
+ didSet {
+ guard self.numberOfColumns > 0 else { return }
+ // Update the cells highlight state.
+ let cellView = self.view(atColumn: 0) as? SuggestionTableCellView
+ cellView?.isHighlighted = self.isSelected
+ }
+ }
+
+ /// Always use a blue highlight for the cells.
+ override var isEmphasized: Bool {
+ get { return true }
+ set {}
+ }
+
+ // MARK: - Constructor
+
+ init(tableView: NSTableView, row: Int) {
+ self.tableView = tableView
+ self.row = row
+ super.init(frame: .zero)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("InitWithCoder not available.")
+ }
+
+ // MARK: - Hover
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ // Define the traking area to execute the mouseEntered and mouseExited events.
+ for trackingArea in self.trackingAreas {
+ self.removeTrackingArea(trackingArea)
+ }
+ let options: NSTrackingArea.Options = [.mouseMoved, .mouseEnteredAndExited, .activeInActiveApp]
+ let trackingArea = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil)
+ self.addTrackingArea(trackingArea)
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ super.mouseMoved(with: event)
+ self.tableView?.selectRowIndexes([row], byExtendingSelection: false)
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ super.mouseExited(with: event)
+ self.tableView?.selectRowIndexes([], byExtendingSelection: false)
+ }
+}
diff --git a/Sources/SuggestionPopup/WindowController/SuggestionWindow.swift b/Sources/SuggestionPopup/WindowController/SuggestionWindow.swift
new file mode 100644
index 0000000..99a7dd3
--- /dev/null
+++ b/Sources/SuggestionPopup/WindowController/SuggestionWindow.swift
@@ -0,0 +1,63 @@
+//
+// AutocompleteWindow.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+
+import AppKit
+
+class SuggestionWindow: NSWindow {
+
+ // MARK: - Constructor
+
+ /// Create a bordless, transparent window which hosts the popup.
+ init() {
+ super.init(contentRect: .zero, styleMask: .borderless, backing: .buffered, defer: true)
+ // Configure the window
+ self.hasShadow = true
+ self.backgroundColor = .clear
+ self.isOpaque = false
+ self.isMovable = false
+ self.isMovableByWindowBackground = false
+ // Assign the contentViewController.
+ self.contentViewController = SuggestionListViewController()
+ }
+
+ // MARK: - Spinner
+
+ func showSpinner() {
+ // Use a fixed height.
+ var size = self.frame.size
+ size.height = 40
+ self.setContentSize(size)
+ let contentViewController = self.contentViewController as? SuggestionListViewController
+ contentViewController?.showSpinner()
+ }
+
+ func hideSpinner() {
+ let contentViewController = self.contentViewController as? SuggestionListViewController
+ contentViewController?.hideSpinner()
+ }
+
+ // MARK: - Results
+
+ func setSuggestions(_ suggestions: [Suggestion]) {
+ let contentViewController = self.contentViewController as? SuggestionListViewController
+ // Update the results.
+ contentViewController?.setSuggestions(suggestions)
+ // Update the content size.
+ let contentSize = contentViewController?.getSuggestedWindowSize() ?? .zero
+ let topLeftPoint = CGPoint(x: self.frame.minX, y: self.frame.maxY)
+ self.setContentSize(contentSize)
+ self.setFrameTopLeftPoint(topLeftPoint)
+ }
+}
+
+// MARK: - Accessibility
+extension SuggestionWindow {
+ /// We ignore this window for accessibility.
+ override func isAccessibilityElement() -> Bool {
+ return false
+ }
+}
diff --git a/Sources/SuggestionPopup/WindowController/SuggestionWindowController.swift b/Sources/SuggestionPopup/WindowController/SuggestionWindowController.swift
new file mode 100644
index 0000000..bd2f54b
--- /dev/null
+++ b/Sources/SuggestionPopup/WindowController/SuggestionWindowController.swift
@@ -0,0 +1,169 @@
+//
+// AutoCompleteWindowController.swift
+// Popup
+//
+// Created by David Klopp on 26.12.20.
+//
+import AppKit
+
+class SuggestionWindowController: NSWindowController, KeyResponder {
+ /// The textfield instance to manage.
+ private weak var searchField: NSTextField!
+
+ /// A referenc to the searchFields parent window.
+ private var parentWindow: NSWindow? { return self.searchField.window }
+
+ /// The currently entered search query.
+ private var searchQuery: String = ""
+
+ /// A reference to the textDidChange observer.
+ private var textDidChangeObserver: NSObjectProtocol?
+
+ /// A reference to the tableView selection change observer.
+ private var selecionChangedObserver: NSObjectProtocol?
+
+ /// Callback handlers.
+ var onShow: SuggestionShowAction?
+ var onHide: SuggestionHideAction?
+ var onHighlight: SuggestionHighlightAction?
+ var onSelect: SuggestionSelectAction?
+
+ // MARK: - Constructor
+
+ init(searchField: NSTextField) {
+ self.searchField = searchField
+ let window = SuggestionWindow()
+ window.hidesOnDeactivate = false
+
+ super.init(window: window)
+
+ // Handle the cell selection.
+ let contentViewController = self.contentViewController as? SuggestionListViewController
+ contentViewController?.target = self
+ contentViewController?.action = #selector(self.selectedSuggestion(_:))
+
+ // Listen for text changes inside the textField.
+ self.registerNotifications()
+ }
+
+
+ // MARK: - Destructor
+
+ deinit {
+ if let observer = self.textDidChangeObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ if let observer = self.selecionChangedObserver {
+ NotificationCenter.default.removeObserver(observer)
+ }
+ self.textDidChangeObserver = nil
+ self.selecionChangedObserver = nil
+ }
+
+ // MARK: - TextField
+
+ private func registerNotifications() {
+ let center = NotificationCenter.default
+
+ self.textDidChangeObserver = center.addObserver(forName: NSTextField.textDidChangeNotification,
+ object: self.searchField,
+ queue: .main) { [weak self] _ in
+ // Save the current search query.
+ self?.searchQuery = self?.searchField.stringValue ?? ""
+ }
+
+ // Listen for tableView cell highlighting.
+ guard let contentViewController = self.contentViewController as? SuggestionListViewController else {
+ return
+ }
+ let tableView = contentViewController.contentView.tableView
+ self.selecionChangedObserver = center.addObserver(forName: NSTableView.selectionDidChangeNotification,
+ object: tableView,
+ queue: .main) { [weak self] notification in
+ guard let row = tableView?.selectedRow, let queryString = self?.searchQuery else { return }
+
+ let editor = self?.searchField.currentEditor() as? NSTextView
+ var suggestion: Suggestion?
+ if row >= 0 {
+ // Cell selected, display the suggestion.
+ suggestion = contentViewController.suggestions[row]
+ let title = suggestion!.title
+
+ // If the search string matches the start of the title, highlight the remaining part,
+ // otherwise highlight the complete title.
+ self?.searchField.stringValue = title
+ let range = NSMakeRange(title.starts(with: queryString) ? queryString.count : 0, title.count)
+ editor?.setSelectedRange(range)
+
+ } else {
+ // Cell was deselected. Reset the search query and clear the seletion.
+ self?.searchField.stringValue = queryString
+ editor?.moveToEndOfLine(nil)
+ }
+ self?.onHighlight?(queryString, suggestion)
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("InitWithCoder not supported.")
+ }
+
+ // MARK: - Selection
+
+ @objc private func selectedSuggestion(_ suggestion: AnyObject) {
+ guard let suggestion = suggestion as? Suggestion else { return }
+ self.onSelect?(self.searchQuery, suggestion)
+ }
+
+ // MARK: - Show / Hide
+
+ /// Show the window.
+ /// - Return: True if the window can be shown, false otherwise.
+ @discardableResult
+ func show() -> Bool {
+ // Make sure the searchField is inside the view hierachy.
+ guard let window = self.window, !window.isVisible,
+ let parentWindow = self.parentWindow,
+ let searchFieldParent = self.searchField.superview else { return false }
+ // The window has the same width as the searchField.
+ var frame = window.frame
+ frame.size.width = self.searchField.frame.width
+ // Position the window directly below the searchField.
+ var location = searchFieldParent.convert(self.searchField.frame.origin, to: nil)
+ location = parentWindow.convertToScreen(CGRect(x: location.x, y: location.y, width: 0, height: 0)).origin
+ location.y -= 5
+ // Apply the frame and position.
+ window.setContentSize(frame.size)
+ window.setFrameTopLeftPoint(location)
+ // Show the window
+ parentWindow.addChildWindow(window, ordered: .above)
+ self.onShow?()
+ return true
+ }
+
+ /// Hide the window.
+ @discardableResult
+ func hide() -> Bool {
+ guard let window = self.window, window.isVisible else { return false }
+ window.parent?.removeChildWindow(window)
+ window.orderOut(nil)
+ self.onHide?()
+ return true
+ }
+
+ // MARK: - Results
+
+ func setSuggestions(_ suggestions: [Suggestion]) {
+ guard let window = self.window as? SuggestionWindow else { return }
+ window.setSuggestions(suggestions)
+ }
+
+ // MARK: - Key Events
+
+ /// Return the event, to allow other classes to handle the event or nil to capture it.
+ func processKeys(with theEvent: NSEvent) -> NSEvent? {
+ // Check if the window's contentViewController can handle the event.
+ let viewController = self.contentViewController as? KeyResponder
+ return viewController != nil ? viewController?.processKeys(with: theEvent) : theEvent
+ }
+}
diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift
new file mode 100644
index 0000000..ba23566
--- /dev/null
+++ b/Tests/LinuxMain.swift
@@ -0,0 +1,7 @@
+import XCTest
+
+import SuggestionPopupTests
+
+var tests = [XCTestCaseEntry]()
+tests += SuggestionPopupTests.allTests()
+XCTMain(tests)
diff --git a/Tests/SuggestionPopupTests/SuggestionPopupTests.swift b/Tests/SuggestionPopupTests/SuggestionPopupTests.swift
new file mode 100644
index 0000000..ceccd7d
--- /dev/null
+++ b/Tests/SuggestionPopupTests/SuggestionPopupTests.swift
@@ -0,0 +1,15 @@
+import XCTest
+@testable import SuggestionPopup
+
+final class SuggestionPopupTests: XCTestCase {
+ func testExample() {
+ // This is an example of a functional test case.
+ // Use XCTAssert and related functions to verify your tests produce the correct
+ // results.
+ //XCTAssertEqual(SuggestionPopup().text, "Hello, World!")
+ }
+
+ static var allTests = [
+ ("testExample", testExample),
+ ]
+}
diff --git a/Tests/SuggestionPopupTests/XCTestManifests.swift b/Tests/SuggestionPopupTests/XCTestManifests.swift
new file mode 100644
index 0000000..5a0d34c
--- /dev/null
+++ b/Tests/SuggestionPopupTests/XCTestManifests.swift
@@ -0,0 +1,9 @@
+import XCTest
+
+#if !canImport(ObjectiveC)
+public func allTests() -> [XCTestCaseEntry] {
+ return [
+ testCase(SuggestionPopupTests.allTests),
+ ]
+}
+#endif
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000..078eb9d
Binary files /dev/null and b/screenshot.png differ