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 + +[![License: GNU Affero General Public license version 3](https://img.shields.io/badge/License-LGPLv3-blue.svg)](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